diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..400a157
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,12 @@
+# Shopify Storefront
+NEXT_PUBLIC_SHOPIFY_DOMAIN=mock.shop
+# NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN=
+# NEXT_PUBLIC_SHOPIFY_API_VERSION=2025-07
+
+# Store assistant — set to 1 to show the Ask launcher; anything else hides it
+NEXT_PUBLIC_ENABLE_AI=0
+
+# Store assistant (/api/chat) — https://openrouter.ai/keys
+OPENROUTER_API_KEY=
+# Optional; defaults to openai/gpt-5.6-luna-pro
+# OPENROUTER_MODEL=
diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts
new file mode 100644
index 0000000..cce2cdd
--- /dev/null
+++ b/app/api/chat/route.ts
@@ -0,0 +1,201 @@
+import { streamText, tool, convertToModelMessages, stepCountIs } from 'ai';
+import { createOpenRouter } from '@openrouter/ai-sdk-provider';
+import { z } from 'zod';
+import {
+ searchProducts,
+ getProduct,
+ getProductsPage,
+ getCollections,
+ getCollectionProductsPage,
+} from '@/services/shopify/catalog';
+
+// Streaming needs the Node runtime here because the Storefront helpers run
+// server-side on each tool call.
+export const maxDuration = 30;
+
+const MODEL = process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.6-luna-pro';
+
+const SYSTEM_PROMPT = `You are the shopping assistant for an online store built on Shopify.
+
+You help shoppers find products, compare options, and understand what the store
+carries. You have tools that read live catalogue data — always use them rather
+than guessing, and never invent products, prices, availability, or policies.
+
+Guidelines:
+- Call a tool whenever the answer depends on catalogue data. If a shopper asks
+ something vague like "what do you have?", call listCollections or
+ searchCatalogue to ground your answer.
+- Prices returned by the tools are in the store's currency; show them as given.
+- Link products as /products/{handle} and collections as /collections/{handle}
+ so the shopper can click through.
+- Keep replies short and conversational — a sentence or two plus a compact list.
+ Do not repeat the raw tool output; the interface already shows it.
+- If a tool returns nothing, say so plainly and suggest a different search.
+- You cannot place orders, change carts, process payments, or look up customer
+ or order data. Say so and point the shopper to the relevant page instead.`;
+
+// Trim the Storefront payloads to what the model actually needs to answer.
+const summariseProduct = (product: {
+ id: string;
+ title: string;
+ handle: string;
+ description?: string;
+ productType?: string;
+ tags?: string[];
+ priceRange: { minVariantPrice: { amount: string; currencyCode: string } };
+ variants?: {
+ edges: Array<{
+ node: {
+ title: string;
+ availableForSale: boolean;
+ selectedOptions?: Array<{ name: string; value: string }>;
+ };
+ }>;
+ };
+ images?: { edges: Array<{ node: { url: string } }> };
+ options?: Array<{ name: string; values: string[] }>;
+}) => ({
+ title: product.title,
+ handle: product.handle,
+ url: `/products/${product.handle}`,
+ price: `${product.priceRange.minVariantPrice.amount} ${product.priceRange.minVariantPrice.currencyCode}`,
+ image: product.images?.edges[0]?.node.url ?? null,
+ description: product.description?.slice(0, 300) ?? null,
+ productType: product.productType || null,
+ tags: product.tags ?? [],
+ options: product.options?.map((option) => ({
+ name: option.name,
+ values: option.values,
+ })),
+ inStock: product.variants?.edges.some((edge) => edge.node.availableForSale),
+});
+
+export async function POST(req: Request) {
+ const apiKey = process.env.OPENROUTER_API_KEY;
+
+ if (!apiKey) {
+ return new Response(
+ JSON.stringify({ error: 'OPENROUTER_API_KEY is not configured.' }),
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
+ );
+ }
+
+ const { messages } = await req.json();
+ const openrouter = createOpenRouter({ apiKey });
+
+ const result = streamText({
+ // `reasoning` asks OpenRouter to stream the model's thinking; the UI
+ // renders it via the Reasoning component.
+ model: openrouter(MODEL, { reasoning: { enabled: true, effort: 'medium' } }),
+ system: SYSTEM_PROMPT,
+ messages: await convertToModelMessages(messages),
+ // Let the model call a tool, read the result, then answer.
+ stopWhen: stepCountIs(5),
+ tools: {
+ searchCatalogue: tool({
+ description:
+ 'Search the store for products matching a term. Use for any question about what the store sells.',
+ inputSchema: z.object({
+ query: z
+ .string()
+ .describe('Search terms, e.g. "green hoodie" or "jacket".'),
+ limit: z.number().int().min(1).max(10).default(5),
+ }),
+ execute: async ({ query, limit }) => {
+ const { products, totalCount } = await searchProducts({
+ query,
+ first: limit,
+ });
+ return {
+ totalCount,
+ products: products.map(summariseProduct),
+ };
+ },
+ }),
+
+ getProductDetails: tool({
+ description:
+ 'Get full details for one product by its handle, including options, variants, and stock.',
+ inputSchema: z.object({
+ handle: z
+ .string()
+ .describe('The product handle, e.g. "flowguard-jacket".'),
+ }),
+ execute: async ({ handle }) => {
+ const product = await getProduct(handle);
+ if (!product) return { found: false, handle };
+
+ return {
+ found: true,
+ ...summariseProduct(product),
+ variants: product.variants.edges.slice(0, 25).map(({ node }) => ({
+ title: node.title,
+ available: node.availableForSale,
+ price: `${node.price.amount} ${node.price.currencyCode}`,
+ options: node.selectedOptions,
+ })),
+ };
+ },
+ }),
+
+ listCollections: tool({
+ description:
+ 'List the store\'s collections. Use when the shopper asks what categories or ranges exist.',
+ inputSchema: z.object({
+ limit: z.number().int().min(1).max(25).default(10),
+ }),
+ execute: async ({ limit }) => {
+ const collections = await getCollections(limit);
+ return {
+ collections: collections.map((collection) => ({
+ title: collection.title,
+ handle: collection.handle,
+ url: `/collections/${collection.handle}`,
+ description: collection.description?.slice(0, 200) ?? null,
+ })),
+ };
+ },
+ }),
+
+ getCollectionProducts: tool({
+ description:
+ 'List the products inside one collection, by collection handle.',
+ inputSchema: z.object({
+ handle: z.string().describe('The collection handle, e.g. "men".'),
+ limit: z.number().int().min(1).max(20).default(8),
+ }),
+ execute: async ({ handle, limit }) => {
+ const page = await getCollectionProductsPage(handle, { first: limit });
+ if (!page.collection) return { found: false, handle };
+
+ return {
+ found: true,
+ collection: page.collection.title,
+ url: `/collections/${handle}`,
+ products: page.products.map(summariseProduct),
+ };
+ },
+ }),
+
+ browseProducts: tool({
+ description:
+ 'Browse the newest products when the shopper has no specific search term.',
+ inputSchema: z.object({
+ limit: z.number().int().min(1).max(20).default(8),
+ }),
+ execute: async ({ limit }) => {
+ const page = await getProductsPage({
+ first: limit,
+ sortKey: 'CREATED_AT',
+ reverse: true,
+ });
+ return { products: page.products.map(summariseProduct) };
+ },
+ }),
+ },
+ });
+
+ // sendReasoning forwards reasoning parts to the client; without it the
+ // stream carries text and tool calls only.
+ return result.toUIMessageStreamResponse({ sendReasoning: true });
+}
diff --git a/app/globals.css b/app/globals.css
index ad08907..9615c91 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1,5 +1,7 @@
@import 'tailwindcss';
+@custom-variant dark (&:is(.dark *));
+
@theme {
/* Refined neutral modern palette */
--color-background: hsl(0 0% 100%);
@@ -56,6 +58,15 @@
}
/* Base styles for light, modern Shopify storefront */
+/* Tailwind v4 defaults an unqualified `border` to currentColor. shadcn
+ components (e.g. Button's outline variant) rely on this base layer to pick up
+ the theme's border colour instead of the text colour. */
+@layer base {
+ * {
+ border-color: var(--color-border);
+ }
+}
+
body {
font-feature-settings:
'kern' 1,
@@ -184,3 +195,36 @@ button,
.price-display {
font-feature-settings: 'tnum';
}
+
+@theme inline {
+ --animate-rainbow: rainbow var(--speed, 2s) infinite linear;
+ --color-color-5: var(--color-5);
+ --color-color-4: var(--color-4);
+ --color-color-3: var(--color-3);
+ --color-color-2: var(--color-2);
+ --color-color-1: var(--color-1);
+ @keyframes rainbow {
+ 0% {
+ background-position: 0%;
+ }
+ 100% {
+ background-position: 200%;
+ }
+ }
+}
+
+:root {
+ --color-1: oklch(66.2% 0.225 25.9);
+ --color-2: oklch(60.4% 0.26 302);
+ --color-3: oklch(69.6% 0.165 251);
+ --color-4: oklch(80.2% 0.134 225);
+ --color-5: oklch(90.7% 0.231 133);
+}
+
+.dark {
+ --color-1: oklch(66.2% 0.225 25.9);
+ --color-2: oklch(60.4% 0.26 302);
+ --color-3: oklch(69.6% 0.165 251);
+ --color-4: oklch(80.2% 0.134 225);
+ --color-5: oklch(90.7% 0.231 133);
+}
\ No newline at end of file
diff --git a/app/layout.tsx b/app/layout.tsx
index 554c4c1..df9f297 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -3,6 +3,7 @@
import React from 'react';
import './globals.css';
import { Geist, Geist_Mono } from 'next/font/google';
+import StoreAssistant from '@/components/shopify/store-assistant';
const geist = Geist({
subsets: ['latin'],
@@ -23,6 +24,7 @@ export default function RootLayout({
{children}
+
);
diff --git a/components.json b/components.json
new file mode 100644
index 0000000..c5d8163
--- /dev/null
+++ b/components.json
@@ -0,0 +1,24 @@
+{
+ "$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": {
+ "@magicui": "https://magicui.design/r/{name}"
+ }
+}
diff --git a/components/ai-elements/attachments.tsx b/components/ai-elements/attachments.tsx
new file mode 100644
index 0000000..d4e4e34
--- /dev/null
+++ b/components/ai-elements/attachments.tsx
@@ -0,0 +1,426 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ HoverCard,
+ HoverCardContent,
+ HoverCardTrigger,
+} from "@/components/ui/hover-card";
+import { cn } from "@/lib/utils";
+import type { FileUIPart, SourceDocumentUIPart } from "ai";
+import {
+ FileTextIcon,
+ GlobeIcon,
+ ImageIcon,
+ Music2Icon,
+ PaperclipIcon,
+ VideoIcon,
+ XIcon,
+} from "lucide-react";
+import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
+import { createContext, useCallback, useContext, useMemo } from "react";
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export type AttachmentData =
+ | (FileUIPart & { id: string })
+ | (SourceDocumentUIPart & { id: string });
+
+export type AttachmentMediaCategory =
+ | "image"
+ | "video"
+ | "audio"
+ | "document"
+ | "source"
+ | "unknown";
+
+export type AttachmentVariant = "grid" | "inline" | "list";
+
+const mediaCategoryIcons: Record = {
+ audio: Music2Icon,
+ document: FileTextIcon,
+ image: ImageIcon,
+ source: GlobeIcon,
+ unknown: PaperclipIcon,
+ video: VideoIcon,
+};
+
+// ============================================================================
+// Utility Functions
+// ============================================================================
+
+export const getMediaCategory = (
+ data: AttachmentData
+): AttachmentMediaCategory => {
+ if (data.type === "source-document") {
+ return "source";
+ }
+
+ const mediaType = data.mediaType ?? "";
+
+ if (mediaType.startsWith("image/")) {
+ return "image";
+ }
+ if (mediaType.startsWith("video/")) {
+ return "video";
+ }
+ if (mediaType.startsWith("audio/")) {
+ return "audio";
+ }
+ if (mediaType.startsWith("application/") || mediaType.startsWith("text/")) {
+ return "document";
+ }
+
+ return "unknown";
+};
+
+export const getAttachmentLabel = (data: AttachmentData): string => {
+ if (data.type === "source-document") {
+ return data.title || data.filename || "Source";
+ }
+
+ const category = getMediaCategory(data);
+ return data.filename || (category === "image" ? "Image" : "Attachment");
+};
+
+const renderAttachmentImage = (
+ url: string,
+ filename: string | undefined,
+ isGrid: boolean
+) =>
+ isGrid ? (
+
+ ) : (
+
+ );
+
+// ============================================================================
+// Contexts
+// ============================================================================
+
+interface AttachmentsContextValue {
+ variant: AttachmentVariant;
+}
+
+const AttachmentsContext = createContext(null);
+
+interface AttachmentContextValue {
+ data: AttachmentData;
+ mediaCategory: AttachmentMediaCategory;
+ onRemove?: () => void;
+ variant: AttachmentVariant;
+}
+
+const AttachmentContext = createContext(null);
+
+// ============================================================================
+// Hooks
+// ============================================================================
+
+export const useAttachmentsContext = () =>
+ useContext(AttachmentsContext) ?? { variant: "grid" as const };
+
+export const useAttachmentContext = () => {
+ const ctx = useContext(AttachmentContext);
+ if (!ctx) {
+ throw new Error("Attachment components must be used within ");
+ }
+ return ctx;
+};
+
+// ============================================================================
+// Attachments - Container
+// ============================================================================
+
+export type AttachmentsProps = HTMLAttributes & {
+ variant?: AttachmentVariant;
+};
+
+export const Attachments = ({
+ variant = "grid",
+ className,
+ children,
+ ...props
+}: AttachmentsProps) => {
+ const contextValue = useMemo(() => ({ variant }), [variant]);
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+// ============================================================================
+// Attachment - Item
+// ============================================================================
+
+export type AttachmentProps = HTMLAttributes & {
+ data: AttachmentData;
+ onRemove?: () => void;
+};
+
+export const Attachment = ({
+ data,
+ onRemove,
+ className,
+ children,
+ ...props
+}: AttachmentProps) => {
+ const { variant } = useAttachmentsContext();
+ const mediaCategory = getMediaCategory(data);
+
+ const contextValue = useMemo(
+ () => ({ data, mediaCategory, onRemove, variant }),
+ [data, mediaCategory, onRemove, variant]
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+// ============================================================================
+// AttachmentPreview - Media preview
+// ============================================================================
+
+export type AttachmentPreviewProps = HTMLAttributes & {
+ fallbackIcon?: ReactNode;
+};
+
+export const AttachmentPreview = ({
+ fallbackIcon,
+ className,
+ ...props
+}: AttachmentPreviewProps) => {
+ const { data, mediaCategory, variant } = useAttachmentContext();
+
+ const iconSize = variant === "inline" ? "size-3" : "size-4";
+
+ const renderIcon = (Icon: typeof ImageIcon) => (
+
+ );
+
+ const renderContent = () => {
+ if (mediaCategory === "image" && data.type === "file" && data.url) {
+ return renderAttachmentImage(data.url, data.filename, variant === "grid");
+ }
+
+ if (mediaCategory === "video" && data.type === "file" && data.url) {
+ return ;
+ }
+
+ const Icon = mediaCategoryIcons[mediaCategory];
+ return fallbackIcon ?? renderIcon(Icon);
+ };
+
+ return (
+
+ {renderContent()}
+
+ );
+};
+
+// ============================================================================
+// AttachmentInfo - Name and type display
+// ============================================================================
+
+export type AttachmentInfoProps = HTMLAttributes & {
+ showMediaType?: boolean;
+};
+
+export const AttachmentInfo = ({
+ showMediaType = false,
+ className,
+ ...props
+}: AttachmentInfoProps) => {
+ const { data, variant } = useAttachmentContext();
+ const label = getAttachmentLabel(data);
+
+ if (variant === "grid") {
+ return null;
+ }
+
+ return (
+
+ {label}
+ {showMediaType && data.mediaType && (
+
+ {data.mediaType}
+
+ )}
+
+ );
+};
+
+// ============================================================================
+// AttachmentRemove - Remove button
+// ============================================================================
+
+export type AttachmentRemoveProps = ComponentProps & {
+ label?: string;
+};
+
+export const AttachmentRemove = ({
+ label = "Remove",
+ className,
+ children,
+ ...props
+}: AttachmentRemoveProps) => {
+ const { onRemove, variant } = useAttachmentContext();
+
+ const handleClick = useCallback(
+ (e: React.MouseEvent) => {
+ e.stopPropagation();
+ onRemove?.();
+ },
+ [onRemove]
+ );
+
+ if (!onRemove) {
+ return null;
+ }
+
+ return (
+
+ );
+};
+
+// ============================================================================
+// AttachmentHoverCard - Hover preview
+// ============================================================================
+
+export type AttachmentHoverCardProps = ComponentProps;
+
+export const AttachmentHoverCard = ({
+ openDelay = 0,
+ closeDelay = 0,
+ ...props
+}: AttachmentHoverCardProps) => (
+
+);
+
+export type AttachmentHoverCardTriggerProps = ComponentProps<
+ typeof HoverCardTrigger
+>;
+
+export const AttachmentHoverCardTrigger = (
+ props: AttachmentHoverCardTriggerProps
+) => ;
+
+export type AttachmentHoverCardContentProps = ComponentProps<
+ typeof HoverCardContent
+>;
+
+export const AttachmentHoverCardContent = ({
+ align = "start",
+ className,
+ ...props
+}: AttachmentHoverCardContentProps) => (
+
+);
+
+// ============================================================================
+// AttachmentEmpty - Empty state
+// ============================================================================
+
+export type AttachmentEmptyProps = HTMLAttributes;
+
+export const AttachmentEmpty = ({
+ className,
+ children,
+ ...props
+}: AttachmentEmptyProps) => (
+
+ {children ?? "No attachments"}
+
+);
diff --git a/components/ai-elements/code-block.tsx b/components/ai-elements/code-block.tsx
new file mode 100644
index 0000000..bc1441a
--- /dev/null
+++ b/components/ai-elements/code-block.tsx
@@ -0,0 +1,562 @@
+"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 }) => (
+
+ {token.content}
+
+);
+
+// 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;
+}) => (
+
+ {keyedLine.tokens.length === 0
+ ? "\n"
+ : keyedLine.tokens.map(({ token, key }) => (
+
+ ))}
+
+);
+
+// Types
+type CodeBlockProps = HTMLAttributes & {
+ code: string;
+ language: BundledLanguage;
+ showLineNumbers?: boolean;
+};
+
+interface TokenizedCode {
+ tokens: ThemedToken[][];
+ fg: string;
+ bg: string;
+}
+
+interface CodeBlockContextType {
+ code: string;
+}
+
+// Context
+const CodeBlockContext = createContext({
+ code: "",
+});
+
+// Highlighter cache (singleton per language)
+const highlighterCache = new Map<
+ string,
+ Promise>
+>();
+
+// Token cache
+const tokensCache = new Map();
+
+// Subscribers for async token updates
+const subscribers = new Map 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> => {
+ 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 (
+
+
+ {keyedLines.map((keyedLine) => (
+
+ ))}
+
+
+ );
+ },
+ (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 & { language: string }) => (
+
+);
+
+export const CodeBlockHeader = ({
+ children,
+ className,
+ ...props
+}: HTMLAttributes) => (
+
+ {children}
+
+);
+
+export const CodeBlockTitle = ({
+ children,
+ className,
+ ...props
+}: HTMLAttributes) => (
+
+ {children}
+
+);
+
+export const CodeBlockFilename = ({
+ children,
+ className,
+ ...props
+}: HTMLAttributes) => (
+
+ {children}
+
+);
+
+export const CodeBlockActions = ({
+ children,
+ className,
+ ...props
+}: HTMLAttributes) => (
+
+ {children}
+
+);
+
+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(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 (
+
+
+
+ );
+};
+
+export const CodeBlock = ({
+ code,
+ language,
+ showLineNumbers = false,
+ className,
+ children,
+ ...props
+}: CodeBlockProps) => {
+ const contextValue = useMemo(() => ({ code }), [code]);
+
+ return (
+
+
+ {children}
+
+
+
+ );
+};
+
+export type CodeBlockCopyButtonProps = ComponentProps & {
+ 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(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 (
+
+ );
+};
+
+export type CodeBlockLanguageSelectorProps = ComponentProps;
+
+export const CodeBlockLanguageSelector = (
+ props: CodeBlockLanguageSelectorProps
+) => ;
+
+export type CodeBlockLanguageSelectorTriggerProps = ComponentProps<
+ typeof SelectTrigger
+>;
+
+export const CodeBlockLanguageSelectorTrigger = ({
+ className,
+ ...props
+}: CodeBlockLanguageSelectorTriggerProps) => (
+
+);
+
+export type CodeBlockLanguageSelectorValueProps = ComponentProps<
+ typeof SelectValue
+>;
+
+export const CodeBlockLanguageSelectorValue = (
+ props: CodeBlockLanguageSelectorValueProps
+) => ;
+
+export type CodeBlockLanguageSelectorContentProps = ComponentProps<
+ typeof SelectContent
+>;
+
+export const CodeBlockLanguageSelectorContent = ({
+ align = "end",
+ ...props
+}: CodeBlockLanguageSelectorContentProps) => (
+
+);
+
+export type CodeBlockLanguageSelectorItemProps = ComponentProps<
+ typeof SelectItem
+>;
+
+export const CodeBlockLanguageSelectorItem = (
+ props: CodeBlockLanguageSelectorItemProps
+) => ;
diff --git a/components/ai-elements/conversation.tsx b/components/ai-elements/conversation.tsx
new file mode 100644
index 0000000..21e5d82
--- /dev/null
+++ b/components/ai-elements/conversation.tsx
@@ -0,0 +1,168 @@
+"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 } from "react";
+import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
+
+export type ConversationProps = ComponentProps;
+
+export const Conversation = ({ className, ...props }: ConversationProps) => (
+
+);
+
+export type ConversationContentProps = ComponentProps<
+ typeof StickToBottom.Content
+>;
+
+export const ConversationContent = ({
+ className,
+ ...props
+}: ConversationContentProps) => (
+
+);
+
+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) => (
+
+ {children ?? (
+ <>
+ {icon &&
{icon}
}
+
+
{title}
+ {description && (
+
{description}
+ )}
+
+ >
+ )}
+
+);
+
+export type ConversationScrollButtonProps = ComponentProps;
+
+export const ConversationScrollButton = ({
+ className,
+ ...props
+}: ConversationScrollButtonProps) => {
+ const { isAtBottom, scrollToBottom } = useStickToBottomContext();
+
+ const handleScrollToBottom = useCallback(() => {
+ scrollToBottom();
+ }, [scrollToBottom]);
+
+ return (
+ !isAtBottom && (
+
+ )
+ );
+};
+
+const getMessageText = (message: UIMessage): string =>
+ message.parts
+ .filter((part) => part.type === "text")
+ .map((part) => part.text)
+ .join("");
+
+export type ConversationDownloadProps = Omit<
+ ComponentProps,
+ "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 (
+
+ );
+};
diff --git a/components/ai-elements/message.tsx b/components/ai-elements/message.tsx
new file mode 100644
index 0000000..cc0e62c
--- /dev/null
+++ b/components/ai-elements/message.tsx
@@ -0,0 +1,360 @@
+"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 & {
+ from: UIMessage["role"];
+};
+
+export const Message = ({ className, from, ...props }: MessageProps) => (
+
+);
+
+export type MessageContentProps = HTMLAttributes;
+
+export const MessageContent = ({
+ children,
+ className,
+ ...props
+}: MessageContentProps) => (
+
+ {children}
+
+);
+
+export type MessageActionsProps = ComponentProps<"div">;
+
+export const MessageActions = ({
+ className,
+ children,
+ ...props
+}: MessageActionsProps) => (
+
+ {children}
+
+);
+
+export type MessageActionProps = ComponentProps & {
+ tooltip?: string;
+ label?: string;
+};
+
+export const MessageAction = ({
+ tooltip,
+ children,
+ label,
+ variant = "ghost",
+ size = "icon-sm",
+ ...props
+}: MessageActionProps) => {
+ const button = (
+
+ );
+
+ if (tooltip) {
+ return (
+
+
+ {button}
+
+ {tooltip}
+
+
+
+ );
+ }
+
+ return button;
+};
+
+interface MessageBranchContextType {
+ currentBranch: number;
+ totalBranches: number;
+ goToPrevious: () => void;
+ goToNext: () => void;
+ branches: ReactElement[];
+ setBranches: (branches: ReactElement[]) => void;
+}
+
+const MessageBranchContext = createContext(
+ 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 & {
+ defaultBranch?: number;
+ onBranchChange?: (branchIndex: number) => void;
+};
+
+export const MessageBranch = ({
+ defaultBranch = 0,
+ onBranchChange,
+ className,
+ ...props
+}: MessageBranchProps) => {
+ const [currentBranch, setCurrentBranch] = useState(defaultBranch);
+ const [branches, setBranches] = useState([]);
+
+ 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(
+ () => ({
+ branches,
+ currentBranch,
+ goToNext,
+ goToPrevious,
+ setBranches,
+ totalBranches: branches.length,
+ }),
+ [branches, currentBranch, goToNext, goToPrevious]
+ );
+
+ return (
+
+ div]:pb-0", className)}
+ {...props}
+ />
+
+ );
+};
+
+export type MessageBranchContentProps = HTMLAttributes
;
+
+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]:pb-0",
+ index === currentBranch ? "block" : "hidden"
+ )}
+ key={branch.key}
+ {...props}
+ >
+ {branch}
+
+ ));
+};
+
+export type MessageBranchSelectorProps = ComponentProps;
+
+export const MessageBranchSelector = ({
+ className,
+ ...props
+}: MessageBranchSelectorProps) => {
+ const { totalBranches } = useMessageBranch();
+
+ // Don't render if there's only one branch
+ if (totalBranches <= 1) {
+ return null;
+ }
+
+ return (
+ *:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md",
+ className
+ )}
+ orientation="horizontal"
+ {...props}
+ />
+ );
+};
+
+export type MessageBranchPreviousProps = ComponentProps;
+
+export const MessageBranchPrevious = ({
+ children,
+ ...props
+}: MessageBranchPreviousProps) => {
+ const { goToPrevious, totalBranches } = useMessageBranch();
+
+ return (
+
+ );
+};
+
+export type MessageBranchNextProps = ComponentProps;
+
+export const MessageBranchNext = ({
+ children,
+ ...props
+}: MessageBranchNextProps) => {
+ const { goToNext, totalBranches } = useMessageBranch();
+
+ return (
+
+ );
+};
+
+export type MessageBranchPageProps = HTMLAttributes;
+
+export const MessageBranchPage = ({
+ className,
+ ...props
+}: MessageBranchPageProps) => {
+ const { currentBranch, totalBranches } = useMessageBranch();
+
+ return (
+
+ {currentBranch + 1} of {totalBranches}
+
+ );
+};
+
+export type MessageResponseProps = ComponentProps;
+
+const streamdownPlugins = { cjk, code, math, mermaid };
+
+export const MessageResponse = memo(
+ ({ className, ...props }: MessageResponseProps) => (
+ *: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) => (
+
+ {children}
+
+);
diff --git a/components/ai-elements/prompt-input.tsx b/components/ai-elements/prompt-input.tsx
new file mode 100644
index 0000000..5be6019
--- /dev/null
+++ b/components/ai-elements/prompt-input.tsx
@@ -0,0 +1,1463 @@
+"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 {
+ CornerDownLeftIcon,
+ ImageIcon,
+ Monitor,
+ PlusIcon,
+ SquareIcon,
+ 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 => {
+ 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 => {
+ 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((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((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;
+}
+
+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,
+ open: () => void
+ ) => void;
+}
+
+const PromptInputController = createContext(
+ null
+);
+const ProviderAttachmentsContext = createContext(
+ null
+);
+
+export const usePromptInputController = () => {
+ const ctx = useContext(PromptInputController);
+ if (!ctx) {
+ throw new Error(
+ "Wrap your component inside 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 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(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(
+ () => ({
+ add,
+ clear,
+ fileInputRef,
+ files: attachmentFiles,
+ openFileDialog,
+ remove,
+ }),
+ [attachmentFiles, add, remove, clear, openFileDialog]
+ );
+
+ const __registerFileInput = useCallback(
+ (ref: RefObject, open: () => void) => {
+ fileInputRef.current = ref.current;
+ openRef.current = open;
+ },
+ []
+ );
+
+ const controller = useMemo(
+ () => ({
+ __registerFileInput,
+ attachments,
+ textInput: {
+ clear: clearInput,
+ setInput: setTextInput,
+ value: textInput,
+ },
+ }),
+ [textInput, clearInput, attachments, __registerFileInput]
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+// ============================================================================
+// Component Context & Hooks
+// ============================================================================
+
+const LocalAttachmentsContext = createContext(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(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 (
+
+ {label}
+
+ );
+};
+
+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 (
+
+
+ {label}
+
+ );
+};
+
+export interface PromptInputMessage {
+ text: string;
+ files: FileUIPart[];
+}
+
+export type PromptInputProps = Omit<
+ HTMLAttributes,
+ "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
+ ) => void | Promise;
+};
+
+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(null);
+ const formRef = useRef(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 = 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(
+ () => ({
+ add,
+ clear: clearAttachments,
+ fileInputRef: inputRef,
+ files: files.map((item) => ({ ...item, id: item.id })),
+ openFileDialog,
+ remove,
+ }),
+ [files, add, remove, clearAttachments, openFileDialog]
+ );
+
+ const refsCtx = useMemo(
+ () => ({
+ 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 = 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 = (
+ <>
+
+
+ >
+ );
+
+ const withReferencedSources = (
+
+ {inner}
+
+ );
+
+ // Always provide LocalAttachmentsContext so children get validated add function
+ return (
+
+ {withReferencedSources}
+
+ );
+};
+
+export type PromptInputBodyProps = HTMLAttributes;
+
+export const PromptInputBody = ({
+ className,
+ ...props
+}: PromptInputBodyProps) => (
+
+);
+
+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 = 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 = 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) => {
+ controller.textInput.setInput(e.currentTarget.value);
+ onChange?.(e);
+ },
+ value: controller.textInput.value,
+ }
+ : {
+ onChange,
+ };
+
+ return (
+
+ );
+};
+
+export type PromptInputHeaderProps = Omit<
+ ComponentProps,
+ "align"
+>;
+
+export const PromptInputHeader = ({
+ className,
+ ...props
+}: PromptInputHeaderProps) => (
+
+);
+
+export type PromptInputFooterProps = Omit<
+ ComponentProps,
+ "align"
+>;
+
+export const PromptInputFooter = ({
+ className,
+ ...props
+}: PromptInputFooterProps) => (
+
+);
+
+export type PromptInputToolsProps = HTMLAttributes;
+
+export const PromptInputTools = ({
+ className,
+ ...props
+}: PromptInputToolsProps) => (
+
+);
+
+export type PromptInputButtonTooltip =
+ | string
+ | {
+ content: ReactNode;
+ shortcut?: string;
+ side?: ComponentProps["side"];
+ };
+
+export type PromptInputButtonProps = ComponentProps & {
+ 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 = (
+
+ );
+
+ 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 (
+
+ {button}
+
+ {tooltipContent}
+ {shortcut && (
+ {shortcut}
+ )}
+
+
+ );
+};
+
+export type PromptInputActionMenuProps = ComponentProps;
+export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
+
+);
+
+export type PromptInputActionMenuTriggerProps = PromptInputButtonProps;
+
+export const PromptInputActionMenuTrigger = ({
+ className,
+ children,
+ ...props
+}: PromptInputActionMenuTriggerProps) => (
+
+
+ {children ?? }
+
+
+);
+
+export type PromptInputActionMenuContentProps = ComponentProps<
+ typeof DropdownMenuContent
+>;
+export const PromptInputActionMenuContent = ({
+ className,
+ ...props
+}: PromptInputActionMenuContentProps) => (
+
+);
+
+export type PromptInputActionMenuItemProps = ComponentProps<
+ typeof DropdownMenuItem
+>;
+export const PromptInputActionMenuItem = ({
+ className,
+ ...props
+}: PromptInputActionMenuItemProps) => (
+
+);
+
+// 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 & {
+ 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 = ;
+
+ if (status === "submitted") {
+ Icon = ;
+ } else if (status === "streaming") {
+ Icon = ;
+ } else if (status === "error") {
+ Icon = ;
+ }
+
+ const handleClick = useCallback(
+ (e: React.MouseEvent) => {
+ if (isGenerating && onStop) {
+ e.preventDefault();
+ onStop();
+ return;
+ }
+ onClick?.(e);
+ },
+ [isGenerating, onStop, onClick]
+ );
+
+ return (
+
+ {children ?? Icon}
+
+ );
+};
+
+export type PromptInputSelectProps = ComponentProps;
+
+export const PromptInputSelect = (props: PromptInputSelectProps) => (
+
+);
+
+export type PromptInputSelectTriggerProps = ComponentProps<
+ typeof SelectTrigger
+>;
+
+export const PromptInputSelectTrigger = ({
+ className,
+ ...props
+}: PromptInputSelectTriggerProps) => (
+
+);
+
+export type PromptInputSelectContentProps = ComponentProps<
+ typeof SelectContent
+>;
+
+export const PromptInputSelectContent = ({
+ className,
+ ...props
+}: PromptInputSelectContentProps) => (
+
+);
+
+export type PromptInputSelectItemProps = ComponentProps;
+
+export const PromptInputSelectItem = ({
+ className,
+ ...props
+}: PromptInputSelectItemProps) => (
+
+);
+
+export type PromptInputSelectValueProps = ComponentProps;
+
+export const PromptInputSelectValue = ({
+ className,
+ ...props
+}: PromptInputSelectValueProps) => (
+
+);
+
+export type PromptInputHoverCardProps = ComponentProps;
+
+export const PromptInputHoverCard = ({
+ openDelay = 0,
+ closeDelay = 0,
+ ...props
+}: PromptInputHoverCardProps) => (
+
+);
+
+export type PromptInputHoverCardTriggerProps = ComponentProps<
+ typeof HoverCardTrigger
+>;
+
+export const PromptInputHoverCardTrigger = (
+ props: PromptInputHoverCardTriggerProps
+) => ;
+
+export type PromptInputHoverCardContentProps = ComponentProps<
+ typeof HoverCardContent
+>;
+
+export const PromptInputHoverCardContent = ({
+ align = "start",
+ ...props
+}: PromptInputHoverCardContentProps) => (
+
+);
+
+export type PromptInputTabsListProps = HTMLAttributes;
+
+export const PromptInputTabsList = ({
+ className,
+ ...props
+}: PromptInputTabsListProps) => ;
+
+export type PromptInputTabProps = HTMLAttributes;
+
+export const PromptInputTab = ({
+ className,
+ ...props
+}: PromptInputTabProps) => ;
+
+export type PromptInputTabLabelProps = HTMLAttributes;
+
+export const PromptInputTabLabel = ({
+ className,
+ ...props
+}: PromptInputTabLabelProps) => (
+ // Content provided via children in props
+ // oxlint-disable-next-line eslint-plugin-jsx-a11y(heading-has-content)
+
+);
+
+export type PromptInputTabBodyProps = HTMLAttributes;
+
+export const PromptInputTabBody = ({
+ className,
+ ...props
+}: PromptInputTabBodyProps) => (
+
+);
+
+export type PromptInputTabItemProps = HTMLAttributes;
+
+export const PromptInputTabItem = ({
+ className,
+ ...props
+}: PromptInputTabItemProps) => (
+
+);
+
+export type PromptInputCommandProps = ComponentProps;
+
+export const PromptInputCommand = ({
+ className,
+ ...props
+}: PromptInputCommandProps) => ;
+
+export type PromptInputCommandInputProps = ComponentProps;
+
+export const PromptInputCommandInput = ({
+ className,
+ ...props
+}: PromptInputCommandInputProps) => (
+
+);
+
+export type PromptInputCommandListProps = ComponentProps;
+
+export const PromptInputCommandList = ({
+ className,
+ ...props
+}: PromptInputCommandListProps) => (
+
+);
+
+export type PromptInputCommandEmptyProps = ComponentProps;
+
+export const PromptInputCommandEmpty = ({
+ className,
+ ...props
+}: PromptInputCommandEmptyProps) => (
+
+);
+
+export type PromptInputCommandGroupProps = ComponentProps;
+
+export const PromptInputCommandGroup = ({
+ className,
+ ...props
+}: PromptInputCommandGroupProps) => (
+
+);
+
+export type PromptInputCommandItemProps = ComponentProps;
+
+export const PromptInputCommandItem = ({
+ className,
+ ...props
+}: PromptInputCommandItemProps) => (
+
+);
+
+export type PromptInputCommandSeparatorProps = ComponentProps<
+ typeof CommandSeparator
+>;
+
+export const PromptInputCommandSeparator = ({
+ className,
+ ...props
+}: PromptInputCommandSeparatorProps) => (
+
+);
diff --git a/components/ai-elements/reasoning.tsx b/components/ai-elements/reasoning.tsx
new file mode 100644
index 0000000..b56adac
--- /dev/null
+++ b/components/ai-elements/reasoning.tsx
@@ -0,0 +1,226 @@
+"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(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 & {
+ isStreaming?: boolean;
+ open?: boolean;
+ defaultOpen?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ duration?: number;
+};
+
+const AUTO_CLOSE_DELAY = 1000;
+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({
+ defaultProp: resolvedDefaultOpen,
+ onChange: onOpenChange,
+ prop: open,
+ });
+ const [duration, setDuration] = useControllableState({
+ defaultProp: undefined,
+ prop: durationProp,
+ });
+
+ const hasEverStreamedRef = useRef(isStreaming);
+ const [hasAutoClosed, setHasAutoClosed] = useState(false);
+ const startTimeRef = useRef(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
+ ) {
+ const timer = setTimeout(() => {
+ setIsOpen(false);
+ setHasAutoClosed(true);
+ }, AUTO_CLOSE_DELAY);
+
+ return () => clearTimeout(timer);
+ }
+ }, [isStreaming, isOpen, setIsOpen, hasAutoClosed]);
+
+ const handleOpenChange = useCallback(
+ (newOpen: boolean) => {
+ setIsOpen(newOpen);
+ },
+ [setIsOpen]
+ );
+
+ const contextValue = useMemo(
+ () => ({ duration, isOpen, isStreaming, setIsOpen }),
+ [duration, isOpen, isStreaming, setIsOpen]
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+ }
+);
+
+export type ReasoningTriggerProps = ComponentProps<
+ typeof CollapsibleTrigger
+> & {
+ getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
+};
+
+const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
+ if (isStreaming || duration === 0) {
+ return Thinking...;
+ }
+ if (duration === undefined) {
+ return Thought for a few seconds
;
+ }
+ return Thought for {duration} seconds
;
+};
+
+export const ReasoningTrigger = memo(
+ ({
+ className,
+ children,
+ getThinkingMessage = defaultGetThinkingMessage,
+ ...props
+ }: ReasoningTriggerProps) => {
+ const { isStreaming, isOpen, duration } = useReasoning();
+
+ return (
+
+ {children ?? (
+ <>
+
+ {getThinkingMessage(isStreaming, duration)}
+
+ >
+ )}
+
+ );
+ }
+);
+
+export type ReasoningContentProps = ComponentProps<
+ typeof CollapsibleContent
+> & {
+ children: string;
+};
+
+const streamdownPlugins = { cjk, code, math, mermaid };
+
+export const ReasoningContent = memo(
+ ({ className, children, ...props }: ReasoningContentProps) => (
+
+ {children}
+
+ )
+);
+
+Reasoning.displayName = "Reasoning";
+ReasoningTrigger.displayName = "ReasoningTrigger";
+ReasoningContent.displayName = "ReasoningContent";
diff --git a/components/ai-elements/shimmer.tsx b/components/ai-elements/shimmer.tsx
new file mode 100644
index 0000000..6b635d0
--- /dev/null
+++ b/components/ai-elements/shimmer.tsx
@@ -0,0 +1,77 @@
+"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;
+
+// Cache motion components at module level to avoid creating during render
+const motionComponentCache = new Map<
+ keyof JSX.IntrinsicElements,
+ React.ComponentType
+>();
+
+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 (
+
+ {children}
+
+ );
+};
+
+export const Shimmer = memo(ShimmerComponent);
diff --git a/components/ai-elements/suggestion.tsx b/components/ai-elements/suggestion.tsx
new file mode 100644
index 0000000..f037ff4
--- /dev/null
+++ b/components/ai-elements/suggestion.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ ScrollArea,
+ ScrollBar,
+} from "@/components/ui/scroll-area";
+import { cn } from "@/lib/utils";
+import type { ComponentProps } from "react";
+import { useCallback } from "react";
+
+export type SuggestionsProps = ComponentProps;
+
+export const Suggestions = ({
+ className,
+ children,
+ ...props
+}: SuggestionsProps) => (
+
+
+ {children}
+
+
+
+);
+
+export type SuggestionProps = Omit, "onClick"> & {
+ suggestion: string;
+ onClick?: (suggestion: string) => void;
+};
+
+export const Suggestion = ({
+ suggestion,
+ onClick,
+ className,
+ variant = "outline",
+ size = "sm",
+ children,
+ ...props
+}: SuggestionProps) => {
+ const handleClick = useCallback(() => {
+ onClick?.(suggestion);
+ }, [onClick, suggestion]);
+
+ return (
+
+ );
+};
diff --git a/components/ai-elements/tool.tsx b/components/ai-elements/tool.tsx
new file mode 100644
index 0000000..18c4bb8
--- /dev/null
+++ b/components/ai-elements/tool.tsx
@@ -0,0 +1,173 @@
+"use client";
+
+import { Badge } from "@/components/ui/badge";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { cn } from "@/lib/utils";
+import type { DynamicToolUIPart, ToolUIPart } from "ai";
+import {
+ CheckCircleIcon,
+ ChevronDownIcon,
+ CircleIcon,
+ ClockIcon,
+ WrenchIcon,
+ XCircleIcon,
+} from "lucide-react";
+import type { ComponentProps, ReactNode } from "react";
+import { isValidElement } from "react";
+
+import { CodeBlock } from "./code-block";
+
+export type ToolProps = ComponentProps;
+
+export const Tool = ({ className, ...props }: ToolProps) => (
+
+);
+
+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 = {
+ "approval-requested": "Awaiting Approval",
+ "approval-responded": "Responded",
+ "input-available": "Running",
+ "input-streaming": "Pending",
+ "output-available": "Completed",
+ "output-denied": "Denied",
+ "output-error": "Error",
+};
+
+const statusIcons: Record = {
+ "approval-requested": ,
+ "approval-responded": ,
+ "input-available": ,
+ "input-streaming": ,
+ "output-available": ,
+ "output-denied": ,
+ "output-error": ,
+};
+
+export const getStatusBadge = (status: ToolPart["state"]) => (
+
+ {statusIcons[status]}
+ {statusLabels[status]}
+
+);
+
+export const ToolHeader = ({
+ className,
+ title,
+ type,
+ state,
+ toolName,
+ ...props
+}: ToolHeaderProps) => {
+ const derivedName =
+ type === "dynamic-tool" ? toolName : type.split("-").slice(1).join("-");
+
+ return (
+
+
+
+ {title ?? derivedName}
+ {getStatusBadge(state)}
+
+
+
+ );
+};
+
+export type ToolContentProps = ComponentProps;
+
+export const ToolContent = ({ className, ...props }: ToolContentProps) => (
+
+);
+
+export type ToolInputProps = ComponentProps<"div"> & {
+ input: ToolPart["input"];
+};
+
+export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
+
+);
+
+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 = {output as ReactNode}
;
+
+ if (typeof output === "object" && !isValidElement(output)) {
+ Output = (
+
+ );
+ } else if (typeof output === "string") {
+ Output = ;
+ }
+
+ return (
+
+
+ {errorText ? "Error" : "Result"}
+
+
+ {errorText &&
{errorText}
}
+ {Output}
+
+
+ );
+};
diff --git a/components/shopify/footer.tsx b/components/shopify/footer.tsx
index 0eec437..284d0e6 100644
--- a/components/shopify/footer.tsx
+++ b/components/shopify/footer.tsx
@@ -42,11 +42,11 @@ const Footer: React.FC = ({
return (