eve
Version:
Filesystem-first framework for durable backend AI agents that run anywhere.
6,083 lines • 187 kB
JavaScript
const WEB_APP_TEMPLATE_FILES={"agent/channels/eve.ts":`import { eveChannel } from "eve/channels/eve";
import { localDev, placeholderAuth, vercelOidc } from "eve/channels/auth";
export default eveChannel({
auth: [
// Lets the eve TUI and your Vercel deployments reach the deployed agent.
vercelOidc(),
// Open on localhost for \`eve dev\` and the REPL; ignored in production.
localDev(),
// This placeholder will not allow browser requests in production.
// Replace it with your app's auth provider, like Auth.js or Clerk,
// or use none() for a public demo.
placeholderAuth(),
],
});
`,"app/_components/agent-chat.tsx":`"use client";
import type { UserContent } from "ai";
import { useEveAgent } from "eve/react";
import { AlertCircleIcon, BrainIcon, PlusIcon, SquareIcon } from "lucide-react";
import { useState } from "react";
import {
Conversation,
ConversationContent,
ConversationScrollButton,
ConversationTopFade,
} from "@/components/ai-elements/conversation";
import { Message, MessageContent } from "@/components/ai-elements/message";
import {
PromptInput,
PromptInputButton,
type PromptInputMessage,
PromptInputSubmit,
PromptInputTextarea,
usePromptInputAttachments,
} from "@/components/ai-elements/prompt-input";
import { Shimmer } from "@/components/ai-elements/shimmer";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { AgentMessage } from "./agent-message";
const AGENT_NAME = "__EVE_INIT_APP_NAME__";
export function AgentChat({
sessionId,
sessionless = false,
}: {
readonly sessionId?: string;
readonly sessionless?: boolean;
}) {
const [cancellationError, setCancellationError] = useState<string>();
const [hasInputText, setHasInputText] = useState(false);
const agent = useEveAgent({
initialSession:
sessionId === undefined
? undefined
: {
sessionId,
streamIndex: 0,
},
resume: sessionId !== undefined,
onSessionChange(session) {
if (sessionId === undefined && session !== undefined) {
// Next patches window.history to navigate, which would detach the active stream.
History.prototype.replaceState.call(
window.history,
window.history.state,
"",
\`/s/\${encodeURIComponent(session.sessionId)}\`,
);
}
},
});
const isBusy = agent.status === "submitted" || agent.status === "streaming";
const isResuming = agent.status === "resuming";
const isEmpty = agent.data.messages.length === 0;
const lastMessage = agent.data.messages.at(-1);
const isPendingAssistantShell =
lastMessage?.role === "assistant" &&
lastMessage.parts.every((part) => part.type === "step-start");
const showPendingThinking =
isBusy &&
(agent.status === "submitted" || lastMessage?.role !== "assistant" || isPendingAssistantShell);
const turnFailure = isBusy || isResuming ? undefined : getLatestTurnFailure(agent.events);
const errorMessage = cancellationError ?? agent.error?.message ?? turnFailure;
const hasConversationContent = sessionless || !isEmpty || errorMessage !== undefined;
const showConversationLayout = isResuming || hasConversationContent;
const activeSessionId = sessionId ?? agent.session?.sessionId;
const requestCancellation = () => {
setCancellationError(undefined);
void agent.cancel().catch((error: unknown) => {
setCancellationError(toErrorMessage(error));
});
};
const handleSubmit = async (message: PromptInputMessage) => {
const text = message.text.trim();
if ((text.length === 0 && message.files.length === 0) || isResuming) return;
setHasInputText(false);
setCancellationError(undefined);
const options = isBusy ? { turnPolicy: "steer" as const } : undefined;
if (message.files.length === 0) {
await agent.send(text, options);
return;
}
const parts: UserContent = [];
if (text.length > 0) {
parts.push({ text, type: "text" });
}
for (const file of message.files) {
parts.push({
data: file.url,
filename: file.filename,
mediaType: file.mediaType,
type: "file",
});
}
await agent.send(parts, options);
};
const composer = (
<PromptInput onSubmit={handleSubmit}>
<PromptInputTextarea
disabled={isResuming}
onChange={(event) => setHasInputText(event.currentTarget.value.trim().length > 0)}
placeholder="Send a message…"
/>
<ComposerAction
hasInputText={hasInputText}
isBusy={isBusy}
isResuming={isResuming}
onCancel={requestCancellation}
/>
</PromptInput>
);
return (
<main className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">
{showConversationLayout ? (
<ChatHeader canStartNewChat={activeSessionId !== undefined} />
) : null}
{showConversationLayout ? (
<Conversation
className="min-h-0 flex-1"
initial={sessionId === undefined ? undefined : false}
resize={activeSessionId === undefined ? "smooth" : "instant"}
scrollRestorationKey={
isEmpty || activeSessionId === undefined
? undefined
: \`eve:web-chat-scroll:\${activeSessionId}\`
}
>
<ConversationTopFade className="top-14" />
<ConversationContent className="mx-auto w-full max-w-3xl gap-6 px-4 pt-20 pb-36 sm:px-6">
{agent.data.messages.map((message, index) =>
showPendingThinking &&
isPendingAssistantShell &&
message.id === lastMessage.id ? null : (
<AgentMessage
canRespond={!isBusy && !isResuming}
isStreaming={
agent.status === "streaming" && index === agent.data.messages.length - 1
}
key={message.id}
message={message}
onInputResponses={(inputResponses) => {
setCancellationError(undefined);
return agent.respond(inputResponses);
}}
/>
),
)}
{showPendingThinking ? <PendingThinking /> : null}
{errorMessage ? <ErrorMessage message={errorMessage} /> : null}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
) : null}
<div
className={cn(
"mx-auto w-full px-4 sm:px-6",
showConversationLayout
? "fixed bottom-0 left-1/2 z-20 max-w-3xl -translate-x-1/2 bg-gradient-to-t from-background via-background to-transparent pt-4 pb-6"
: "flex max-w-xl flex-1 flex-col items-center justify-center gap-8 pb-[10vh]",
)}
>
{showConversationLayout ? null : (
<div className="flex flex-col items-center gap-3 text-center">
<h1 className="font-medium text-5xl tracking-tighter">{AGENT_NAME}</h1>
</div>
)}
<div className="w-full">{composer}</div>
</div>
</main>
);
}
function ComposerAction({
hasInputText,
isBusy,
isResuming,
onCancel,
}: {
readonly hasInputText: boolean;
readonly isBusy: boolean;
readonly isResuming: boolean;
readonly onCancel: () => void;
}) {
const attachments = usePromptInputAttachments();
const canSubmit = hasInputText || attachments.files.length > 0;
if (!isBusy || canSubmit) {
return <PromptInputSubmit disabled={isResuming} />;
}
return (
<PromptInputButton
aria-label="Stop"
className="absolute right-2.5 bottom-2.5"
onClick={onCancel}
variant="outline"
>
<SquareIcon className="size-3 fill-current" />
</PromptInputButton>
);
}
function ErrorMessage({ message }: { readonly message: string }) {
return (
<Message className="max-w-full" from="assistant">
<MessageContent>
<div
className="flex w-full items-start gap-3 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2.5 text-sm"
role="alert"
>
<AlertCircleIcon className="mt-0.5 size-4 shrink-0 text-destructive" />
<div>
<p className="font-medium">Request failed</p>
<p className="mt-0.5 text-muted-foreground">{message}</p>
</div>
</div>
</MessageContent>
</Message>
);
}
function ChatHeader({ canStartNewChat }: { readonly canStartNewChat: boolean }) {
return (
<header className="pointer-events-none fixed top-0 right-0 left-0 z-20 h-14">
<div className="relative mx-auto flex h-full w-full max-w-3xl items-center justify-center bg-background px-24">
<span className="truncate text-muted-foreground text-sm">{AGENT_NAME}</span>
{canStartNewChat ? (
<Button
aria-label="Start a new chat"
className="pointer-events-auto fixed top-3 right-6 pr-4"
onClick={() => window.location.assign("/s")}
size="sm"
type="button"
variant="ghost"
>
<PlusIcon className="size-4" />
<span className="hidden font-normal text-sm sm:inline">New chat</span>
</Button>
) : null}
</div>
</header>
);
}
function PendingThinking() {
return (
<Message aria-live="polite" from="assistant">
<MessageContent>
<div className="mb-4 flex w-full items-center gap-2 text-muted-foreground text-sm">
<BrainIcon className="size-4" />
<Shimmer duration={1}>Thinking</Shimmer>
</div>
</MessageContent>
</Message>
);
}
function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Unable to cancel the response.";
}
function getLatestTurnFailure(
events: ReturnType<typeof useEveAgent>["events"],
): string | undefined {
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index];
if (event.type === "turn.failed") {
return event.data.code === "MODEL_CALL_FAILED"
? "The model is temporarily unavailable. Please try again."
: event.data.message;
}
if (event.type === "turn.completed" || event.type === "turn.cancelled") {
return undefined;
}
if (event.type === "message.received") {
return undefined;
}
}
return undefined;
}
`,"app/_components/agent-message.tsx":`"use client";
import type {
EveAuthorizationPart,
EveDynamicToolPart,
EveMessage,
EveMessageInputRequest,
EveMessagePart,
} from "eve/react";
import { useState } from "react";
import {
ArrowRightIcon,
CheckCircleIcon,
CheckIcon,
ExternalLinkIcon,
FileIcon,
ImageIcon,
KeyRoundIcon,
XCircleIcon,
} from "lucide-react";
import { Message, MessageContent, MessageResponse } from "@/components/ai-elements/message";
import {
Question,
QuestionInput,
QuestionOption,
QuestionOptions,
QuestionPrompt,
type QuestionResponse,
QuestionSubmit,
type QuestionValue,
} from "@/components/ai-elements/question";
import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/ai-elements/reasoning";
import {
BashToolContent,
Tool,
ToolContent,
ToolHeader,
ToolInput,
ToolOutput,
} from "@/components/ai-elements/tool";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type AgentInputResponse = {
readonly optionId?: string;
readonly requestId: string;
readonly text?: string;
};
type EveFilePart = Extract<EveMessagePart, { type: "file" }>;
export function AgentMessage({
canRespond,
isStreaming,
message,
onInputResponses,
}: {
readonly canRespond: boolean;
readonly isStreaming: boolean;
readonly message: EveMessage;
readonly onInputResponses: (responses: readonly AgentInputResponse[]) => void | Promise<void>;
}) {
const lastTextIndex = message.parts.reduce(
(last, part, index) => (part.type === "text" ? index : last),
-1,
);
const hasAssistantText =
message.role === "assistant" &&
message.parts.some((part) => part.type === "text" && part.text.length > 0);
return (
<Message
data-optimistic={message.metadata?.optimistic ? "true" : undefined}
from={message.role}
>
<MessageContent>
{message.parts.map((part, index) =>
hasAssistantText && part.type === "reasoning" ? null : (
<AgentMessagePart
canRespond={canRespond}
key={partKey(part, index)}
onInputResponses={onInputResponses}
part={part}
showCaret={isStreaming && message.role === "assistant" && index === lastTextIndex}
/>
),
)}
</MessageContent>
</Message>
);
}
function AgentMessagePart({
canRespond,
onInputResponses,
part,
showCaret,
}: {
readonly canRespond: boolean;
readonly onInputResponses: (responses: readonly AgentInputResponse[]) => void | Promise<void>;
readonly part: EveMessagePart;
readonly showCaret: boolean;
}) {
switch (part.type) {
case "step-start":
return null;
case "text":
return (
<MessageResponse caret="block" isAnimating={showCaret}>
{part.text}
</MessageResponse>
);
case "reasoning":
return (
<Reasoning defaultOpen isStreaming={part.state === "streaming"}>
<ReasoningTrigger />
<ReasoningContent>{part.text}</ReasoningContent>
</Reasoning>
);
case "file":
return <AttachmentPart part={part} />;
case "authorization":
return <AuthorizationPrompt part={part} />;
case "dynamic-tool": {
const inputRequest = part.toolMetadata?.eve?.inputRequest;
if (inputRequest?.kind === "question") {
return (
<QuestionRequest
canRespond={canRespond}
inputRequest={inputRequest}
inputResponse={part.toolMetadata?.eve?.inputResponse}
onInputResponses={onInputResponses}
/>
);
}
return (
<Tool
defaultOpen={part.state === "approval-requested" || part.state === "approval-responded"}
>
<ToolHeader
state={part.state}
title={part.toolName}
toolName={part.toolName}
type="dynamic-tool"
/>
<ToolContent>
{part.toolName === "bash" ? (
<BashToolContent errorText={part.errorText} input={part.input} output={part.output} />
) : (
<ToolInput input={part.input} />
)}
<InputRequestActions
canRespond={canRespond}
part={part}
onInputResponses={onInputResponses}
/>
{part.toolName === "bash" ? null : (
<ToolOutput errorText={part.errorText} output={part.output} />
)}
</ToolContent>
</Tool>
);
}
}
}
function QuestionRequest({
canRespond,
inputRequest,
inputResponse,
onInputResponses,
}: {
readonly canRespond: boolean;
readonly inputRequest: EveMessageInputRequest;
readonly inputResponse?: AgentInputResponse;
readonly onInputResponses: (responses: readonly AgentInputResponse[]) => void | Promise<void>;
}) {
const hasOptions = (inputRequest.options?.length ?? 0) > 0;
const acceptsFreeform = inputRequest.allowFreeform === true || !hasOptions;
const [questionValue, setQuestionValue] = useState<QuestionValue>({
selectedValues: inputResponse?.optionId ? [inputResponse.optionId] : [],
text: inputResponse?.text ?? "",
});
const submitOption = (optionId: string) => {
setQuestionValue((value) => ({ ...value, selectedValues: [optionId] }));
return onInputResponses([
{
optionId,
requestId: inputRequest.requestId,
},
]);
};
const submitResponse = ({ selectedValues, text }: QuestionResponse) =>
onInputResponses([
{
optionId: selectedValues[0],
requestId: inputRequest.requestId,
text,
},
]);
return (
<Question
disabled={!canRespond || inputResponse !== undefined}
onSubmit={submitResponse}
onValueChange={setQuestionValue}
value={questionValue}
>
<QuestionPrompt>{inputRequest.prompt}</QuestionPrompt>
{hasOptions ? (
<QuestionOptions className="flex-col items-stretch" aria-label={inputRequest.prompt}>
{inputRequest.options?.map((option, index) => (
<QuestionOption
className="justify-start px-3 py-2 text-left"
key={option.id}
onClick={() => void submitOption(option.id)}
value={option.id}
>
<span className="min-w-0 flex-1">
<span className="block text-foreground text-sm leading-tight">{option.label}</span>
{option.description ? (
<span className="block text-sm text-muted-foreground leading-tight">
{option.description}
</span>
) : null}
</span>
{inputResponse === undefined ? (
<span aria-hidden="true" className="relative size-6 shrink-0">
<span className="absolute inset-0 flex items-center justify-center rounded-full bg-foreground/8 text-xs text-muted-foreground transition-opacity group-hover/option:opacity-0 group-focus-visible/option:opacity-0">
{index + 1}
</span>
<ArrowRightIcon className="absolute top-1/2 left-1/2 size-4 -translate-x-1/2 -translate-y-1/2 text-muted-foreground opacity-0 transition-[color,opacity] group-hover/option:text-foreground group-hover/option:opacity-100 group-focus-visible/option:opacity-100" />
</span>
) : (
<CheckIcon className="size-4 shrink-0 opacity-0 transition-opacity group-data-[state=checked]/option:opacity-100" />
)}
</QuestionOption>
))}
</QuestionOptions>
) : null}
{acceptsFreeform ? (
<div className="relative">
<QuestionInput
aria-label="Answer"
className={inputResponse === undefined ? "pr-12 pb-12" : undefined}
placeholder="Type your answer…"
/>
{inputResponse === undefined && questionValue.text.trim().length > 0 ? (
<QuestionSubmit
aria-label="Answer"
className="absolute right-2 bottom-2"
size="icon-sm"
>
<ArrowRightIcon />
</QuestionSubmit>
) : null}
</div>
) : null}
</Question>
);
}
function AttachmentPart({ part }: { readonly part: EveFilePart }) {
const label = part.filename ?? "Attachment";
const detail = [part.mediaType, formatBytes(part.size)].filter(Boolean).join(" - ");
const isImage = part.mediaType.startsWith("image/") && part.url !== undefined;
const Icon = isImage ? ImageIcon : FileIcon;
const body = (
<span className="flex max-w-sm items-center gap-3 rounded-md border bg-background/60 p-2 text-sm">
{isImage ? (
<img alt={label} className="size-12 shrink-0 rounded-sm object-cover" src={part.url} />
) : (
<span className="flex size-10 shrink-0 items-center justify-center rounded-sm bg-muted text-muted-foreground">
<Icon className="size-4" />
</span>
)}
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{label}</span>
{detail ? <span className="block truncate text-muted-foreground">{detail}</span> : null}
</span>
{part.url ? <ExternalLinkIcon className="size-4 shrink-0 text-muted-foreground" /> : null}
</span>
);
return part.url ? (
<a href={part.url} rel="noreferrer" target="_blank">
{body}
</a>
) : (
body
);
}
function AuthorizationPrompt({ part }: { readonly part: EveAuthorizationPart }) {
const isAuthorized = part.state === "completed" && part.outcome === "authorized";
const isCompleted = part.state === "completed";
const Icon = isAuthorized ? CheckCircleIcon : isCompleted ? XCircleIcon : KeyRoundIcon;
const instructions = part.authorization?.instructions;
const shouldShowInstructions = instructions !== undefined && instructions !== part.description;
return (
<div
className={cn(
"space-y-3 rounded-md border p-3",
isAuthorized
? "border-emerald-500/30 bg-emerald-500/5"
: isCompleted
? "border-destructive/30 bg-destructive/5"
: "border-blue-500/30 bg-blue-500/5",
)}
>
<div className="flex items-start gap-3">
<span
className={cn(
"mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full",
isAuthorized
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: isCompleted
? "bg-destructive/10 text-destructive"
: "bg-blue-500/10 text-blue-700 dark:text-blue-300",
)}
>
<Icon className="size-4" />
</span>
<div className="min-w-0 flex-1 space-y-2">
<p className="font-medium text-sm">{authorizationTitle(part)}</p>
<p className="text-muted-foreground text-sm">{authorizationDescription(part)}</p>
{shouldShowInstructions ? (
<p className="text-muted-foreground text-sm">{instructions}</p>
) : null}
{part.state === "required" && part.authorization?.userCode ? (
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted-foreground">Code</span>
<code className="rounded-md bg-background px-2 py-1 font-mono">
{part.authorization.userCode}
</code>
</div>
) : null}
{part.state === "required" && part.authorization?.url ? (
<Button asChild size="sm">
<a href={part.authorization.url} rel="noreferrer" target="_blank">
<ExternalLinkIcon className="size-4" />
Sign in with {part.displayName}
</a>
</Button>
) : null}
</div>
</div>
</div>
);
}
function authorizationTitle(part: EveAuthorizationPart): string {
if (part.state === "required") {
return \`Connect \${part.displayName}\`;
}
if (part.outcome === "authorized") {
return \`\${part.displayName} connected\`;
}
return \`\${part.displayName} authorization \${formatAuthorizationOutcome(part.outcome)}\`;
}
function authorizationDescription(part: EveAuthorizationPart): string {
if (part.state === "required") {
return part.description;
}
if (part.outcome === "authorized") {
return \`\${part.displayName} connected.\`;
}
const tail = part.reason !== undefined ? \` (\${part.reason})\` : "";
return \`\${part.displayName} authorization \${formatAuthorizationOutcome(part.outcome)}\${tail}.\`;
}
function formatAuthorizationOutcome(outcome: NonNullable<EveAuthorizationPart["outcome"]>): string {
switch (outcome) {
case "authorized":
return "authorized";
case "declined":
return "declined";
case "failed":
return "failed";
case "timed-out":
return "timed out";
}
}
function formatBytes(size: number | undefined): string | undefined {
if (size === undefined) {
return undefined;
}
if (size < 1024) {
return \`\${size} B\`;
}
if (size < 1024 * 1024) {
return \`\${(size / 1024).toFixed(1)} KB\`;
}
return \`\${(size / (1024 * 1024)).toFixed(1)} MB\`;
}
function InputRequestActions({
canRespond,
onInputResponses,
part,
}: {
readonly canRespond: boolean;
readonly onInputResponses: (responses: readonly AgentInputResponse[]) => void | Promise<void>;
readonly part: EveDynamicToolPart;
}) {
const inputRequest = part.toolMetadata?.eve?.inputRequest;
if (!inputRequest) {
return null;
}
const inputResponse = part.toolMetadata?.eve?.inputResponse;
const selectedOption = inputRequest.options?.find(
(option) => option.id === inputResponse?.optionId,
);
return (
<div className="space-y-3 rounded-md border border-yellow-500/30 bg-yellow-500/5 p-3">
<p className="text-muted-foreground text-sm">{inputRequest.prompt}</p>
{inputResponse ? (
<p className="font-medium text-sm">
Responded: {selectedOption?.label ?? inputResponse.text ?? inputResponse.optionId}
</p>
) : (
<div className="flex flex-wrap gap-2">
{inputRequest.options?.map((option) => (
<Button
disabled={!canRespond}
key={option.id}
onClick={() => {
void onInputResponses([
{
optionId: option.id,
requestId: inputRequest.requestId,
},
]);
}}
size="sm"
type="button"
variant={option.style === "danger" ? "destructive" : "default"}
>
{option.label}
</Button>
))}
</div>
)}
</div>
);
}
function partKey(part: EveMessagePart, index: number): string {
switch (part.type) {
case "authorization":
return \`authorization:\${part.turnId}:\${part.stepIndex}:\${part.name}\`;
case "dynamic-tool":
return part.toolCallId;
default:
return \`\${part.type}:\${index}\`;
}
}
`,"app/apple-icon.tsx":`import { ImageResponse } from "next/og";
export const size = {
width: 180,
height: 180,
};
export const contentType = "image/png";
export default function AppleIcon() {
return new ImageResponse(
<svg fill="none" viewBox="0 0 102 102" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0h102v102H0z" fill="#000" />
<path
d="M49.28 66.94 75.03 34.96h-6.89L47.91 60.11l-5.49 6.83h6.86ZM0 34.96h42.4v5.11H0zm0 13.32h27.66v5.11H0zm0 13.54h27.66v5.11H0zm69.63-26.86H102v5.11H69.63zm4.71 13.32H102v5.11H74.34zm0 13.54H102v5.11H74.34z"
fill="#fff"
/>
</svg>,
size,
);
}
`,"app/globals.css":`@import "tailwindcss";
@source "../node_modules/streamdown/dist/*.js";
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-sans: "Geist", "Geist Fallback", ui-sans-serif, system-ui, sans-serif;
--font-mono: "Geist Mono", "Geist Mono Fallback", ui-monospace, monospace;
}
:root {
color-scheme: light;
/* Soft neutral page with white elevated surfaces so cards/composer pop. */
--background: oklch(0.971 0 0);
--foreground: oklch(0.16 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.16 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.16 0 0);
--primary: oklch(0.19 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.94 0 0);
--secondary-foreground: oklch(0.19 0 0);
--muted: oklch(0.94 0 0);
--muted-foreground: oklch(0.6 0 0);
--accent: oklch(0.94 0 0);
--accent-foreground: oklch(0.19 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.916 0 0);
--input: oklch(0.916 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.625rem;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
}
}
* {
border-color: var(--border);
}
html {
height: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
min-height: 100%;
margin: 0;
background: var(--background);
font-family: var(--font-sans);
}
button,
input,
textarea {
font: inherit;
}
`,"app/icon.svg":`<svg width="102" height="102" viewBox="0 0 102 102" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="#000" d="M0 0h102v102H0z" />
<path
fill="#fff"
d="M49.28 66.94 75.03 34.96h-6.89L47.91 60.11l-5.49 6.83h6.86ZM0 34.96h42.4v5.11H0zm0 13.32h27.66v5.11H0zm0 13.54h27.66v5.11H0zm69.63-26.86H102v5.11H69.63zm4.71 13.32H102v5.11H74.34zm0 13.54H102v5.11H74.34z"
/>
</svg>
`,"app/layout.tsx":`import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import type { ReactNode } from "react";
import { TooltipProvider } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import "./globals.css";
const sans = Geist({
variable: "--font-sans",
subsets: ["latin"],
weight: "variable",
display: "swap",
});
const mono = Geist_Mono({
variable: "--font-mono",
subsets: ["latin"],
weight: "variable",
display: "swap",
});
export const metadata: Metadata = {
title: "__EVE_INIT_APP_NAME__",
description: "A Next.js starter for eve agents with AI Elements.",
};
export default function RootLayout({ children }: { readonly children: ReactNode }) {
return (
<html className={cn(sans.variable, mono.variable)} lang="en">
<body>
<TooltipProvider>{children}</TooltipProvider>
</body>
</html>
);
}
`,"app/page.tsx":`import { AgentChat } from "@/app/_components/agent-chat";
export default function Page() {
return <AgentChat />;
}
`,"app/s/[sessionId]/page.tsx":`import { AgentChat } from "@/app/_components/agent-chat";
export default async function SessionPage({
params,
}: {
readonly params: Promise<{ readonly sessionId: string }>;
}) {
const { sessionId } = await params;
return <AgentChat sessionId={sessionId} />;
}
`,"app/s/page.tsx":`import { AgentChat } from "@/app/_components/agent-chat";
export default function NewSessionPage() {
return <AgentChat sessionless />;
}
`,"components/ai-elements/chain-of-thought.tsx":`"use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import { Badge } from "@/components/ui/badge";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import type { LucideIcon } from "lucide-react";
import { BrainIcon, ChevronDownIcon, DotIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { createContext, memo, useContext, useMemo } from "react";
interface ChainOfThoughtContextValue {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
}
const ChainOfThoughtContext = createContext<ChainOfThoughtContextValue | null>(null);
const useChainOfThought = () => {
const context = useContext(ChainOfThoughtContext);
if (!context) {
throw new Error("ChainOfThought components must be used within ChainOfThought");
}
return context;
};
export type ChainOfThoughtProps = ComponentProps<"div"> & {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
};
export const ChainOfThought = memo(
({
className,
open,
defaultOpen = false,
onOpenChange,
children,
...props
}: ChainOfThoughtProps) => {
const [isOpen, setIsOpen] = useControllableState({
defaultProp: defaultOpen,
onChange: onOpenChange,
prop: open,
});
const chainOfThoughtContext = useMemo(() => ({ isOpen, setIsOpen }), [isOpen, setIsOpen]);
return (
<ChainOfThoughtContext.Provider value={chainOfThoughtContext}>
<div className={cn("not-prose w-full space-y-4", className)} {...props}>
{children}
</div>
</ChainOfThoughtContext.Provider>
);
},
);
export type ChainOfThoughtHeaderProps = ComponentProps<typeof CollapsibleTrigger>;
export const ChainOfThoughtHeader = memo(
({ className, children, ...props }: ChainOfThoughtHeaderProps) => {
const { isOpen, setIsOpen } = useChainOfThought();
return (
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
className,
)}
{...props}
>
<BrainIcon className="size-4" />
<span className="flex-1 text-left">{children ?? "Chain of Thought"}</span>
<ChevronDownIcon
className={cn("size-4 transition-transform", isOpen ? "rotate-180" : "rotate-0")}
/>
</CollapsibleTrigger>
</Collapsible>
);
},
);
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
icon?: LucideIcon;
label: ReactNode;
description?: ReactNode;
status?: "complete" | "active" | "pending";
};
const stepStatusStyles = {
active: "text-foreground",
complete: "text-muted-foreground",
pending: "text-muted-foreground/50",
};
export const ChainOfThoughtStep = memo(
({
className,
icon: Icon = DotIcon,
label,
description,
status = "complete",
children,
...props
}: ChainOfThoughtStepProps) => (
<div
className={cn(
"flex gap-2 text-sm",
stepStatusStyles[status],
"fade-in-0 slide-in-from-top-2 animate-in",
className,
)}
{...props}
>
<div className="relative mt-0.5">
<Icon className="size-4" />
<div className="absolute top-7 bottom-0 left-1/2 -mx-px w-px bg-border" />
</div>
<div className="flex-1 space-y-2 overflow-hidden">
<div>{label}</div>
{description && <div className="text-muted-foreground text-xs">{description}</div>}
{children}
</div>
</div>
),
);
export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">;
export const ChainOfThoughtSearchResults = memo(
({ className, ...props }: ChainOfThoughtSearchResultsProps) => (
<div className={cn("flex flex-wrap items-center gap-2", className)} {...props} />
),
);
export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>;
export const ChainOfThoughtSearchResult = memo(
({ className, children, ...props }: ChainOfThoughtSearchResultProps) => (
<Badge
className={cn("gap-1 px-2 py-0.5 font-normal text-xs", className)}
variant="secondary"
{...props}
>
{children}
</Badge>
),
);
export type ChainOfThoughtContentProps = ComponentProps<typeof CollapsibleContent>;
export const ChainOfThoughtContent = memo(
({ className, children, ...props }: ChainOfThoughtContentProps) => {
const { isOpen } = useChainOfThought();
return (
<Collapsible open={isOpen}>
<CollapsibleContent
className={cn(
"mt-2 space-y-3",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className,
)}
{...props}
>
{children}
</CollapsibleContent>
</Collapsible>
);
},
);
export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
caption?: string;
};
export const ChainOfThoughtImage = memo(
({ className, children, caption, ...props }: ChainOfThoughtImageProps) => (
<div className={cn("mt-2 space-y-2", className)} {...props}>
<div className="relative flex max-h-[22rem] items-center justify-center overflow-hidden rounded-lg bg-muted p-3">
{children}
</div>
{caption && <p className="text-muted-foreground text-xs">{caption}</p>}
</div>
),
);
ChainOfThought.displayName = "ChainOfThought";
ChainOfThoughtHeader.displayName = "ChainOfThoughtHeader";
ChainOfThoughtStep.displayName = "ChainOfThoughtStep";
ChainOfThoughtSearchResults.displayName = "ChainOfThoughtSearchResults";
ChainOfThoughtSearchResult.displayName = "ChainOfThoughtSearchResult";
ChainOfThoughtContent.displayName = "ChainOfThoughtContent";
ChainOfThoughtImage.displayName = "ChainOfThoughtImage";
`,"components/ai-elements/code-block.tsx":`"use client";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { CheckIcon, CopyIcon } from "lucide-react";
import type { ComponentProps, CSSProperties, HTMLAttributes } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type { BundledLanguage, BundledTheme, HighlighterGeneric, ThemedToken } from "shiki";
import { createHighlighter } from "shiki";
// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline
// oxlint-disable-next-line eslint(no-bitwise)
const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1;
// oxlint-disable-next-line eslint(no-bitwise)
const isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2;
const isUnderline = (fontStyle: number | undefined) =>
// oxlint-disable-next-line eslint(no-bitwise)
fontStyle && fontStyle & 4;
// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint
interface KeyedToken {
token: ThemedToken;
key: string;
}
interface KeyedLine {
tokens: KeyedToken[];
key: string;
}
const addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] =>
lines.map((line, lineIdx) => ({
key: \`line-\${lineIdx}\`,
tokens: line.map((token, tokenIdx) => ({
key: \`line-\${lineIdx}-\${tokenIdx}\`,
token,
})),
}));
// Token rendering component
const TokenSpan = ({ token }: { token: ThemedToken }) => (
<span
className="dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)]"
style={
{
backgroundColor: token.bgColor,
color: token.color,
fontStyle: isItalic(token.fontStyle) ? "italic" : undefined,
fontWeight: isBold(token.fontStyle) ? "bold" : undefined,
textDecoration: isUnderline(token.fontStyle) ? "underline" : undefined,
...token.htmlStyle,
} as CSSProperties
}
>
{token.content}
</span>
);
// Line number styles using CSS counters
const LINE_NUMBER_CLASSES = cn(
"block",
"before:content-[counter(line)]",
"before:inline-block",
"before:[counter-increment:line]",
"before:w-8",
"before:mr-4",
"before:text-right",
"before:text-muted-foreground/50",
"before:font-mono",
"before:select-none",
);
// Line rendering component
const LineSpan = ({
keyedLine,
showLineNumbers,
}: {
keyedLine: KeyedLine;
showLineNumbers: boolean;
}) => (
<span className={showLineNumbers ? LINE_NUMBER_CLASSES : "block"}>
{keyedLine.tokens.length === 0
? "\\n"
: keyedLine.tokens.map(({ token, key }) => <TokenSpan key={key} token={token} />)}
</span>
);
// Types
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: BundledLanguage;
showLineNumbers?: boolean;
};
interface TokenizedCode {
tokens: ThemedToken[][];
fg: string;
bg: string;
}
interface CodeBlockContextType {
code: string;
}
// Context
const CodeBlockContext = createContext<CodeBlockContextType>({
code: "",
});
// Highlighter cache (singleton per language)
const highlighterCache = new Map<
string,
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
>();
// Token cache
const tokensCache = new Map<string, TokenizedCode>();
// Subscribers for async token updates
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
const getTokensCacheKey = (code: string, language: BundledLanguage) => {
const start = code.slice(0, 100);
const end = code.length > 100 ? code.slice(-100) : "";
return \`\${language}:\${code.length}:\${start}:\${end}\`;
};
const getHighlighter = (
language: BundledLanguage,
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
const cached = highlighterCache.get(language);
if (cached) {
return cached;
}
const highlighterPromise = createHighlighter({
langs: [language],
themes: ["github-light", "github-dark"],
});
highlighterCache.set(language, highlighterPromise);
return highlighterPromise;
};
// Create raw tokens for immediate display while highlighting loads
const createRawTokens = (code: string): TokenizedCode => ({
bg: "transparent",
fg: "inherit",
tokens: code.split("\\n").map((line) =>
line === ""
? []
: [
{
color: "inherit",
content: line,
} as ThemedToken,
],
),
});
// Synchronous highlight with callback for async results
export const highlightCode = (
code: string,
language: BundledLanguage,
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
callback?: (result: TokenizedCode) => void,
): TokenizedCode | null => {
const tokensCacheKey = getTokensCacheKey(code, language);
// Return cached result if available
const cached = tokensCache.get(tokensCacheKey);
if (cached) {
return cached;
}
// Subscribe callback if provided
if (callback) {
if (!subscribers.has(tokensCacheKey)) {
subscribers.set(tokensCacheKey, new Set());
}
subscribers.get(tokensCacheKey)?.add(callback);
}
// Start highlighting in background - fire-and-forget async pattern
getHighlighter(language)
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
.then((highlighter) => {
const availableLangs = highlighter.getLoadedLanguages();
const langToUse = availableLangs.includes(language) ? language : "text";
const result = highlighter.codeToTokens(code, {
lang: langToUse,
themes: {
dark: "github-dark",
light: "github-light",
},
});
const tokenized: TokenizedCode = {
bg: result.bg ?? "transparent",
fg: result.fg ?? "inherit",
tokens: result.tokens,
};
// Cache the result
tokensCache.set(tokensCacheKey, tokenized);
// Notify all subscribers
const subs = subscribers.get(tokensCacheKey);
if (subs) {
for (const sub of subs) {
sub(tokenized);
}
subscribers.delete(tokensCacheKey);
}
})
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then), eslint-plugin-promise(prefer-await-to-callbacks)
.catch((error) => {
console.error("Failed to highlight code:", error);
subscribers.delete(tokensCacheKey);
});
return null;
};
const CodeBlockBody = memo(
({
tokenized,
showLineNumbers,
className,
}: {
tokenized: TokenizedCode;
showLineNumbers: boolean;
className?: string;
}) => {
const preStyle = useMemo(
() => ({
backgroundColor: tokenized.bg,
color: tokenized.fg,
}),
[tokenized.bg, tokenized.fg],
);
const keyedLines = useMemo(() => addKeysToTokens(tokenized.tokens), [tokenized.tokens]);
return (
<pre
className={cn(
"dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)] m-0 p-4 text-sm",
className,
)}
style={preStyle}
>
<code
className={cn(
"font-mono text-sm",
showLineNumbers && "[counter-increment:line_0] [counter-reset:line]",
)}
>
{keyedLines.map((keyedLine) => (
<LineSpan key={keyedLine.key} keyedLine={keyedLine} showLineNumbers={showLineNumbers} />
))}
</code>
</pre>
);
},
(prevProps, nextProps) =>
prevProps.tokenized === nextProps.tokenized &&
prevProps.showLineNumbers === nextProps.showLineNumbers &&
prevProps.className === nextProps.className,
);
CodeBlockBody.displayName = "CodeBlockBody";
export const CodeBlockContainer = ({
className,
language,
style,
...props
}: HTMLAttributes<HTMLDivElement> & { language: string }) => (
<div
className={cn(
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
className,
)}
data-language={language}
style={{
containIntrinsicSize: "auto 200px",
contentVisibility: "auto",
...style,
}}
{...props}
/>
);
export const CodeBlockHeader = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex items-center justify-between border-b bg-muted/80 px-3 py-2 text-muted-foreground text-xs",
className,
)}
{...props}
>
{children}
</div>
);
export const CodeBlockTitle = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex items-center gap-2", className)} {...props}>
{children}
</div>
);
export const CodeBlockFilename = ({
children,
className,
...props
}: HTMLAttributes<HTMLSpanElement>) => (
<span className={cn("font-mono", className)} {...props}>
{children}
</span>
);
export const CodeBlockActions = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div className={cn("-my-1 -mr-1 flex items-center gap-2", className)} {...props}>
{children}
</div>
);
export const CodeBlockContent = ({
code,
language,
showLineNumbers = false,
}: {
code: string;
language: BundledLanguage;
showLineNumbers?: boolean;
}) => {
// Memoized raw tokens for immediate display
const rawTokens = useMemo(() => createRawTokens(code), [code]);
// Synchronous cache lookup — avoids setState in effect for cached results
const syncTokens = useMemo(
() => highlightCode(code, language) ?? rawTokens,
[code, language, rawTokens],
);
// Async highlighting result (populated after shiki loads)
const [asyncTokens, setAsyncTokens] = useState<TokenizedCode | null>(null);
const asyncKeyRef = useRef({ code, language });
// Invalidate stale async tokens synchronously during render
if (asyncKeyRef.current.code !== code || asyncKeyRef.current.language !== language) {
asyncKeyRef.current = { code, language };
setAsyncTokens(null);
}
useEffect(() => {
let cancelled = false;
highlightCode(code, language, (result) => {
if (!cancelled) {
setAsyncTokens(result);
}
});
return () => {
cancelled = true;
};
}, [code, language]);
const tokenized = asyncTokens ?? syncTokens;
return (
<div className="relative overflow-auto">
<CodeBlockBody showLineNumbers={showLineNumbers} tokenized={tokenized} />
</div>
);
};
export const CodeBlock = ({
code,
language,
showLineNumbers = false,
className,
children,
...props
}: CodeBlockProps) => {
const contextValue = useMemo(() => ({ code }), [code]);
return (
<CodeBlockContext.Provider value={contextValue}>
<CodeBlockContainer className={className} language={language} {...props}>
{children}
<CodeBlockContent code={code} language={language} showLineNumbers={showLineNumbers} />
</CodeBlockContainer>
</CodeBlockContext.Provider>
);
};
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
};
export const CodeBlockCopyButton = ({
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: CodeBlockCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<number>(0);
const { code } = useContext(CodeBlockContext);
const copyToClipboard = useCallback(async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try {
if (!isCopied) {
await navigator.clipboard.writeText(code);
setIsCopied(true);
onCopy?.();
timeoutRef.current = window.setTimeout(() => setIsCopied(false), timeout);
}
} catch (error) {
onError?.(error as Error);
}
}, [code, onCopy, onError, timeout, isCopied]);
useEffect(
() => () => {
window.clearTimeout(timeoutRef.current);
},
[],
);
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
<Button
className={cn("shrink-0", className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
);
};
export type CodeBlockLanguageSelectorProps = ComponentProps<typeof Select>;
export const CodeBlockLanguageSelector = (props: CodeBlockLanguageSelectorProps) => (
<Select {...props} />
);
export type CodeBlockLanguageSelectorTriggerProps = ComponentProps<typeof SelectTrigger>;
export const CodeBlockLanguageSelectorTrigger = ({
className,
...props
}: CodeBlockLanguageSelectorTriggerProps) => (
<SelectTrigger
className={cn("h-7 border-none bg-transparent px-2 text-xs shadow-none", className)}
size="sm"
{...props}
/>
);
export type CodeBlockLanguageSelectorValueProps = ComponentProps<typeof SelectValue>;
export const CodeBlockLanguageSelectorValue = (props: CodeBlockLanguageSelectorValueProps) => (
<SelectValue {...props} />
);
export type CodeBlockLanguageSelectorContentProps = ComponentProps<typeof SelectContent>;
export const CodeBlockLanguageSelectorContent = ({
align = "end",
...props
}: CodeBlockLanguageSelectorContentProps) => <SelectContent align={align} {...props} />;
export type CodeBlockLanguageSelectorItemProps = ComponentProps<typeof SelectItem>;
export const CodeBlockLanguageSelectorItem = (props: CodeBlockLanguageSelectorItemProps) => (
<SelectItem {...props} />
);
`,"components/ai-elements/conversation.tsx":`"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { UIMessage } from "ai";
import { ArrowDownIcon, DownloadIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
export type ConversationProps = ComponentProps<typeof StickToBottom> & {
scrollRestorationKey?: string;
};
export const Conversation = ({
children,
className,
initial,
scrollRestorationKey,
...props
}: ConversationProps) => (
<StickToBottom
className={cn("relative flex-1 overflow-y-hidden", className)}
initial={initial ?? (scrollRestorationKey === undefined ? "smooth" : false)}
resize="smooth"
role="log"
{...props}
>
{typeof children === "function" ? (
(context) => (
<>
{children(context)}
{scrollRestorationKey === undefined ? null : (
<ConversationScrollRestoration storageKey={scrollRestorationKey} />
)}
</>
)
) : (
<>
{children}
{scrollRestorationKey === undefined ? null : (
<ConversationScrollRestoration storageKey={scrollRestorationKey} />
)}
</>
)}
</StickToBottom>
);
function ConversationScrollRestoration({ storageKey }: { readonly storageKey: string }) {
const { scrollRef, scrollToBottom, state } = useStickToBottomContext();
const restoredKeyRef = useRef<string | undefined>(undefined);
useLayoutEffect(() => {
const scrollElement = scrollRef.current;
if (scrollElement === null) return;
if (restoredKeyRef.current !== storageKey) {
const saved = readScrollPosition(sessionStorage.getItem(storageKey));
if (saved?.atBottom === false) {
scrollElement.scrollTop = saved.scrollTop;
requestAnimationFrame(() => {
scrollElement.scrollTop = saved.scrollTop;
});
} else {
scrollElement.scrollTop = scrollElement.scrollHeight;
scrollToBottom({ animation: "instant", ignoreEscapes: true });
}
restoredKeyRef.current = storageKey;
}
const saveNow = () => {
sessionStorage.setItem(
storageKey,
JSON.stringify({
atBottom: state.isAtBottom || state.isNearBottom,
scrollTop: scrollElement.scrollTop,
}),
);
};
let frame: number | undefined;
const scheduleSave = () => {
if (frame !== undefined) return;
frame = requestAnimationFrame(() => {
frame = undefined;
saveNow();
});
};
scrollElement.addEventListener("scroll", scheduleSave, { passive: true });
window.addEventListener("pagehide", saveNow);
return () => {
scrollElement.removeEventListener("scroll", scheduleSave);
window.removeEventListener("pagehide", saveNow);
if (frame !== undefined) cancelAnimationFrame(frame);
saveNow();
};
}, [scrollRef, scrollToBottom, state, storageKey]);
return null;
}
function readScrollPosition(value: string | null):
| {
readonly atBottom: boolean;
readonly scrollTop: number;
}
| undefined {
if (value === null) return undefined;
try {
const parsed = JSON.parse(value) as { atBottom?: unknown; scrollTop?: unknown };
return typeof parsed.atBottom === "boolean" && typeof parsed.scrollTop === "number"
? { atBottom: parsed.atBottom, scrollTop: parsed.scrollTop }
: undefined;
} catch {
return undefined;
}
}
export type ConversationContentProps = ComponentProps<typeof StickToBottom.Content>;
export const ConversationContent = ({ className, ...props }: ConversationContentProps) => (
<StickToBottom.Content className={cn("flex flex-col gap-8 p-4", className)} {...props} />
);
export type ConversationTopFadeProps = ComponentProps<"div">;
export const ConversationTopFade = ({ className, ...props }: ConversationTopFadeProps) => {
const { contentRef, scrollRef } = useStickToBottomContext();
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const scrollElement = scrollRef.current;
if (scrollElement === null) return;
const updateVisibility = () => {
setIsVisible(scrollElement.scrollTop > 0);
};
updateVisibility();
scrollElement.addEventListener("scroll", updateVisibility, { passive: true });
const resizeObserver = new ResizeObserver(updateVisibility);
resizeObserver.observe(scrollElement);
if (contentRef.current !== null) {
resizeObserver.observe(contentRef.current);
}
return () => {
scrollElement.removeEventListener("scroll", updateVisibility);
resizeObserver.disconnect();
};
}, [contentRef, scrollRef]);
return (
<div
{...props}
aria-hidden
className={cn(
"pointer-events-none absolute inset-x-0 top-0 z-10 h-6 bg-linear-to-b from-background via-background/80 to-transparent transition-opacity duration-150",
isVisible ? "opacity-100" : "opacity-0",
className,
)}
data-slot="conversation-top-fade"
/>
);
};
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
title?: string;
description?: string;
icon?: React.ReactNode;
};
export const ConversationEmptyState = ({
className,
title = "No messages yet",
description = "Start a conversation to see messages here",
icon,
children,
...props
}: ConversationEmptyStateProps) => (
<div
className={cn(
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
className,
)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<h3 className="font-medium text-sm">{title}</h3>
{description && <p className="text-muted-foreground text-sm">{description}</p>}
</div>
</>
)}
</div>
);
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const [isReady, setIsReady] = useState(false);
useEffect(() => setIsReady(true), []);
const handleScrollToBottom = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
return (
isReady &&
!isAtBottom && (
<Button
aria-label="Scroll to bottom"
className={cn(
"absolute bottom-32 left-[50%] translate-x-[-50%] rounded-full dark:bg-background dark:hover:bg-muted",
className,
)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
);
};
const getMessageText = (message: UIMessage): string =>
message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("");
export type ConversationDownloadProps = Omit<ComponentProps<typeof Button>, "onClick"> & {
messages: UIMessage[];
filename?: string;
formatMessage?: (message: UIMessage, index: number) => string;
};
const defaultFormatMessage = (message: UIMessage): string => {
const roleLabel = message.role.charAt(0).toUpperCase() + message.role.slice(1);
return \`**\${roleLabel}:** \${getMessageText(message)}\`;
};
export const messagesToMarkdown = (
messages: UIMessage[],
formatMessage: (message: UIMessage, index: number) => string = defaultFormatMessage,
): string => messages.map((msg, i) => formatMessage(msg, i)).join("\\n\\n");
export const ConversationDownload = ({
messages,
filename = "conversation.md",
formatMessage = defaultFormatMessage,
className,
children,
...props
}: ConversationDownloadProps) => {
const handleDownload = useCallback(() => {
const markdown = messagesToMarkdown(messages, formatMessage);
const blob = new Blob([markdown], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}, [messages, filename, formatMessage]);
return (
<Button
className={cn(
"absolute top-4 right-4 rounded-full dark:bg-background dark:hover:bg-muted",
className,
)}
onClick={handleDownload}
size="icon"
type="button"
variant="outline"
{...props}
>
{children ?? <DownloadIcon className="size-4" />}
</Button>
);
};
`,"components/ai-elements/message.tsx":`"use client";
import { Button } from "@/components/ui/button";
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import type { UIMessage } from "ai";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Streamdown } from "streamdown";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
"group flex w-full max-w-[95%] flex-col gap-2",
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
className,
)}
{...props}
/>
);
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({ children, className, ...props }: MessageContentProps) => (
<div
className={cn(
"is-user:dark flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
"group-[.is-user]:ml-auto group-[.is-user]:rounded-2xl group-[.is-user]:bg-primary group-[.is-user]:px-4 group-[.is-user]:py-2.5 group-[.is-user]:text-primary-foreground",
"group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground",
"group-data-[optimistic=true]:opacity-70",
className,
)}
{...props}
>
{children}
</div>
);
export type MessageActionsProps = ComponentProps<"div">;
export const MessageActions = ({ className, children, ...props }: MessageActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
);
export type MessageActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};
export const MessageAction = ({
tooltip,
children,
label,
variant = "ghost",
size = "icon-sm",
...props
}: MessageActionProps) => {
const button = (
<Button size={size} type="button" variant={variant} {...props}>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};
interface MessageBranchContextType {
currentBranch: number;
totalBranches: number;
goToPrevious: () => void;
goToNext: () => void;
branches: ReactElement[];
setBranches: (branches: ReactElement[]) => void;
}
const MessageBranchContext = createContext<MessageBranchContextType | null>(null);
const useMessageBranch = () => {
const context = useContext(MessageBranchContext);
if (!context) {
throw new Error("MessageBranch components must be used within MessageBranch");
}
return context;
};
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
defaultBranch?: number;
onBranchChange?: (branchIndex: number) => void;
};
export const MessageBranch = ({
defaultBranch = 0,
onBranchChange,
className,
...props
}: MessageBranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]);
const handleBranchChange = useCallback(
(newBranch: number) => {
setCurrentBranch(newBranch);
onBranchChange?.(newBranch);
},
[onBranchChange],
);
const goToPrevious = useCallback(() => {
const newBranch = currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
handleBranchChange(newBranch);
}, [currentBranch, branches.length, handleBranchChange]);
const goToNext = useCallback(() => {
const newBranch = currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
handleBranchChange(newBranch);
}, [currentBranch, branches.length, handleBranchChange]);
const contextValue = useMemo<MessageBranchContextType>(
() => ({
branches,
currentBranch,
goToNext,
goToPrevious,
setBranches,
totalBranches: branches.length,
}),
[branches, currentBranch, goToNext, goToPrevious],
);
return (
<MessageBranchContext.Provider value={contextValue}>
<div className={cn("grid w-full gap-2 [&>div]:pb-0", className)} {...props} />
</MessageBranchContext.Provider>
);
};
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageBranchContent = ({ children, ...props }: MessageBranchContentProps) => {
const { currentBranch, setBranches, branches } = useMessageBranch();
const childrenArray = useMemo(
() => (Array.isArray(children) ? children : [children]),
[children],
);
// Use useEffect to update branches when they change
useEffect(() => {
if (branches.length !== childrenArray.length) {
setBranches(childrenArray);
}
}, [childrenArray, branches, setBranches]);
return childrenArray.map((branch, index) => (
<div
className={cn(
"grid gap-2 overflow-hidden [&>div]:pb-0",
index === currentBranch ? "block" : "hidden",
)}
key={branch.key}
{...props}
>
{branch}
</div>
));
};
export type MessageBranchSelectorProps = ComponentProps<typeof ButtonGroup>;
export const MessageBranchSelector = ({ className, ...props }: MessageBranchSelectorProps) => {
const { totalBranches } = useMessageBranch();
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null;
}
return (
<ButtonGroup
className={cn(
"[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md",
className,
)}
orientation="horizontal"
{...props}
/>
);
};
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
export const MessageBranchPrevious = ({ children, ...props }: MessageBranchPreviousProps) => {
const { goToPrevious, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Previous branch"
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
);
};
export type MessageBranchNextProps = ComponentProps<typeof Button>;
export const MessageBranchNext = ({ children, ...props }: MessageBranchNextProps) => {
const { goToNext, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Next branch"
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
);
};
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
export const MessageBranchPage = ({ className, ...props }: MessageBranchPageProps) => {
const { currentBranch, totalBranches } = useMessageBranch();
return (
<ButtonGroupText
className={cn("border-none bg-transparent text-muted-foreground shadow-none", className)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</ButtonGroupText>
);
};
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
const streamdownPlugins = { cjk, code, math, mermaid };
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
className={cn("size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0", className)}
plugins={streamdownPlugins}
{...props}
/>
),
(prevProps, nextProps) =>
prevProps.children === nextProps.children && nextProps.isAnimating === prevProps.isAnimating,
);
MessageResponse.displayName = "MessageResponse";
export type MessageToolbarProps = ComponentProps<"div">;
export const MessageToolbar = ({ className, children, ...props }: MessageToolbarProps) => (
<div className={cn("mt-4 flex w-full items-center justify-between gap-4", className)} {...props}>
{children}
</div>
);
`,"components/ai-elements/prompt-input.tsx":`"use client";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupTextarea,
} from "@/components/ui/input-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { ChatStatus, FileUIPart, SourceDocumentUIPart } from "ai";
import { ArrowUpIcon, ImageIcon, Monitor, PlusIcon, XIcon } from "lucide-react";
import { nanoid } from "nanoid";
import type {
ChangeEvent,
ChangeEventHandler,
ClipboardEventHandler,
ComponentProps,
FormEvent,
FormEventHandler,
HTMLAttributes,
KeyboardEventHandler,
PropsWithChildren,
ReactNode,
RefObject,
} from "react";
import {
Children,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
// ============================================================================
// Helpers
// ============================================================================
const convertBlobUrlToDataUrl = async (url: string): Promise<string | null> => {
try {
const response = await fetch(url);
const blob = await response.blob();
// FileReader uses callback-based API, wrapping in Promise is necessary
// oxlint-disable-next-line eslint-plugin-promise(avoid-new)
return new Promise((resolve) => {
const reader = new FileReader();
// oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
reader.onloadend = () => resolve(reader.result as string);
// oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
reader.onerror = () => resolve(null);
reader.readAsDataURL(blob);
});
} catch {
return null;
}
};
const captureScreenshot = async (): Promise<File | null> => {
if (typeof navigator === "undefined" || !navigator.mediaDevices?.getDisplayMedia) {
return null;
}
let stream: MediaStream | null = null;
const video = document.createElement("video");
video.muted = true;
video.playsInline = true;
try {
stream = await navigator.mediaDevices.getDisplayMedia({
audio: false,
video: true,
});
video.srcObject = stream;
// Video element uses callback-based API, wrapping in Promise is necessary
// oxlint-disable-next-line eslint-plugin-promise(avoid-new)
await new Promise<void>((resolve, reject) => {
// oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
video.onloadedmetadata = () => resolve();
// oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
video.onerror = () => reject(new Error("Failed to load screen stream"));
});
await video.play();
const width = video.videoWidth;
const height = video.videoHeight;
if (!width || !height) {
return null;
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) {
return null;
}
context.drawImage(video, 0, 0, width, height);
// canvas.toBlob uses callback-based API, wrapping in Promise is necessary
// oxlint-disable-next-line eslint-plugin-promise(avoid-new)
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, "image/png");
});
if (!blob) {
return null;
}
const timestamp = new Date()
.toISOString()
.replaceAll(/[:.]/g, "-")
.replace("T", "_")
.replace("Z", "");
return new File([blob], \`screenshot-\${timestamp}.png\`, {
lastModified: Date.now(),
type: "image/png",
});
} finally {
if (stream) {
for (const track of stream.getTracks()) {
track.stop();
}
}
video.pause();
video.srcObject = null;
}
};
// ============================================================================
// Provider Context & Types
// ============================================================================
export interface AttachmentsContext {
files: (FileUIPart & { id: string })[];
add: (files: File[] | FileList) => void;
remove: (id: string) => void;
clear: () => void;
openFileDialog: () => void;
fileInputRef: RefObject<HTMLInputElement | null>;
}
export interface TextInputContext {
value: string;
setInput: (v: string) => void;
clear: () => void;
}
export interface PromptInputControllerProps {
textInput: TextInputContext;
attachments: AttachmentsContext;
/** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */
__registerFileInput: (ref: RefObject<HTMLInputElement | null>, open: () => void) => void;
}
const PromptInputController = createContext<PromptInputControllerProps | null>(null);
const ProviderAttachmentsContext = createContext<AttachmentsContext | null>(null);
export const usePromptInputController = () => {
const ctx = useContext(PromptInputController);
if (!ctx) {
throw new Error(
"Wrap your component inside <PromptInputProvider> to use usePromptInputController().",
);
}
return ctx;
};
// Optional variants (do NOT throw). Useful for dual-mode components.
const useOptionalPromptInputController = () => useContext(PromptInputController);
export const useProviderAttachments = () => {
const ctx = useContext(ProviderAttachmentsContext);
if (!ctx) {
throw new Error(
"Wrap your component inside <PromptInputProvider> to use useProviderAttachments().",
);
}
return ctx;
};
const useOptionalProviderAttachments = () => useContext(ProviderAttachmentsContext);
export type PromptInputProviderProps = PropsWithChildren<{
initialInput?: string;
}>;
/**
* Optional global provider that lifts PromptInput state outside of PromptInput.
* If you don't use it, PromptInput stays fully self-managed.
*/
export const PromptInputProvider = ({
initialInput: initialTextInput = "",
children,
}: PromptInputProviderProps) => {
// ----- textInput state
const [textInput, setTextInput] = useState(initialTextInput);
const clearInput = useCallback(() => setTextInput(""), []);
// ----- attachments state (global when wrapped)
const [attachmentFiles, setAttachmentFiles] = useState<(FileUIPart & { id: string })[]>([]);
const fileInputRef = useRef<HTMLInputElement | null>(null);
// oxlint-disable-next-line eslint(no-empty-function)
const openRef = useRef<() => void>(() => {});
const add = useCallback((files: File[] | FileList) => {
const incoming = [...files];
if (incoming.length === 0) {
return;
}
setAttachmentFiles((prev) => [
...prev,
...incoming.map((file) => ({
filename: file.name,
id: nanoid(),
mediaType: file.type,
type: "file" as const,
url: URL.createObjectURL(file),
})),
]);
}, []);
const remove = useCallback((id: string) => {
setAttachmentFiles((prev) => {
const found = prev.find((f) => f.id === id);
if (found?.url) {
URL.revokeObjectURL(found.url);
}
return prev.filter((f) => f.id !== id);
});
}, []);
const clear = useCallback(() => {
setAttachmentFiles((prev) => {
for (const f of prev) {
if (f.url) {
URL.revokeObjectURL(f.url);
}
}
return [];
});
}, []);
// Keep a ref to attachments for cleanup on unmount (avoids stale closure)
const attachmentsRef = useRef(attachmentFiles);
useEffect(() => {
attachmentsRef.current = attachmentFiles;
}, [attachmentFiles]);
// Cleanup blob URLs on unmount to prevent memory leaks
useEffect(
() => () => {
for (const f of attachmentsRef.current) {
if (f.url) {
URL.revokeObjectURL(f.url);
}
}
},
[],
);
const openFileDialog = useCallback(() => {
openRef.current?.();
}, []);
const attachments = useMemo<AttachmentsContext>(
() => ({
add,
clear,
fileInputRef,
files: attachmentFiles,
openFileDialog,
remove,
}),
[attachmentFiles, add, remove, clear, openFileDialog],
);
const __registerFileInput = useCallback(
(ref: RefObject<HTMLInputElement | null>, open: () => void) => {
fileInputRef.current = ref.current;
openRef.current = open;
},
[],
);
const controller = useMemo<PromptInputControllerProps>(
() => ({
__registerFileInput,
attachments,
textInput: {
clear: clearInput,
setInput: setTextInput,
value: textInput,
},
}),
[textInput, clearInput, attachments, __registerFileInput],
);
return (
<PromptInputController.Provider value={controller}>
<ProviderAttachmentsContext.Provider value={attachments}>
{children}
</ProviderAttachmentsContext.Provider>
</PromptInputController.Provider>
);
};
// ============================================================================
// Component Context & Hooks
// ============================================================================
const LocalAttachmentsContext = createContext<AttachmentsContext | null>(null);
export const usePromptInputAttachments = () => {
// Prefer local context (inside PromptInput) as it has validation, fall back to provider
const provider = useOptionalProviderAttachments();
const local = useContext(LocalAttachmentsContext);
const context = local ?? provider;
if (!context) {
throw new Error(
"usePromptInputAttachments must be used within a PromptInput or PromptInputProvider",
);
}
return context;
};
// ============================================================================
// Referenced Sources (Local to PromptInput)
// ============================================================================
export interface ReferencedSourcesContext {
sources: (SourceDocumentUIPart & { id: string })[];
add: (sources: SourceDocumentUIPart[] | SourceDocumentUIPart) => void;
remove: (id: string) => void;
clear: () => void;
}
export const LocalReferencedSourcesContext = createContext<ReferencedSourcesContext | null>(null);
export const usePromptInputReferencedSources = () => {
const ctx = useContext(LocalReferencedSourcesContext);
if (!ctx) {
throw new Error(
"usePromptInputReferencedSources must be used within a LocalReferencedSourcesContext.Provider",
);
}
return ctx;
};
export type PromptInputActionAddAttachmentsProps = ComponentProps<typeof DropdownMenuItem> & {
label?: string;
};
export const PromptInputActionAddAttachments = ({
label = "Add photos or files",
...props
}: PromptInputActionAddAttachmentsProps) => {
const attachments = usePromptInputAttachments();
const handleSelect = useCallback(
(e: Event) => {
e.preventDefault();
attachments.openFileDialog();
},
[attachments],
);
return (
<DropdownMenuItem {...props} onSelect={handleSelect}>
<ImageIcon className="mr-2 size-4" /> {label}
</DropdownMenuItem>
);
};
export type PromptInputActionAddScreenshotProps = ComponentProps<typeof DropdownMenuItem> & {
label?: string;
};
export const PromptInputActionAddScreenshot = ({
label = "Take screenshot",
onSelect,
...props
}: PromptInputActionAddScreenshotProps) => {
const attachments = usePromptInputAttachments();
const handleSelect = useCallback(
async (event: Event) => {
onSelect?.(event);
if (event.defaultPrevented) {
return;
}
try {
const screenshot = await captureScreenshot();
if (screenshot) {
attachments.add([screenshot]);
}
} catch (error) {
if (
error instanceof DOMException &&
(error.name === "NotAllowedError" || error.name === "AbortError")
) {
return;
}
throw error;
}
},
[onSelect, attachments],
);
return (
<DropdownMenuItem {...props} onSelect={handleSelect}>
<Monitor className="mr-2 size-4" />
{label}
</DropdownMenuItem>
);
};
export interface PromptInputMessage {
text: string;
files: FileUIPart[];
}
export type PromptInputProps = Omit<HTMLAttributes<HTMLFormElement>, "onSubmit" | "onError"> & {
// e.g., "image/*" or leave undefined for any
accept?: string;
multiple?: boolean;
// When true, accepts drops anywhere on document. Default false (opt-in).
globalDrop?: boolean;
// Render a hidden input with given name and keep it in sync for native form posts. Default false.
syncHiddenInput?: boolean;
// Minimal constraints
maxFiles?: number;
// bytes
maxFileSize?: number;
onError?: (err: { code: "max_files" | "max_file_size" | "accept"; message: string }) => void;
onSubmit: (
message: PromptInputMessage,
event: FormEvent<HTMLFormElement>,
) => void | Promise<void>;
};
export const PromptInput = ({
className,
accept,
multiple,
globalDrop,
syncHiddenInput,
maxFiles,
maxFileSize,
onError,
onSubmit,
children,
...props
}: PromptInputProps) => {
// Try to use a provider controller if present
const controller = useOptionalPromptInputController();
const usingProvider = !!controller;
// Refs
const inputRef = useRef<HTMLInputElement | null>(null);
const formRef = useRef<HTMLFormElement | null>(null);
// ----- Local attachments (only used when no provider)
const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);
const files = usingProvider ? controller.attachments.files : items;
// ----- Local referenced sources (always local to PromptInput)
const [referencedSources, setReferencedSources] = useState<
(SourceDocumentUIPart & { id: string })[]
>([]);
// Keep a ref to files for cleanup on unmount (avoids stale closure)
const filesRef = useRef(files);
useEffect(() => {
filesRef.current = files;
}, [files]);
const openFileDialogLocal = useCallback(() => {
inputRef.current?.click();
}, []);
const matchesAccept = useCallback(
(f: File) => {
if (!accept || accept.trim() === "") {
return true;
}
const patterns = accept
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return patterns.some((pattern) => {
if (pattern.endsWith("/*")) {
// e.g: image/* -> image/
const prefix = pattern.slice(0, -1);
return f.type.startsWith(prefix);
}
return f.type === pattern;
});
},
[accept],
);
const addLocal = useCallback(
(fileList: File[] | FileList) => {
const incoming = [...fileList];
const accepted = incoming.filter((f) => matchesAccept(f));
if (incoming.length && accepted.length === 0) {
onError?.({
code: "accept",
message: "No files match the accepted types.",
});
return;
}
const withinSize = (f: File) => (maxFileSize ? f.size <= maxFileSize : true);
const sized = accepted.filter(withinSize);
if (accepted.length > 0 && sized.length === 0) {
onError?.({
code: "max_file_size",
message: "All files exceed the maximum size.",
});
return;
}
setItems((prev) => {
const capacity =
typeof maxFiles === "number" ? Math.max(0, maxFiles - prev.length) : undefined;
const capped = typeof capacity === "number" ? sized.slice(0, capacity) : sized;
if (typeof capacity === "number" && sized.length > capacity) {
onError?.({
code: "max_files",
message: "Too many files. Some were not added.",
});
}
const next: (FileUIPart & { id: string })[] = [];
for (const file of capped) {
next.push({
filename: file.name,
id: nanoid(),
mediaType: file.type,
type: "file",
url: URL.createObjectURL(file),
});
}
return [...prev, ...next];
});
},
[matchesAccept, maxFiles, maxFileSize, onError],
);
const removeLocal = useCallback(
(id: string) =>
setItems((prev) => {
const found = prev.find((file) => file.id === id);
if (found?.url) {
URL.revokeObjectURL(found.url);
}
return prev.filter((file) => file.id !== id);
}),
[],
);
// Wrapper that validates files before calling provider's add
const addWithProviderValidation = useCallback(
(fileList: File[] | FileList) => {
const incoming = [...fileList];
const accepted = incoming.filter((f) => matchesAccept(f));
if (incoming.length && accepted.length === 0) {
onError?.({
code: "accept",
message: "No files match the accepted types.",
});
return;
}
const withinSize = (f: File) => (maxFileSize ? f.size <= maxFileSize : true);
const sized = accepted.filter(withinSize);
if (accepted.length > 0 && sized.length === 0) {
onError?.({
code: "max_file_size",
message: "All files exceed the maximum size.",
});
return;
}
const currentCount = files.length;
const capacity =
typeof maxFiles === "number" ? Math.max(0, maxFiles - currentCount) : undefined;
const capped = typeof capacity === "number" ? sized.slice(0, capacity) : sized;
if (typeof capacity === "number" && sized.length > capacity) {
onError?.({
code: "max_files",
message: "Too many files. Some were not added.",
});
}
if (capped.length > 0) {
controller?.attachments.add(capped);
}
},
[matchesAccept, maxFileSize, maxFiles, onError, files.length, controller],
);
const clearAttachments = useCallback(
() =>
usingProvider
? controller?.attachments.clear()
: setItems((prev) => {
for (const file of prev) {
if (file.url) {
URL.revokeObjectURL(file.url);
}
}
return [];
}),
[usingProvider, controller],
);
const clearReferencedSources = useCallback(() => setReferencedSources([]), []);
const add = usingProvider ? addWithProviderValidation : addLocal;
const remove = usingProvider ? controller.attachments.remove : removeLocal;
const openFileDialog = usingProvider
? controller.attachments.openFileDialog
: openFileDialogLocal;
const clear = useCallback(() => {
clearAttachments();
clearReferencedSources();
}, [clearAttachments, clearReferencedSources]);
// Let provider know about our hidden file input so external menus can call openFileDialog()
useEffect(() => {
if (!usingProvider) {
return;
}
controller.__registerFileInput(inputRef, () => inputRef.current?.click());
}, [usingProvider, controller]);
// Note: File input cannot be programmatically set for security reasons
// The syncHiddenInput prop is no longer functional
useEffect(() => {
if (syncHiddenInput && inputRef.current && files.length === 0) {
inputRef.current.value = "";
}
}, [files, syncHiddenInput]);
// Attach drop handlers on nearest form and document (opt-in)
useEffect(() => {
const form = formRef.current;
if (!form) {
return;
}
if (globalDrop) {
// when global drop is on, let the document-level handler own drops
return;
}
const onDragOver = (e: DragEvent) => {
if (e.dataTransfer?.types?.includes("Files")) {
e.preventDefault();
}
};
const onDrop = (e: DragEvent) => {
if (e.dataTransfer?.types?.includes("Files")) {
e.preventDefault();
}
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
add(e.dataTransfer.files);
}
};
form.addEventListener("dragover", onDragOver);
form.addEventListener("drop", onDrop);
return () => {
form.removeEventListener("dragover", onDragOver);
form.removeEventListener("drop", onDrop);
};
}, [add, globalDrop]);
useEffect(() => {
if (!globalDrop) {
return;
}
const onDragOver = (e: DragEvent) => {
if (e.dataTransfer?.types?.includes("Files")) {
e.preventDefault();
}
};
const onDrop = (e: DragEvent) => {
if (e.dataTransfer?.types?.includes("Files")) {
e.preventDefault();
}
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
add(e.dataTransfer.files);
}
};
document.addEventListener("dragover", onDragOver);
document.addEventListener("drop", onDrop);
return () => {
document.removeEventListener("dragover", onDragOver);
document.removeEventListener("drop", onDrop);
};
}, [add, globalDrop]);
useEffect(
() => () => {
if (!usingProvider) {
for (const f of filesRef.current) {
if (f.url) {
URL.revokeObjectURL(f.url);
}
}
}
},
[usingProvider],
);
const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback(
(event) => {
if (event.currentTarget.files) {
add(event.currentTarget.files);
}
// Reset input value to allow selecting files that were previously removed
event.currentTarget.value = "";
},
[add],
);
const attachmentsCtx = useMemo<AttachmentsContext>(
() => ({
add,
clear: clearAttachments,
fileInputRef: inputRef,
files: files.map((item) => ({ ...item, id: item.id })),
openFileDialog,
remove,
}),
[files, add, remove, clearAttachments, openFileDialog],
);
const refsCtx = useMemo<ReferencedSourcesContext>(
() => ({
add: (incoming: SourceDocumentUIPart[] | SourceDocumentUIPart) => {
const array = Array.isArray(incoming) ? incoming : [incoming];
setReferencedSources((prev) => [...prev, ...array.map((s) => ({ ...s, id: nanoid() }))]);
},
clear: clearReferencedSources,
remove: (id: string) => {
setReferencedSources((prev) => prev.filter((s) => s.id !== id));
},
sources: referencedSources,
}),
[referencedSources, clearReferencedSources],
);
const handleSubmit: FormEventHandler<HTMLFormElement> = useCallback(
async (event) => {
event.preventDefault();
const form = event.currentTarget;
const text = usingProvider
? controller.textInput.value
: (() => {
const formData = new FormData(form);
return (formData.get("message") as string) || "";
})();
// Reset form immediately after capturing text to avoid race condition
// where user input during async blob conversion would be lost
if (!usingProvider) {
form.reset();
}
try {
// Convert blob URLs to data URLs asynchronously
const convertedFiles: FileUIPart[] = await Promise.all(
files.map(async ({ id: _id, ...item }) => {
if (item.url?.startsWith("blob:")) {
const dataUrl = await convertBlobUrlToDataUrl(item.url);
// If conversion failed, keep the original blob URL
return {
...item,
url: dataUrl ?? item.url,
};
}
return item;
}),
);
const result = onSubmit({ files: convertedFiles, text }, event);
// Handle both sync and async onSubmit
if (result instanceof Promise) {
try {
await result;
clear();
if (usingProvider) {
controller.textInput.clear();
}
} catch {
// Don't clear on error - user may want to retry
}
} else {
// Sync function completed without throwing, clear inputs
clear();
if (usingProvider) {
controller.textInput.clear();
}
}
} catch {
// Don't clear on error - user may want to retry
}
},
[usingProvider, controller, files, onSubmit, clear],
);
// Render with or without local provider
const inner = (
<>
<input
accept={accept}
aria-label="Upload files"
className="hidden"
multiple={multiple}
onChange={handleChange}
ref={inputRef}
title="Upload files"
type="file"
/>
<form className="w-full" onSubmit={handleSubmit} ref={formRef} {...props}>
<InputGroup
className={cn(
"overflow-hidden rounded-2xl bg-card/80 shadow-sm backdrop-blur-md",
"focus-within:border-foreground! has-[[data-slot=input-group-control]:focus-visible]:border-foreground!",
className,
)}
>
{children}
</InputGroup>
</form>
</>
);
const withReferencedSources = (
<LocalReferencedSourcesContext.Provider value={refsCtx}>
{inner}
</LocalReferencedSourcesContext.Provider>
);
// Always provide LocalAttachmentsContext so children get validated add function
return (
<LocalAttachmentsContext.Provider value={attachmentsCtx}>
{withReferencedSources}
</LocalAttachmentsContext.Provider>
);
};
export type PromptInputBodyProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputBody = ({ className, ...props }: PromptInputBodyProps) => (
<div className={cn("contents", className)} {...props} />
);
export type PromptInputTextareaProps = ComponentProps<typeof InputGroupTextarea>;
export const PromptInputTextarea = ({
onChange,
onKeyDown,
className,
placeholder = "What would you like to know?",
...props
}: PromptInputTextareaProps) => {
const controller = useOptionalPromptInputController();
const attachments = usePromptInputAttachments();
const [isComposing, setIsComposing] = useState(false);
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = useCallback(
(e) => {
// Call the external onKeyDown handler first
onKeyDown?.(e);
// If the external handler prevented default, don't run internal logic
if (e.defaultPrevented) {
return;
}
if (e.key === "Enter") {
if (isComposing || e.nativeEvent.isComposing) {
return;
}
if (e.shiftKey) {
return;
}
e.preventDefault();
// Check if the submit button is disabled before submitting
const { form } = e.currentTarget;
const submitButton = form?.querySelector(
'button[type="submit"]',
) as HTMLButtonElement | null;
if (submitButton?.disabled) {
return;
}
form?.requestSubmit();
}
// Remove last attachment when Backspace is pressed and textarea is empty
if (e.key === "Backspace" && e.currentTarget.value === "" && attachments.files.length > 0) {
e.preventDefault();
const lastAttachment = attachments.files.at(-1);
if (lastAttachment) {
attachments.remove(lastAttachment.id);
}
}
},
[onKeyDown, isComposing, attachments],
);
const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = useCallback(
(event) => {
const items = event.clipboardData?.items;
if (!items) {
return;
}
const files: File[] = [];
for (const item of items) {
if (item.kind === "file") {
const file = item.getAsFile();
if (file) {
files.push(file);
}
}
}
if (files.length > 0) {
event.preventDefault();
attachments.add(files);
}
},
[attachments],
);
const handleCompositionEnd = useCallback(() => setIsComposing(false), []);
const handleCompositionStart = useCallback(() => setIsComposing(true), []);
const controlledProps = controller
? {
onChange: (e: ChangeEvent<HTMLTextAreaElement>) => {
controller.textInput.setInput(e.currentTarget.value);
onChange?.(e);
},
value: controller.textInput.value,
}
: {
onChange,
};
return (
<InputGroupTextarea
className={cn("field-sizing-content max-h-48 min-h-18 text-sm!", className)}
name="message"
onCompositionEnd={handleCompositionEnd}
onCompositionStart={handleCompositionStart}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={placeholder}
{...props}
{...controlledProps}
/>
);
};
export type PromptInputHeaderProps = Omit<ComponentProps<typeof InputGroupAddon>, "align">;
export const PromptInputHeader = ({ className, ...props }: PromptInputHeaderProps) => (
<InputGroupAddon
align="block-end"
className={cn("order-first flex-wrap gap-1", className)}
{...props}
/>
);
export type PromptInputFooterProps = Omit<ComponentProps<typeof InputGroupAddon>, "align">;
export const PromptInputFooter = ({ className, ...props }: PromptInputFooterProps) => (
<InputGroupAddon
align="block-end"
className={cn("justify-between gap-1", className)}
{...props}
/>
);
export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputTools = ({ className, ...props }: PromptInputToolsProps) => (
<div className={cn("flex min-w-0 items-center gap-1", className)} {...props} />
);
export type PromptInputButtonTooltip =
| string
| {
content: ReactNode;
shortcut?: string;
side?: ComponentProps<typeof TooltipContent>["side"];
};
export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton> & {
tooltip?: PromptInputButtonTooltip;
};
export const PromptInputButton = ({
variant = "ghost",
className,
size,
tooltip,
...props
}: PromptInputButtonProps) => {
const newSize = size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm");
const button = (
<InputGroupButton
className={cn(
"rounded-full",
variant === "outline" && "bg-card hover:bg-card dark:hover:bg-input/30",
className,
)}
size={newSize}
type="button"
variant={variant}
{...props}
/>
);
if (!tooltip) {
return button;
}
const tooltipContent = typeof tooltip === "string" ? tooltip : tooltip.content;
const shortcut = typeof tooltip === "string" ? undefined : tooltip.shortcut;
const side = typeof tooltip === "string" ? "top" : (tooltip.side ?? "top");
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side={side}>
{tooltipContent}
{shortcut && <span className="ml-2 text-muted-foreground">{shortcut}</span>}
</TooltipContent>
</Tooltip>
);
};
export type PromptInputActionMenuProps = ComponentProps<typeof DropdownMenu>;
export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
<DropdownMenu {...props} />
);
export type PromptInputActionMenuTriggerProps = PromptInputButtonProps;
export const PromptInputActionMenuTrigger = ({
className,
children,
...props
}: PromptInputActionMenuTriggerProps) => (
<DropdownMenuTrigger asChild>
<PromptInputButton className={className} {...props}>
{children ?? <PlusIcon className="size-4" />}
</PromptInputButton>
</DropdownMenuTrigger>
);
export type PromptInputActionMenuContentProps = ComponentProps<typeof DropdownMenuContent>;
export const PromptInputActionMenuContent = ({
className,
...props
}: PromptInputActionMenuContentProps) => (
<DropdownMenuContent align="start" className={cn(className)} {...props} />
);
export type PromptInputActionMenuItemProps = ComponentProps<typeof DropdownMenuItem>;
export const PromptInputActionMenuItem = ({
className,
...props
}: PromptInputActionMenuItemProps) => <DropdownMenuItem className={cn(className)} {...props} />;
// Note: Actions that perform side-effects (like opening a file dialog)
// are provided in opt-in modules (e.g., prompt-input-attachments).
export type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & {
status?: ChatStatus;
onStop?: () => void;
};
export const PromptInputSubmit = ({
className,
variant = "default",
size = "icon-sm",
status,
onStop,
onClick,
children,
...props
}: PromptInputSubmitProps) => {
const isGenerating = status === "submitted" || status === "streaming";
let Icon = <ArrowUpIcon className="size-4" />;
if (status === "submitted") {
Icon = <Spinner />;
} else if (status === "streaming") {
Icon = <span aria-hidden className="size-2.5 rounded-[2px] bg-current" />;
} else if (status === "error") {
Icon = <XIcon className="size-4" />;
}
const handleClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
if (isGenerating && onStop) {
e.preventDefault();
onStop();
return;
}
onClick?.(e);
},
[isGenerating, onStop, onClick],
);
return (
<InputGroupButton
aria-label={isGenerating ? "Stop" : "Submit"}
className={cn("absolute right-2.5 bottom-2.5 rounded-full", className)}
onClick={handleClick}
size={size}
type={isGenerating && onStop ? "button" : "submit"}
variant={variant}
{...props}
>
{children ?? Icon}
</InputGroupButton>
);
};
export type PromptInputSelectProps = ComponentProps<typeof Select>;
export const PromptInputSelect = (props: PromptInputSelectProps) => <Select {...props} />;
export type PromptInputSelectTriggerProps = ComponentProps<typeof SelectTrigger>;
export const PromptInputSelectTrigger = ({
className,
...props
}: PromptInputSelectTriggerProps) => (
<SelectTrigger
className={cn(
"border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors",
"hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground",
className,
)}
{...props}
/>
);
export type PromptInputSelectContentProps = ComponentProps<typeof SelectContent>;
export const PromptInputSelectContent = ({
className,
...props
}: PromptInputSelectContentProps) => <SelectContent className={cn(className)} {...props} />;
export type PromptInputSelectItemProps = ComponentProps<typeof SelectItem>;
export const PromptInputSelectItem = ({ className, ...props }: PromptInputSelectItemProps) => (
<SelectItem className={cn(className)} {...props} />
);
export type PromptInputSelectValueProps = ComponentProps<typeof SelectValue>;
export const PromptInputSelectValue = ({ className, ...props }: PromptInputSelectValueProps) => (
<SelectValue className={cn(className)} {...props} />
);
export type PromptInputHoverCardProps = ComponentProps<typeof HoverCard>;
export const PromptInputHoverCard = ({
openDelay = 0,
closeDelay = 0,
...props
}: PromptInputHoverCardProps) => (
<HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
);
export type PromptInputHoverCardTriggerProps = ComponentProps<typeof HoverCardTrigger>;
export const PromptInputHoverCardTrigger = (props: PromptInputHoverCardTriggerProps) => (
<HoverCardTrigger {...props} />
);
export type PromptInputHoverCardContentProps = ComponentProps<typeof HoverCardContent>;
export const PromptInputHoverCardContent = ({
align = "start",
...props
}: PromptInputHoverCardContentProps) => <HoverCardContent align={align} {...props} />;
export type PromptInputTabsListProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputTabsList = ({ className, ...props }: PromptInputTabsListProps) => (
<div className={cn(className)} {...props} />
);
export type PromptInputTabProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputTab = ({ className, ...props }: PromptInputTabProps) => (
<div className={cn(className)} {...props} />
);
export type PromptInputTabLabelProps = HTMLAttributes<HTMLHeadingElement>;
export const PromptInputTabLabel = ({ className, ...props }: PromptInputTabLabelProps) => (
// Content provided via children in props
// oxlint-disable-next-line eslint-plugin-jsx-a11y(heading-has-content)
<h3 className={cn("mb-2 px-3 font-medium text-muted-foreground text-xs", className)} {...props} />
);
export type PromptInputTabBodyProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputTabBody = ({ className, ...props }: PromptInputTabBodyProps) => (
<div className={cn("space-y-1", className)} {...props} />
);
export type PromptInputTabItemProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputTabItem = ({ className, ...props }: PromptInputTabItemProps) => (
<div
className={cn("flex items-center gap-2 px-3 py-2 text-xs hover:bg-accent", className)}
{...props}
/>
);
export type PromptInputCommandProps = ComponentProps<typeof Command>;
export const PromptInputCommand = ({ className, ...props }: PromptInputCommandProps) => (
<Command className={cn(className)} {...props} />
);
export type PromptInputCommandInputProps = ComponentProps<typeof CommandInput>;
export const PromptInputCommandInput = ({ className, ...props }: PromptInputCommandInputProps) => (
<CommandInput className={cn(className)} {...props} />
);
export type PromptInputCommandListProps = ComponentProps<typeof CommandList>;
export const PromptInputCommandList = ({ className, ...props }: PromptInputCommandListProps) => (
<CommandList className={cn(className)} {...props} />
);
export type PromptInputCommandEmptyProps = ComponentProps<typeof CommandEmpty>;
export const PromptInputCommandEmpty = ({ className, ...props }: PromptInputCommandEmptyProps) => (
<CommandEmpty className={cn(className)} {...props} />
);
export type PromptInputCommandGroupProps = ComponentProps<typeof CommandGroup>;
export const PromptInputCommandGroup = ({ className, ...props }: PromptInputCommandGroupProps) => (
<CommandGroup className={cn(className)} {...props} />
);
export type PromptInputCommandItemProps = ComponentProps<typeof CommandItem>;
export const PromptInputCommandItem = ({ className, ...props }: PromptInputCommandItemProps) => (
<CommandItem className={cn(className)} {...props} />
);
export type PromptInputCommandSeparatorProps = ComponentProps<typeof CommandSeparator>;
export const PromptInputCommandSeparator = ({
className,
...props
}: PromptInputCommandSeparatorProps) => <CommandSeparator className={cn(className)} {...props} />;
`,"components/ai-elements/question.tsx":`"use client";
import type {
ChangeEvent,
ComponentProps,
FormEvent,
HTMLAttributes,
KeyboardEvent,
MouseEvent,
ReactNode,
} from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { createContext, useCallback, useContext, useMemo, useState } from "react";
export interface QuestionValue {
selectedValues: readonly string[];
text: string;
}
export interface QuestionResponse {
selectedValues: readonly string[];
text?: string;
}
type SelectionMode = "multiple" | "single";
interface QuestionContextValue {
disabled: boolean;
selectedValues: readonly string[];
selectionMode: SelectionMode;
setText: (text: string) => void;
text: string;
toggleValue: (value: string) => void;
}
const QuestionContext = createContext<QuestionContextValue | null>(null);
const useQuestion = () => {
const context = useContext(QuestionContext);
if (!context) {
throw new Error("Question components must be used within Question");
}
return context;
};
export type QuestionProps = Omit<ComponentProps<"form">, "defaultValue" | "onSubmit" | "value"> & {
defaultValue?: QuestionValue;
disabled?: boolean;
onSubmit?: (
response: QuestionResponse,
event: FormEvent<HTMLFormElement>,
) => void | Promise<void>;
onValueChange?: (value: QuestionValue) => void;
selectionMode?: SelectionMode;
value?: QuestionValue;
};
const EMPTY_VALUE: QuestionValue = { selectedValues: [], text: "" };
const getSelectedValues = (
currentValues: readonly string[],
optionValue: string,
selectionMode: SelectionMode,
): readonly string[] => {
const isSelected = currentValues.includes(optionValue);
if (selectionMode === "single") {
return isSelected ? [] : [optionValue];
}
if (isSelected) {
return currentValues.filter((item) => item !== optionValue);
}
return [...currentValues, optionValue];
};
export const Question = ({
children,
className,
defaultValue = EMPTY_VALUE,
disabled = false,
onSubmit,
onValueChange,
selectionMode = "single",
value: controlledValue,
...props
}: QuestionProps) => {
const [internalValue, setInternalValue] = useState(defaultValue);
const value = controlledValue ?? internalValue;
const setValue = useCallback(
(nextValue: QuestionValue) => {
if (controlledValue === undefined) {
setInternalValue(nextValue);
}
onValueChange?.(nextValue);
},
[controlledValue, onValueChange],
);
const setText = useCallback(
(text: string) => {
setValue({ ...value, text });
},
[setValue, value],
);
const toggleValue = useCallback(
(optionValue: string) => {
const selectedValues = getSelectedValues(value.selectedValues, optionValue, selectionMode);
setValue({ ...value, selectedValues });
},
[selectionMode, setValue, value],
);
const contextValue = useMemo(
() => ({
disabled,
selectedValues: value.selectedValues,
selectionMode,
setText,
text: value.text,
toggleValue,
}),
[disabled, selectionMode, setText, toggleValue, value],
);
const handleSubmit = useCallback(
async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (disabled) {
return;
}
const text = value.text.trim();
if (value.selectedValues.length === 0 && text.length === 0) {
return;
}
await onSubmit?.(
{
selectedValues: value.selectedValues,
text: text.length > 0 ? text : undefined,
},
event,
);
},
[disabled, onSubmit, value],
);
return (
<QuestionContext.Provider value={contextValue}>
<form
className={cn("space-y-3 rounded-xl border bg-card p-4", className)}
onSubmit={handleSubmit}
{...props}
>
{children}
</form>
</QuestionContext.Provider>
);
};
export type QuestionPromptProps = HTMLAttributes<HTMLParagraphElement>;
export const QuestionPrompt = ({ className, ...props }: QuestionPromptProps) => (
<p className={cn("font-medium text-sm leading-snug", className)} {...props} />
);
export type QuestionDescriptionProps = HTMLAttributes<HTMLParagraphElement>;
export const QuestionDescription = ({ className, ...props }: QuestionDescriptionProps) => (
<p className={cn("text-muted-foreground text-sm", className)} {...props} />
);
export type QuestionOptionsProps = HTMLAttributes<HTMLDivElement>;
export const QuestionOptions = ({ className, ...props }: QuestionOptionsProps) => {
const { selectionMode } = useQuestion();
return (
<div
className={cn("flex flex-wrap gap-1.5", className)}
role={selectionMode === "single" ? "radiogroup" : "group"}
{...props}
/>
);
};
export type QuestionOptionProps = Omit<ComponentProps<typeof Button>, "value"> & {
value: string;
};
export const QuestionOption = ({
children,
className,
disabled,
onClick,
value,
variant,
...props
}: QuestionOptionProps) => {
const question = useQuestion();
const isSelected = question.selectedValues.includes(value);
const role = question.selectionMode === "single" ? "radio" : "checkbox";
const handleClick = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
question.toggleValue(value);
onClick?.(event);
},
[onClick, question, value],
);
return (
<Button
aria-checked={isSelected}
// Selection only changes colors: the border is always present so the
// layout never shifts when an option is picked.
className={cn(
"group/option h-auto whitespace-normal border border-input font-normal shadow-none transition-colors",
isSelected
? "border-foreground/20 bg-accent text-accent-foreground disabled:opacity-100"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
className,
)}
data-state={isSelected ? "checked" : "unchecked"}
disabled={question.disabled || disabled}
onClick={handleClick}
role={role}
type="button"
variant={variant ?? "ghost"}
{...props}
>
{children ?? value}
</Button>
);
};
export type QuestionInputProps = Omit<ComponentProps<typeof Textarea>, "defaultValue" | "value">;
export const QuestionInput = ({
className,
disabled,
onChange,
onKeyDown,
...props
}: QuestionInputProps) => {
const question = useQuestion();
const handleChange = useCallback(
(event: ChangeEvent<HTMLTextAreaElement>) => {
question.setText(event.currentTarget.value);
onChange?.(event);
},
[onChange, question],
);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>) => {
onKeyDown?.(event);
if (
event.defaultPrevented ||
event.key !== "Enter" ||
event.shiftKey ||
event.nativeEvent.isComposing
) {
return;
}
event.preventDefault();
event.currentTarget.form?.requestSubmit();
},
[onKeyDown],
);
return (
<Textarea
className={cn(
"min-h-16 resize-none rounded-lg text-sm shadow-none focus-visible:border-foreground!",
className,
)}
disabled={question.disabled || disabled}
onChange={handleChange}
onKeyDown={handleKeyDown}
value={question.text}
{...props}
/>
);
};
export type QuestionActionsProps = HTMLAttributes<HTMLDivElement>;
export const QuestionActions = ({ className, ...props }: QuestionActionsProps) => (
<div className={cn("flex items-center justify-end gap-2", className)} {...props} />
);
export type QuestionSubmitProps = ComponentProps<typeof Button> & {
children?: ReactNode;
};
export const QuestionSubmit = ({
children = "Submit",
className,
disabled,
size = "sm",
...props
}: QuestionSubmitProps) => {
const question = useQuestion();
const hasResponse = question.selectedValues.length > 0 || question.text.trim().length > 0;
return (
<Button
className={cn("text-sm! shadow-none", className)}
disabled={question.disabled || disabled || !hasResponse}
size={size}
type="submit"
{...props}
>
{children}
</Button>
);
};
`,"components/ai-elements/reasoning.tsx":`"use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Streamdown } from "streamdown";
import { Shimmer } from "./shimmer";
interface ReasoningContextValue {
isStreaming: boolean;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
duration: number | undefined;
}
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
export const useReasoning = () => {
const context = useContext(ReasoningContext);
if (!context) {
throw new Error("Reasoning components must be used within Reasoning");
}
return context;
};
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
isStreaming?: boolean;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
duration?: number;
};
const MS_IN_S = 1000;
export const Reasoning = memo(
({
className,
isStreaming = false,
open,
defaultOpen,
onOpenChange,
duration: durationProp,
children,
...props
}: ReasoningProps) => {
const resolvedDefaultOpen = defaultOpen ?? isStreaming;
// Track if defaultOpen was explicitly set to false (to prevent auto-open)
const isExplicitlyClosed = defaultOpen === false;
const [isOpen, setIsOpen] = useControllableState<boolean>({
defaultProp: resolvedDefaultOpen,
onChange: onOpenChange,
prop: open,
});
const [duration, setDuration] = useControllableState<number | undefined>({
defaultProp: undefined,
prop: durationProp,
});
const hasEverStreamedRef = useRef(isStreaming);
const [hasAutoClosed, setHasAutoClosed] = useState(false);
const startTimeRef = useRef<number | null>(null);
// Track when streaming starts and compute duration
useEffect(() => {
if (isStreaming) {
hasEverStreamedRef.current = true;
if (startTimeRef.current === null) {
startTimeRef.current = Date.now();
}
} else if (startTimeRef.current !== null) {
setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S));
startTimeRef.current = null;
}
}, [isStreaming, setDuration]);
// Auto-open when streaming starts (unless explicitly closed)
useEffect(() => {
if (isStreaming && !isOpen && !isExplicitlyClosed) {
setIsOpen(true);
}
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
// Auto-close when streaming ends (once only, and only if it ever streamed)
useEffect(() => {
if (hasEverStreamedRef.current && !isStreaming && isOpen && !hasAutoClosed) {
setIsOpen(false);
setHasAutoClosed(true);
}
}, [isStreaming, isOpen, setIsOpen, hasAutoClosed]);
const handleOpenChange = useCallback(
(newOpen: boolean) => {
setIsOpen(newOpen);
},
[setIsOpen],
);
const contextValue = useMemo(
() => ({ duration, isOpen, isStreaming, setIsOpen }),
[duration, isOpen, isStreaming, setIsOpen],
);
return (
<ReasoningContext.Provider value={contextValue}>
<Collapsible
className={cn("not-prose mb-4 w-full", className)}
onOpenChange={handleOpenChange}
open={isOpen}
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
);
},
);
export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
};
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) {
return <Shimmer duration={1}>Thinking...</Shimmer>;
}
if (duration === undefined) {
return <p>Thought for a few seconds</p>;
}
return <p>Thought for {duration} seconds</p>;
};
export const ReasoningTrigger = memo(
({
className,
children,
getThinkingMessage = defaultGetThinkingMessage,
...props
}: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning();
return (
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
className,
)}
{...props}
>
{children ?? (
<>
<BrainIcon className="size-4" />
{getThinkingMessage(isStreaming, duration)}
<ChevronDownIcon
className={cn("size-4 transition-transform", isOpen ? "rotate-180" : "rotate-0")}
/>
</>
)}
</CollapsibleTrigger>
);
},
);
export type ReasoningContentProps = ComponentProps<typeof CollapsibleContent> & {
children: string;
};
const streamdownPlugins = { cjk, code, math, mermaid };
export const ReasoningContent = memo(({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
className={cn(
"mt-4 text-sm",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className,
)}
{...props}
>
<Streamdown plugins={streamdownPlugins}>{children}</Streamdown>
</CollapsibleContent>
));
Reasoning.displayName = "Reasoning";
ReasoningTrigger.displayName = "ReasoningTrigger";
ReasoningContent.displayName = "ReasoningContent";
`,"components/ai-elements/shimmer.tsx":`"use client";
import { cn } from "@/lib/utils";
import type { MotionProps } from "motion/react";
import { motion } from "motion/react";
import type { CSSProperties, ElementType, JSX } from "react";
import { memo, useMemo } from "react";
type MotionHTMLProps = MotionProps & Record<string, unknown>;
// Cache motion components at module level to avoid creating during render
const motionComponentCache = new Map<
keyof JSX.IntrinsicElements,
React.ComponentType<MotionHTMLProps>
>();
const getMotionComponent = (element: keyof JSX.IntrinsicElements) => {
let component = motionComponentCache.get(element);
if (!component) {
component = motion.create(element);
motionComponentCache.set(element, component);
}
return component;
};
export interface TextShimmerProps {
children: string;
as?: ElementType;
className?: string;
duration?: number;
spread?: number;
}
const ShimmerComponent = ({
children,
as: Component = "p",
className,
duration = 2,
spread = 2,
}: TextShimmerProps) => {
const MotionComponent = getMotionComponent(Component as keyof JSX.IntrinsicElements);
const dynamicSpread = useMemo(() => (children?.length ?? 0) * spread, [children, spread]);
return (
<MotionComponent
animate={{ backgroundPosition: "0% center" }}
className={cn(
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
className,
)}
initial={{ backgroundPosition: "100% center" }}
style={
{
"--spread": \`\${dynamicSpread}px\`,
backgroundImage:
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
} as CSSProperties
}
transition={{
duration,
ease: "linear",
repeat: Number.POSITIVE_INFINITY,
}}
>
{children}
</MotionComponent>
);
};
export const Shimmer = memo(ShimmerComponent);
`,"components/ai-elements/tool.tsx":`"use client";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import type { DynamicToolUIPart, ToolUIPart } from "ai";
import { ChevronRightIcon, TerminalIcon, WrenchIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { isValidElement } from "react";
import { CodeBlock } from "./code-block";
const compactCodeBlockClassName =
"rounded-none border-0 bg-transparent [&_pre]:!bg-transparent [&_pre]:px-3 [&_pre]:pt-2 [&_pre]:pb-3 [&_pre]:text-xs [&_code]:text-xs";
export type ToolProps = ComponentProps<typeof Collapsible>;
export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible className={cn("group not-prose w-full", className)} {...props} />
);
export type ToolPart = ToolUIPart | DynamicToolUIPart;
export type ToolHeaderProps = {
title?: string;
className?: string;
} & (
| { type: ToolUIPart["type"]; state: ToolUIPart["state"]; toolName?: never }
| {
type: DynamicToolUIPart["type"];
state: DynamicToolUIPart["state"];
toolName: string;
}
);
const statusLabels: Record<ToolPart["state"], string> = {
"approval-requested": "Awaiting Approval",
"approval-responded": "Responded",
"input-available": "Running",
"input-streaming": "Pending",
"output-available": "Completed",
"output-denied": "Denied",
"output-error": "Error",
};
export const getStatusIndicator = (status: ToolPart["state"]) =>
status === "output-available" ? null : (
<span className={cn("text-sm", status === "output-error" && "text-destructive")}>
{statusLabels[status]}
</span>
);
export const ToolHeader = ({
className,
title,
type,
state,
toolName,
...props
}: ToolHeaderProps) => {
const derivedName = type === "dynamic-tool" ? toolName : type.split("-").slice(1).join("-");
const displayName = title ?? derivedName;
return (
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 py-0.5 text-left text-muted-foreground transition-colors hover:text-foreground",
className,
)}
{...props}
>
{displayName === "bash" ? (
<TerminalIcon className="size-4 shrink-0" />
) : (
<WrenchIcon className="size-4 shrink-0" />
)}
<span className="text-sm">{displayName}</span>
{getStatusIndicator(state)}
<ChevronRightIcon className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-90" />
</CollapsibleTrigger>
);
};
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
<CollapsibleContent
className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 space-y-4 py-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className,
)}
{...props}
/>
);
export type BashToolContentProps = ComponentProps<"div"> & {
input: ToolPart["input"];
output: ToolPart["output"];
errorText: ToolPart["errorText"];
};
export const BashToolContent = ({
className,
input,
output,
errorText,
...props
}: BashToolContentProps) => {
const command = getRecordValue(input, "command");
const stdout = getRecordValue(output, "stdout") ?? (typeof output === "string" ? output : "");
const stderr = getRecordValue(output, "stderr") ?? errorText ?? "";
const exitCode = getRecordValue(output, "exitCode");
const hasResult = Boolean(stdout || stderr || (typeof exitCode === "number" && exitCode !== 0));
return (
<div className={cn("space-y-2", className)} {...props}>
<pre className="overflow-x-auto whitespace-pre-wrap rounded-md bg-muted/50 p-3 font-mono text-xs leading-relaxed">
<code>
<span className="text-muted-foreground">$ </span>
{command ?? "…"}
</code>
</pre>
{hasResult ? (
<pre className="overflow-x-auto whitespace-pre-wrap rounded-md bg-muted/50 p-3 font-mono text-xs leading-relaxed">
<code>
<span className="mb-2 block font-sans text-[10px] text-muted-foreground uppercase tracking-wide">
Output
</span>
{stdout ? <span className="block">{String(stdout).trimEnd()}</span> : null}
{stderr ? (
<span className="block text-destructive">{String(stderr).trimEnd()}</span>
) : null}
{typeof exitCode === "number" && exitCode !== 0 ? (
<span className="block text-muted-foreground">Exited with code {exitCode}</span>
) : null}
</code>
</pre>
) : null}
</div>
);
};
const getRecordValue = (value: unknown, key: string): string | number | undefined => {
if (typeof value !== "object" || value === null || !(key in value)) {
return undefined;
}
const property = value[key as keyof typeof value];
return typeof property === "string" || typeof property === "number" ? property : undefined;
};
export type ToolInputProps = ComponentProps<"div"> & {
input: ToolPart["input"];
};
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
<div className={cn("overflow-hidden rounded-md bg-muted/50", className)} {...props}>
<span className="block px-3 pt-3 font-sans text-[10px] text-muted-foreground uppercase tracking-wide">
Parameters
</span>
<div>
<CodeBlock
className={compactCodeBlockClassName}
code={JSON.stringify(input, null, 2)}
language="json"
/>
</div>
</div>
);
export type ToolOutputProps = ComponentProps<"div"> & {
output: ToolPart["output"];
errorText: ToolPart["errorText"];
};
export const ToolOutput = ({ className, output, errorText, ...props }: ToolOutputProps) => {
if (!(output || errorText)) {
return null;
}
let Output = <div>{output as ReactNode}</div>;
if (typeof output === "object" && !isValidElement(output)) {
Output = (
<CodeBlock
className={compactCodeBlockClassName}
code={JSON.stringify(output, null, 2)}
language="json"
/>
);
} else if (typeof output === "string") {
Output = <CodeBlock className={compactCodeBlockClassName} code={output} language="json" />;
}
return (
<div
className={cn(
"overflow-x-auto rounded-md text-xs [&_table]:w-full",
errorText ? "bg-destructive/10 text-destructive" : "bg-muted/50 text-foreground",
className,
)}
{...props}
>
<span className="block px-3 pt-3 font-sans text-[10px] text-muted-foreground uppercase tracking-wide">
{errorText ? "Error" : "Result"}
</span>
{errorText && <div className="px-3 pt-2 pb-3">{errorText}</div>}
{Output}
</div>
);
};
`,"components/ui/badge.tsx":`import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span";
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };
`,"components/ui/button-group.tsx":`import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
const buttonGroupVariants = cva(
"flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
},
);
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
);
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "div";
return (
<Comp
className={cn(
"flex items-center gap-2 rounded-md border bg-muted px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative m-0! self-stretch bg-input data-[orientation=vertical]:h-auto",
className,
)}
{...props}
/>
);
}
export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants };
`,"components/ui/button.tsx":`import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
`,"components/ui/collapsible.tsx":`"use client";
import { Collapsible as CollapsiblePrimitive } from "radix-ui";
function Collapsible({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return <CollapsiblePrimitive.CollapsibleTrigger data-slot="collapsible-trigger" {...props} />;
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return <CollapsiblePrimitive.CollapsibleContent data-slot="collapsible-content" {...props} />;
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
`,"components/ui/command.tsx":`"use client";
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { SearchIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div 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" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
);
}
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn("max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto", className)}
{...props}
/>
);
}
function CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
);
}
function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className,
)}
{...props}
/>
);
}
function CommandShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
`,"components/ui/dialog.tsx":`"use client";
import * as React from "react";
import { XIcon } from "lucide-react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
`,"components/ui/dropdown-menu.tsx":`"use client";
import * as React from "react";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props}
/>
);
}
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
`,"components/ui/hover-card.tsx":`"use client";
import * as React from "react";
import { HoverCard as HoverCardPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function HoverCard({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
}
function HoverCardTrigger({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return <HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />;
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className,
)}
{...props}
/>
</HoverCardPrimitive.Portal>
);
}
export { HoverCard, HoverCardTrigger, HoverCardContent };
`,"components/ui/input-group.tsx":`"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex w-full items-center rounded-md border border-input shadow-xs transition-[color,box-shadow] outline-none dark:bg-input/30",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50",
// Error state.
"has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className,
)}
{...props}
/>
);
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start": "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end": "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3",
"block-end":
"order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3",
},
},
defaultVariants: {
align: "inline-start",
},
},
);
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return;
}
e.currentTarget.parentElement?.querySelector("input")?.focus();
}}
{...props}
/>
);
}
const inputGroupButtonVariants = cva("flex items-center gap-2 text-sm shadow-none", {
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
"icon-xs": "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
});
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function InputGroupInput({ className, ...props }: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className,
)}
{...props}
/>
);
}
function InputGroupTextarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className,
)}
{...props}
/>
);
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
};
`,"components/ui/input.tsx":`import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Input };
`,"components/ui/select.tsx":`"use client";
import * as React from "react";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { Select as SelectPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
`,"components/ui/separator.tsx":`"use client";
import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator };
`,"components/ui/spinner.tsx":`import { Loader2Icon } from "lucide-react";
import { cn } from "@/lib/utils";
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
);
}
export { Spinner };
`,"components/ui/textarea.tsx":`import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Textarea };
`,"components/ui/tooltip.tsx":`"use client";
import * as React from "react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
`,"components.json":`{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
`,"css.d.ts":`declare module "*.css";
`,"lib/utils.ts":`import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
`,"next-env.d.ts":`/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
`,"next.config.ts":`import type { NextConfig } from "next";
import { withEve } from "eve/next";
const nextConfig: NextConfig = {};
export default withEve(nextConfig__EVE_INIT_WITH_EVE_OPTIONS__);
`,"postcss.config.mjs":`const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
`,"tsconfig.json":`{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}
`},WEB_APP_SIGN_IN_WITH_VERCEL_TEMPLATE_FILES={"agent/channels/eve.ts":`import { eveChannel } from "eve/channels/eve";
import { localDev, type AuthFn, vercelOidc } from "eve/channels/auth";
import { auth } from "@/lib/auth";
const betterAuthSession: AuthFn<Request> = async (request) => {
const session = await auth.api.getSession({ headers: request.headers });
if (!session) return null;
const attributes: Record<string, string> = {
email: session.user.email,
name: session.user.name,
};
if (session.user.image) {
attributes.picture = session.user.image;
}
return {
attributes,
authenticator: "better-auth:vercel",
principalId: session.user.id,
principalType: "user",
};
};
export default eveChannel({
auth: [betterAuthSession, vercelOidc(), localDev()],
});
`,"app/_components/authenticated-agent-chat.tsx":`import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { AgentChat } from "./agent-chat";
import { AccountControl, SignIn } from "./web-chat-auth";
export async function AuthenticatedAgentChat({
sessionId,
sessionless,
}: {
readonly sessionId?: string;
readonly sessionless?: boolean;
}) {
if (process.env.NODE_ENV === "development") {
return <AgentChat sessionId={sessionId} sessionless={sessionless} />;
}
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return <SignIn />;
return (
<>
<AgentChat sessionId={sessionId} sessionless={sessionless} />
<AccountControl
email={session.user.email}
image={session.user.image}
name={session.user.name}
/>
</>
);
}
`,"app/_components/web-chat-auth.tsx":`"use client";
import { LogOutIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { authClient } from "@/lib/auth-client";
const AGENT_NAME = "__EVE_INIT_APP_NAME__";
export function SignIn() {
const [pending, setPending] = useState(false);
const [error, setError] = useState<string>();
async function signIn() {
setPending(true);
setError(undefined);
try {
const result = await authClient.signIn.social({
callbackURL: "/",
provider: "vercel",
});
if (!result.error) return;
setPending(false);
setError("Sign-in failed. Try again.");
} catch {
setPending(false);
setError("Sign-in failed. Try again.");
}
}
return (
<main className="flex min-h-dvh items-center justify-center bg-background px-8 text-foreground">
<div className="flex w-full max-w-[22rem] flex-col gap-5">
<div className="text-foreground opacity-[0.08] dark:opacity-[0.12]">
<EveWordmark className="h-auto w-[4.875rem]" />
</div>
<section aria-label="Sign in" className="flex flex-col gap-2">
<h1 className="max-w-full break-words font-medium text-sm leading-6">{AGENT_NAME}</h1>
<p className="flex flex-wrap items-center gap-2 text-muted-foreground text-sm leading-6">
<span className="inline-flex items-center gap-2 text-emerald-600 dark:text-emerald-400">
<span aria-hidden="true" className="size-1.5 rounded-full bg-current" />
Ready
</span>
<span aria-hidden="true" className="text-border">
/
</span>
<span>Sign in to start a session</span>
</p>
<Button className="mt-3 w-full gap-2 text-sm" disabled={pending} onClick={signIn}>
<svg aria-hidden="true" className="size-3 fill-current" viewBox="0 0 24 20">
<path d="M12 0 24 20H0L12 0Z" />
</svg>
<span className="leading-5">{pending ? "Redirecting…" : "Continue with Vercel"}</span>
</Button>
{error ? (
<p className="text-destructive text-sm" role="alert">
{error}
</p>
) : null}
</section>
</div>
</main>
);
}
function EveWordmark({ className }: { readonly className?: string }) {
return (
<svg
aria-hidden="true"
className={className}
fill="none"
viewBox="0 0 169 53"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M169 8.47h-51.39L81.73 53H70.36L113 0H169zM169 44.51v8.47h-45.87V44.5zM45.87 52.98H0V44.5h45.87zM38.66 30.55H0v-8.47h38.66z"
fill="currentColor"
/>
<path d="M169 30.55h-38.66v-8.47H169zM75.52 8.47H0V0h75.52z" fill="currentColor" />
</svg>
);
}
export function AccountControl({
email,
image,
name,
}: {
readonly email: string;
readonly image?: string | null;
readonly name: string;
}) {
const [imageFailed, setImageFailed] = useState(false);
const [pending, setPending] = useState(false);
const initials = getInitials(name, email);
async function signOut() {
setPending(true);
try {
await authClient.signOut({
fetchOptions: {
onError: () => setPending(false),
onSuccess: () => window.location.assign("/"),
},
});
} catch {
setPending(false);
}
}
return (
<div className="fixed top-3 left-4 z-30 flex h-8 items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={\`Open account menu for \${name}\`}
className="relative size-7 cursor-pointer overflow-hidden rounded-full p-0"
size="icon-sm"
variant="ghost"
>
{image && !imageFailed ? (
<img
alt=""
className="size-full object-cover"
onError={() => setImageFailed(true)}
src={image}
/>
) : (
<span aria-hidden="true" className="font-medium text-xs">
{initials}
</span>
)}
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 rounded-full border border-black/20 dark:border-white/25"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<div className="min-w-0 px-2 py-1.5 text-sm">
<span className="block truncate font-medium leading-5" title={name}>
{name}
</span>
<span className="block truncate text-muted-foreground leading-5" title={email}>
{email}
</span>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer justify-between"
disabled={pending}
onSelect={signOut}
>
{pending ? "Logging out…" : "Log out"}
<LogOutIcon aria-hidden="true" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
function getInitials(name: string, email: string): string {
const parts = name.trim().split(/\\s+/).filter(Boolean);
if (parts.length >= 2) {
return \`\${parts[0]?.[0] ?? ""}\${parts.at(-1)?.[0] ?? ""}\`.toUpperCase();
}
return (parts[0]?.[0] ?? email[0] ?? "?").toUpperCase();
}
`,"app/api/auth/[...all]/route.ts":`import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth";
export const { GET, POST } = toNextJsHandler(auth);
`,"app/layout.tsx":`import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import type { ReactNode } from "react";
import { TooltipProvider } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import "./globals.css";
const sans = Geist({
variable: "--font-sans",
subsets: ["latin"],
weight: "variable",
display: "swap",
});
const mono = Geist_Mono({
variable: "--font-mono",
subsets: ["latin"],
weight: "variable",
display: "swap",
});
export const metadata: Metadata = {
title: "__EVE_INIT_APP_NAME__",
description: "A Next.js starter for eve agents with AI Elements.",
};
// The page and Eve routes validate the generated app's Better Auth session.
export default function RootLayout({ children }: { readonly children: ReactNode }) {
return (
<html className={cn(sans.variable, mono.variable)} lang="en">
<body>
<TooltipProvider>{children}</TooltipProvider>
</body>
</html>
);
}
`,"app/page.tsx":`import { AuthenticatedAgentChat } from "./_components/authenticated-agent-chat";
export default function Page() {
return <AuthenticatedAgentChat />;
}
`,"app/s/[sessionId]/page.tsx":`import { AuthenticatedAgentChat } from "@/app/_components/authenticated-agent-chat";
export default async function SessionPage({
params,
}: {
readonly params: Promise<{ readonly sessionId: string }>;
}) {
const { sessionId } = await params;
return <AuthenticatedAgentChat sessionId={sessionId} />;
}
`,"app/s/page.tsx":`import { AuthenticatedAgentChat } from "@/app/_components/authenticated-agent-chat";
export default function NewSessionPage() {
return <AuthenticatedAgentChat sessionless />;
}
`,"lib/auth-client.ts":`"use client";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient();
`,"lib/auth.ts":`import { betterAuth } from "better-auth";
const SESSION_MAX_AGE_SECONDS = 8 * 60 * 60;
const DEVELOPMENT_ALLOWED_HOSTS = ["localhost:*", "127.0.0.1:*"];
function getAllowedHosts(): string[] {
if (process.env.NODE_ENV === "development") {
return DEVELOPMENT_ALLOWED_HOSTS;
}
const deploymentHosts = [
process.env.VERCEL_URL,
process.env.VERCEL_BRANCH_URL,
process.env.VERCEL_PROJECT_PRODUCTION_URL,
].filter((host): host is string => Boolean(host));
if (deploymentHosts.length === 0) {
throw new Error("No trusted deployment hosts are configured");
}
return Array.from(new Set(deploymentHosts));
}
function requireEnvironmentVariable(name: string): string {
const value = process.env[name];
if (value) return value;
if (process.env.NODE_ENV === "development") return \`development-\${name}\`;
throw new Error(\`Missing required environment variable: \${name}\`);
}
export const auth = betterAuth({
baseURL: {
allowedHosts: getAllowedHosts(),
protocol: process.env.NODE_ENV === "development" ? "auto" : "https",
},
secret: requireEnvironmentVariable("BETTER_AUTH_SECRET"),
session: {
expiresIn: SESSION_MAX_AGE_SECONDS,
disableSessionRefresh: true,
cookieCache: {
enabled: true,
maxAge: SESSION_MAX_AGE_SECONDS,
refreshCache: false,
strategy: "jwe",
},
},
socialProviders: {
vercel: {
clientId: requireEnvironmentVariable("VERCEL_APP_CLIENT_ID"),
clientSecret: requireEnvironmentVariable("VERCEL_APP_CLIENT_SECRET"),
},
},
});
`},WEB_APP_TEMPLATE_PACKAGE_JSON={scripts:{build:`next build`,"build:eve":`eve build`,dev:`next dev`,"dev:eve":`eve dev`,start:`next start`,"start:eve":`eve start`,typecheck:`tsc --noEmit -p tsconfig.json`},dependencies:{"@radix-ui/react-use-controllable-state":`1.2.2`,"@shikijs/core":`3.23.0`,"@shikijs/engine-javascript":`3.23.0`,"@shikijs/engine-oniguruma":`3.23.0`,"@streamdown/cjk":`1.0.3`,"@streamdown/code":`1.1.1`,"@streamdown/math":`1.0.2`,"@streamdown/mermaid":`1.0.2`,"@tailwindcss/postcss":`4.3.0`,"class-variance-authority":`0.7.1`,clsx:`2.1.1`,cmdk:`1.1.1`,"lucide-react":`1.16.0`,motion:`12.40.0`,nanoid:`5.1.11`,next:`16.3.0-preview.6`,"radix-ui":`1.4.3`,react:`19.2.6`,"react-dom":`19.2.6`,shiki:`3.23.0`,streamdown:`2.5.0`,"tailwind-merge":`3.6.0`,tailwindcss:`4.3.0`,"use-stick-to-bottom":`1.1.4`,zod:`4.5.4`},devDependencies:{"@types/node":`26`,"@types/react":`19.2.15`,"@types/react-dom":`19.2.3`,typescript:`6.0.3`}};export{WEB_APP_SIGN_IN_WITH_VERCEL_TEMPLATE_FILES,WEB_APP_TEMPLATE_FILES,WEB_APP_TEMPLATE_PACKAGE_JSON};