("");
+
+ // We cancel Handsontable's own sort (see `beforeColumnSort`), so the
+ // columnSorting plugin's per-column state never advances and the `sortOrder`
+ // it hands us is always "asc". Track the direction ourselves to toggle
+ // asc → desc on repeated clicks of the same column header.
+ const sortRef = useRef<{ colId: string; sortOrder: "asc" | "desc" } | null>(
+ null
+ );
+
+ const order = useMemo(() => ({ rowIds, colIds }), [rowIds, colIds]);
+
+ // Plain Handsontable function renderer: paints value + formatting + presence
+ // borders + comment marker directly onto the (recycled) , synchronously
+ // on every Handsontable render. Every style is set or reset on every call, so
+ // a recycled/repainted cell never keeps a previous cell's content or styles.
+ const renderCell = useCallback(
+ (
+ _instance: HotInstance,
+ td: HTMLTableCellElement,
+ row: number,
+ col: number,
+ _prop: string | number,
+ value: unknown
+ ) => {
+ // Reset everything this renderer may set (idempotent on recycled tds).
+ td.style.background = "";
+ td.style.boxShadow = "";
+ td.style.fontWeight = "";
+ td.style.fontStyle = "";
+ td.style.textDecoration = "";
+ td.style.color = "";
+ td.style.textAlign = "";
+ td.classList.remove("has-comment");
+ td.removeAttribute("title");
+
+ const rowId = rowIdsRef.current[row];
+ const colId = colIdsRef.current[col];
+ const raw = value == null ? "" : String(value);
+
+ // Transient frame (order not resolved yet): still show the raw value.
+ if (!rowId || !colId) {
+ td.textContent = raw;
+ return;
+ }
+
+ const key = cellKey(rowId, colId);
+ const format = cellsFormatRef.current[key];
+
+ td.textContent = formatDisplayValue(raw, format?.numberFormat);
+
+ if (format) {
+ if (format.bold) {
+ td.style.fontWeight = "600";
+ }
+ if (format.italic) {
+ td.style.fontStyle = "italic";
+ }
+ const decorations: string[] = [];
+ if (format.underline) {
+ decorations.push("underline");
+ }
+ if (format.strike) {
+ decorations.push("line-through");
+ }
+ if (decorations.length) {
+ td.style.textDecoration = decorations.join(" ");
+ }
+ if (format.color) {
+ td.style.color = format.color;
+ }
+ if (format.align) {
+ td.style.textAlign = format.align;
+ }
+ if (format.background) {
+ td.style.background = format.background;
+ }
+ }
+
+ const selectors = presenceByCellRef.current[key];
+ if (selectors && selectors.length) {
+ // Draw only the boundary edges of each user's region, so a multi-cell
+ // selection reads as one box rather than a border around every cell.
+ // Stack multiple users concentrically (first listed sits on top).
+ const shadows: string[] = [];
+ for (let i = 0; i < selectors.length; i++) {
+ const s = selectors[i];
+ const w = 2 + i * 2;
+ if (s.top) {
+ shadows.push(`inset 0 ${w}px 0 0 ${s.color}`);
+ }
+ if (s.bottom) {
+ shadows.push(`inset 0 -${w}px 0 0 ${s.color}`);
+ }
+ if (s.left) {
+ shadows.push(`inset ${w}px 0 0 0 ${s.color}`);
+ }
+ if (s.right) {
+ shadows.push(`inset -${w}px 0 0 0 ${s.color}`);
+ }
+ }
+ if (shadows.length) {
+ td.style.boxShadow = shadows.join(", ");
+ }
+ td.title = selectors.map((s) => s.name).join(", ");
+ }
+
+ if (threadKeysRef.current.has(key)) {
+ td.classList.add("has-comment");
+ }
+ },
+ []
+ );
+
+ // Repaint the grid whenever formatting, presence, or the set of comment
+ // threads change. (Value/order changes flow through the `data` prop, which
+ // already triggers a Handsontable re-render.)
+ useEffect(() => {
+ hotRef.current?.hotInstance?.render();
+ }, [cellsFormat, presenceByCell, threadKeys]);
+
+ const colHeaders = useCallback(
+ (index: number) => colIndexToLetters(index),
+ []
+ );
+ const rowHeaders = useCallback((index: number) => String(index + 1), []);
+
+ const afterChange = useCallback(
+ (changes: CellChange[] | null, source: ChangeSource) => {
+ if (!changes || source === "loadData") {
+ return;
+ }
+ const instance = hotRef.current?.hotInstance;
+ for (const [visualRow, prop, , newVal] of changes) {
+ if (typeof prop !== "number") {
+ continue;
+ }
+ const rowId = rowIdsRef.current[visualRow];
+ const colId = colIdsRef.current[prop];
+ if (!rowId || !colId) {
+ continue;
+ }
+ // With the Formulas plugin, `newVal` is the *computed* result. Persist
+ // the underlying source instead
+ const source = instance?.getSourceDataAtCell(visualRow, prop) ?? newVal;
+ actions.setCellValue(
+ rowId,
+ colId,
+ source === null || source === undefined ? "" : String(source)
+ );
+ }
+ },
+ [actions]
+ );
+
+ const onSelection = useCallback(
+ (row: number, col: number, row2: number, col2: number) => {
+ const r1 = Math.max(0, Math.min(row, row2));
+ const r2 = Math.max(row, row2);
+ const c1 = Math.max(0, Math.min(col, col2));
+ const c2 = Math.max(col, col2);
+ const selectedRowIds = rowIdsRef.current.slice(r1, r2 + 1);
+ const selectedColIds = colIdsRef.current.slice(c1, c2 + 1);
+ if (!selectedRowIds.length || !selectedColIds.length) {
+ return;
+ }
+ const anchor = {
+ rowId: rowIdsRef.current[Math.max(0, row)] ?? selectedRowIds[0],
+ colId: colIdsRef.current[Math.max(0, col)] ?? selectedColIds[0],
+ };
+ const key = `${r1},${c1},${r2},${c2}`;
+ if (key === lastSelKey.current) {
+ return;
+ }
+ lastSelKey.current = key;
+ setSelection({
+ rowIds: selectedRowIds,
+ colIds: selectedColIds,
+ anchor,
+ });
+ // Humans only broadcast their single active cell, even when a range is
+ // selected — the multi-cell box is reserved for the AI's live edits.
+ updateMyPresence({ selectedCells: [anchor] });
+ },
+ [setSelection, updateMyPresence]
+ );
+
+ const onDeselect = useCallback(() => {
+ lastSelKey.current = "";
+ setSelection(null);
+ updateMyPresence({ selectedCells: null });
+ }, [setSelection, updateMyPresence]);
+
+ const afterColumnResize = useCallback(
+ (newSize: number, column: number) => {
+ const colId = colIdsRef.current[column];
+ if (colId) {
+ actions.setColWidth(colId, newSize);
+ }
+ },
+ [actions]
+ );
+
+ const afterRowResize = useCallback(
+ (newSize: number, row: number) => {
+ const rowId = rowIdsRef.current[row];
+ if (rowId) {
+ actions.setRowHeight(rowId, newSize);
+ }
+ },
+ [actions]
+ );
+
+ // Cancel Handsontable's own visual move and reorder the shared id list
+ // instead. The grid stays a pure identity projection of Storage.
+ const beforeRowMove = useCallback(
+ (
+ movedRows: number[],
+ finalIndex: number,
+ _drop: number | undefined,
+ movePossible: boolean
+ ) => {
+ if (!movePossible) {
+ return;
+ }
+ const next = reorder(rowIdsRef.current, movedRows, finalIndex);
+ actions.setRowOrder(next);
+ return false;
+ },
+ [actions]
+ );
+
+ const beforeColumnMove = useCallback(
+ (
+ movedColumns: number[],
+ finalIndex: number,
+ _drop: number | undefined,
+ movePossible: boolean
+ ) => {
+ if (!movePossible) {
+ return;
+ }
+ const next = reorder(colIdsRef.current, movedColumns, finalIndex);
+ actions.setColOrder(next);
+ return false;
+ },
+ [actions]
+ );
+
+ const beforeColumnSort = useCallback(
+ (
+ _current: unknown,
+ destination: { column: number; sortOrder?: "asc" | "desc" }[]
+ ) => {
+ const config = destination?.[0];
+ if (!config) {
+ return false;
+ }
+ const colId = colIdsRef.current[config.column];
+ if (!colId) {
+ return false;
+ }
+ const prev = sortRef.current;
+ const sortOrder: "asc" | "desc" =
+ prev && prev.colId === colId && prev.sortOrder === "asc"
+ ? "desc"
+ : "asc";
+ sortRef.current = { colId, sortOrder };
+ const sorted = [...rowIdsRef.current].sort((a, b) =>
+ compareValues(
+ valuesRef.current[cellKey(a, colId)] ?? "",
+ valuesRef.current[cellKey(b, colId)] ?? ""
+ )
+ );
+ if (sortOrder === "desc") {
+ sorted.reverse();
+ }
+ actions.setRowOrder(sorted);
+ return false;
+ },
+ [actions]
+ );
+
+ return (
+
+
+ target instanceof HTMLElement && target.closest(".lb-portal") !== null
+ }
+ afterColumnResize={afterColumnResize}
+ afterRowResize={afterRowResize}
+ beforeRowMove={beforeRowMove}
+ beforeColumnMove={beforeColumnMove}
+ beforeColumnSort={beforeColumnSort}
+ manualColumnResize={true}
+ manualRowResize={true}
+ manualRowMove={true}
+ manualColumnMove={true}
+ columnSorting={{ headerAction: false, indicator: false }}
+ width="100%"
+ height="100%"
+ licenseKey="non-commercial-and-evaluation"
+ autoWrapRow={true}
+ autoWrapCol={true}
+ autoRowSize={false}
+ autoColumnSize={false}
+ // renderAllRows={true}
+ // renderAllColumns={true}
+ stretchH="none"
+ rowHeaderWidth={48}
+ />
+
+
+ );
+}
diff --git a/examples/nextjs-ai-spreadsheet/app/Toolbar.tsx b/examples/nextjs-ai-spreadsheet/app/Toolbar.tsx
new file mode 100644
index 0000000000..06d905b989
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/app/Toolbar.tsx
@@ -0,0 +1,515 @@
+"use client";
+
+import { useMemo, type ReactNode } from "react";
+import { shallow } from "@liveblocks/client";
+import {
+ useCanRedo,
+ useCanUndo,
+ useRedo,
+ useStorage,
+ useUndo,
+} from "@liveblocks/react/suspense";
+import {
+ AlignCenterIcon,
+ AlignLeftIcon,
+ AlignRightIcon,
+ BaselineIcon,
+ BoldIcon,
+ DollarSignIcon,
+ EraserIcon,
+ HashIcon,
+ ItalicIcon,
+ MessageSquarePlusIcon,
+ PaintBucketIcon,
+ PanelRightCloseIcon,
+ PanelRightOpenIcon,
+ PercentIcon,
+ PlusIcon,
+ Redo2Icon,
+ StrikethroughIcon,
+ TableIcon,
+ Trash2Icon,
+ UnderlineIcon,
+ Undo2Icon,
+} from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { HelpButton } from "@/components/HelpButton";
+import { Separator } from "@/components/ui/separator";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ cellKey,
+ type CellFormat,
+ type NumberFormat,
+} from "@/liveblocks.config";
+import { useSelectionValue } from "./SelectionContext";
+import { useCellThread } from "./CellThreadContext";
+import {
+ useSpreadsheetActions,
+ type CellTarget,
+} from "./useSpreadsheetActions";
+
+const TEXT_COLORS = [
+ "#171717",
+ "#ef4444",
+ "#f97316",
+ "#eab308",
+ "#22c55e",
+ "#3b82f6",
+ "#8b5cf6",
+ "#ec4899",
+ "#fee2e2",
+ "#ffedd5",
+ "#fef9c3",
+ "#dcfce7",
+ "#dbeafe",
+ "#ede9fe",
+ "#fce7f3",
+ "#f3f4f6",
+];
+
+const FILL_COLORS = [
+ "#fee2e2",
+ "#ffedd5",
+ "#fef9c3",
+ "#dcfce7",
+ "#dbeafe",
+ "#ede9fe",
+ "#fce7f3",
+ "#f3f4f6",
+ "#171717",
+ "#ef4444",
+ "#f97316",
+ "#eab308",
+ "#22c55e",
+ "#3b82f6",
+ "#8b5cf6",
+ "#ec4899",
+];
+
+export function Toolbar({
+ chatOpen,
+ onToggleChat,
+}: {
+ chatOpen: boolean;
+ onToggleChat: () => void;
+}) {
+ // The grid keeps its selection when focus moves to the toolbar
+ // (`outsideClickDeselects={false}` on the ), so toolbar actions can
+ // read the live selection directly.
+ const selection = useSelectionValue();
+ const { setOpenCell } = useCellThread();
+ const actions = useSpreadsheetActions();
+ const undo = useUndo();
+ const redo = useRedo();
+ const canUndo = useCanUndo();
+ const canRedo = useCanRedo();
+
+ const rowIds = useStorage((root) => [...root.rowIds], shallow);
+ const colIds = useStorage((root) => [...root.colIds], shallow);
+
+ // Toggle states reflect the active (anchor) cell, like a real spreadsheet.
+ const anchorFormat = useStorage(
+ (root) =>
+ selection
+ ? root.cells[cellKey(selection.anchor.rowId, selection.anchor.colId)]
+ ?.format
+ : undefined,
+ shallow
+ );
+
+ const targets = useMemo(() => {
+ if (!selection) {
+ return [];
+ }
+ const result: CellTarget[] = [];
+ for (const rowId of selection.rowIds) {
+ for (const colId of selection.colIds) {
+ result.push({ rowId, colId });
+ }
+ }
+ return result;
+ }, [selection]);
+
+ const hasSelection = targets.length > 0;
+
+ const toggle = (key: "bold" | "italic" | "underline" | "strike") => {
+ if (!hasSelection) {
+ return;
+ }
+ const patch: Partial = {};
+ patch[key] = !anchorFormat?.[key];
+ actions.applyFormat(targets, patch);
+ };
+
+ const setAlign = (align: CellFormat["align"]) => {
+ if (!hasSelection) {
+ return;
+ }
+ actions.applyFormat(targets, {
+ align: anchorFormat?.align === align ? undefined : align,
+ });
+ };
+
+ const setNumberFormat = (numberFormat: NumberFormat) => {
+ if (!hasSelection) {
+ return;
+ }
+ actions.applyFormat(targets, {
+ numberFormat: numberFormat === "general" ? undefined : numberFormat,
+ });
+ };
+
+ const setColor = (color: string | undefined) => {
+ if (hasSelection) {
+ actions.applyFormat(targets, { color });
+ }
+ };
+
+ const setBackground = (background: string | undefined) => {
+ if (hasSelection) {
+ actions.applyFormat(targets, { background });
+ }
+ };
+
+ const anchorRowIndex = selection
+ ? rowIds.indexOf(selection.anchor.rowId)
+ : -1;
+ const anchorColIndex = selection
+ ? colIds.indexOf(selection.anchor.colId)
+ : -1;
+
+ return (
+
+
undo()}
+ disabled={!canUndo}
+ icon={ }
+ />
+ redo()}
+ disabled={!canRedo}
+ icon={ }
+ />
+
+
+
+ toggle("bold")}
+ icon={ }
+ />
+ toggle("italic")}
+ icon={ }
+ />
+ toggle("underline")}
+ icon={ }
+ />
+ toggle("strike")}
+ icon={ }
+ />
+
+
+
+ }
+ resetLabel="Automatic"
+ />
+ }
+ resetLabel="No fill"
+ />
+
+
+
+ setAlign("left")}
+ icon={ }
+ />
+ setAlign("center")}
+ icon={ }
+ />
+ setAlign("right")}
+ icon={ }
+ />
+
+
+
+ setNumberFormat("general")}
+ icon={ }
+ />
+ setNumberFormat("currency")}
+ icon={ }
+ />
+ setNumberFormat("percent")}
+ icon={ }
+ />
+
+
+
+ actions.clearFormatting(targets)}
+ icon={ }
+ />
+
+
+
+
+
+
+
+
+
+
+ Insert / delete
+
+
+ Rows
+ actions.insertRow(anchorRowIndex, actions.nanoid())}
+ >
+ Insert row above
+
+
+ actions.insertRow(anchorRowIndex + 1, actions.nanoid())
+ }
+ >
+ Insert row below
+
+ selection && actions.deleteRows(selection.rowIds)}
+ >
+ Delete selected rows
+
+
+ Columns
+
+ actions.insertColumn(anchorColIndex, actions.nanoid())
+ }
+ >
+ Insert column left
+
+
+ actions.insertColumn(anchorColIndex + 1, actions.nanoid())
+ }
+ >
+ Insert column right
+
+ selection && actions.deleteColumns(selection.colIds)}
+ >
+ Delete selected columns
+
+
+
+
+
+
+ selection && setOpenCell(selection.anchor)}
+ icon={ }
+ />
+
+
+
+
+
+
+ {chatOpen ? (
+
+ ) : (
+
+ )}
+
+
+
+ {chatOpen ? "Hide AI chat" : "Show AI chat"}
+
+
+
+
+ );
+}
+
+function ToolbarSeparator() {
+ return ;
+}
+
+function ToolButton({
+ label,
+ icon,
+ onClick,
+ disabled,
+ active,
+}: {
+ label: string;
+ icon: ReactNode;
+ onClick: () => void;
+ disabled?: boolean;
+ active?: boolean;
+}) {
+ return (
+
+
+ event.preventDefault()}
+ onClick={onClick}
+ disabled={disabled}
+ aria-label={label}
+ aria-pressed={active}
+ >
+ {icon}
+
+
+ {label}
+
+ );
+}
+
+function ColorMenu({
+ label,
+ icon,
+ colors,
+ current,
+ onSelect,
+ disabled,
+ resetLabel,
+}: {
+ label: string;
+ icon: ReactNode;
+ colors: string[];
+ current: string | undefined;
+ onSelect: (color: string | undefined) => void;
+ disabled?: boolean;
+ resetLabel: string;
+}) {
+ return (
+
+
+
+
+
+
+ {icon}
+
+
+
+
+
+ {label}
+
+
+ {label}
+
+ {colors.map((color) => (
+ onSelect(color)}
+ className="size-6 rounded-md border transition-transform hover:scale-110 active:scale-[0.96]"
+ style={{ background: color }}
+ />
+ ))}
+
+
+ onSelect(undefined)}>
+ {resetLabel}
+
+
+
+ );
+}
diff --git a/examples/nextjs-ai-spreadsheet/app/api/ai-chat/route.ts b/examples/nextjs-ai-spreadsheet/app/api/ai-chat/route.ts
new file mode 100644
index 0000000000..2a990d9126
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/app/api/ai-chat/route.ts
@@ -0,0 +1,287 @@
+import { Liveblocks } from "@liveblocks/node";
+import { NextRequest, NextResponse } from "next/server";
+import { AI_USER_AVATAR, AI_USER_ID, AI_USER_NAME } from "@/database";
+import {
+ commentsText,
+ createSpreadsheetTools,
+ readStorage,
+ showAiEditing,
+ snapshotText,
+} from "@/lib/spreadsheet-server";
+import type { JsonObject } from "@/liveblocks.config";
+
+/**
+ * Generates an assistant reply that *edits the spreadsheet* and streams its
+ * answer into the room's chat feed using `@liveblocks/node`.
+ *
+ * The model runs with tools that write to Storage via `mutateStorage` and show
+ * the AI's live selection via `setPresence` — so everyone connected sees both
+ * the chat text and the grid fill in, in realtime, as the model works.
+ */
+
+type ChatMessage = { role: "user" | "assistant"; content: string };
+
+type ToolDisplay = {
+ name: string;
+ input: JsonObject;
+ output?: string;
+};
+
+type AssistantUpdate = {
+ content: string;
+ reasoning?: string;
+ tools?: ToolDisplay[];
+ suggestions?: string[];
+ usedTokens?: number;
+ maxTokens?: number;
+ streaming: boolean;
+};
+
+const MAX_TOKENS = 128_000;
+
+const AUTHOR = {
+ userId: AI_USER_ID,
+ name: AI_USER_NAME,
+ avatar: AI_USER_AVATAR,
+} as const;
+
+export async function POST(request: NextRequest) {
+ if (!process.env.LIVEBLOCKS_SECRET_KEY) {
+ return new NextResponse("Missing LIVEBLOCKS_SECRET_KEY", { status: 403 });
+ }
+
+ const liveblocks = new Liveblocks({
+ secret: process.env.LIVEBLOCKS_SECRET_KEY,
+ });
+
+ const { roomId, feedId, messages, model } = (await request.json()) as {
+ roomId: string;
+ feedId: string;
+ messages: ChatMessage[];
+ model?: string;
+ };
+
+ if (
+ !roomId?.startsWith("liveblocks:examples:nextjs-ai-spreadsheet") ||
+ !feedId
+ ) {
+ return new NextResponse("Invalid room or feed", { status: 400 });
+ }
+
+ try {
+ await liveblocks.createFeed({
+ roomId,
+ feedId,
+ metadata: { title: "AI chat" },
+ });
+ } catch {
+ // Feed already exists, ignore.
+ }
+
+ const created = await liveblocks.createFeedMessage({
+ roomId,
+ feedId,
+ data: { role: "assistant", content: "", streaming: true, model, ...AUTHOR },
+ });
+ const messageId = created.id;
+
+ const update = (data: AssistantUpdate) =>
+ liveblocks.updateFeedMessage({
+ roomId,
+ feedId,
+ messageId,
+ data: { role: "assistant", model, ...AUTHOR, ...data },
+ });
+
+ // No mock fallback — the spreadsheet AI needs a real, tool-calling model.
+ if (!process.env.AI_GATEWAY_API_KEY) {
+ await update({
+ content:
+ "I can't edit the spreadsheet without an AI provider key. Add an " +
+ "`AI_GATEWAY_API_KEY` to `.env.local` (see the Vercel AI Gateway docs) " +
+ "and try again.",
+ streaming: false,
+ });
+ return NextResponse.json({ ok: true });
+ }
+
+ try {
+ await streamReply(liveblocks, roomId, messages, model, update);
+ } catch (error) {
+ const reason = error instanceof Error ? error.message : "Unknown error";
+ await update({
+ content: `Sorry, something went wrong.\n\n\`${reason}\``,
+ streaming: false,
+ }).catch(() => {});
+ }
+
+ return NextResponse.json({ ok: true });
+}
+
+type UpdateFn = (data: AssistantUpdate) => Promise;
+
+const SYSTEM_PROMPT = [
+ "You are an assistant embedded in a realtime, multiplayer spreadsheet.",
+ "Use the provided tools to edit the spreadsheet directly — don't just describe",
+ "changes, make them. Reference cells in A1 notation (e.g. B2, A1:C5).",
+ "Prefer `setRangeValues` to fill tables in one call. You don't need to clear",
+ "cells first — `setRangeValues` and `setCellValue` overwrite existing values.",
+ "You can write spreadsheet formulas as cell values (anything starting with",
+ "`=`, e.g. `=SUM(A1:A5)`, `=A2*B2`, `=AVERAGE(B:B)`); they're evaluated",
+ "automatically by HyperFormula. Prefer formulas over pre-computed numbers for",
+ "totals and other derived values, so they stay correct when inputs change.",
+ "Keep your chat replies short (one or two sentences) and describe what you",
+ "did. Reply in Markdown.",
+ "Always check that cells use the right number format for their data: apply",
+ "the currency format to money, the percent format to rates/ratios, and keep",
+ "general for plain numbers and text. When you add or edit values, set (or",
+ "correct) the format with `formatCells` so columns stay consistent.",
+ "Use comments to highlight problems in the sheet: when you spot an error,",
+ "inconsistency, or something that needs the user's attention (e.g. a wrong",
+ "total, a typo, a suspicious value, or a missing entry), leave a short comment",
+ "on that cell with `addComment` explaining the issue, instead of silently",
+ "fixing it or only mentioning it in chat.",
+].join(" ");
+
+// What the AI's tools can actually do — used to keep generated follow-up
+const CAPABILITIES = [
+ "The assistant can ONLY do the following to the spreadsheet:",
+ "- Set a single cell's value, or fill a rectangular range with values.",
+ "- Write spreadsheet formulas in cells, e.g. `=SUM(A1:A5)`, `=A1*B1`,",
+ ' `=AVERAGE(B2:B10)`, `=IF(A1>10,"high","low")`. They\'re evaluated',
+ " automatically (HyperFormula, ~Excel-compatible functions).",
+ "- Clear the values in a range.",
+ "- Format cells: bold, italic, underline, strikethrough, horizontal",
+ " alignment (left/center/right), text color, fill (background) color, and",
+ " number format (general, currency, or percent).",
+ "- Sort all rows by a column (ascending or descending).",
+ "- Insert or delete a row or a column.",
+ "- Add or delete a comment thread on a cell.",
+ "It CANNOT: add borders, merge cells, create charts, freeze rows/columns,",
+ "add images, or change fonts/font sizes.",
+ "Only suggest actions from the supported list above.",
+].join("\n");
+
+async function streamReply(
+ liveblocks: Liveblocks,
+ roomId: string,
+ messages: ChatMessage[],
+ model: string | undefined,
+ update: UpdateFn
+) {
+ const { streamText, generateText, Output, stepCountIs } = await import("ai");
+ const { z } = await import("zod");
+
+ showAiEditing(liveblocks, roomId, null);
+
+ const storage = await readStorage(liveblocks, roomId);
+ const comments = await commentsText(liveblocks, roomId, storage);
+
+ const tools = await createSpreadsheetTools(liveblocks, roomId);
+
+ const result = streamText({
+ model: model ?? "openai/gpt-5.4-mini",
+ system: `${SYSTEM_PROMPT}\n\n${snapshotText(storage)}${
+ comments ? `\n\n${comments}` : ""
+ }`,
+ messages,
+ tools,
+ stopWhen: stepCountIs(16),
+ providerOptions: {
+ openai: { reasoningEffort: "low", reasoningSummary: "auto" },
+ anthropic: { thinking: { type: "enabled", budgetTokens: 4096 } },
+ google: { thinkingConfig: { includeThoughts: true } },
+ },
+ });
+
+ let content = "";
+ let reasoning = "";
+ const toolsDisplay: ToolDisplay[] = [];
+ const toolIndexById = new Map();
+ let lastFlush = 0;
+
+ const flush = async (force = false) => {
+ const now = Date.now();
+ if (!force && now - lastFlush < 80) {
+ return;
+ }
+ lastFlush = now;
+ await update({
+ content,
+ reasoning: reasoning || undefined,
+ tools: toolsDisplay.length ? toolsDisplay : undefined,
+ streaming: true,
+ });
+ };
+
+ for await (const part of result.fullStream) {
+ if (part.type === "text-delta") {
+ content += part.text;
+ await flush();
+ } else if (part.type === "reasoning-delta") {
+ reasoning += part.text;
+ await flush();
+ } else if (part.type === "tool-call") {
+ toolIndexById.set(part.toolCallId, toolsDisplay.length);
+ toolsDisplay.push({
+ name: part.toolName,
+ // Tool-call args from the AI SDK are JSON-serializable by construction.
+ input: (part.input ?? {}) as JsonObject,
+ });
+ await flush(true);
+ } else if (part.type === "tool-result") {
+ const index = toolIndexById.get(part.toolCallId);
+ if (index !== undefined && toolsDisplay[index]) {
+ toolsDisplay[index].output = String(part.output ?? "");
+ }
+ await flush(true);
+ }
+ }
+
+ if (!reasoning) {
+ reasoning = (await result.reasoningText) ?? "";
+ }
+ const usage = await result.usage;
+
+ // Generate three contextual follow-up suggestions based on the updated sheet
+ let suggestions: string[] = [];
+ try {
+ const updatedStorage = await readStorage(liveblocks, roomId);
+ const { output } = await generateText({
+ model: model ?? "openai/gpt-5.4-mini",
+ output: Output.object({
+ schema: z.object({
+ suggestions: z
+ .array(z.string())
+ .length(3)
+ .describe("Three short next prompts the user might send."),
+ }),
+ }),
+ system:
+ "You suggest the user's likely next message in a spreadsheet AI chat. " +
+ "Return exactly 3 short, specific, actionable prompts (max ~6 words " +
+ "each) the user could tap next, as imperative phrases with no numbering. " +
+ "Every suggestion must be something the assistant can actually do.\n\n" +
+ CAPABILITIES,
+ prompt:
+ `Current spreadsheet:\n${snapshotText(updatedStorage)}\n\n` +
+ `The assistant just replied:\n${content || "(made edits to the sheet)"}\n\n` +
+ "Suggest 3 useful next prompts.",
+ });
+ if (output.suggestions?.length) {
+ suggestions = output.suggestions.slice(0, 3);
+ }
+ } catch {
+ // Keep the static fallback suggestions.
+ }
+
+ await update({
+ content,
+ reasoning: reasoning || undefined,
+ tools: toolsDisplay.length ? toolsDisplay : undefined,
+ suggestions,
+ usedTokens: usage.totalTokens ?? 0,
+ maxTokens: MAX_TOKENS,
+ streaming: false,
+ });
+}
diff --git a/examples/nextjs-ai-spreadsheet/app/api/liveblocks-auth/route.ts b/examples/nextjs-ai-spreadsheet/app/api/liveblocks-auth/route.ts
new file mode 100644
index 0000000000..a29bc4fe31
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/app/api/liveblocks-auth/route.ts
@@ -0,0 +1,32 @@
+import { Liveblocks } from "@liveblocks/node";
+import { NextRequest, NextResponse } from "next/server";
+import { getRandomUser } from "@/database";
+
+/**
+ * Authenticating your Liveblocks application
+ * https://liveblocks.io/docs/authentication
+ */
+
+export async function POST(_request: NextRequest) {
+ if (!process.env.LIVEBLOCKS_SECRET_KEY) {
+ return new NextResponse("Missing LIVEBLOCKS_SECRET_KEY", { status: 403 });
+ }
+
+ const liveblocks = new Liveblocks({
+ secret: process.env.LIVEBLOCKS_SECRET_KEY,
+ });
+
+ // Pick a random example user so each connection has a name and avatar that
+ // resolve through `resolveUsers` (used by AvatarStack, presence, and Comments).
+ const user = getRandomUser();
+
+ const session = liveblocks.prepareSession(`${user.id}`, {
+ userInfo: user.info,
+ });
+
+ // Use a naming pattern to allow access to rooms with a wildcard
+ session.allow(`liveblocks:examples:*`, session.FULL_ACCESS);
+
+ const { status, body } = await session.authorize();
+ return new NextResponse(body, { status });
+}
diff --git a/examples/nextjs-ai-spreadsheet/app/api/liveblocks-webhook/route.ts b/examples/nextjs-ai-spreadsheet/app/api/liveblocks-webhook/route.ts
new file mode 100644
index 0000000000..8ba0651465
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/app/api/liveblocks-webhook/route.ts
@@ -0,0 +1,43 @@
+import { NextResponse } from "next/server";
+import { Liveblocks, WebhookHandler } from "@liveblocks/node";
+import { replyToComment } from "@/lib/spreadsheet-server";
+
+// Add your webhook secret from the project's webhooks dashboard. Point a
+// `commentCreated` webhook at this endpoint to enable AI comment replies.
+const WEBHOOK_SECRET = process.env.LIVEBLOCKS_WEBHOOK_SECRET_KEY;
+
+export async function POST(request: Request) {
+ if (!WEBHOOK_SECRET) {
+ return new NextResponse("LIVEBLOCKS_WEBHOOK_SECRET_KEY is not set", {
+ status: 500,
+ });
+ }
+
+ const rawBody = await request.text();
+
+ let event;
+ try {
+ event = new WebhookHandler(WEBHOOK_SECRET).verifyRequest({
+ headers: request.headers,
+ rawBody,
+ });
+ } catch (error) {
+ console.error(error);
+ return new NextResponse("Could not verify webhook call", { status: 400 });
+ }
+
+ // Reply when the AI is @mentioned in a new comment.
+ if (event.type === "commentCreated" && process.env.LIVEBLOCKS_SECRET_KEY) {
+ const { roomId, threadId, commentId } = event.data;
+ const liveblocks = new Liveblocks({
+ secret: process.env.LIVEBLOCKS_SECRET_KEY,
+ });
+ try {
+ await replyToComment(liveblocks, roomId, threadId, commentId);
+ } catch (error) {
+ console.error(error);
+ }
+ }
+
+ return NextResponse.json({ ok: true });
+}
diff --git a/examples/nextjs-ai-spreadsheet/app/api/users/route.ts b/examples/nextjs-ai-spreadsheet/app/api/users/route.ts
new file mode 100644
index 0000000000..d4f2e52c40
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/app/api/users/route.ts
@@ -0,0 +1,16 @@
+import { getUser } from "@/database";
+import { NextRequest, NextResponse } from "next/server";
+
+export async function GET(request: NextRequest) {
+ const { searchParams } = new URL(request.url);
+ const userIds = searchParams.getAll("userIds");
+
+ if (!userIds || !Array.isArray(userIds)) {
+ return new NextResponse("Missing or invalid userIds", { status: 400 });
+ }
+
+ return NextResponse.json(
+ userIds.map((userId) => getUser(userId)?.info || null),
+ { status: 200 }
+ );
+}
diff --git a/examples/nextjs-ai-spreadsheet/app/api/users/search/route.ts b/examples/nextjs-ai-spreadsheet/app/api/users/search/route.ts
new file mode 100644
index 0000000000..27dcd0c34c
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/app/api/users/search/route.ts
@@ -0,0 +1,18 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getUsers } from "@/database";
+
+/**
+ * Returns a list of user IDs from a partial search input.
+ * For `resolveMentionSuggestions` in Providers.tsx (used by Comments).
+ */
+
+export async function GET(request: NextRequest) {
+ const { searchParams } = new URL(request.url);
+ const text = (searchParams.get("text") ?? "").toLowerCase();
+
+ const filteredUserIds = getUsers()
+ .filter((user) => user.info.name.toLowerCase().includes(text))
+ .map((user) => user.id);
+
+ return NextResponse.json(filteredUserIds);
+}
diff --git a/examples/nextjs-ai-spreadsheet/app/globals.css b/examples/nextjs-ai-spreadsheet/app/globals.css
new file mode 100644
index 0000000000..81ac253f9d
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/app/globals.css
@@ -0,0 +1,158 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+@import "@liveblocks/react-ui/styles.css";
+@import "handsontable/styles/handsontable.min.css";
+@import "handsontable/styles/ht-theme-main.min.css";
+
+@custom-variant dark (&:is(.dark *));
+
+:root {
+ --radius: 0.625rem;
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.305 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+}
+
+.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);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.556 0 0);
+}
+
+@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);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
+
+/* Liveblocks Comments inside Handsontable cells */
+.lb-root {
+ --lb-accent: #4444ff;
+}
+
+.lb-portal {
+ z-index: 500;
+}
+
+/* Cell content (value + formatting + presence borders) is painted imperatively
+ into each by the Handsontable function renderer (see Table.tsx). */
+.handsontable td {
+ /* Positioning context for the comment marker pseudo-element. */
+ position: relative;
+ vertical-align: middle;
+ /* Align digits in columns of numbers. */
+ font-variant-numeric: tabular-nums;
+}
+
+/* Classic "this cell has a comment" marker: a small filled triangle hugging
+ the cell's top-right corner. The renderer toggles `.has-comment` on the
+ when a thread exists for that cell. */
+.handsontable td.has-comment::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: 9px;
+ height: 9px;
+ background: var(--lb-accent, #4444ff);
+ clip-path: polygon(0 0, 100% 0, 100% 100%);
+ pointer-events: none;
+ z-index: 2;
+}
diff --git a/examples/nextjs-ai-spreadsheet/app/layout.tsx b/examples/nextjs-ai-spreadsheet/app/layout.tsx
new file mode 100644
index 0000000000..f724f5b353
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/app/layout.tsx
@@ -0,0 +1,35 @@
+import "./globals.css";
+import { ReactNode, Suspense } from "react";
+import { Providers } from "./Providers";
+
+export const metadata = {
+ title: "Liveblocks · AI Spreadsheet",
+};
+
+export default function RootLayout({ children }: { children: ReactNode }) {
+ return (
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/examples/nextjs-ai-spreadsheet/app/page.tsx b/examples/nextjs-ai-spreadsheet/app/page.tsx
new file mode 100644
index 0000000000..c9ebeef5f7
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/app/page.tsx
@@ -0,0 +1,10 @@
+import { Room } from "./Room";
+import { Spreadsheet } from "./Spreadsheet";
+
+export default function Page() {
+ return (
+
+
+
+ );
+}
diff --git a/examples/nextjs-ai-spreadsheet/app/useSpreadsheetActions.ts b/examples/nextjs-ai-spreadsheet/app/useSpreadsheetActions.ts
new file mode 100644
index 0000000000..8f6e45fb29
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/app/useSpreadsheetActions.ts
@@ -0,0 +1,225 @@
+"use client";
+
+import { LiveMap, LiveObject } from "@liveblocks/client";
+import { useMutation } from "@liveblocks/react/suspense";
+import { nanoid } from "nanoid";
+import {
+ cellKey,
+ MIN_COL_WIDTH,
+ MIN_ROW_HEIGHT,
+ type CellData,
+ type CellFormat,
+} from "@/liveblocks.config";
+import { isFormatEmpty, mergeFormat } from "@/lib/format";
+
+type Cells = LiveMap>;
+
+export type CellTarget = { rowId: string; colId: string };
+
+// --- Pure helpers operating on the mutable `cells` map -----------------------
+
+function writeValue(
+ cells: Cells,
+ rowId: string,
+ colId: string,
+ value: string
+): void {
+ const key = cellKey(rowId, colId);
+ const cell = cells.get(key);
+
+ // Keep Storage sparse: an empty cell with no formatting is removed entirely.
+ if (value === "") {
+ if (cell) {
+ if (isFormatEmpty(cell.get("format"))) {
+ cells.delete(key);
+ } else {
+ cell.set("value", "");
+ }
+ }
+ return;
+ }
+
+ if (cell) {
+ cell.set("value", value);
+ } else {
+ cells.set(key, new LiveObject({ value }));
+ }
+}
+
+function writeFormat(
+ cells: Cells,
+ rowId: string,
+ colId: string,
+ patch: Partial
+): void {
+ const key = cellKey(rowId, colId);
+ const cell = cells.get(key);
+ const merged = mergeFormat(cell?.get("format"), patch);
+
+ if (!cell) {
+ if (merged) {
+ cells.set(key, new LiveObject({ value: "", format: merged }));
+ }
+ return;
+ }
+
+ cell.set("format", merged);
+ if ((cell.get("value") ?? "") === "" && isFormatEmpty(merged)) {
+ cells.delete(key);
+ }
+}
+
+function clearFormatCell(cells: Cells, rowId: string, colId: string): void {
+ const key = cellKey(rowId, colId);
+ const cell = cells.get(key);
+ if (!cell) {
+ return;
+ }
+ if ((cell.get("value") ?? "") === "") {
+ cells.delete(key);
+ } else {
+ cell.set("format", undefined);
+ }
+}
+
+// --- Hook --------------------------------------------------------------------
+
+export function useSpreadsheetActions() {
+ const setCellValue = useMutation(
+ ({ storage }, rowId: string, colId: string, value: string) => {
+ writeValue(storage.get("cells"), rowId, colId, value);
+ },
+ []
+ );
+
+ const applyFormat = useMutation(
+ ({ storage }, targets: CellTarget[], patch: Partial) => {
+ const cells = storage.get("cells");
+ for (const { rowId, colId } of targets) {
+ writeFormat(cells, rowId, colId, patch);
+ }
+ },
+ []
+ );
+
+ const clearFormatting = useMutation(({ storage }, targets: CellTarget[]) => {
+ const cells = storage.get("cells");
+ for (const { rowId, colId } of targets) {
+ clearFormatCell(cells, rowId, colId);
+ }
+ }, []);
+
+ const clearValues = useMutation(({ storage }, targets: CellTarget[]) => {
+ const cells = storage.get("cells");
+ for (const { rowId, colId } of targets) {
+ writeValue(cells, rowId, colId, "");
+ }
+ }, []);
+
+ const setColWidth = useMutation(({ storage }, colId: string, width: number) => {
+ storage
+ .get("colWidths")
+ .set(colId, Math.max(MIN_COL_WIDTH, Math.round(width)));
+ }, []);
+
+ const setRowHeight = useMutation(
+ ({ storage }, rowId: string, height: number) => {
+ storage
+ .get("rowHeights")
+ .set(rowId, Math.max(MIN_ROW_HEIGHT, Math.round(height)));
+ },
+ []
+ );
+
+ // Replaces the visual order with a permutation of the existing ids. Setting
+ // each index in place avoids clearing the list (no flicker, no migration).
+ const setRowOrder = useMutation(({ storage }, newRowIds: string[]) => {
+ const list = storage.get("rowIds");
+ newRowIds.forEach((id, index) => {
+ if (list.get(index) !== id) {
+ list.set(index, id);
+ }
+ });
+ }, []);
+
+ const setColOrder = useMutation(({ storage }, newColIds: string[]) => {
+ const list = storage.get("colIds");
+ newColIds.forEach((id, index) => {
+ if (list.get(index) !== id) {
+ list.set(index, id);
+ }
+ });
+ }, []);
+
+ const insertRow = useMutation(
+ ({ storage }, atIndex: number, newId: string) => {
+ storage.get("rowIds").insert(newId, atIndex);
+ },
+ []
+ );
+
+ const insertColumn = useMutation(
+ ({ storage }, atIndex: number, newId: string) => {
+ storage.get("colIds").insert(newId, atIndex);
+ },
+ []
+ );
+
+ const deleteRows = useMutation(({ storage }, rowIds: string[]) => {
+ const list = storage.get("rowIds");
+ if (list.length <= rowIds.length) {
+ return; // never delete every row
+ }
+ const cells = storage.get("cells");
+ const heights = storage.get("rowHeights");
+ const colIds = [...storage.get("colIds")];
+
+ for (const rowId of rowIds) {
+ const index = [...list].indexOf(rowId);
+ if (index !== -1) {
+ list.delete(index);
+ }
+ for (const colId of colIds) {
+ cells.delete(cellKey(rowId, colId));
+ }
+ heights.delete(rowId);
+ }
+ }, []);
+
+ const deleteColumns = useMutation(({ storage }, colIds: string[]) => {
+ const list = storage.get("colIds");
+ if (list.length <= colIds.length) {
+ return; // never delete every column
+ }
+ const cells = storage.get("cells");
+ const widths = storage.get("colWidths");
+ const rowIds = [...storage.get("rowIds")];
+
+ for (const colId of colIds) {
+ const index = [...list].indexOf(colId);
+ if (index !== -1) {
+ list.delete(index);
+ }
+ for (const rowId of rowIds) {
+ cells.delete(cellKey(rowId, colId));
+ }
+ widths.delete(colId);
+ }
+ }, []);
+
+ return {
+ nanoid,
+ setCellValue,
+ applyFormat,
+ clearFormatting,
+ clearValues,
+ setColWidth,
+ setRowHeight,
+ setRowOrder,
+ setColOrder,
+ insertRow,
+ insertColumn,
+ deleteRows,
+ deleteColumns,
+ };
+}
diff --git a/examples/nextjs-ai-spreadsheet/components.json b/examples/nextjs-ai-spreadsheet/components.json
new file mode 100644
index 0000000000..26ad91e754
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components.json
@@ -0,0 +1,21 @@
+{
+ "$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"
+ }
+}
diff --git a/examples/nextjs-ai-spreadsheet/components/HelpButton.tsx b/examples/nextjs-ai-spreadsheet/components/HelpButton.tsx
new file mode 100644
index 0000000000..d38de069aa
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/HelpButton.tsx
@@ -0,0 +1,304 @@
+"use client";
+
+import { CSSProperties, ReactNode, useEffect, useState } from "react";
+import { createPortal } from "react-dom";
+import { Button } from "./ui/button";
+
+const EXAMPLE_NAME = "Realtime AI spreadsheet";
+const EXAMPLE_URL = "https://liveblocks.io/examples/nextjs-ai-spreadsheet";
+
+type Feature = {
+ icon: ReactNode;
+ title: string;
+ description: ReactNode;
+};
+
+const FEATURES: Feature[] = [
+ {
+ icon: ,
+ title: "Multiplayer spreadsheet",
+ description:
+ "Cells, formatting, sizes, and row/column order live in Liveblocks Storage and sync instantly to everyone.",
+ },
+ {
+ icon: ,
+ title: "An AI that edits cells",
+ description:
+ "Ask the chat to fill, format, or restructure the grid. You can also tag AI with @Liveblocks AI inside a comment.",
+ },
+ {
+ icon: ,
+ title: "See the AI working",
+ description:
+ "The AI appears as a participant — its selection border hops cell to cell in realtime via server-side presence.",
+ },
+ {
+ icon: ,
+ title: "Comments on any cell",
+ description:
+ "Leave threaded comments anchored to a cell. They follow the cell even when rows and columns are moved.",
+ },
+];
+
+const styles: Record = {
+ backdrop: {
+ position: "fixed",
+ inset: 0,
+ zIndex: 2147483000,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ padding: 16,
+ background: "rgba(23, 23, 23, 0.2)",
+ },
+ panel: {
+ background: "#ffffff",
+ border: "1px solid #e5e5e5",
+ borderRadius: 12,
+ boxShadow:
+ "0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)",
+ width: "100%",
+ maxWidth: 448,
+ maxHeight: "80vh",
+ overflowY: "auto",
+ },
+ header: {
+ display: "flex",
+ alignItems: "flex-start",
+ justifyContent: "space-between",
+ gap: 16,
+ padding: 20,
+ borderBottom: "1px solid #e5e5e5",
+ },
+ title: { fontSize: 14, fontWeight: 600, color: "#171717", margin: 0 },
+ titleLink: { color: "inherit", textDecoration: "none" },
+ desc: { fontSize: 14, color: "#737373", marginTop: 4, marginBottom: 0 },
+ close: {
+ flexShrink: 0,
+ marginTop: -4,
+ marginRight: -4,
+ padding: 6,
+ borderRadius: 6,
+ border: "none",
+ background: "transparent",
+ color: "#737373",
+ cursor: "pointer",
+ lineHeight: 0,
+ },
+ list: {
+ listStyle: "none",
+ margin: 0,
+ padding: 20,
+ display: "flex",
+ flexDirection: "column",
+ gap: 16,
+ },
+ item: { display: "flex", alignItems: "flex-start", gap: 16 },
+ iconWrap: {
+ flexShrink: 0,
+ marginTop: 2,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ width: 28,
+ height: 28,
+ borderRadius: 6,
+ background: "#f5f5f5",
+ color: "#404040",
+ },
+ featureTitle: { fontSize: 14, fontWeight: 500, color: "#171717", margin: 0 },
+ featureDesc: {
+ fontSize: 14,
+ color: "#737373",
+ marginTop: 2,
+ marginBottom: 0,
+ },
+};
+
+const HOVER_CSS = `
+.lb-help-title-link:hover { text-decoration: underline !important; }
+.lb-help-close:hover { background:#f5f5f5 !important; color:#171717 !important; }
+.lb-help, .lb-help * { box-sizing: border-box; }
+`;
+
+export function HelpButton() {
+ const [isOpen, setIsOpen] = useState(false);
+
+ useEffect(() => {
+ if (!isOpen) {
+ return;
+ }
+
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape") {
+ setIsOpen(false);
+ }
+ }
+
+ document.addEventListener("keydown", onKeyDown);
+ return () => document.removeEventListener("keydown", onKeyDown);
+ }, [isOpen]);
+
+ return (
+ <>
+
+ setIsOpen(true)}
+ aria-label="How to use this example"
+ >
+
+
+
+ {isOpen && typeof document !== "undefined"
+ ? createPortal(
+ setIsOpen(false)}
+ >
+
event.stopPropagation()}
+ >
+
+
+
+
How to use this example
+
+
setIsOpen(false)}
+ >
+
+
+
+
+
+ {FEATURES.map((feature) => (
+
+ {feature.icon}
+
+
{feature.title}
+
{feature.description}
+
+
+ ))}
+
+
+
,
+ document.body
+ )
+ : null}
+ >
+ );
+}
+
+function HelpIcon() {
+ return (
+
+
+
+
+
+ );
+}
+
+function CloseIcon() {
+ return (
+
+
+
+ );
+}
+
+function FeatureIconBase({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function TableIcon() {
+ return (
+
+
+
+
+ );
+}
+
+function CommentIcon() {
+ return (
+
+
+
+ );
+}
+
+function SparklesIcon() {
+ return (
+
+
+
+
+ );
+}
+
+function UsersIcon() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/artifact.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/artifact.tsx
new file mode 100644
index 0000000000..c90cb5fe3d
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/artifact.tsx
@@ -0,0 +1,147 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import { type LucideIcon, XIcon } from "lucide-react";
+import type { ComponentProps, HTMLAttributes } from "react";
+
+export type ArtifactProps = HTMLAttributes;
+
+export const Artifact = ({ className, ...props }: ArtifactProps) => (
+
+);
+
+export type ArtifactHeaderProps = HTMLAttributes;
+
+export const ArtifactHeader = ({
+ className,
+ ...props
+}: ArtifactHeaderProps) => (
+
+);
+
+export type ArtifactCloseProps = ComponentProps;
+
+export const ArtifactClose = ({
+ className,
+ children,
+ size = "sm",
+ variant = "ghost",
+ ...props
+}: ArtifactCloseProps) => (
+
+ {children ?? }
+ Close
+
+);
+
+export type ArtifactTitleProps = HTMLAttributes;
+
+export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => (
+
+);
+
+export type ArtifactDescriptionProps = HTMLAttributes;
+
+export const ArtifactDescription = ({
+ className,
+ ...props
+}: ArtifactDescriptionProps) => (
+
+);
+
+export type ArtifactActionsProps = HTMLAttributes;
+
+export const ArtifactActions = ({
+ className,
+ ...props
+}: ArtifactActionsProps) => (
+
+);
+
+export type ArtifactActionProps = ComponentProps & {
+ tooltip?: string;
+ label?: string;
+ icon?: LucideIcon;
+};
+
+export const ArtifactAction = ({
+ tooltip,
+ label,
+ icon: Icon,
+ children,
+ className,
+ size = "sm",
+ variant = "ghost",
+ ...props
+}: ArtifactActionProps) => {
+ const button = (
+
+ {Icon ? : children}
+ {label || tooltip}
+
+ );
+
+ if (tooltip) {
+ return (
+
+
+ {button}
+
+ {tooltip}
+
+
+
+ );
+ }
+
+ return button;
+};
+
+export type ArtifactContentProps = HTMLAttributes;
+
+export const ArtifactContent = ({
+ className,
+ ...props
+}: ArtifactContentProps) => (
+
+);
diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/canvas.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/canvas.tsx
new file mode 100644
index 0000000000..5aa83cb5e7
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/canvas.tsx
@@ -0,0 +1,22 @@
+import { Background, ReactFlow, type ReactFlowProps } from "@xyflow/react";
+import type { ReactNode } from "react";
+import "@xyflow/react/dist/style.css";
+
+type CanvasProps = ReactFlowProps & {
+ children?: ReactNode;
+};
+
+export const Canvas = ({ children, ...props }: CanvasProps) => (
+
+
+ {children}
+
+);
diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/chain-of-thought.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/chain-of-thought.tsx
new file mode 100644
index 0000000000..bebc66123d
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/chain-of-thought.tsx
@@ -0,0 +1,231 @@
+"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 {
+ ChevronDownIcon,
+ DotIcon,
+ type LucideIcon,
+ WrenchIcon,
+} from "lucide-react";
+import type { ComponentProps, ReactNode } from "react";
+import { createContext, memo, useContext, useMemo } from "react";
+
+type ChainOfThoughtContextValue = {
+ isOpen: boolean;
+ setIsOpen: (open: boolean) => void;
+};
+
+const ChainOfThoughtContext = createContext(
+ 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({
+ prop: open,
+ defaultProp: defaultOpen,
+ onChange: onOpenChange,
+ });
+
+ const chainOfThoughtContext = useMemo(
+ () => ({ isOpen, setIsOpen }),
+ [isOpen, setIsOpen]
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+ }
+);
+
+export type ChainOfThoughtHeaderProps = ComponentProps<
+ typeof CollapsibleTrigger
+>;
+
+export const ChainOfThoughtHeader = memo(
+ ({ className, children, ...props }: ChainOfThoughtHeaderProps) => {
+ const { isOpen, setIsOpen } = useChainOfThought();
+
+ return (
+
+
+
+
+ {children ?? "Chain of Thought"}
+
+
+
+
+ );
+ }
+);
+
+export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
+ icon?: LucideIcon;
+ label: ReactNode;
+ description?: ReactNode;
+ status?: "complete" | "active" | "pending";
+};
+
+export const ChainOfThoughtStep = memo(
+ ({
+ className,
+ icon: Icon = DotIcon,
+ label,
+ description,
+ status = "complete",
+ children,
+ ...props
+ }: ChainOfThoughtStepProps) => {
+ const statusStyles = {
+ complete: "text-muted-foreground",
+ active: "text-foreground",
+ pending: "text-muted-foreground/50",
+ };
+
+ return (
+
+
+
+
{label}
+ {description && (
+
{description}
+ )}
+ {children}
+
+
+ );
+ }
+);
+
+export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">;
+
+export const ChainOfThoughtSearchResults = memo(
+ ({ className, ...props }: ChainOfThoughtSearchResultsProps) => (
+
+ )
+);
+
+export type ChainOfThoughtSearchResultProps = ComponentProps;
+
+export const ChainOfThoughtSearchResult = memo(
+ ({ className, children, ...props }: ChainOfThoughtSearchResultProps) => (
+
+ {children}
+
+ )
+);
+
+export type ChainOfThoughtContentProps = ComponentProps<
+ typeof CollapsibleContent
+>;
+
+export const ChainOfThoughtContent = memo(
+ ({ className, children, ...props }: ChainOfThoughtContentProps) => {
+ const { isOpen } = useChainOfThought();
+
+ return (
+
+
+ {children}
+
+
+ );
+ }
+);
+
+export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
+ caption?: string;
+};
+
+export const ChainOfThoughtImage = memo(
+ ({ className, children, caption, ...props }: ChainOfThoughtImageProps) => (
+
+
+ {children}
+
+ {caption &&
{caption}
}
+
+ )
+);
+
+ChainOfThought.displayName = "ChainOfThought";
+ChainOfThoughtHeader.displayName = "ChainOfThoughtHeader";
+ChainOfThoughtStep.displayName = "ChainOfThoughtStep";
+ChainOfThoughtSearchResults.displayName = "ChainOfThoughtSearchResults";
+ChainOfThoughtSearchResult.displayName = "ChainOfThoughtSearchResult";
+ChainOfThoughtContent.displayName = "ChainOfThoughtContent";
+ChainOfThoughtImage.displayName = "ChainOfThoughtImage";
diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/checkpoint.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/checkpoint.tsx
new file mode 100644
index 0000000000..d9a5d326c8
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/checkpoint.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import { BookmarkIcon, type LucideProps } from "lucide-react";
+import type { ComponentProps, HTMLAttributes } from "react";
+
+export type CheckpointProps = HTMLAttributes;
+
+export const Checkpoint = ({
+ className,
+ children,
+ ...props
+}: CheckpointProps) => (
+
+ {children}
+
+
+);
+
+export type CheckpointIconProps = LucideProps;
+
+export const CheckpointIcon = ({
+ className,
+ children,
+ ...props
+}: CheckpointIconProps) =>
+ children ?? (
+
+ );
+
+export type CheckpointTriggerProps = ComponentProps & {
+ tooltip?: string;
+};
+
+export const CheckpointTrigger = ({
+ children,
+ className,
+ variant = "ghost",
+ size = "sm",
+ tooltip,
+ ...props
+}: CheckpointTriggerProps) =>
+ tooltip ? (
+
+
+
+ {children}
+
+
+
+ {tooltip}
+
+
+ ) : (
+
+ {children}
+
+ );
diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/code-block.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/code-block.tsx
new file mode 100644
index 0000000000..b6865f0dc4
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/code-block.tsx
@@ -0,0 +1,178 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import { CheckIcon, CopyIcon } from "lucide-react";
+import {
+ type ComponentProps,
+ createContext,
+ type HTMLAttributes,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+import { type BundledLanguage, codeToHtml, type ShikiTransformer } from "shiki";
+
+type CodeBlockProps = HTMLAttributes & {
+ code: string;
+ language: BundledLanguage;
+ showLineNumbers?: boolean;
+};
+
+type CodeBlockContextType = {
+ code: string;
+};
+
+const CodeBlockContext = createContext({
+ code: "",
+});
+
+const lineNumberTransformer: ShikiTransformer = {
+ name: "line-numbers",
+ line(node, line) {
+ node.children.unshift({
+ type: "element",
+ tagName: "span",
+ properties: {
+ className: [
+ "inline-block",
+ "min-w-10",
+ "mr-4",
+ "text-right",
+ "select-none",
+ "text-muted-foreground",
+ ],
+ },
+ children: [{ type: "text", value: String(line) }],
+ });
+ },
+};
+
+export async function highlightCode(
+ code: string,
+ language: BundledLanguage,
+ showLineNumbers = false
+) {
+ const transformers: ShikiTransformer[] = showLineNumbers
+ ? [lineNumberTransformer]
+ : [];
+
+ return await Promise.all([
+ codeToHtml(code, {
+ lang: language,
+ theme: "one-light",
+ transformers,
+ }),
+ codeToHtml(code, {
+ lang: language,
+ theme: "one-dark-pro",
+ transformers,
+ }),
+ ]);
+}
+
+export const CodeBlock = ({
+ code,
+ language,
+ showLineNumbers = false,
+ className,
+ children,
+ ...props
+}: CodeBlockProps) => {
+ const [html, setHtml] = useState("");
+ const [darkHtml, setDarkHtml] = useState("");
+ const mounted = useRef(false);
+
+ useEffect(() => {
+ highlightCode(code, language, showLineNumbers).then(([light, dark]) => {
+ if (!mounted.current) {
+ setHtml(light);
+ setDarkHtml(dark);
+ mounted.current = true;
+ }
+ });
+
+ return () => {
+ mounted.current = false;
+ };
+ }, [code, language, showLineNumbers]);
+
+ return (
+
+
+
+
+
+ {children && (
+
+ {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 { code } = useContext(CodeBlockContext);
+
+ const copyToClipboard = async () => {
+ if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
+ onError?.(new Error("Clipboard API not available"));
+ return;
+ }
+
+ try {
+ await navigator.clipboard.writeText(code);
+ setIsCopied(true);
+ onCopy?.();
+ setTimeout(() => setIsCopied(false), timeout);
+ } catch (error) {
+ onError?.(error as Error);
+ }
+ };
+
+ const Icon = isCopied ? CheckIcon : CopyIcon;
+
+ return (
+
+ {children ?? }
+
+ );
+};
diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/confirmation.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/confirmation.tsx
new file mode 100644
index 0000000000..2ec0aab578
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/confirmation.tsx
@@ -0,0 +1,176 @@
+"use client";
+
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import type { ToolUIPart } from "ai";
+import {
+ type ComponentProps,
+ createContext,
+ type ReactNode,
+ useContext,
+} from "react";
+
+type ToolUIPartApproval =
+ | {
+ id: string;
+ approved?: never;
+ reason?: never;
+ }
+ | {
+ id: string;
+ approved: boolean;
+ reason?: string;
+ }
+ | {
+ id: string;
+ approved: true;
+ reason?: string;
+ }
+ | {
+ id: string;
+ approved: true;
+ reason?: string;
+ }
+ | {
+ id: string;
+ approved: false;
+ reason?: string;
+ }
+ | undefined;
+
+type ConfirmationContextValue = {
+ approval: ToolUIPartApproval;
+ state: ToolUIPart["state"];
+};
+
+const ConfirmationContext = createContext(
+ null
+);
+
+const useConfirmation = () => {
+ const context = useContext(ConfirmationContext);
+
+ if (!context) {
+ throw new Error("Confirmation components must be used within Confirmation");
+ }
+
+ return context;
+};
+
+export type ConfirmationProps = ComponentProps & {
+ approval?: ToolUIPartApproval;
+ state: ToolUIPart["state"];
+};
+
+export const Confirmation = ({
+ className,
+ approval,
+ state,
+ ...props
+}: ConfirmationProps) => {
+ if (!approval || state === "input-streaming" || state === "input-available") {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+};
+
+export type ConfirmationTitleProps = ComponentProps;
+
+export const ConfirmationTitle = ({
+ className,
+ ...props
+}: ConfirmationTitleProps) => (
+
+);
+
+export type ConfirmationRequestProps = {
+ children?: ReactNode;
+};
+
+export const ConfirmationRequest = ({ children }: ConfirmationRequestProps) => {
+ const { state } = useConfirmation();
+
+ // Only show when approval is requested
+ if (state !== "approval-requested") {
+ return null;
+ }
+
+ return children;
+};
+
+export type ConfirmationAcceptedProps = {
+ children?: ReactNode;
+};
+
+export const ConfirmationAccepted = ({
+ children,
+}: ConfirmationAcceptedProps) => {
+ const { approval, state } = useConfirmation();
+
+ // Only show when approved and in response states
+ if (
+ !approval?.approved ||
+ (state !== "approval-responded" &&
+ state !== "output-denied" &&
+ state !== "output-available")
+ ) {
+ return null;
+ }
+
+ return children;
+};
+
+export type ConfirmationRejectedProps = {
+ children?: ReactNode;
+};
+
+export const ConfirmationRejected = ({
+ children,
+}: ConfirmationRejectedProps) => {
+ const { approval, state } = useConfirmation();
+
+ // Only show when rejected and in response states
+ if (
+ approval?.approved !== false ||
+ (state !== "approval-responded" &&
+ state !== "output-denied" &&
+ state !== "output-available")
+ ) {
+ return null;
+ }
+
+ return children;
+};
+
+export type ConfirmationActionsProps = ComponentProps<"div">;
+
+export const ConfirmationActions = ({
+ className,
+ ...props
+}: ConfirmationActionsProps) => {
+ const { state } = useConfirmation();
+
+ // Only show when approval is requested
+ if (state !== "approval-requested") {
+ return null;
+ }
+
+ return (
+
+ );
+};
+
+export type ConfirmationActionProps = ComponentProps;
+
+export const ConfirmationAction = (props: ConfirmationActionProps) => (
+
+);
diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/connection.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/connection.tsx
new file mode 100644
index 0000000000..bb73356d67
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/connection.tsx
@@ -0,0 +1,28 @@
+import type { ConnectionLineComponent } from "@xyflow/react";
+
+const HALF = 0.5;
+
+export const Connection: ConnectionLineComponent = ({
+ fromX,
+ fromY,
+ toX,
+ toY,
+}) => (
+
+
+
+
+);
diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/context.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/context.tsx
new file mode 100644
index 0000000000..f49d7caea2
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/context.tsx
@@ -0,0 +1,408 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ HoverCard,
+ HoverCardContent,
+ HoverCardTrigger,
+} from "@/components/ui/hover-card";
+import { Progress } from "@/components/ui/progress";
+import { cn } from "@/lib/utils";
+import type { LanguageModelUsage } from "ai";
+import { type ComponentProps, createContext, useContext } from "react";
+import { getUsage } from "tokenlens";
+
+const PERCENT_MAX = 100;
+const ICON_RADIUS = 10;
+const ICON_VIEWBOX = 24;
+const ICON_CENTER = 12;
+const ICON_STROKE_WIDTH = 2;
+
+type ModelId = string;
+
+type ContextSchema = {
+ usedTokens: number;
+ maxTokens: number;
+ usage?: LanguageModelUsage;
+ modelId?: ModelId;
+};
+
+const ContextContext = createContext(null);
+
+const useContextValue = () => {
+ const context = useContext(ContextContext);
+
+ if (!context) {
+ throw new Error("Context components must be used within Context");
+ }
+
+ return context;
+};
+
+export type ContextProps = ComponentProps & ContextSchema;
+
+export const Context = ({
+ usedTokens,
+ maxTokens,
+ usage,
+ modelId,
+ ...props
+}: ContextProps) => (
+
+
+
+);
+
+const ContextIcon = () => {
+ const { usedTokens, maxTokens } = useContextValue();
+ const circumference = 2 * Math.PI * ICON_RADIUS;
+ const usedPercent = usedTokens / maxTokens;
+ const dashOffset = circumference * (1 - usedPercent);
+
+ return (
+
+
+
+
+ );
+};
+
+export type ContextTriggerProps = ComponentProps;
+
+export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
+ const { usedTokens, maxTokens } = useContextValue();
+ const usedPercent = usedTokens / maxTokens;
+ const renderedPercent = new Intl.NumberFormat("en-US", {
+ style: "percent",
+ maximumFractionDigits: 1,
+ }).format(usedPercent);
+
+ return (
+
+ {children ?? (
+
+
+ {renderedPercent}
+
+
+
+ )}
+
+ );
+};
+
+export type ContextContentProps = ComponentProps;
+
+export const ContextContent = ({
+ className,
+ ...props
+}: ContextContentProps) => (
+
+);
+
+export type ContextContentHeaderProps = ComponentProps<"div">;
+
+export const ContextContentHeader = ({
+ children,
+ className,
+ ...props
+}: ContextContentHeaderProps) => {
+ const { usedTokens, maxTokens } = useContextValue();
+ const usedPercent = usedTokens / maxTokens;
+ const displayPct = new Intl.NumberFormat("en-US", {
+ style: "percent",
+ maximumFractionDigits: 1,
+ }).format(usedPercent);
+ const used = new Intl.NumberFormat("en-US", {
+ notation: "compact",
+ }).format(usedTokens);
+ const total = new Intl.NumberFormat("en-US", {
+ notation: "compact",
+ }).format(maxTokens);
+
+ return (
+
+ {children ?? (
+ <>
+
+
{displayPct}
+
+ {used} / {total}
+
+
+
+ >
+ )}
+
+ );
+};
+
+export type ContextContentBodyProps = ComponentProps<"div">;
+
+export const ContextContentBody = ({
+ children,
+ className,
+ ...props
+}: ContextContentBodyProps) => (
+
+ {children}
+
+);
+
+export type ContextContentFooterProps = ComponentProps<"div">;
+
+export const ContextContentFooter = ({
+ children,
+ className,
+ ...props
+}: ContextContentFooterProps) => {
+ const { modelId, usage } = useContextValue();
+ const costUSD = modelId
+ ? getUsage({
+ modelId,
+ usage: {
+ input: usage?.inputTokens ?? 0,
+ output: usage?.outputTokens ?? 0,
+ },
+ }).costUSD?.totalUSD
+ : undefined;
+ const totalCost = new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ }).format(costUSD ?? 0);
+
+ return (
+
+ {children ?? (
+ <>
+ Total cost
+ {totalCost}
+ >
+ )}
+
+ );
+};
+
+export type ContextInputUsageProps = ComponentProps<"div">;
+
+export const ContextInputUsage = ({
+ className,
+ children,
+ ...props
+}: ContextInputUsageProps) => {
+ const { usage, modelId } = useContextValue();
+ const inputTokens = usage?.inputTokens ?? 0;
+
+ if (children) {
+ return children;
+ }
+
+ if (!inputTokens) {
+ return null;
+ }
+
+ const inputCost = modelId
+ ? getUsage({
+ modelId,
+ usage: { input: inputTokens, output: 0 },
+ }).costUSD?.totalUSD
+ : undefined;
+ const inputCostText = new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ }).format(inputCost ?? 0);
+
+ return (
+
+ Input
+
+
+ );
+};
+
+export type ContextOutputUsageProps = ComponentProps<"div">;
+
+export const ContextOutputUsage = ({
+ className,
+ children,
+ ...props
+}: ContextOutputUsageProps) => {
+ const { usage, modelId } = useContextValue();
+ const outputTokens = usage?.outputTokens ?? 0;
+
+ if (children) {
+ return children;
+ }
+
+ if (!outputTokens) {
+ return null;
+ }
+
+ const outputCost = modelId
+ ? getUsage({
+ modelId,
+ usage: { input: 0, output: outputTokens },
+ }).costUSD?.totalUSD
+ : undefined;
+ const outputCostText = new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ }).format(outputCost ?? 0);
+
+ return (
+
+ Output
+
+
+ );
+};
+
+export type ContextReasoningUsageProps = ComponentProps<"div">;
+
+export const ContextReasoningUsage = ({
+ className,
+ children,
+ ...props
+}: ContextReasoningUsageProps) => {
+ const { usage, modelId } = useContextValue();
+ const reasoningTokens = usage?.reasoningTokens ?? 0;
+
+ if (children) {
+ return children;
+ }
+
+ if (!reasoningTokens) {
+ return null;
+ }
+
+ const reasoningCost = modelId
+ ? getUsage({
+ modelId,
+ usage: { reasoningTokens },
+ }).costUSD?.totalUSD
+ : undefined;
+ const reasoningCostText = new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ }).format(reasoningCost ?? 0);
+
+ return (
+
+ Reasoning
+
+
+ );
+};
+
+export type ContextCacheUsageProps = ComponentProps<"div">;
+
+export const ContextCacheUsage = ({
+ className,
+ children,
+ ...props
+}: ContextCacheUsageProps) => {
+ const { usage, modelId } = useContextValue();
+ const cacheTokens = usage?.cachedInputTokens ?? 0;
+
+ if (children) {
+ return children;
+ }
+
+ if (!cacheTokens) {
+ return null;
+ }
+
+ const cacheCost = modelId
+ ? getUsage({
+ modelId,
+ usage: { cacheReads: cacheTokens, input: 0, output: 0 },
+ }).costUSD?.totalUSD
+ : undefined;
+ const cacheCostText = new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ }).format(cacheCost ?? 0);
+
+ return (
+
+ Cache
+
+
+ );
+};
+
+const TokensWithCost = ({
+ tokens,
+ costText,
+}: {
+ tokens?: number;
+ costText?: string;
+}) => (
+
+ {tokens === undefined
+ ? "—"
+ : new Intl.NumberFormat("en-US", {
+ notation: "compact",
+ }).format(tokens)}
+ {costText ? (
+ • {costText}
+ ) : null}
+
+);
diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/controls.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/controls.tsx
new file mode 100644
index 0000000000..770a8262aa
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/controls.tsx
@@ -0,0 +1,18 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import { Controls as ControlsPrimitive } from "@xyflow/react";
+import type { ComponentProps } from "react";
+
+export type ControlsProps = ComponentProps;
+
+export const Controls = ({ className, ...props }: ControlsProps) => (
+ button]:rounded-md [&>button]:border-none! [&>button]:bg-transparent! [&>button]:hover:bg-secondary!",
+ className
+ )}
+ {...props}
+ />
+);
diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/conversation.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/conversation.tsx
new file mode 100644
index 0000000000..aa380f573f
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/conversation.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import { ArrowDownIcon } 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 && (
+
+
+
+ )
+ );
+};
diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/edge.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/edge.tsx
new file mode 100644
index 0000000000..3cec409d10
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/edge.tsx
@@ -0,0 +1,140 @@
+import {
+ BaseEdge,
+ type EdgeProps,
+ getBezierPath,
+ getSimpleBezierPath,
+ type InternalNode,
+ type Node,
+ Position,
+ useInternalNode,
+} from "@xyflow/react";
+
+const Temporary = ({
+ id,
+ sourceX,
+ sourceY,
+ targetX,
+ targetY,
+ sourcePosition,
+ targetPosition,
+}: EdgeProps) => {
+ const [edgePath] = getSimpleBezierPath({
+ sourceX,
+ sourceY,
+ sourcePosition,
+ targetX,
+ targetY,
+ targetPosition,
+ });
+
+ return (
+
+ );
+};
+
+const getHandleCoordsByPosition = (
+ node: InternalNode,
+ handlePosition: Position
+) => {
+ // Choose the handle type based on position - Left is for target, Right is for source
+ const handleType = handlePosition === Position.Left ? "target" : "source";
+
+ const handle = node.internals.handleBounds?.[handleType]?.find(
+ (h) => h.position === handlePosition
+ );
+
+ if (!handle) {
+ return [0, 0] as const;
+ }
+
+ let offsetX = handle.width / 2;
+ let offsetY = handle.height / 2;
+
+ // this is a tiny detail to make the markerEnd of an edge visible.
+ // The handle position that gets calculated has the origin top-left, so depending which side we are using, we add a little offset
+ // when the handlePosition is Position.Right for example, we need to add an offset as big as the handle itself in order to get the correct position
+ switch (handlePosition) {
+ case Position.Left:
+ offsetX = 0;
+ break;
+ case Position.Right:
+ offsetX = handle.width;
+ break;
+ case Position.Top:
+ offsetY = 0;
+ break;
+ case Position.Bottom:
+ offsetY = handle.height;
+ break;
+ default:
+ throw new Error(`Invalid handle position: ${handlePosition}`);
+ }
+
+ const x = node.internals.positionAbsolute.x + handle.x + offsetX;
+ const y = node.internals.positionAbsolute.y + handle.y + offsetY;
+
+ return [x, y] as const;
+};
+
+const getEdgeParams = (
+ source: InternalNode,
+ target: InternalNode
+) => {
+ const sourcePos = Position.Right;
+ const [sx, sy] = getHandleCoordsByPosition(source, sourcePos);
+ const targetPos = Position.Left;
+ const [tx, ty] = getHandleCoordsByPosition(target, targetPos);
+
+ return {
+ sx,
+ sy,
+ tx,
+ ty,
+ sourcePos,
+ targetPos,
+ };
+};
+
+const Animated = ({ id, source, target, markerEnd, style }: EdgeProps) => {
+ const sourceNode = useInternalNode(source);
+ const targetNode = useInternalNode(target);
+
+ if (!(sourceNode && targetNode)) {
+ return null;
+ }
+
+ const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams(
+ sourceNode,
+ targetNode
+ );
+
+ const [edgePath] = getBezierPath({
+ sourceX: sx,
+ sourceY: sy,
+ sourcePosition: sourcePos,
+ targetX: tx,
+ targetY: ty,
+ targetPosition: targetPos,
+ });
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
+
+export const Edge = {
+ Temporary,
+ Animated,
+};
diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/image.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/image.tsx
new file mode 100644
index 0000000000..542812a328
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/image.tsx
@@ -0,0 +1,24 @@
+import { cn } from "@/lib/utils";
+import type { Experimental_GeneratedImage } from "ai";
+
+export type ImageProps = Experimental_GeneratedImage & {
+ className?: string;
+ alt?: string;
+};
+
+export const Image = ({
+ base64,
+ uint8Array,
+ mediaType,
+ ...props
+}: ImageProps) => (
+
+);
diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/inline-citation.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/inline-citation.tsx
new file mode 100644
index 0000000000..5977081bb4
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/inline-citation.tsx
@@ -0,0 +1,287 @@
+"use client";
+
+import { Badge } from "@/components/ui/badge";
+import {
+ Carousel,
+ type CarouselApi,
+ CarouselContent,
+ CarouselItem,
+} from "@/components/ui/carousel";
+import {
+ HoverCard,
+ HoverCardContent,
+ HoverCardTrigger,
+} from "@/components/ui/hover-card";
+import { cn } from "@/lib/utils";
+import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
+import {
+ type ComponentProps,
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+} from "react";
+
+export type InlineCitationProps = ComponentProps<"span">;
+
+export const InlineCitation = ({
+ className,
+ ...props
+}: InlineCitationProps) => (
+
+);
+
+export type InlineCitationTextProps = ComponentProps<"span">;
+
+export const InlineCitationText = ({
+ className,
+ ...props
+}: InlineCitationTextProps) => (
+
+);
+
+export type InlineCitationCardProps = ComponentProps;
+
+export const InlineCitationCard = (props: InlineCitationCardProps) => (
+
+);
+
+export type InlineCitationCardTriggerProps = ComponentProps & {
+ sources: string[];
+};
+
+export const InlineCitationCardTrigger = ({
+ sources,
+ className,
+ ...props
+}: InlineCitationCardTriggerProps) => (
+
+
+ {sources[0] ? (
+ <>
+ {new URL(sources[0]).hostname}{" "}
+ {sources.length > 1 && `+${sources.length - 1}`}
+ >
+ ) : (
+ "unknown"
+ )}
+
+
+);
+
+export type InlineCitationCardBodyProps = ComponentProps<"div">;
+
+export const InlineCitationCardBody = ({
+ className,
+ ...props
+}: InlineCitationCardBodyProps) => (
+
+);
+
+const CarouselApiContext = createContext(undefined);
+
+const useCarouselApi = () => {
+ const context = useContext(CarouselApiContext);
+ return context;
+};
+
+export type InlineCitationCarouselProps = ComponentProps;
+
+export const InlineCitationCarousel = ({
+ className,
+ children,
+ ...props
+}: InlineCitationCarouselProps) => {
+ const [api, setApi] = useState();
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+export type InlineCitationCarouselContentProps = ComponentProps<"div">;
+
+export const InlineCitationCarouselContent = (
+ props: InlineCitationCarouselContentProps
+) => ;
+
+export type InlineCitationCarouselItemProps = ComponentProps<"div">;
+
+export const InlineCitationCarouselItem = ({
+ className,
+ ...props
+}: InlineCitationCarouselItemProps) => (
+
+);
+
+export type InlineCitationCarouselHeaderProps = ComponentProps<"div">;
+
+export const InlineCitationCarouselHeader = ({
+ className,
+ ...props
+}: InlineCitationCarouselHeaderProps) => (
+
+);
+
+export type InlineCitationCarouselIndexProps = ComponentProps<"div">;
+
+export const InlineCitationCarouselIndex = ({
+ children,
+ className,
+ ...props
+}: InlineCitationCarouselIndexProps) => {
+ const api = useCarouselApi();
+ const [current, setCurrent] = useState(0);
+ const [count, setCount] = useState(0);
+
+ useEffect(() => {
+ if (!api) {
+ return;
+ }
+
+ setCount(api.scrollSnapList().length);
+ setCurrent(api.selectedScrollSnap() + 1);
+
+ api.on("select", () => {
+ setCurrent(api.selectedScrollSnap() + 1);
+ });
+ }, [api]);
+
+ return (
+
+ {children ?? `${current}/${count}`}
+
+ );
+};
+
+export type InlineCitationCarouselPrevProps = ComponentProps<"button">;
+
+export const InlineCitationCarouselPrev = ({
+ className,
+ ...props
+}: InlineCitationCarouselPrevProps) => {
+ const api = useCarouselApi();
+
+ const handleClick = useCallback(() => {
+ if (api) {
+ api.scrollPrev();
+ }
+ }, [api]);
+
+ return (
+
+
+
+ );
+};
+
+export type InlineCitationCarouselNextProps = ComponentProps<"button">;
+
+export const InlineCitationCarouselNext = ({
+ className,
+ ...props
+}: InlineCitationCarouselNextProps) => {
+ const api = useCarouselApi();
+
+ const handleClick = useCallback(() => {
+ if (api) {
+ api.scrollNext();
+ }
+ }, [api]);
+
+ return (
+
+
+
+ );
+};
+
+export type InlineCitationSourceProps = ComponentProps<"div"> & {
+ title?: string;
+ url?: string;
+ description?: string;
+};
+
+export const InlineCitationSource = ({
+ title,
+ url,
+ description,
+ className,
+ children,
+ ...props
+}: InlineCitationSourceProps) => (
+
+ {title && (
+
{title}
+ )}
+ {url && (
+
{url}
+ )}
+ {description && (
+
+ {description}
+
+ )}
+ {children}
+
+);
+
+export type InlineCitationQuoteProps = ComponentProps<"blockquote">;
+
+export const InlineCitationQuote = ({
+ children,
+ className,
+ ...props
+}: InlineCitationQuoteProps) => (
+
+ {children}
+
+);
diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/loader.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/loader.tsx
new file mode 100644
index 0000000000..5f0cfce400
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/loader.tsx
@@ -0,0 +1,96 @@
+import { cn } from "@/lib/utils";
+import type { HTMLAttributes } from "react";
+
+type LoaderIconProps = {
+ size?: number;
+};
+
+const LoaderIcon = ({ size = 16 }: LoaderIconProps) => (
+
+ Loader
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+export type LoaderProps = HTMLAttributes & {
+ size?: number;
+};
+
+export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
+
+
+
+);
diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/message.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/message.tsx
new file mode 100644
index 0000000000..63718a2f70
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/message.tsx
@@ -0,0 +1,445 @@
+"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 type { FileUIPart, UIMessage } from "ai";
+import {
+ ChevronLeftIcon,
+ ChevronRightIcon,
+ PaperclipIcon,
+ XIcon,
+} from "lucide-react";
+import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
+import { createContext, memo, useContext, useEffect, 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 = (
+
+ {children}
+ {label || tooltip}
+
+ );
+
+ if (tooltip) {
+ return (
+
+
+ {button}
+
+ {tooltip}
+
+
+
+ );
+ }
+
+ return button;
+};
+
+type 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 = (newBranch: number) => {
+ setCurrentBranch(newBranch);
+ onBranchChange?.(newBranch);
+ };
+
+ const goToPrevious = () => {
+ const newBranch =
+ currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
+ handleBranchChange(newBranch);
+ };
+
+ const goToNext = () => {
+ const newBranch =
+ currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
+ handleBranchChange(newBranch);
+ };
+
+ const contextValue: MessageBranchContextType = {
+ currentBranch,
+ totalBranches: branches.length,
+ goToPrevious,
+ goToNext,
+ branches,
+ setBranches,
+ };
+
+ return (
+
+ div]:pb-0", className)}
+ {...props}
+ />
+
+ );
+};
+
+export type MessageBranchContentProps = HTMLAttributes
;
+
+export const MessageBranchContent = ({
+ children,
+ ...props
+}: MessageBranchContentProps) => {
+ const { currentBranch, setBranches, branches } = useMessageBranch();
+ const childrenArray = Array.isArray(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 = HTMLAttributes & {
+ from: UIMessage["role"];
+};
+
+export const MessageBranchSelector = ({
+ className,
+ from,
+ ...props
+}: MessageBranchSelectorProps) => {
+ const { totalBranches } = useMessageBranch();
+
+ // Don't render if there's only one branch
+ if (totalBranches <= 1) {
+ return null;
+ }
+
+ return (
+
+ );
+};
+
+export type MessageBranchPreviousProps = ComponentProps;
+
+export const MessageBranchPrevious = ({
+ children,
+ ...props
+}: MessageBranchPreviousProps) => {
+ const { goToPrevious, totalBranches } = useMessageBranch();
+
+ return (
+
+ {children ?? }
+
+ );
+};
+
+export type MessageBranchNextProps = ComponentProps;
+
+export const MessageBranchNext = ({
+ children,
+ className,
+ ...props
+}: MessageBranchNextProps) => {
+ const { goToNext, totalBranches } = useMessageBranch();
+
+ return (
+
+ {children ?? }
+
+ );
+};
+
+export type MessageBranchPageProps = HTMLAttributes;
+
+export const MessageBranchPage = ({
+ className,
+ ...props
+}: MessageBranchPageProps) => {
+ const { currentBranch, totalBranches } = useMessageBranch();
+
+ return (
+
+ {currentBranch + 1} of {totalBranches}
+
+ );
+};
+
+export type MessageResponseProps = ComponentProps;
+
+export const MessageResponse = memo(
+ ({ className, ...props }: MessageResponseProps) => (
+ *:first-child]:mt-0 [&>*:last-child]:mb-0",
+ className
+ )}
+ {...props}
+ />
+ ),
+ (prevProps, nextProps) => prevProps.children === nextProps.children
+);
+
+MessageResponse.displayName = "MessageResponse";
+
+export type MessageAttachmentProps = HTMLAttributes & {
+ data: FileUIPart;
+ className?: string;
+ onRemove?: () => void;
+};
+
+export function MessageAttachment({
+ data,
+ className,
+ onRemove,
+ ...props
+}: MessageAttachmentProps) {
+ const filename = data.filename || "";
+ const mediaType =
+ data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
+ const isImage = mediaType === "image";
+ const attachmentLabel = filename || (isImage ? "Image" : "Attachment");
+
+ return (
+
+ {isImage ? (
+ <>
+
+ {onRemove && (
+
{
+ e.stopPropagation();
+ onRemove();
+ }}
+ type="button"
+ variant="ghost"
+ >
+
+ Remove
+
+ )}
+ >
+ ) : (
+ <>
+
+
+
+
+
+ {attachmentLabel}
+
+
+ {onRemove && (
+
{
+ e.stopPropagation();
+ onRemove();
+ }}
+ type="button"
+ variant="ghost"
+ >
+
+ Remove
+
+ )}
+ >
+ )}
+
+ );
+}
+
+export type MessageAttachmentsProps = ComponentProps<"div">;
+
+export function MessageAttachments({
+ children,
+ className,
+ ...props
+}: MessageAttachmentsProps) {
+ if (!children) {
+ return null;
+ }
+
+ return (
+
+ {children}
+
+ );
+}
+
+export type MessageToolbarProps = ComponentProps<"div">;
+
+export const MessageToolbar = ({
+ className,
+ children,
+ ...props
+}: MessageToolbarProps) => (
+
+ {children}
+
+);
diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/model-selector.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/model-selector.tsx
new file mode 100644
index 0000000000..ef6ebd7e8b
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/model-selector.tsx
@@ -0,0 +1,205 @@
+import {
+ Command,
+ CommandDialog,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+ CommandShortcut,
+} from "@/components/ui/command";
+import {
+ Dialog,
+ DialogContent,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { cn } from "@/lib/utils";
+import type { ComponentProps, ReactNode } from "react";
+
+export type ModelSelectorProps = ComponentProps;
+
+export const ModelSelector = (props: ModelSelectorProps) => (
+
+);
+
+export type ModelSelectorTriggerProps = ComponentProps;
+
+export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
+
+);
+
+export type ModelSelectorContentProps = ComponentProps & {
+ title?: ReactNode;
+};
+
+export const ModelSelectorContent = ({
+ className,
+ children,
+ title = "Model Selector",
+ ...props
+}: ModelSelectorContentProps) => (
+
+ {title}
+
+ {children}
+
+
+);
+
+export type ModelSelectorDialogProps = ComponentProps;
+
+export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
+
+);
+
+export type ModelSelectorInputProps = ComponentProps;
+
+export const ModelSelectorInput = ({
+ className,
+ ...props
+}: ModelSelectorInputProps) => (
+
+);
+
+export type ModelSelectorListProps = ComponentProps;
+
+export const ModelSelectorList = (props: ModelSelectorListProps) => (
+
+);
+
+export type ModelSelectorEmptyProps = ComponentProps;
+
+export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
+
+);
+
+export type ModelSelectorGroupProps = ComponentProps;
+
+export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
+
+);
+
+export type ModelSelectorItemProps = ComponentProps;
+
+export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
+
+);
+
+export type ModelSelectorShortcutProps = ComponentProps;
+
+export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
+
+);
+
+export type ModelSelectorSeparatorProps = ComponentProps<
+ typeof CommandSeparator
+>;
+
+export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
+
+);
+
+export type ModelSelectorLogoProps = Omit<
+ ComponentProps<"img">,
+ "src" | "alt"
+> & {
+ provider:
+ | "moonshotai-cn"
+ | "lucidquery"
+ | "moonshotai"
+ | "zai-coding-plan"
+ | "alibaba"
+ | "xai"
+ | "vultr"
+ | "nvidia"
+ | "upstage"
+ | "groq"
+ | "github-copilot"
+ | "mistral"
+ | "vercel"
+ | "nebius"
+ | "deepseek"
+ | "alibaba-cn"
+ | "google-vertex-anthropic"
+ | "venice"
+ | "chutes"
+ | "cortecs"
+ | "github-models"
+ | "togetherai"
+ | "azure"
+ | "baseten"
+ | "huggingface"
+ | "opencode"
+ | "fastrouter"
+ | "google"
+ | "google-vertex"
+ | "cloudflare-workers-ai"
+ | "inception"
+ | "wandb"
+ | "openai"
+ | "zhipuai-coding-plan"
+ | "perplexity"
+ | "openrouter"
+ | "zenmux"
+ | "v0"
+ | "iflowcn"
+ | "synthetic"
+ | "deepinfra"
+ | "zhipuai"
+ | "submodel"
+ | "zai"
+ | "inference"
+ | "requesty"
+ | "morph"
+ | "lmstudio"
+ | "anthropic"
+ | "aihubmix"
+ | "fireworks-ai"
+ | "modelscope"
+ | "llama"
+ | "scaleway"
+ | "amazon-bedrock"
+ | "cerebras"
+ | (string & {});
+};
+
+export const ModelSelectorLogo = ({
+ provider,
+ className,
+ ...props
+}: ModelSelectorLogoProps) => (
+
+);
+
+export type ModelSelectorLogoGroupProps = ComponentProps<"div">;
+
+export const ModelSelectorLogoGroup = ({
+ className,
+ ...props
+}: ModelSelectorLogoGroupProps) => (
+ img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
+ className
+ )}
+ {...props}
+ />
+);
+
+export type ModelSelectorNameProps = ComponentProps<"span">;
+
+export const ModelSelectorName = ({
+ className,
+ ...props
+}: ModelSelectorNameProps) => (
+
+);
diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/node.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/node.tsx
new file mode 100644
index 0000000000..75ac59a153
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/node.tsx
@@ -0,0 +1,71 @@
+import {
+ Card,
+ CardAction,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { cn } from "@/lib/utils";
+import { Handle, Position } from "@xyflow/react";
+import type { ComponentProps } from "react";
+
+export type NodeProps = ComponentProps
& {
+ handles: {
+ target: boolean;
+ source: boolean;
+ };
+};
+
+export const Node = ({ handles, className, ...props }: NodeProps) => (
+
+ {handles.target && }
+ {handles.source && }
+ {props.children}
+
+);
+
+export type NodeHeaderProps = ComponentProps;
+
+export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => (
+
+);
+
+export type NodeTitleProps = ComponentProps;
+
+export const NodeTitle = (props: NodeTitleProps) => ;
+
+export type NodeDescriptionProps = ComponentProps;
+
+export const NodeDescription = (props: NodeDescriptionProps) => (
+
+);
+
+export type NodeActionProps = ComponentProps;
+
+export const NodeAction = (props: NodeActionProps) => ;
+
+export type NodeContentProps = ComponentProps;
+
+export const NodeContent = ({ className, ...props }: NodeContentProps) => (
+
+);
+
+export type NodeFooterProps = ComponentProps;
+
+export const NodeFooter = ({ className, ...props }: NodeFooterProps) => (
+
+);
diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/open-in-chat.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/open-in-chat.tsx
new file mode 100644
index 0000000000..0c62a6ac4a
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/open-in-chat.tsx
@@ -0,0 +1,365 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { cn } from "@/lib/utils";
+import {
+ ChevronDownIcon,
+ ExternalLinkIcon,
+ MessageCircleIcon,
+} from "lucide-react";
+import { type ComponentProps, createContext, useContext } from "react";
+
+const providers = {
+ github: {
+ title: "Open in GitHub",
+ createUrl: (url: string) => url,
+ icon: (
+
+ GitHub
+
+
+ ),
+ },
+ scira: {
+ title: "Open in Scira",
+ createUrl: (q: string) =>
+ `https://scira.ai/?${new URLSearchParams({
+ q,
+ })}`,
+ icon: (
+
+ Scira AI
+
+
+
+
+
+
+
+
+ ),
+ },
+ chatgpt: {
+ title: "Open in ChatGPT",
+ createUrl: (prompt: string) =>
+ `https://chatgpt.com/?${new URLSearchParams({
+ hints: "search",
+ prompt,
+ })}`,
+ icon: (
+
+ OpenAI
+
+
+ ),
+ },
+ claude: {
+ title: "Open in Claude",
+ createUrl: (q: string) =>
+ `https://claude.ai/new?${new URLSearchParams({
+ q,
+ })}`,
+ icon: (
+
+ Claude
+
+
+ ),
+ },
+ t3: {
+ title: "Open in T3 Chat",
+ createUrl: (q: string) =>
+ `https://t3.chat/new?${new URLSearchParams({
+ q,
+ })}`,
+ icon: ,
+ },
+ v0: {
+ title: "Open in v0",
+ createUrl: (q: string) =>
+ `https://v0.app?${new URLSearchParams({
+ q,
+ })}`,
+ icon: (
+
+ v0
+
+
+
+ ),
+ },
+ cursor: {
+ title: "Open in Cursor",
+ createUrl: (text: string) => {
+ const url = new URL("https://cursor.com/link/prompt");
+ url.searchParams.set("text", text);
+ return url.toString();
+ },
+ icon: (
+
+ Cursor
+
+
+ ),
+ },
+};
+
+const OpenInContext = createContext<{ query: string } | undefined>(undefined);
+
+const useOpenInContext = () => {
+ const context = useContext(OpenInContext);
+ if (!context) {
+ throw new Error("OpenIn components must be used within an OpenIn provider");
+ }
+ return context;
+};
+
+export type OpenInProps = ComponentProps & {
+ query: string;
+};
+
+export const OpenIn = ({ query, ...props }: OpenInProps) => (
+
+
+
+);
+
+export type OpenInContentProps = ComponentProps;
+
+export const OpenInContent = ({ className, ...props }: OpenInContentProps) => (
+
+);
+
+export type OpenInItemProps = ComponentProps;
+
+export const OpenInItem = (props: OpenInItemProps) => (
+
+);
+
+export type OpenInLabelProps = ComponentProps;
+
+export const OpenInLabel = (props: OpenInLabelProps) => (
+
+);
+
+export type OpenInSeparatorProps = ComponentProps;
+
+export const OpenInSeparator = (props: OpenInSeparatorProps) => (
+
+);
+
+export type OpenInTriggerProps = ComponentProps;
+
+export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => (
+
+ {children ?? (
+
+ Open in chat
+
+
+ )}
+
+);
+
+export type OpenInChatGPTProps = ComponentProps;
+
+export const OpenInChatGPT = (props: OpenInChatGPTProps) => {
+ const { query } = useOpenInContext();
+ return (
+
+
+ {providers.chatgpt.icon}
+ {providers.chatgpt.title}
+
+
+
+ );
+};
+
+export type OpenInClaudeProps = ComponentProps;
+
+export const OpenInClaude = (props: OpenInClaudeProps) => {
+ const { query } = useOpenInContext();
+ return (
+
+
+ {providers.claude.icon}
+ {providers.claude.title}
+
+
+
+ );
+};
+
+export type OpenInT3Props = ComponentProps;
+
+export const OpenInT3 = (props: OpenInT3Props) => {
+ const { query } = useOpenInContext();
+ return (
+
+
+ {providers.t3.icon}
+ {providers.t3.title}
+
+
+
+ );
+};
+
+export type OpenInSciraProps = ComponentProps;
+
+export const OpenInScira = (props: OpenInSciraProps) => {
+ const { query } = useOpenInContext();
+ return (
+
+
+ {providers.scira.icon}
+ {providers.scira.title}
+
+
+
+ );
+};
+
+export type OpenInv0Props = ComponentProps;
+
+export const OpenInv0 = (props: OpenInv0Props) => {
+ const { query } = useOpenInContext();
+ return (
+
+
+ {providers.v0.icon}
+ {providers.v0.title}
+
+
+
+ );
+};
+
+export type OpenInCursorProps = ComponentProps;
+
+export const OpenInCursor = (props: OpenInCursorProps) => {
+ const { query } = useOpenInContext();
+ return (
+
+
+ {providers.cursor.icon}
+ {providers.cursor.title}
+
+
+
+ );
+};
diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/panel.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/panel.tsx
new file mode 100644
index 0000000000..059cb7ac21
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/panel.tsx
@@ -0,0 +1,15 @@
+import { cn } from "@/lib/utils";
+import { Panel as PanelPrimitive } from "@xyflow/react";
+import type { ComponentProps } from "react";
+
+type PanelProps = ComponentProps;
+
+export const Panel = ({ className, ...props }: PanelProps) => (
+
+);
diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/plan.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/plan.tsx
new file mode 100644
index 0000000000..be04d883be
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/plan.tsx
@@ -0,0 +1,142 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardAction,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { cn } from "@/lib/utils";
+import { ChevronsUpDownIcon } from "lucide-react";
+import type { ComponentProps } from "react";
+import { createContext, useContext } from "react";
+import { Shimmer } from "./shimmer";
+
+type PlanContextValue = {
+ isStreaming: boolean;
+};
+
+const PlanContext = createContext(null);
+
+const usePlan = () => {
+ const context = useContext(PlanContext);
+ if (!context) {
+ throw new Error("Plan components must be used within Plan");
+ }
+ return context;
+};
+
+export type PlanProps = ComponentProps & {
+ isStreaming?: boolean;
+};
+
+export const Plan = ({
+ className,
+ isStreaming = false,
+ children,
+ ...props
+}: PlanProps) => (
+
+
+ {children}
+
+
+);
+
+export type PlanHeaderProps = ComponentProps;
+
+export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => (
+
+);
+
+export type PlanTitleProps = Omit<
+ ComponentProps,
+ "children"
+> & {
+ children: string;
+};
+
+export const PlanTitle = ({ children, ...props }: PlanTitleProps) => {
+ const { isStreaming } = usePlan();
+
+ return (
+
+ {isStreaming ? {children} : children}
+
+ );
+};
+
+export type PlanDescriptionProps = Omit<
+ ComponentProps,
+ "children"
+> & {
+ children: string;
+};
+
+export const PlanDescription = ({
+ className,
+ children,
+ ...props
+}: PlanDescriptionProps) => {
+ const { isStreaming } = usePlan();
+
+ return (
+
+ {isStreaming ? {children} : children}
+
+ );
+};
+
+export type PlanActionProps = ComponentProps;
+
+export const PlanAction = (props: PlanActionProps) => (
+
+);
+
+export type PlanContentProps = ComponentProps;
+
+export const PlanContent = (props: PlanContentProps) => (
+
+
+
+);
+
+export type PlanFooterProps = ComponentProps<"div">;
+
+export const PlanFooter = (props: PlanFooterProps) => (
+
+);
+
+export type PlanTriggerProps = ComponentProps;
+
+export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => (
+
+
+
+ Toggle plan
+
+
+);
diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/prompt-input.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/prompt-input.tsx
new file mode 100644
index 0000000000..1f071d08e7
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/prompt-input.tsx
@@ -0,0 +1,1413 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+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 { cn } from "@/lib/utils";
+import type { ChatStatus, FileUIPart } from "ai";
+import {
+ CornerDownLeftIcon,
+ ImageIcon,
+ Loader2Icon,
+ MicIcon,
+ PaperclipIcon,
+ PlusIcon,
+ SquareIcon,
+ XIcon,
+} from "lucide-react";
+import { nanoid } from "nanoid";
+import {
+ type ChangeEvent,
+ type ChangeEventHandler,
+ Children,
+ type ClipboardEventHandler,
+ type ComponentProps,
+ createContext,
+ type FormEvent,
+ type FormEventHandler,
+ Fragment,
+ type HTMLAttributes,
+ type KeyboardEventHandler,
+ type PropsWithChildren,
+ type ReactNode,
+ type RefObject,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+
+// ============================================================================
+// Provider Context & Types
+// ============================================================================
+
+export type AttachmentsContext = {
+ files: (FileUIPart & { id: string })[];
+ add: (files: File[] | FileList) => void;
+ remove: (id: string) => void;
+ clear: () => void;
+ openFileDialog: () => void;
+ fileInputRef: RefObject;
+};
+
+export type TextInputContext = {
+ value: string;
+ setInput: (v: string) => void;
+ clear: () => void;
+};
+
+export type 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 function 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);
+ const openRef = useRef<() => void>(() => {});
+
+ const add = useCallback((files: File[] | FileList) => {
+ const incoming = Array.from(files);
+ if (incoming.length === 0) {
+ return;
+ }
+
+ setAttachmentFiles((prev) =>
+ prev.concat(
+ incoming.map((file) => ({
+ id: nanoid(),
+ type: "file" as const,
+ url: URL.createObjectURL(file),
+ mediaType: file.type,
+ filename: file.name,
+ }))
+ )
+ );
+ }, []);
+
+ 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);
+ attachmentsRef.current = attachmentFiles;
+
+ // Cleanup blob URLs on unmount to prevent memory leaks
+ useEffect(() => {
+ return () => {
+ for (const f of attachmentsRef.current) {
+ if (f.url) {
+ URL.revokeObjectURL(f.url);
+ }
+ }
+ };
+ }, []);
+
+ const openFileDialog = useCallback(() => {
+ openRef.current?.();
+ }, []);
+
+ const attachments = useMemo(
+ () => ({
+ files: attachmentFiles,
+ add,
+ remove,
+ clear,
+ openFileDialog,
+ fileInputRef,
+ }),
+ [attachmentFiles, add, remove, clear, openFileDialog]
+ );
+
+ const __registerFileInput = useCallback(
+ (ref: RefObject, open: () => void) => {
+ fileInputRef.current = ref.current;
+ openRef.current = open;
+ },
+ []
+ );
+
+ const controller = useMemo(
+ () => ({
+ textInput: {
+ value: textInput,
+ setInput: setTextInput,
+ clear: clearInput,
+ },
+ attachments,
+ __registerFileInput,
+ }),
+ [textInput, clearInput, attachments, __registerFileInput]
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+// ============================================================================
+// Component Context & Hooks
+// ============================================================================
+
+const LocalAttachmentsContext = createContext(null);
+
+export const usePromptInputAttachments = () => {
+ // Dual-mode: prefer provider if present, otherwise use local
+ const provider = useOptionalProviderAttachments();
+ const local = useContext(LocalAttachmentsContext);
+ const context = provider ?? local;
+ if (!context) {
+ throw new Error(
+ "usePromptInputAttachments must be used within a PromptInput or PromptInputProvider"
+ );
+ }
+ return context;
+};
+
+export type PromptInputAttachmentProps = HTMLAttributes & {
+ data: FileUIPart & { id: string };
+ className?: string;
+};
+
+export function PromptInputAttachment({
+ data,
+ className,
+ ...props
+}: PromptInputAttachmentProps) {
+ const attachments = usePromptInputAttachments();
+
+ const filename = data.filename || "";
+
+ const mediaType =
+ data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
+ const isImage = mediaType === "image";
+
+ const attachmentLabel = filename || (isImage ? "Image" : "Attachment");
+
+ return (
+
+
+
+
+
+ {isImage ? (
+
+ ) : (
+
+ )}
+
+
{
+ e.stopPropagation();
+ attachments.remove(data.id);
+ }}
+ type="button"
+ variant="ghost"
+ >
+
+ Remove
+
+
+
+
{attachmentLabel}
+
+
+
+
+ {isImage && (
+
+
+
+ )}
+
+
+
+ {filename || (isImage ? "Image" : "Attachment")}
+
+ {data.mediaType && (
+
+ {data.mediaType}
+
+ )}
+
+
+
+
+
+ );
+}
+
+export type PromptInputAttachmentsProps = Omit<
+ HTMLAttributes,
+ "children"
+> & {
+ children: (attachment: FileUIPart & { id: string }) => ReactNode;
+};
+
+export function PromptInputAttachments({
+ children,
+ className,
+ ...props
+}: PromptInputAttachmentsProps) {
+ const attachments = usePromptInputAttachments();
+
+ if (!attachments.files.length) {
+ return null;
+ }
+
+ return (
+
+ {attachments.files.map((file) => (
+ {children(file)}
+ ))}
+
+ );
+}
+
+export type PromptInputActionAddAttachmentsProps = ComponentProps<
+ typeof DropdownMenuItem
+> & {
+ label?: string;
+};
+
+export const PromptInputActionAddAttachments = ({
+ label = "Add photos or files",
+ ...props
+}: PromptInputActionAddAttachmentsProps) => {
+ const attachments = usePromptInputAttachments();
+
+ return (
+ {
+ e.preventDefault();
+ attachments.openFileDialog();
+ }}
+ >
+ {label}
+
+ );
+};
+
+export type PromptInputMessage = {
+ text: string;
+ files: FileUIPart[];
+};
+
+export type PromptInputProps = Omit<
+ HTMLAttributes,
+ "onSubmit" | "onError"
+> & {
+ accept?: string; // e.g., "image/*" or leave undefined for any
+ 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;
+ maxFileSize?: number; // bytes
+ 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;
+
+ // Keep a ref to files for cleanup on unmount (avoids stale closure)
+ const filesRef = useRef(files);
+ filesRef.current = 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("/*")) {
+ const prefix = pattern.slice(0, -1); // e.g: image/* -> image/
+ return f.type.startsWith(prefix);
+ }
+ return f.type === pattern;
+ });
+ },
+ [accept]
+ );
+
+ const addLocal = useCallback(
+ (fileList: File[] | FileList) => {
+ const incoming = Array.from(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({
+ id: nanoid(),
+ type: "file",
+ url: URL.createObjectURL(file),
+ mediaType: file.type,
+ filename: file.name,
+ });
+ }
+ return prev.concat(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);
+ }),
+ []
+ );
+
+ const clearLocal = useCallback(
+ () =>
+ setItems((prev) => {
+ for (const file of prev) {
+ if (file.url) {
+ URL.revokeObjectURL(file.url);
+ }
+ }
+ return [];
+ }),
+ []
+ );
+
+ const add = usingProvider ? controller.attachments.add : addLocal;
+ const remove = usingProvider ? controller.attachments.remove : removeLocal;
+ const clear = usingProvider ? controller.attachments.clear : clearLocal;
+ const openFileDialog = usingProvider
+ ? controller.attachments.openFileDialog
+ : openFileDialogLocal;
+
+ // 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) return; // when global drop is on, let the document-level handler own drops
+
+ 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);
+ }
+ }
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup only on unmount; filesRef always current
+ [usingProvider]
+ );
+
+ const handleChange: ChangeEventHandler = (event) => {
+ if (event.currentTarget.files) {
+ add(event.currentTarget.files);
+ }
+ // Reset input value to allow selecting files that were previously removed
+ event.currentTarget.value = "";
+ };
+
+ const convertBlobUrlToDataUrl = async (
+ url: string
+ ): Promise => {
+ try {
+ const response = await fetch(url);
+ const blob = await response.blob();
+ return new Promise((resolve) => {
+ const reader = new FileReader();
+ reader.onloadend = () => resolve(reader.result as string);
+ reader.onerror = () => resolve(null);
+ reader.readAsDataURL(blob);
+ });
+ } catch {
+ return null;
+ }
+ };
+
+ const ctx = useMemo(
+ () => ({
+ files: files.map((item) => ({ ...item, id: item.id })),
+ add,
+ remove,
+ clear,
+ openFileDialog,
+ fileInputRef: inputRef,
+ }),
+ [files, add, remove, clear, openFileDialog]
+ );
+
+ const handleSubmit: FormEventHandler = (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();
+ }
+
+ // Convert blob URLs to data URLs asynchronously
+ Promise.all(
+ files.map(async ({ id, ...item }) => {
+ if (item.url && 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;
+ })
+ )
+ .then((convertedFiles: FileUIPart[]) => {
+ try {
+ const result = onSubmit({ text, files: convertedFiles }, event);
+
+ // Handle both sync and async onSubmit
+ if (result instanceof Promise) {
+ result
+ .then(() => {
+ clear();
+ if (usingProvider) {
+ controller.textInput.clear();
+ }
+ })
+ .catch(() => {
+ // Don't clear on error - user may want to retry
+ });
+ } else {
+ // Sync function completed without throwing, clear attachments
+ clear();
+ if (usingProvider) {
+ controller.textInput.clear();
+ }
+ }
+ } catch {
+ // Don't clear on error - user may want to retry
+ }
+ })
+ .catch(() => {
+ // Don't clear on error - user may want to retry
+ });
+ };
+
+ // Render with or without local provider
+ const inner = (
+ <>
+
+
+ >
+ );
+
+ return usingProvider ? (
+ inner
+ ) : (
+
+ {inner}
+
+ );
+};
+
+export type PromptInputBodyProps = HTMLAttributes;
+
+export const PromptInputBody = ({
+ className,
+ ...props
+}: PromptInputBodyProps) => (
+
+);
+
+export type PromptInputTextareaProps = ComponentProps<
+ typeof InputGroupTextarea
+>;
+
+export const PromptInputTextarea = ({
+ onChange,
+ className,
+ placeholder = "What would you like to know?",
+ ...props
+}: PromptInputTextareaProps) => {
+ const controller = useOptionalPromptInputController();
+ const attachments = usePromptInputAttachments();
+ const [isComposing, setIsComposing] = useState(false);
+
+ const handleKeyDown: KeyboardEventHandler = (e) => {
+ 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.form;
+ 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);
+ }
+ }
+ };
+
+ const handlePaste: ClipboardEventHandler = (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);
+ }
+ };
+
+ const controlledProps = controller
+ ? {
+ value: controller.textInput.value,
+ onChange: (e: ChangeEvent) => {
+ controller.textInput.setInput(e.currentTarget.value);
+ onChange?.(e);
+ },
+ }
+ : {
+ onChange,
+ };
+
+ return (
+ setIsComposing(false)}
+ onCompositionStart={() => setIsComposing(true)}
+ onKeyDown={handleKeyDown}
+ onPaste={handlePaste}
+ placeholder={placeholder}
+ {...props}
+ {...controlledProps}
+ />
+ );
+};
+
+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 PromptInputButtonProps = ComponentProps;
+
+export const PromptInputButton = ({
+ variant = "ghost",
+ className,
+ size,
+ ...props
+}: PromptInputButtonProps) => {
+ const newSize =
+ size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm");
+
+ return (
+
+ );
+};
+
+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;
+};
+
+export const PromptInputSubmit = ({
+ className,
+ variant = "default",
+ size = "icon-sm",
+ status,
+ children,
+ ...props
+}: PromptInputSubmitProps) => {
+ let Icon = ;
+
+ if (status === "submitted") {
+ Icon = ;
+ } else if (status === "streaming") {
+ Icon = ;
+ } else if (status === "error") {
+ Icon = ;
+ }
+
+ return (
+
+ {children ?? Icon}
+
+ );
+};
+
+interface SpeechRecognition extends EventTarget {
+ continuous: boolean;
+ interimResults: boolean;
+ lang: string;
+ start(): void;
+ stop(): void;
+ onstart: ((this: SpeechRecognition, ev: Event) => any) | null;
+ onend: ((this: SpeechRecognition, ev: Event) => any) | null;
+ onresult:
+ | ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => any)
+ | null;
+ onerror:
+ | ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => any)
+ | null;
+}
+
+interface SpeechRecognitionEvent extends Event {
+ results: SpeechRecognitionResultList;
+ resultIndex: number;
+}
+
+type SpeechRecognitionResultList = {
+ readonly length: number;
+ item(index: number): SpeechRecognitionResult;
+ [index: number]: SpeechRecognitionResult;
+};
+
+type SpeechRecognitionResult = {
+ readonly length: number;
+ item(index: number): SpeechRecognitionAlternative;
+ [index: number]: SpeechRecognitionAlternative;
+ isFinal: boolean;
+};
+
+type SpeechRecognitionAlternative = {
+ transcript: string;
+ confidence: number;
+};
+
+interface SpeechRecognitionErrorEvent extends Event {
+ error: string;
+}
+
+declare global {
+ interface Window {
+ SpeechRecognition: {
+ new (): SpeechRecognition;
+ };
+ webkitSpeechRecognition: {
+ new (): SpeechRecognition;
+ };
+ }
+}
+
+export type PromptInputSpeechButtonProps = ComponentProps<
+ typeof PromptInputButton
+> & {
+ textareaRef?: RefObject;
+ onTranscriptionChange?: (text: string) => void;
+};
+
+export const PromptInputSpeechButton = ({
+ className,
+ textareaRef,
+ onTranscriptionChange,
+ ...props
+}: PromptInputSpeechButtonProps) => {
+ const [isListening, setIsListening] = useState(false);
+ const [recognition, setRecognition] = useState(
+ null
+ );
+ const recognitionRef = useRef(null);
+
+ useEffect(() => {
+ if (
+ typeof window !== "undefined" &&
+ ("SpeechRecognition" in window || "webkitSpeechRecognition" in window)
+ ) {
+ const SpeechRecognition =
+ window.SpeechRecognition || window.webkitSpeechRecognition;
+ const speechRecognition = new SpeechRecognition();
+
+ speechRecognition.continuous = true;
+ speechRecognition.interimResults = true;
+ speechRecognition.lang = "en-US";
+
+ speechRecognition.onstart = () => {
+ setIsListening(true);
+ };
+
+ speechRecognition.onend = () => {
+ setIsListening(false);
+ };
+
+ speechRecognition.onresult = (event) => {
+ let finalTranscript = "";
+
+ for (let i = event.resultIndex; i < event.results.length; i++) {
+ const result = event.results[i];
+ if (result.isFinal) {
+ finalTranscript += result[0]?.transcript ?? "";
+ }
+ }
+
+ if (finalTranscript && textareaRef?.current) {
+ const textarea = textareaRef.current;
+ const currentValue = textarea.value;
+ const newValue =
+ currentValue + (currentValue ? " " : "") + finalTranscript;
+
+ textarea.value = newValue;
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
+ onTranscriptionChange?.(newValue);
+ }
+ };
+
+ speechRecognition.onerror = (event) => {
+ console.error("Speech recognition error:", event.error);
+ setIsListening(false);
+ };
+
+ recognitionRef.current = speechRecognition;
+ setRecognition(speechRecognition);
+ }
+
+ return () => {
+ if (recognitionRef.current) {
+ recognitionRef.current.stop();
+ }
+ };
+ }, [textareaRef, onTranscriptionChange]);
+
+ const toggleListening = useCallback(() => {
+ if (!recognition) {
+ return;
+ }
+
+ if (isListening) {
+ recognition.stop();
+ } else {
+ recognition.start();
+ }
+ }, [recognition, isListening]);
+
+ return (
+
+
+
+ );
+};
+
+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) => (
+
+);
+
+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/examples/nextjs-ai-spreadsheet/components/ai-elements/queue.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/queue.tsx
new file mode 100644
index 0000000000..0c91d1300b
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/queue.tsx
@@ -0,0 +1,274 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { cn } from "@/lib/utils";
+import { ChevronDownIcon, PaperclipIcon } from "lucide-react";
+import type { ComponentProps } from "react";
+
+export type QueueMessagePart = {
+ type: string;
+ text?: string;
+ url?: string;
+ filename?: string;
+ mediaType?: string;
+};
+
+export type QueueMessage = {
+ id: string;
+ parts: QueueMessagePart[];
+};
+
+export type QueueTodo = {
+ id: string;
+ title: string;
+ description?: string;
+ status?: "pending" | "completed";
+};
+
+export type QueueItemProps = ComponentProps<"li">;
+
+export const QueueItem = ({ className, ...props }: QueueItemProps) => (
+
+);
+
+export type QueueItemIndicatorProps = ComponentProps<"span"> & {
+ completed?: boolean;
+};
+
+export const QueueItemIndicator = ({
+ completed = false,
+ className,
+ ...props
+}: QueueItemIndicatorProps) => (
+
+);
+
+export type QueueItemContentProps = ComponentProps<"span"> & {
+ completed?: boolean;
+};
+
+export const QueueItemContent = ({
+ completed = false,
+ className,
+ ...props
+}: QueueItemContentProps) => (
+
+);
+
+export type QueueItemDescriptionProps = ComponentProps<"div"> & {
+ completed?: boolean;
+};
+
+export const QueueItemDescription = ({
+ completed = false,
+ className,
+ ...props
+}: QueueItemDescriptionProps) => (
+
+);
+
+export type QueueItemActionsProps = ComponentProps<"div">;
+
+export const QueueItemActions = ({
+ className,
+ ...props
+}: QueueItemActionsProps) => (
+
+);
+
+export type QueueItemActionProps = Omit<
+ ComponentProps,
+ "variant" | "size"
+>;
+
+export const QueueItemAction = ({
+ className,
+ ...props
+}: QueueItemActionProps) => (
+
+);
+
+export type QueueItemAttachmentProps = ComponentProps<"div">;
+
+export const QueueItemAttachment = ({
+ className,
+ ...props
+}: QueueItemAttachmentProps) => (
+
+);
+
+export type QueueItemImageProps = ComponentProps<"img">;
+
+export const QueueItemImage = ({
+ className,
+ ...props
+}: QueueItemImageProps) => (
+
+);
+
+export type QueueItemFileProps = ComponentProps<"span">;
+
+export const QueueItemFile = ({
+ children,
+ className,
+ ...props
+}: QueueItemFileProps) => (
+
+
+ {children}
+
+);
+
+export type QueueListProps = ComponentProps;
+
+export const QueueList = ({
+ children,
+ className,
+ ...props
+}: QueueListProps) => (
+
+
+
+);
+
+// QueueSection - collapsible section container
+export type QueueSectionProps = ComponentProps;
+
+export const QueueSection = ({
+ className,
+ defaultOpen = true,
+ ...props
+}: QueueSectionProps) => (
+
+);
+
+// QueueSectionTrigger - section header/trigger
+export type QueueSectionTriggerProps = ComponentProps<"button">;
+
+export const QueueSectionTrigger = ({
+ children,
+ className,
+ ...props
+}: QueueSectionTriggerProps) => (
+
+
+ {children}
+
+
+);
+
+// QueueSectionLabel - label content with icon and count
+export type QueueSectionLabelProps = ComponentProps<"span"> & {
+ count?: number;
+ label: string;
+ icon?: React.ReactNode;
+};
+
+export const QueueSectionLabel = ({
+ count,
+ label,
+ icon,
+ className,
+ ...props
+}: QueueSectionLabelProps) => (
+
+
+ {icon}
+
+ {count} {label}
+
+
+);
+
+// QueueSectionContent - collapsible content area
+export type QueueSectionContentProps = ComponentProps<
+ typeof CollapsibleContent
+>;
+
+export const QueueSectionContent = ({
+ className,
+ ...props
+}: QueueSectionContentProps) => (
+
+);
+
+export type QueueProps = ComponentProps<"div">;
+
+export const Queue = ({ className, ...props }: QueueProps) => (
+
+);
diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/reasoning.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/reasoning.tsx
new file mode 100644
index 0000000000..6b5ba5150a
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/reasoning.tsx
@@ -0,0 +1,187 @@
+"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 { BrainIcon, ChevronDownIcon } from "lucide-react";
+import type { ComponentProps, ReactNode } from "react";
+import { createContext, memo, useContext, useEffect, useState } from "react";
+import { Streamdown } from "streamdown";
+import { Shimmer } from "./shimmer";
+
+type 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 = true,
+ onOpenChange,
+ duration: durationProp,
+ children,
+ ...props
+ }: ReasoningProps) => {
+ const [isOpen, setIsOpen] = useControllableState({
+ prop: open,
+ defaultProp: defaultOpen,
+ onChange: onOpenChange,
+ });
+ const [duration, setDuration] = useControllableState({
+ prop: durationProp,
+ defaultProp: undefined,
+ });
+
+ const [hasAutoClosed, setHasAutoClosed] = useState(false);
+ const [startTime, setStartTime] = useState(null);
+
+ // Track duration when streaming starts and ends
+ useEffect(() => {
+ if (isStreaming) {
+ if (startTime === null) {
+ setStartTime(Date.now());
+ }
+ } else if (startTime !== null) {
+ setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S));
+ setStartTime(null);
+ }
+ }, [isStreaming, startTime, setDuration]);
+
+ // Auto-open when streaming starts, auto-close when streaming ends (once only)
+ useEffect(() => {
+ if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
+ // Add a small delay before closing to allow user to see the content
+ const timer = setTimeout(() => {
+ setIsOpen(false);
+ setHasAutoClosed(true);
+ }, AUTO_CLOSE_DELAY);
+
+ return () => clearTimeout(timer);
+ }
+ }, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed]);
+
+ const handleOpenChange = (newOpen: boolean) => {
+ setIsOpen(newOpen);
+ };
+
+ 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;
+};
+
+export const ReasoningContent = memo(
+ ({ className, children, ...props }: ReasoningContentProps) => (
+
+ {children}
+
+ )
+);
+
+Reasoning.displayName = "Reasoning";
+ReasoningTrigger.displayName = "ReasoningTrigger";
+ReasoningContent.displayName = "ReasoningContent";
diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/shimmer.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/shimmer.tsx
new file mode 100644
index 0000000000..9163aac4d1
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/shimmer.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import { motion } from "motion/react";
+import {
+ type CSSProperties,
+ type ElementType,
+ type JSX,
+ memo,
+ useMemo,
+} from "react";
+
+export type 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 = motion.create(
+ Component as keyof JSX.IntrinsicElements
+ );
+
+ const dynamicSpread = useMemo(
+ () => (children?.length ?? 0) * spread,
+ [children, spread]
+ );
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const Shimmer = memo(ShimmerComponent);
diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/sources.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/sources.tsx
new file mode 100644
index 0000000000..0756664f3c
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/sources.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { cn } from "@/lib/utils";
+import { BookIcon, ChevronDownIcon } from "lucide-react";
+import type { ComponentProps } from "react";
+
+export type SourcesProps = ComponentProps<"div">;
+
+export const Sources = ({ className, ...props }: SourcesProps) => (
+
+);
+
+export type SourcesTriggerProps = ComponentProps & {
+ count: number;
+};
+
+export const SourcesTrigger = ({
+ className,
+ count,
+ children,
+ ...props
+}: SourcesTriggerProps) => (
+
+ {children ?? (
+ <>
+ Used {count} sources
+
+ >
+ )}
+
+);
+
+export type SourcesContentProps = ComponentProps;
+
+export const SourcesContent = ({
+ className,
+ ...props
+}: SourcesContentProps) => (
+
+);
+
+export type SourceProps = ComponentProps<"a">;
+
+export const Source = ({ href, title, children, ...props }: SourceProps) => (
+
+ {children ?? (
+ <>
+
+ {title}
+ >
+ )}
+
+);
diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/suggestion.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/suggestion.tsx
new file mode 100644
index 0000000000..b875172b79
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/suggestion.tsx
@@ -0,0 +1,53 @@
+"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";
+
+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 = () => {
+ onClick?.(suggestion);
+ };
+
+ return (
+
+ {children || suggestion}
+
+ );
+};
diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/task.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/task.tsx
new file mode 100644
index 0000000000..eeb802c61d
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/task.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { cn } from "@/lib/utils";
+import { ChevronDownIcon, SearchIcon } from "lucide-react";
+import type { ComponentProps } from "react";
+
+export type TaskItemFileProps = ComponentProps<"div">;
+
+export const TaskItemFile = ({
+ children,
+ className,
+ ...props
+}: TaskItemFileProps) => (
+
+ {children}
+
+);
+
+export type TaskItemProps = ComponentProps<"div">;
+
+export const TaskItem = ({ children, className, ...props }: TaskItemProps) => (
+
+ {children}
+
+);
+
+export type TaskProps = ComponentProps;
+
+export const Task = ({
+ defaultOpen = true,
+ className,
+ ...props
+}: TaskProps) => (
+
+);
+
+export type TaskTriggerProps = ComponentProps & {
+ title: string;
+};
+
+export const TaskTrigger = ({
+ children,
+ className,
+ title,
+ ...props
+}: TaskTriggerProps) => (
+
+ {children ?? (
+
+ )}
+
+);
+
+export type TaskContentProps = ComponentProps;
+
+export const TaskContent = ({
+ children,
+ className,
+ ...props
+}: TaskContentProps) => (
+
+
+ {children}
+
+
+);
diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/tool.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/tool.tsx
new file mode 100644
index 0000000000..f26b94b538
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/tool.tsx
@@ -0,0 +1,163 @@
+"use client";
+
+import { Badge } from "@/components/ui/badge";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { cn } from "@/lib/utils";
+import type { 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 ToolHeaderProps = {
+ title?: string;
+ type: ToolUIPart["type"];
+ state: ToolUIPart["state"];
+ className?: string;
+};
+
+const getStatusBadge = (status: ToolUIPart["state"]) => {
+ const labels: Record = {
+ "input-streaming": "Pending",
+ "input-available": "Running",
+ "approval-requested": "Awaiting Approval",
+ "approval-responded": "Responded",
+ "output-available": "Completed",
+ "output-error": "Error",
+ "output-denied": "Denied",
+ };
+
+ const icons: Record = {
+ "input-streaming": ,
+ "input-available": ,
+ "approval-requested": ,
+ "approval-responded": ,
+ "output-available": ,
+ "output-error": ,
+ "output-denied": ,
+ };
+
+ return (
+
+ {icons[status]}
+ {labels[status]}
+
+ );
+};
+
+export const ToolHeader = ({
+ className,
+ title,
+ type,
+ state,
+ ...props
+}: ToolHeaderProps) => (
+
+
+
+
+ {title ?? type.split("-").slice(1).join("-")}
+
+ {getStatusBadge(state)}
+
+
+
+);
+
+export type ToolContentProps = ComponentProps;
+
+export const ToolContent = ({ className, ...props }: ToolContentProps) => (
+
+);
+
+export type ToolInputProps = ComponentProps<"div"> & {
+ input: ToolUIPart["input"];
+};
+
+export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
+
+);
+
+export type ToolOutputProps = ComponentProps<"div"> & {
+ output: ToolUIPart["output"];
+ errorText: ToolUIPart["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/examples/nextjs-ai-spreadsheet/components/ai-elements/toolbar.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/toolbar.tsx
new file mode 100644
index 0000000000..b55aa8895d
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/toolbar.tsx
@@ -0,0 +1,16 @@
+import { cn } from "@/lib/utils";
+import { NodeToolbar, Position } from "@xyflow/react";
+import type { ComponentProps } from "react";
+
+type ToolbarProps = ComponentProps;
+
+export const Toolbar = ({ className, ...props }: ToolbarProps) => (
+
+);
diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/web-preview.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/web-preview.tsx
new file mode 100644
index 0000000000..8f0ab5a530
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/web-preview.tsx
@@ -0,0 +1,263 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { Input } from "@/components/ui/input";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import { ChevronDownIcon } from "lucide-react";
+import type { ComponentProps, ReactNode } from "react";
+import { createContext, useContext, useEffect, useState } from "react";
+
+export type WebPreviewContextValue = {
+ url: string;
+ setUrl: (url: string) => void;
+ consoleOpen: boolean;
+ setConsoleOpen: (open: boolean) => void;
+};
+
+const WebPreviewContext = createContext(null);
+
+const useWebPreview = () => {
+ const context = useContext(WebPreviewContext);
+ if (!context) {
+ throw new Error("WebPreview components must be used within a WebPreview");
+ }
+ return context;
+};
+
+export type WebPreviewProps = ComponentProps<"div"> & {
+ defaultUrl?: string;
+ onUrlChange?: (url: string) => void;
+};
+
+export const WebPreview = ({
+ className,
+ children,
+ defaultUrl = "",
+ onUrlChange,
+ ...props
+}: WebPreviewProps) => {
+ const [url, setUrl] = useState(defaultUrl);
+ const [consoleOpen, setConsoleOpen] = useState(false);
+
+ const handleUrlChange = (newUrl: string) => {
+ setUrl(newUrl);
+ onUrlChange?.(newUrl);
+ };
+
+ const contextValue: WebPreviewContextValue = {
+ url,
+ setUrl: handleUrlChange,
+ consoleOpen,
+ setConsoleOpen,
+ };
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+export type WebPreviewNavigationProps = ComponentProps<"div">;
+
+export const WebPreviewNavigation = ({
+ className,
+ children,
+ ...props
+}: WebPreviewNavigationProps) => (
+
+ {children}
+
+);
+
+export type WebPreviewNavigationButtonProps = ComponentProps & {
+ tooltip?: string;
+};
+
+export const WebPreviewNavigationButton = ({
+ onClick,
+ disabled,
+ tooltip,
+ children,
+ ...props
+}: WebPreviewNavigationButtonProps) => (
+
+
+
+
+ {children}
+
+
+
+ {tooltip}
+
+
+
+);
+
+export type WebPreviewUrlProps = ComponentProps;
+
+export const WebPreviewUrl = ({
+ value,
+ onChange,
+ onKeyDown,
+ ...props
+}: WebPreviewUrlProps) => {
+ const { url, setUrl } = useWebPreview();
+ const [inputValue, setInputValue] = useState(url);
+
+ // Sync input value with context URL when it changes externally
+ useEffect(() => {
+ setInputValue(url);
+ }, [url]);
+
+ const handleChange = (event: React.ChangeEvent) => {
+ setInputValue(event.target.value);
+ onChange?.(event);
+ };
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === "Enter") {
+ const target = event.target as HTMLInputElement;
+ setUrl(target.value);
+ }
+ onKeyDown?.(event);
+ };
+
+ return (
+
+ );
+};
+
+export type WebPreviewBodyProps = ComponentProps<"iframe"> & {
+ loading?: ReactNode;
+};
+
+export const WebPreviewBody = ({
+ className,
+ loading,
+ src,
+ ...props
+}: WebPreviewBodyProps) => {
+ const { url } = useWebPreview();
+
+ return (
+
+
+ {loading}
+
+ );
+};
+
+export type WebPreviewConsoleProps = ComponentProps<"div"> & {
+ logs?: Array<{
+ level: "log" | "warn" | "error";
+ message: string;
+ timestamp: Date;
+ }>;
+};
+
+export const WebPreviewConsole = ({
+ className,
+ logs = [],
+ children,
+ ...props
+}: WebPreviewConsoleProps) => {
+ const { consoleOpen, setConsoleOpen } = useWebPreview();
+
+ return (
+
+
+
+ Console
+
+
+
+
+
+ {logs.length === 0 ? (
+
No console output
+ ) : (
+ logs.map((log, index) => (
+
+
+ {log.timestamp.toLocaleTimeString()}
+ {" "}
+ {log.message}
+
+ ))
+ )}
+ {children}
+
+
+
+ );
+};
diff --git a/examples/nextjs-ai-spreadsheet/components/ui/alert.tsx b/examples/nextjs-ai-spreadsheet/components/ui/alert.tsx
new file mode 100644
index 0000000000..f99164ed4b
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ui/alert.tsx
@@ -0,0 +1,66 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const alertVariants = cva(
+ "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
+ {
+ variants: {
+ variant: {
+ default: "bg-card text-card-foreground",
+ destructive:
+ "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Alert({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ )
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Alert, AlertTitle, AlertDescription }
diff --git a/examples/nextjs-ai-spreadsheet/components/ui/badge.tsx b/examples/nextjs-ai-spreadsheet/components/ui/badge.tsx
new file mode 100644
index 0000000000..6eb2a057aa
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ui/badge.tsx
@@ -0,0 +1,48 @@
+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 & { asChild?: boolean }) {
+ const Comp = asChild ? Slot.Root : "span"
+
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/examples/nextjs-ai-spreadsheet/components/ui/button-group.tsx b/examples/nextjs-ai-spreadsheet/components/ui/button-group.tsx
new file mode 100644
index 0000000000..cd550d7afc
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ui/button-group.tsx
@@ -0,0 +1,83 @@
+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) {
+ return (
+
+ )
+}
+
+function ButtonGroupText({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"div"> & {
+ asChild?: boolean
+}) {
+ const Comp = asChild ? Slot.Root : "div"
+
+ return (
+
+ )
+}
+
+function ButtonGroupSeparator({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ ButtonGroup,
+ ButtonGroupSeparator,
+ ButtonGroupText,
+ buttonGroupVariants,
+}
diff --git a/examples/nextjs-ai-spreadsheet/components/ui/button.tsx b/examples/nextjs-ai-spreadsheet/components/ui/button.tsx
new file mode 100644
index 0000000000..4d38506cee
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ui/button.tsx
@@ -0,0 +1,64 @@
+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 & {
+ asChild?: boolean
+ }) {
+ const Comp = asChild ? Slot.Root : "button"
+
+ return (
+
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/examples/nextjs-ai-spreadsheet/components/ui/card.tsx b/examples/nextjs-ai-spreadsheet/components/ui/card.tsx
new file mode 100644
index 0000000000..acf57dc5a5
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ui/card.tsx
@@ -0,0 +1,92 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Card({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/examples/nextjs-ai-spreadsheet/components/ui/carousel.tsx b/examples/nextjs-ai-spreadsheet/components/ui/carousel.tsx
new file mode 100644
index 0000000000..0e05a77ea1
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ui/carousel.tsx
@@ -0,0 +1,241 @@
+"use client"
+
+import * as React from "react"
+import useEmblaCarousel, {
+ type UseEmblaCarouselType,
+} from "embla-carousel-react"
+import { ArrowLeft, ArrowRight } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+
+type CarouselApi = UseEmblaCarouselType[1]
+type UseCarouselParameters = Parameters
+type CarouselOptions = UseCarouselParameters[0]
+type CarouselPlugin = UseCarouselParameters[1]
+
+type CarouselProps = {
+ opts?: CarouselOptions
+ plugins?: CarouselPlugin
+ orientation?: "horizontal" | "vertical"
+ setApi?: (api: CarouselApi) => void
+}
+
+type CarouselContextProps = {
+ carouselRef: ReturnType[0]
+ api: ReturnType[1]
+ scrollPrev: () => void
+ scrollNext: () => void
+ canScrollPrev: boolean
+ canScrollNext: boolean
+} & CarouselProps
+
+const CarouselContext = React.createContext(null)
+
+function useCarousel() {
+ const context = React.useContext(CarouselContext)
+
+ if (!context) {
+ throw new Error("useCarousel must be used within a ")
+ }
+
+ return context
+}
+
+function Carousel({
+ orientation = "horizontal",
+ opts,
+ setApi,
+ plugins,
+ className,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & CarouselProps) {
+ const [carouselRef, api] = useEmblaCarousel(
+ {
+ ...opts,
+ axis: orientation === "horizontal" ? "x" : "y",
+ },
+ plugins
+ )
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false)
+ const [canScrollNext, setCanScrollNext] = React.useState(false)
+
+ const onSelect = React.useCallback((api: CarouselApi) => {
+ if (!api) return
+ setCanScrollPrev(api.canScrollPrev())
+ setCanScrollNext(api.canScrollNext())
+ }, [])
+
+ const scrollPrev = React.useCallback(() => {
+ api?.scrollPrev()
+ }, [api])
+
+ const scrollNext = React.useCallback(() => {
+ api?.scrollNext()
+ }, [api])
+
+ const handleKeyDown = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === "ArrowLeft") {
+ event.preventDefault()
+ scrollPrev()
+ } else if (event.key === "ArrowRight") {
+ event.preventDefault()
+ scrollNext()
+ }
+ },
+ [scrollPrev, scrollNext]
+ )
+
+ React.useEffect(() => {
+ if (!api || !setApi) return
+ setApi(api)
+ }, [api, setApi])
+
+ React.useEffect(() => {
+ if (!api) return
+ onSelect(api)
+ api.on("reInit", onSelect)
+ api.on("select", onSelect)
+
+ return () => {
+ api?.off("select", onSelect)
+ }
+ }, [api, onSelect])
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
+ const { carouselRef, orientation } = useCarousel()
+
+ return (
+
+ )
+}
+
+function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
+ const { orientation } = useCarousel()
+
+ return (
+
+ )
+}
+
+function CarouselPrevious({
+ className,
+ variant = "outline",
+ size = "icon",
+ ...props
+}: React.ComponentProps) {
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel()
+
+ return (
+
+
+ Previous slide
+
+ )
+}
+
+function CarouselNext({
+ className,
+ variant = "outline",
+ size = "icon",
+ ...props
+}: React.ComponentProps) {
+ const { orientation, scrollNext, canScrollNext } = useCarousel()
+
+ return (
+
+
+ Next slide
+
+ )
+}
+
+export {
+ type CarouselApi,
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselPrevious,
+ CarouselNext,
+}
diff --git a/examples/nextjs-ai-spreadsheet/components/ui/collapsible.tsx b/examples/nextjs-ai-spreadsheet/components/ui/collapsible.tsx
new file mode 100644
index 0000000000..2f7a4e7fc6
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ui/collapsible.tsx
@@ -0,0 +1,33 @@
+"use client"
+
+import { Collapsible as CollapsiblePrimitive } from "radix-ui"
+
+function Collapsible({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function CollapsibleTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CollapsibleContent({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent }
diff --git a/examples/nextjs-ai-spreadsheet/components/ui/command.tsx b/examples/nextjs-ai-spreadsheet/components/ui/command.tsx
new file mode 100644
index 0000000000..8fe3ccb406
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ui/command.tsx
@@ -0,0 +1,184 @@
+"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) {
+ return (
+
+ )
+}
+
+function CommandDialog({
+ title = "Command Palette",
+ description = "Search for a command to run...",
+ children,
+ className,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ title?: string
+ description?: string
+ className?: string
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+ {title}
+ {description}
+
+
+
+ {children}
+
+
+
+ )
+}
+
+function CommandInput({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ )
+}
+
+function CommandList({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandEmpty({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandGroup({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandItem({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+export {
+ Command,
+ CommandDialog,
+ CommandInput,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandShortcut,
+ CommandSeparator,
+}
diff --git a/examples/nextjs-ai-spreadsheet/components/ui/dialog.tsx b/examples/nextjs-ai-spreadsheet/components/ui/dialog.tsx
new file mode 100644
index 0000000000..84bdef4bb3
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ui/dialog.tsx
@@ -0,0 +1,158 @@
+"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) {
+ return
+}
+
+function DialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogClose({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+
+ Close
+
+ )}
+
+ )
+}
+
+function DialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/examples/nextjs-ai-spreadsheet/components/ui/dropdown-menu.tsx b/examples/nextjs-ai-spreadsheet/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000000..ae1fcf62f1
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ui/dropdown-menu.tsx
@@ -0,0 +1,257 @@
+"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) {
+ return
+}
+
+function DropdownMenuPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuContent({
+ className,
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function DropdownMenuGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuRadioGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function DropdownMenuSub({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function DropdownMenuSubContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+}
diff --git a/examples/nextjs-ai-spreadsheet/components/ui/hover-card.tsx b/examples/nextjs-ai-spreadsheet/components/ui/hover-card.tsx
new file mode 100644
index 0000000000..91e869c0d3
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ui/hover-card.tsx
@@ -0,0 +1,44 @@
+"use client"
+
+import * as React from "react"
+import { HoverCard as HoverCardPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function HoverCard({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function HoverCardTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function HoverCardContent({
+ className,
+ align = "center",
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export { HoverCard, HoverCardTrigger, HoverCardContent }
diff --git a/examples/nextjs-ai-spreadsheet/components/ui/input-group.tsx b/examples/nextjs-ai-spreadsheet/components/ui/input-group.tsx
new file mode 100644
index 0000000000..009defb0a8
--- /dev/null
+++ b/examples/nextjs-ai-spreadsheet/components/ui/input-group.tsx
@@ -0,0 +1,170 @@
+"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 (
+ 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) {
+ return (
+