├── CLAUDE.md
├── example.env.local
├── .codesandbox
├── sandbox.config.json
└── tasks.json
├── src
├── app
│ ├── favicon.ico
│ ├── layout.tsx
│ ├── dev
│ │ └── fortune-demo
│ │ │ └── page.tsx
│ ├── chat
│ │ └── page.tsx
│ ├── page.tsx
│ └── globals.css
├── lib
│ ├── utils.ts
│ ├── id-generator.ts
│ ├── tab-context-helper.ts
│ ├── constants.ts
│ ├── tambo.ts
│ ├── spreadsheet-selection-context.ts
│ ├── graph-data-utils.ts
│ ├── spreadsheet-error-resolver.ts
│ ├── thread-hooks.ts
│ └── fortune-sheet-utils.ts
├── hooks
│ ├── usePersistentContextKey.ts
│ ├── useSpreadsheetData.ts
│ ├── useMultipleSpreadsheetData.ts
│ └── use-formula-autocomplete.ts
├── schemas
│ └── spreadsheet-schemas.ts
├── types
│ ├── formula-autocomplete.ts
│ └── spreadsheet-types.ts
├── components
│ ├── tambo
│ │ ├── message-generation-stage.tsx
│ │ ├── dictation-button.tsx
│ │ ├── suggestions-tooltip.tsx
│ │ ├── thread-container.tsx
│ │ ├── scrollable-message-container.tsx
│ │ ├── thread-content.tsx
│ │ ├── graph-component.ts
│ │ ├── message-thread-full.tsx
│ │ ├── markdown-components.tsx
│ │ └── message-suggestions.tsx
│ ├── ApiKeyCheck.tsx
│ └── ui
│ │ ├── spreadsheet-tabs.tsx
│ │ └── formula-autocomplete.tsx
└── tools
│ └── tab-tools.ts
├── postcss.config.mjs
├── conductor.json
├── next.config.ts
├── .claude
├── settings.local.json
├── agents
│ └── researcher.md
└── commands
│ ├── plan.md
│ ├── execute.md
│ └── story.md
├── eslint.config.mjs
├── .gitignore
├── tsconfig.json
├── KNOWNISSUES.md
├── package.json
├── conductor-setup.sh
├── tailwind.config.ts
├── AGENTS.md
├── CONTRIBUTING.md
├── README.md
└── .cursor
└── rules
└── tambo-ai.mdc
/CLAUDE.md:
--------------------------------------------------------------------------------
1 | Read @AGENTS.md!
2 |
--------------------------------------------------------------------------------
/example.env.local:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_TAMBO_API_KEY=api-key-here
--------------------------------------------------------------------------------
/.codesandbox/sandbox.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "template": "node"
3 | }
4 |
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/michaelmagan/cheatsheet/HEAD/src/app/favicon.ico
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | const config = {
2 | plugins: ["@tailwindcss/postcss"],
3 | };
4 |
5 | export default config;
6 |
--------------------------------------------------------------------------------
/conductor.json:
--------------------------------------------------------------------------------
1 | {
2 | "scripts": {
3 | "setup": "./conductor-setup.sh",
4 | "run": "npm run dev"
5 | },
6 | "runScriptMode": "nonconcurrent"
7 | }
8 |
--------------------------------------------------------------------------------
/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from "next";
2 |
3 | const nextConfig: NextConfig = {
4 | outputFileTracingRoot: process.cwd(),
5 | };
6 |
7 | export default nextConfig;
8 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/src/lib/id-generator.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Generates a unique ID for components, tabs, etc.
3 | * Uses timestamp + random string for uniqueness.
4 | */
5 | export const generateId = (): string => {
6 | return `id-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
7 | };
8 |
--------------------------------------------------------------------------------
/.claude/settings.local.json:
--------------------------------------------------------------------------------
1 | {
2 | "permissions": {
3 | "allow": [
4 | "Bash(npm run lint)",
5 | "Bash(find:*)",
6 | "Bash(npm run build:*)",
7 | "Bash(npm run check-types:*)",
8 | "WebFetch(domain:docs.tambo.co)",
9 | ],
10 | "deny": []
11 | }
12 | }
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
2 | import nextTypescript from "eslint-config-next/typescript";
3 |
4 | const eslintConfig = [...nextCoreWebVitals, ...nextTypescript, {
5 | ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts"]
6 | }];
7 |
8 | export default eslintConfig;
9 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 | .pnpm-debug.log*
32 |
33 | # env files (can opt-in for committing if needed)
34 | .env*
35 |
36 | # vercel
37 | .vercel
38 |
39 | # typescript
40 | *.tsbuildinfo
41 | next-env.d.ts
42 |
43 | # plans
44 | .plans
45 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./src/*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/KNOWNISSUES.md:
--------------------------------------------------------------------------------
1 | # Known Issues
2 |
3 | ## 1. FortuneSheet setSelection Frozen Object Error
4 |
5 | **Problem:**
6 | Calling `workbook.setSelection()` causes error: "Cannot assign to read only property 'row'"
7 |
8 | **Root Cause:**
9 | FortuneSheet's internal `normalizeSelection` function tries to mutate frozen/immutable objects
10 |
11 | **Impact:**
12 | Cannot highlight cells visually when AI updates them (UX enhancement only - core functionality works)
13 |
14 | **Current Solution:**
15 | Removed all `setSelection` calls from spreadsheet tools
16 |
17 | **Future Investigation:**
18 | 1. Check if FortuneSheet has alternative API for selection
19 | 2. Try FortuneSheet version upgrade
20 | 3. Consider patching normalizeSelection to avoid mutation
21 |
22 | **Related Commits:**
23 | Added in d9eef1d, removed in current changes
24 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import "./globals.css";
2 | import "@fortune-sheet/react/dist/index.css";
3 | import { Geist, Geist_Mono } from "next/font/google";
4 | import { FortuneSheetProvider } from "@/lib/fortune-sheet-store";
5 |
6 | const geistSans = Geist({
7 | variable: "--font-geist-sans",
8 | subsets: ["latin"],
9 | });
10 |
11 | const geistMono = Geist_Mono({
12 | variable: "--font-geist-mono",
13 | subsets: ["latin"],
14 | });
15 |
16 | export default function RootLayout({
17 | children,
18 | }: Readonly<{
19 | children: React.ReactNode;
20 | }>) {
21 | return (
22 |
23 |
26 | {children}
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/.claude/agents/researcher.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: researcher
3 | description: General-purpose research agent that can analyze codebases, evaluate technologies, and research implementation approaches. Use this agent to gather information, analyze patterns, and understand existing implementations before planning or coding.
4 | model: sonnet
5 | color: green
6 | tools: Read, Glob, Grep, WebSearch, WebFetch, Bash
7 | ---
8 |
9 | You are a research specialist. Your task will be provided in the prompt when you are invoked.
10 |
11 | Always provide:
12 |
13 | - Concise, focused summaries
14 | - Specific file paths, package names, or documentation links when relevant
15 | - Clear categorization of findings
16 | - High-level insights without implementation details
17 |
18 | Your research should be thorough but summarized - focus on what's most relevant to the task at hand.
19 |
--------------------------------------------------------------------------------
/.codesandbox/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | // These tasks will run in order when initializing your CodeSandbox project.
3 | "setupTasks": [
4 | {
5 | "command": "npm install",
6 | "name": "Installing Dependencies"
7 | }
8 | ],
9 |
10 | // These tasks can be run from CodeSandbox. Running one will open a log in the app.
11 | "tasks": {
12 | "dev": {
13 | "name": "dev",
14 | "command": "npm run dev",
15 | "runAtStart": true
16 | },
17 | "build": {
18 | "name": "build",
19 | "command": "npm run build"
20 | },
21 | "start": {
22 | "name": "start",
23 | "command": "npm run start"
24 | },
25 | "lint": {
26 | "name": "lint",
27 | "command": "npm run lint"
28 | },
29 | "init": {
30 | "name": "init",
31 | "command": "npm run init"
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/hooks/usePersistentContextKey.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 |
3 | const STORAGE_KEY = "tambo-context-key";
4 |
5 | function createContextKey(prefix: string) {
6 | const randomUUID =
7 | typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
8 | ? crypto.randomUUID()
9 | : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
10 |
11 | return `${prefix}-${randomUUID}`;
12 | }
13 |
14 | /**
15 | * Ensures each user gets a stable context key generated on their first visit.
16 | */
17 | export function usePersistentContextKey(prefix = "tambo-template") {
18 | const contextKey = useMemo(() => {
19 | if (typeof window === "undefined") {
20 | return null;
21 | }
22 |
23 | const prefixWithSeparator = `${prefix}-`;
24 |
25 | try {
26 | const existing = window.localStorage.getItem(STORAGE_KEY);
27 | if (existing && existing.startsWith(prefixWithSeparator)) {
28 | return existing;
29 | }
30 | } catch {
31 | // Ignore storage read errors and fall back to generating a volatile key.
32 | }
33 |
34 | const newKey = createContextKey(prefix);
35 | try {
36 | window.localStorage.setItem(STORAGE_KEY, newKey);
37 | } catch {
38 | // Ignore storage write errors; the key will remain in-memory for this session.
39 | }
40 |
41 | return newKey;
42 | }, [prefix]);
43 |
44 | return contextKey;
45 | }
46 |
--------------------------------------------------------------------------------
/src/lib/tab-context-helper.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { fortuneSheetStore } from "@/lib/fortune-sheet-store";
4 |
5 | export const tabContextHelper = () => {
6 | try {
7 | if (typeof window === "undefined") {
8 | return null;
9 | }
10 |
11 | const workbook = fortuneSheetStore.getWorkbook();
12 | const workbookSheets =
13 | workbook && typeof workbook.getAllSheets === "function"
14 | ? workbook.getAllSheets()
15 | : null;
16 | const sheets =
17 | workbookSheets && workbookSheets.length > 0
18 | ? workbookSheets
19 | : fortuneSheetStore.getState().sheets;
20 | if (!sheets || sheets.length === 0) {
21 | return null;
22 | }
23 |
24 | const activeSheet =
25 | (workbook &&
26 | typeof workbook.getSheet === "function" &&
27 | workbook.getSheet()) ||
28 | sheets.find((sheet) => sheet.status === 1) ||
29 | sheets[0] ||
30 | null;
31 |
32 | const summary = sheets
33 | .map((sheet, index) => {
34 | const id = sheet.id ?? `sheet-${index + 1}`;
35 | const label = sheet.name ?? `Sheet ${index + 1}`;
36 | return activeSheet && id === activeSheet.id ? `${label} (active)` : label;
37 | })
38 | .join(", ");
39 |
40 | return `Tabs: ${summary}. Active tab: "${activeSheet?.name ?? "Unknown"}"${
41 | activeSheet?.id ? ` (ID: ${activeSheet.id})` : ""
42 | }.`;
43 | } catch (error) {
44 | console.error("Error in tabContextHelper:", error);
45 | return null;
46 | }
47 | };
48 |
--------------------------------------------------------------------------------
/src/schemas/spreadsheet-schemas.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Shared Zod schemas for spreadsheet validation
3 | * Centralized schemas to eliminate duplication across components
4 | */
5 |
6 | import { z } from "zod";
7 |
8 | // ============================================
9 | // FortuneSheet Cell Schema
10 | // ============================================
11 |
12 | export const fortuneCellSchema = z.object({
13 | r: z.number(),
14 | c: z.number(),
15 | v: z
16 | .object({
17 | v: z.any().optional(),
18 | m: z.any().optional(),
19 | f: z.string().optional(),
20 | ct: z.record(z.any()).optional(),
21 | bg: z.string().optional(),
22 | })
23 | .nullable()
24 | .optional(),
25 | });
26 |
27 | // ============================================
28 | // FortuneSheet Sheet Schema
29 | // ============================================
30 |
31 | export const fortuneSheetStateSchema = z.object({
32 | sheetId: z.string().describe("Current sheet ID"),
33 | name: z.string().describe("Current sheet name"),
34 | rowCount: z.number().describe("Number of rows in the sheet"),
35 | columnCount: z.number().describe("Number of columns in the sheet"),
36 | celldata: z.array(fortuneCellSchema).describe("Sparse cell data"),
37 | });
38 |
39 | // ============================================
40 | // Interactable Props Schema
41 | // ============================================
42 |
43 | export const interactableSpreadsheetPropsSchema = z.object({
44 | className: z.string().optional(),
45 | state: fortuneSheetStateSchema.nullable().optional(),
46 | });
47 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "spreadsheet-template",
3 | "version": "0.1.0",
4 | "private": true,
5 | "engines": {
6 | "node": ">=22.0.0",
7 | "npm": ">=11.0.0"
8 | },
9 | "scripts": {
10 | "dev": "next dev",
11 | "build": "next build",
12 | "start": "next start",
13 | "lint": "eslint . && tsc --noEmit",
14 | "check-types": "tsc --noEmit",
15 | "test": "npm run lint && npm run check-types",
16 | "init": "npx tambo init"
17 | },
18 | "dependencies": {
19 | "@fortune-sheet/core": "^1.0.2",
20 | "@fortune-sheet/react": "^1.0.2",
21 | "@radix-ui/react-dropdown-menu": "2.1.16",
22 | "@tambo-ai/react": "^0.64.1",
23 | "@tambo-ai/typescript-sdk": "^0.77.0",
24 | "class-variance-authority": "^0.7.1",
25 | "dompurify": "^3.3.0",
26 | "framer-motion": "^12.23.24",
27 | "highlight.js": "^11.11.1",
28 | "json-stringify-pretty-compact": "^4.0.0",
29 | "lucide-react": "^0.554.0",
30 | "next": "15.5.9",
31 | "radix-ui": "^1.4.3",
32 | "react": "^19.1.1",
33 | "react-dom": "^19.1.1",
34 | "recharts": "^3.4.1",
35 | "streamdown": "^1.5.1",
36 | "zustand": "^5.0.8"
37 | },
38 | "devDependencies": {
39 | "@tailwindcss/postcss": "^4.1.17",
40 | "@types/node": "^24",
41 | "@types/react": "^19",
42 | "@types/react-dom": "^19",
43 | "autoprefixer": "^10.4.22",
44 | "clsx": "^2.1.1",
45 | "eslint": "^9",
46 | "eslint-config-next": "16.0.3",
47 | "postcss": "^8.5.6",
48 | "tailwind-merge": "^3.4.0",
49 | "tailwindcss": "^4",
50 | "typescript": "^5"
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/conductor-setup.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -e
3 |
4 | echo "🚀 Starting Conductor workspace setup..."
5 |
6 | # Check for required tools
7 | echo "✓ Checking prerequisites..."
8 |
9 | if ! command -v node &> /dev/null; then
10 | echo "❌ Error: Node.js is not installed"
11 | echo "Please install Node.js (>=22.0.0) before running setup"
12 | exit 1
13 | fi
14 |
15 | if ! command -v npm &> /dev/null; then
16 | echo "❌ Error: npm is not installed"
17 | echo "Please install npm (>=11.0.0) before running setup"
18 | exit 1
19 | fi
20 |
21 | # Check Node version
22 | NODE_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
23 | if [ "$NODE_VERSION" -lt 22 ]; then
24 | echo "❌ Error: Node.js version must be >=22.0.0 (current: $(node -v))"
25 | exit 1
26 | fi
27 |
28 | echo "✓ Node.js $(node -v) and npm $(npm -v) found"
29 |
30 | # Install dependencies
31 | echo "📦 Installing npm dependencies..."
32 | npm install
33 |
34 | # Set up environment variables
35 | echo "🔧 Setting up environment variables..."
36 |
37 | if [ -n "$CONDUCTOR_ROOT_PATH" ] && [ -f "$CONDUCTOR_ROOT_PATH/.env.local" ]; then
38 | echo "✓ Linking .env.local from repository root..."
39 | ln -sf "$CONDUCTOR_ROOT_PATH/.env.local" .env.local
40 | echo "✓ Environment file linked successfully"
41 | elif [ -f ".env.local" ]; then
42 | echo "✓ .env.local already exists in workspace"
43 | else
44 | echo "⚠️ Warning: No .env.local file found"
45 | echo "You'll need to create one with NEXT_PUBLIC_TAMBO_API_KEY"
46 | echo "Run 'npm run init' to set up Tambo, or copy from example.env.local"
47 | fi
48 |
49 | echo "✅ Workspace setup complete!"
50 | echo "You can now run the development server with the Run button in Conductor"
51 |
--------------------------------------------------------------------------------
/src/app/dev/fortune-demo/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Workbook } from "@fortune-sheet/react";
4 | import "@fortune-sheet/react/dist/index.css";
5 |
6 | export default function FortuneDemoPage() {
7 | return (
8 |
9 |
10 | FortuneSheet Sandbox
11 |
12 | Temporary development route for evaluating the FortuneSheet workbook
13 | during migration. The workbook below uses the default configuration
14 | with a single blank sheet.
15 |
16 |
17 |
18 |
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config: Config = {
4 | darkMode: "class",
5 | content: [
6 | "./pages/**/*.{ts,tsx}",
7 | "./components/**/*.{ts,tsx}",
8 | "./app/**/*.{ts,tsx}",
9 | "./src/**/*.{ts,tsx}",
10 | ],
11 | theme: {
12 | extend: {
13 | colors: {
14 | background: "hsl(var(--background))",
15 | foreground: "hsl(var(--foreground))",
16 | card: {
17 | DEFAULT: "hsl(var(--card))",
18 | foreground: "hsl(var(--card-foreground))",
19 | },
20 | popover: {
21 | DEFAULT: "hsl(var(--popover))",
22 | foreground: "hsl(var(--popover-foreground))",
23 | },
24 | primary: {
25 | DEFAULT: "hsl(var(--primary))",
26 | foreground: "hsl(var(--primary-foreground))",
27 | },
28 | secondary: {
29 | DEFAULT: "hsl(var(--secondary))",
30 | foreground: "hsl(var(--secondary-foreground))",
31 | },
32 | muted: {
33 | DEFAULT: "hsl(var(--muted))",
34 | foreground: "hsl(var(--muted-foreground))",
35 | },
36 | accent: {
37 | DEFAULT: "hsl(var(--accent))",
38 | foreground: "hsl(var(--accent-foreground))",
39 | },
40 | destructive: {
41 | DEFAULT: "hsl(var(--destructive))",
42 | foreground: "hsl(var(--destructive-foreground))",
43 | },
44 | border: "hsl(var(--border))",
45 | input: "hsl(var(--input))",
46 | ring: "hsl(var(--ring))",
47 | },
48 | borderRadius: {
49 | lg: "var(--radius)",
50 | md: "calc(var(--radius) - 2px)",
51 | sm: "calc(var(--radius) - 4px)",
52 | },
53 | },
54 | },
55 | };
56 |
57 | export default config;
58 |
--------------------------------------------------------------------------------
/src/lib/constants.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @file constants.ts
3 | * @description Shared constants used across the application
4 | */
5 |
6 | // ============================================
7 | // Spreadsheet Limits
8 | // ============================================
9 |
10 | /** Maximum number of columns allowed in a spreadsheet */
11 | export const MAX_SPREADSHEET_COLUMNS = 100;
12 |
13 | /** Maximum number of rows allowed in a spreadsheet */
14 | export const MAX_SPREADSHEET_ROWS = 1000;
15 |
16 | /** Default number of columns in a new spreadsheet tab */
17 | export const DEFAULT_SPREADSHEET_COLUMNS = 5;
18 |
19 | /** Default number of rows in a new spreadsheet tab */
20 | export const DEFAULT_SPREADSHEET_ROWS = 20;
21 |
22 | /** Default column width in pixels */
23 | export const DEFAULT_COLUMN_WIDTH = 150;
24 |
25 | /** Row header column width in pixels */
26 | export const ROW_HEADER_WIDTH = 50;
27 |
28 | // ============================================
29 | // Input Validation
30 | // ============================================
31 |
32 | /** Maximum length for text input in cells */
33 | export const MAX_TEXT_LENGTH = 1000;
34 |
35 | /** Maximum length for tab names */
36 | export const MAX_TAB_NAME_LENGTH = 100;
37 |
38 | // ============================================
39 | // UI Timeouts
40 | // ============================================
41 |
42 | /** Timeout duration for pending delete confirmation (milliseconds) */
43 | export const DELETE_CONFIRMATION_TIMEOUT = 10000;
44 |
45 | /** Timeout duration for deduplication operations (milliseconds) */
46 | export const DEDUPLICATION_TIMEOUT = 100;
47 |
48 | // ============================================
49 | // DnD (Drag and Drop)
50 | // ============================================
51 |
52 | /** Minimum distance in pixels before drag is activated */
53 | export const DND_ACTIVATION_DISTANCE = 8;
54 |
--------------------------------------------------------------------------------
/src/types/formula-autocomplete.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Formula autocomplete type definitions
3 | * Types for formula function autocomplete system
4 | */
5 |
6 | // ============================================
7 | // Function Information Types
8 | // ============================================
9 |
10 | /**
11 | * Information about a formula function for autocomplete
12 | */
13 | export interface FunctionInfo {
14 | name: string; // Function name (e.g., "SUM", "AVERAGE")
15 | signature: string; // Function signature (e.g., "SUM(number1, [number2], ...)")
16 | description: string; // Brief description of what the function does
17 | category?: string; // Category (e.g., "Math", "Statistical", "Text")
18 | example?: string; // Example usage (e.g., "SUM(A1:A10)")
19 | }
20 |
21 | // ============================================
22 | // Autocomplete State Types
23 | // ============================================
24 |
25 | /**
26 | * Autocomplete state returned by the hook
27 | */
28 | export interface AutocompleteState {
29 | isOpen: boolean; // Whether the autocomplete dropdown is open
30 | suggestions: FunctionInfo[]; // Current list of suggestions
31 | selectedIndex: number; // Index of the currently selected suggestion
32 | position?: { top: number; left: number }; // Position for the dropdown
33 | query: string; // Current search query
34 | }
35 |
36 | /**
37 | * Autocomplete actions for controlling the autocomplete
38 | */
39 | export interface AutocompleteActions {
40 | open: (query: string, position?: { top: number; left: number }) => void;
41 | close: () => void;
42 | selectNext: () => void;
43 | selectPrevious: () => void;
44 | selectFunction: (functionName: string) => void;
45 | updateQuery: (query: string) => void;
46 | }
47 |
--------------------------------------------------------------------------------
/src/components/tambo/message-generation-stage.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { cn } from "@/lib/utils";
4 | import { type GenerationStage, useTambo } from "@tambo-ai/react";
5 | import { Loader2Icon } from "lucide-react";
6 | import * as React from "react";
7 |
8 | /**
9 | * Represents the generation stage of a message
10 | * @property {string} className - Optional className for custom styling
11 | * @property {boolean} showLabel - Whether to show the label
12 | */
13 |
14 | export interface GenerationStageProps
15 | extends React.HTMLAttributes {
16 | showLabel?: boolean;
17 | }
18 |
19 | export function MessageGenerationStage({
20 | className,
21 | showLabel = true,
22 | ...props
23 | }: GenerationStageProps) {
24 | const { thread, isIdle } = useTambo();
25 | const stage = thread?.generationStage;
26 |
27 | // Only render if we have a generation stage
28 | if (!stage) {
29 | return null;
30 | }
31 |
32 | // Map stage names to more user-friendly labels
33 | const stageLabels: Record = {
34 | IDLE: "Idle",
35 | CHOOSING_COMPONENT: "Choosing component",
36 | FETCHING_CONTEXT: "Fetching context",
37 | HYDRATING_COMPONENT: "Preparing component",
38 | STREAMING_RESPONSE: "Generating response",
39 | COMPLETE: "Complete",
40 | ERROR: "Error",
41 | CANCELLED: "Cancelled",
42 | };
43 |
44 | const label =
45 | stageLabels[stage] || `${stage.charAt(0).toUpperCase() + stage.slice(1)}`;
46 |
47 | if (isIdle) {
48 | return null;
49 | }
50 |
51 | return (
52 |
59 |
60 | {showLabel && {label} }
61 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/src/lib/tambo.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @file tambo.ts
3 | * @description Central configuration file for Tambo components and tools
4 | *
5 | * This file serves as the central place to register your Tambo components and tools.
6 | * It exports arrays that will be used by the TamboProvider.
7 | *
8 | * Read more about Tambo at https://tambo.co/docs
9 | */
10 |
11 | import type { TamboComponent } from "@tambo-ai/react";
12 | import { TamboTool } from "@tambo-ai/react";
13 | import { spreadsheetTools, getSpreadsheetInfoTool } from "@/tools/spreadsheet-tools";
14 | import { validateSpreadsheetFormulaTool, getSpreadsheetErrorsTool } from "@/tools/spreadsheet-validation-tools";
15 | import { tabTools } from "@/tools/tab-tools";
16 | import { graphComponent } from "@/components/tambo/graph-component";
17 |
18 | /**
19 | * tools
20 | *
21 | * This array contains all the Tambo tools that are registered for use within the application.
22 | * Each tool is defined with its name, description, and expected props. The tools
23 | * can be controlled by AI to dynamically interact with the spreadsheet.
24 | */
25 |
26 | export const tools: TamboTool[] = [
27 | // Validation tools (placed first for discoverability)
28 | validateSpreadsheetFormulaTool,
29 | getSpreadsheetErrorsTool,
30 | getSpreadsheetInfoTool,
31 | // Spreadsheet tools
32 | ...spreadsheetTools,
33 | // Tab tools
34 | ...tabTools,
35 | ];
36 |
37 | /**
38 | * components
39 | *
40 | * This array contains all the Tambo components that are registered for use within the application.
41 | * Note: Spreadsheet is NOT in this array - it's auto-created with each tab and only accessible
42 | * via the InteractableSpreadsheet (AI cannot create spreadsheets inline, only interact with existing ones)
43 | */
44 | export const components: TamboComponent[] = [
45 | // Spreadsheet is intentionally NOT registered as a component
46 | // It's automatically created with each tab/canvas
47 | graphComponent,
48 | ];
49 |
--------------------------------------------------------------------------------
/src/lib/spreadsheet-selection-context.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { fortuneSheetStore } from "@/lib/fortune-sheet-store";
4 | import { columnIndexToLetter } from "@/lib/fortune-sheet-utils";
5 | import type { Selection } from "@fortune-sheet/core";
6 |
7 | function selectionToRange(selection: Selection): string | null {
8 | const rowStart = selection.row?.[0];
9 | const rowEnd = selection.row?.[1] ?? rowStart;
10 | const colStart = selection.column?.[0];
11 | const colEnd = selection.column?.[1] ?? colStart;
12 |
13 | if (
14 | rowStart === undefined ||
15 | colStart === undefined ||
16 | rowStart === null ||
17 | colStart === null
18 | ) {
19 | return null;
20 | }
21 |
22 | const startRow = Math.min(rowStart, rowEnd ?? rowStart);
23 | const endRow = Math.max(rowStart, rowEnd ?? rowStart);
24 | const startCol = Math.min(colStart, colEnd ?? colStart);
25 | const endCol = Math.max(colStart, colEnd ?? colStart);
26 |
27 | const startRef = `${columnIndexToLetter(startCol)}${startRow + 1}`;
28 | const endRef = `${columnIndexToLetter(endCol)}${endRow + 1}`;
29 |
30 | return startRef === endRef ? startRef : `${startRef}:${endRef}`;
31 | }
32 |
33 | export const spreadsheetSelectionContextHelper = () => {
34 | try {
35 | if (typeof window === "undefined") {
36 | return null;
37 | }
38 |
39 | const workbook = fortuneSheetStore.getWorkbook();
40 | if (!workbook) {
41 | return null;
42 | }
43 |
44 | const selections = workbook.getSelection();
45 | if (!selections || selections.length === 0) {
46 | return null;
47 | }
48 |
49 | const formatted = selections
50 | .map((selection) => selectionToRange(selection))
51 | .filter((range): range is string => Boolean(range));
52 |
53 | if (formatted.length === 0) {
54 | return null;
55 | }
56 |
57 | const activeSheet =
58 | (typeof workbook.getSheet === "function" && workbook.getSheet()) ?? null;
59 |
60 | const sheetLabel = activeSheet ? ` on sheet "${activeSheet.name}"` : "";
61 | return `User currently has selected: ${formatted.join(
62 | ", "
63 | )}${sheetLabel}.`;
64 | } catch (error) {
65 | console.error("Error in spreadsheetSelectionContextHelper:", error);
66 | return null;
67 | }
68 | };
69 |
--------------------------------------------------------------------------------
/src/components/tambo/dictation-button.tsx:
--------------------------------------------------------------------------------
1 | import { Tooltip } from "@/components/tambo/suggestions-tooltip";
2 | import { useTamboThreadInput, useTamboVoice } from "@tambo-ai/react";
3 | import { Loader2Icon, Mic, Square } from "lucide-react";
4 | import React, { useEffect, useRef } from "react";
5 |
6 | /**
7 | * Button for dictating speech into the message input.
8 | */
9 | export default function DictationButton() {
10 | const {
11 | startRecording,
12 | stopRecording,
13 | isRecording,
14 | isTranscribing,
15 | transcript,
16 | transcriptionError,
17 | } = useTamboVoice();
18 | const { value, setValue } = useTamboThreadInput();
19 | const lastProcessedTranscriptRef = useRef("");
20 |
21 | const handleStartRecording = () => {
22 | lastProcessedTranscriptRef.current = "";
23 | startRecording();
24 | };
25 |
26 | const handleStopRecording = () => {
27 | stopRecording();
28 | };
29 |
30 | useEffect(() => {
31 | if (transcript && transcript !== lastProcessedTranscriptRef.current) {
32 | lastProcessedTranscriptRef.current = transcript;
33 | setValue(value + " " + transcript);
34 | }
35 | }, [transcript, value, setValue]);
36 |
37 | if (isTranscribing) {
38 | return (
39 |
40 |
41 |
42 | );
43 | }
44 |
45 | return (
46 |
47 | {transcriptionError}
48 | {isRecording ? (
49 |
50 |
55 |
56 |
57 |
58 | ) : (
59 |
60 |
65 |
66 |
67 |
68 | )}
69 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/AGENTS.md:
--------------------------------------------------------------------------------
1 | # Agent Cheat Sheet
2 |
3 | AI-powered spreadsheet using **Tambo** (https://tambo.co) + FortuneSheet. See https://docs.tambo.co/llms.txt for full Tambo documentation.
4 |
5 | ## Tambo Architecture
6 |
7 | **Three-Layer System:**
8 | 1. **Context Helpers** - Auto-included in every AI message (read-only)
9 | 2. **Interactables** - Publish component state via `withInteractable` HOC
10 | 3. **Tools** - Functions AI calls to mutate state (in `src/tools/`)
11 |
12 | **Configuration:** `src/lib/tambo.ts` registers all components and tools.
13 |
14 | **Critical Streaming Gotchas:**
15 | - Props stream token-by-token as LLM generates
16 | - Use `useTamboStreamStatus()` to detect streaming
17 | - Safe destructuring: `const { prop = default } = props || {}`
18 | - Optional chaining: `array?.[0]?.property`
19 |
20 | ## FortuneSheet Integration
21 |
22 | **State:** `src/lib/fortune-sheet-store.tsx` (Zustand store + mounted Workbook)
23 |
24 | **Context:**
25 | - `spreadsheet` - Active sheet snapshot as markdown table
26 | - `selection` - Current selection in A1 notation
27 | - `TabsState` - Sheet ids/names/active tab (interactable)
28 |
29 | **Tools:** `src/tools/spreadsheet-tools.ts`
30 | - updateCell, updateRange, **updateStyles**, readCell, readRange
31 | - addRow, removeRow, addColumn, removeColumn
32 | - clearRange, sortByColumn
33 |
34 | **Key Rules:**
35 | - All operations go through `fortuneSheetStore` (no local state forks)
36 | - Context helpers must tolerate SSR (`return null` when `window` undefined)
37 | - Selection requires mounted Workbook
38 |
39 | ## Development
40 |
41 | **Commands:**
42 | - `npm run dev` - Dev server
43 | - `npm run build` - Production build
44 | - `npm run lint` - ESLint
45 | - `npm run check-types` - TypeScript check
46 |
47 | **Style:**
48 | - TypeScript + React (App Router) + functional components
49 | - 2-space indent, PascalCase components, camelCase hooks, kebab-case utils
50 | - FortuneSheet helpers in `src/lib/fortune-sheet-*.ts`
51 |
52 | **Build & Commit:**
53 | - Run `npm run build` BEFORE committing (not after every fix)
54 | - Imperative mood (`Add utils`, `Fix context`)
55 | - Mention affected Tambo tools/components explicitly
56 |
57 | **Dependency Upgrades:**
58 | - When upgrading Tambo packages or UI components, preserve custom edits
59 | - Key customizations to preserve:
60 | - `src/components/tambo/message-thread-full.tsx` - GitHub and Tambo buttons in sidebar
61 | - `src/components/tambo/thread-history.tsx` - Exported `useThreadHistoryContext` hook
62 | - Always review diffs before accepting upstream changes
63 |
--------------------------------------------------------------------------------
/src/components/tambo/suggestions-tooltip.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { Tooltip as TooltipPrimitive } from "radix-ui";
5 | import { cn } from "@/lib/utils";
6 |
7 | /**
8 | * Represents a tooltip component
9 | * @property {string} className - Optional className for custom styling
10 | * @property {number} sideOffset - Offset for the tooltip side
11 | */
12 |
13 | // Provider component that should wrap any tooltips
14 | const TooltipProvider = TooltipPrimitive.Provider;
15 |
16 | // Root component for individual tooltips
17 | const TooltipRoot = TooltipPrimitive.Root;
18 |
19 | // Trigger component that wraps the element that triggers the tooltip
20 | const TooltipTrigger = TooltipPrimitive.Trigger;
21 |
22 | // Content component for tooltip
23 | const TooltipContent = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, sideOffset = 4, ...props }, ref) => (
27 |
36 | ));
37 | TooltipContent.displayName = TooltipPrimitive.Content.displayName;
38 |
39 | // Simplified Tooltip component with a unified API
40 | interface TooltipProps {
41 | content: React.ReactNode;
42 | children: React.ReactNode;
43 | delayDuration?: number;
44 | open?: boolean;
45 | defaultOpen?: boolean;
46 | onOpenChange?: (open: boolean) => void;
47 | side?: "top" | "right" | "bottom" | "left";
48 | align?: "start" | "center" | "end";
49 | className?: string;
50 | }
51 |
52 | function Tooltip({
53 | content,
54 | children,
55 | delayDuration = 300,
56 | open,
57 | defaultOpen,
58 | onOpenChange,
59 | side = "top",
60 | align = "center",
61 | className,
62 | }: TooltipProps) {
63 | return (
64 |
70 | {children}
71 |
72 | {content}
73 |
74 |
75 | );
76 | }
77 |
78 | export {
79 | Tooltip,
80 | TooltipContent,
81 | TooltipProvider,
82 | TooltipRoot,
83 | TooltipTrigger,
84 | };
85 |
--------------------------------------------------------------------------------
/src/types/spreadsheet-types.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @file spreadsheet-types.ts
3 | * @description Shared type definitions for spreadsheet tools and validation
4 | */
5 |
6 | /**
7 | * Primitive value accepted by updateSpreadsheetCells
8 | */
9 | export type CellValueInput = string | number | null;
10 |
11 | /**
12 | * Represents a single cell mutation request
13 | */
14 | export interface CellUpdateRequest {
15 | /** Cell address in A1 notation (e.g., "B5") */
16 | address: string;
17 | /** Value to write (text, number, formula string, or null/empty) */
18 | value: CellValueInput;
19 | }
20 |
21 | /**
22 | * Evaluation result for a single cell after a read or update operation
23 | */
24 | export interface CellEvaluation {
25 | /** Cell address in A1 notation (e.g., "B5") */
26 | address: string;
27 | /** Zero-based row index */
28 | row: number;
29 | /** Zero-based column index */
30 | column: number;
31 | /** Raw value from the cell */
32 | rawValue: unknown;
33 | /** Human-readable display value */
34 | displayValue: string | null;
35 | /** Formula string if cell contains a formula (starts with =) */
36 | formula: string | null;
37 | /** Error code if cell evaluation failed (e.g., "#DIV/0!", "#NAME?") */
38 | error: string | null;
39 | }
40 |
41 | /**
42 | * Summary of a single cell update operation
43 | */
44 | export interface CellUpdateSummary {
45 | /** Cell address in A1 notation */
46 | address: string;
47 | /** Display value after update */
48 | value: string | null;
49 | /** Formula if applicable */
50 | formula: string | null;
51 | }
52 |
53 | /**
54 | * Detailed information about a cell error
55 | */
56 | export interface ErrorDetail {
57 | /** Error type category (e.g., "formula_error", "division_error") */
58 | type: string;
59 | /** Error code from Excel (e.g., "#DIV/0!", "#NAME?") */
60 | code: string;
61 | /** Human-readable resolution guidance for fixing the error */
62 | resolution: string;
63 | }
64 |
65 | /**
66 | * Summary of a bulk operation (e.g., updateSpreadsheetCells)
67 | */
68 | export interface BulkOperationSummary {
69 | /** Total number of cells in the operation */
70 | total: number;
71 | /** Number of cells that updated successfully */
72 | succeeded: number;
73 | /** Number of cells that failed with errors */
74 | failed: number;
75 | }
76 |
77 | /**
78 | * Information about additional errors beyond the first
79 | */
80 | export interface MoreErrorsInfo {
81 | /** Number of additional errors (excludes firstError) */
82 | count: number;
83 | /** Sample of error cell addresses (up to 5) */
84 | addresses: string[];
85 | /** Message directing user to getSpreadsheetErrors tool */
86 | note: string;
87 | }
88 |
89 | /**
90 | * Optional parameter type for requesting detailed evaluations in bulk operations
91 | */
92 | export type ReturnDetailsParam = boolean;
93 |
--------------------------------------------------------------------------------
/src/app/chat/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { useMcpServers } from "@/components/tambo/mcp-config-modal";
3 | import { MessageThreadFull } from "@/components/tambo/message-thread-full";
4 | import SpreadsheetTabs from "@/components/ui/spreadsheet-tabs";
5 | import { components, tools } from "@/lib/tambo";
6 | import { spreadsheetContextHelper } from "@/lib/spreadsheet-context-helper";
7 | import { spreadsheetSelectionContextHelper } from "@/lib/spreadsheet-selection-context";
8 | import { tabContextHelper } from "@/lib/tab-context-helper";
9 | import { usePersistentContextKey } from "@/hooks/usePersistentContextKey";
10 | import { TamboProvider } from "@tambo-ai/react";
11 | import { TamboMcpProvider } from "@tambo-ai/react/mcp";
12 | import { useState } from "react";
13 | import { PanelLeftIcon, PanelRightIcon } from "lucide-react";
14 |
15 | export default function Home() {
16 | const mcpServers = useMcpServers();
17 | const [showSpreadsheet, setShowSpreadsheet] = useState(true);
18 | const contextKey = usePersistentContextKey();
19 |
20 | // You can customize default suggestions via MessageThreadFull internals
21 |
22 | return (
23 |
24 |
35 |
36 | {/* Mobile toggle button */}
37 | setShowSpreadsheet(!showSpreadsheet)}
39 | className="md:hidden fixed top-4 right-4 z-50 p-2 rounded-lg bg-accent hover:bg-accent/80 shadow-lg border border-border"
40 | aria-label={showSpreadsheet ? "Show chat" : "Show spreadsheet"}
41 | >
42 | {showSpreadsheet ? : }
43 |
44 |
45 |
46 | {/* Chat panel - hidden on mobile when spreadsheet is shown */}
47 |
48 | {contextKey ? : null}
49 |
50 |
51 | {/* Spreadsheet panel - responsive width and visibility */}
52 |
53 | {/* Visual spreadsheet tabs UI */}
54 |
55 |
56 |
57 |
58 |
59 |
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/src/components/ApiKeyCheck.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 | import { XIcon } from "lucide-react";
5 |
6 | interface ApiKeyCheckProps {
7 | children: React.ReactNode;
8 | }
9 |
10 | export function ApiKeyCheck({ children }: ApiKeyCheckProps) {
11 | const [showApiKeyAlert, setShowApiKeyAlert] = useState(!process.env.NEXT_PUBLIC_TAMBO_API_KEY);
12 |
13 | const copyToClipboard = (text: string) => {
14 | navigator.clipboard.writeText(text);
15 | };
16 |
17 | if (!showApiKeyAlert) {
18 | return <>{children}>;
19 | }
20 |
21 | return (
22 | <>
23 | {/* API Key Missing Alert */}
24 |
25 |
26 |
setShowApiKeyAlert(false)}
28 | className="absolute top-4 right-4 p-1 hover:bg-accent rounded-md transition-colors"
29 | aria-label="Close"
30 | >
31 |
32 |
33 |
Tambo API Key Required
34 |
35 | To get started, you need to initialize Tambo:
36 |
37 |
38 |
npx tambo init
39 |
copyToClipboard("npx tambo init")}
41 | className="p-2 hover:bg-accent rounded-md transition-colors"
42 | title="Copy to clipboard"
43 | >
44 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | Or visit{" "}
62 |
68 | tambo.co/cli-auth
69 | {" "}
70 | to get your API key and set it in{" "}
71 | .env.local
72 |
73 |
74 |
75 | {children}
76 | >
77 | );
78 | }
79 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { useMcpServers } from "@/components/tambo/mcp-config-modal";
3 | import { MessageThreadFull } from "@/components/tambo/message-thread-full";
4 | import SpreadsheetTabs from "@/components/ui/spreadsheet-tabs";
5 | import { ApiKeyCheck } from "@/components/ApiKeyCheck";
6 | import { components, tools } from "@/lib/tambo";
7 | import { spreadsheetContextHelper } from "@/lib/spreadsheet-context-helper";
8 | import { spreadsheetSelectionContextHelper } from "@/lib/spreadsheet-selection-context";
9 | import { tabContextHelper } from "@/lib/tab-context-helper";
10 | import { usePersistentContextKey } from "@/hooks/usePersistentContextKey";
11 | import { TamboProvider } from "@tambo-ai/react";
12 | import { TamboMcpProvider } from "@tambo-ai/react/mcp";
13 | import { useState } from "react";
14 | import { PanelLeftIcon, PanelRightIcon } from "lucide-react";
15 |
16 | export default function Home() {
17 | const mcpServers = useMcpServers();
18 | const [showSpreadsheet, setShowSpreadsheet] = useState(true);
19 | const contextKey = usePersistentContextKey();
20 |
21 | // You can customize default suggestions via MessageThreadFull internals
22 |
23 | return (
24 |
25 |
26 |
37 |
38 | {/* Mobile toggle button */}
39 | setShowSpreadsheet(!showSpreadsheet)}
41 | className="md:hidden fixed top-4 right-4 z-50 p-2 rounded-lg bg-accent hover:bg-accent/80 shadow-lg border border-border"
42 | aria-label={showSpreadsheet ? "Show chat" : "Show spreadsheet"}
43 | >
44 | {showSpreadsheet ? : }
45 |
46 |
47 |
48 | {/* Chat panel - hidden on mobile when spreadsheet is shown */}
49 |
50 | {contextKey ? : null}
51 |
52 |
53 | {/* Spreadsheet panel - responsive width and visibility */}
54 |
55 | {/* Visual spreadsheet tabs UI */}
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 | );
64 | }
65 |
--------------------------------------------------------------------------------
/src/components/tambo/thread-container.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 | import {
3 | useCanvasDetection,
4 | usePositioning,
5 | useMergeRefs,
6 | } from "@/lib/thread-hooks";
7 | import * as React from "react";
8 | import { useRef } from "react";
9 |
10 | /**
11 | * Props for the ThreadContainer component
12 | */
13 | export type ThreadContainerProps = React.HTMLAttributes;
14 |
15 | /**
16 | * A responsive container component for message threads that handles
17 | * positioning relative to canvas space and sidebar.
18 | *
19 | * It automatically detects canvas presence and adjusts layout accordingly.
20 | */
21 | export const ThreadContainer = React.forwardRef<
22 | HTMLDivElement,
23 | ThreadContainerProps
24 | >(({ className, children, ...props }, ref) => {
25 | const containerRef = useRef(null);
26 | const { hasCanvasSpace, canvasIsOnLeft } = useCanvasDetection(containerRef);
27 | const { isLeftPanel, historyPosition } = usePositioning(
28 | className,
29 | canvasIsOnLeft,
30 | hasCanvasSpace,
31 | );
32 | const mergedRef = useMergeRefs(ref, containerRef);
33 |
34 | return (
35 |
67 | {children}
68 |
69 | );
70 | });
71 | ThreadContainer.displayName = "ThreadContainer";
72 |
73 | /**
74 | * Hook that provides positioning context for thread containers
75 | *
76 | * @returns {Object} Object containing:
77 | * - containerRef: Reference to container element
78 | * - hasCanvasSpace: Whether canvas space is available
79 | * - canvasIsOnLeft: Whether canvas is positioned on the left
80 | * - isLeftPanel: Whether the container is positioned as a left panel
81 | * - historyPosition: Position of history sidebar ("left" or "right")
82 | */
83 | export function useThreadContainerContext() {
84 | const containerRef = useRef(null);
85 | const { hasCanvasSpace, canvasIsOnLeft } = useCanvasDetection(containerRef);
86 | const { isLeftPanel, historyPosition } = usePositioning(
87 | "",
88 | canvasIsOnLeft,
89 | hasCanvasSpace,
90 | );
91 |
92 | return {
93 | containerRef,
94 | hasCanvasSpace,
95 | canvasIsOnLeft,
96 | isLeftPanel,
97 | historyPosition,
98 | };
99 | }
100 |
--------------------------------------------------------------------------------
/src/hooks/useSpreadsheetData.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 | import type { CellWithRowAndCol } from "@fortune-sheet/core";
3 | import { useFortuneSheet } from "@/lib/fortune-sheet-store";
4 | import {
5 | buildCelldataLookup,
6 | getCellFromLookup,
7 | getSheetColumnCount,
8 | getSheetRowCount,
9 | parseRangeReference,
10 | toCellWithRowAndCol,
11 | } from "@/lib/fortune-sheet-utils";
12 |
13 | /**
14 | * useSpreadsheetData Hook
15 | *
16 | * Fetches cells from the FortuneSheet in-memory store and returns values
17 | * using FortuneSheet's native sheet schema.
18 | *
19 | * @param tabId - The ID of the spreadsheet tab to read from
20 | * @param range - The A1 notation range (e.g., "A1:C5")
21 | * @returns Object containing cells array, loading state, error, and staleness indicator
22 | */
23 | export function useSpreadsheetData({
24 | tabId,
25 | range,
26 | }: {
27 | tabId: string;
28 | range: string;
29 | }): {
30 | cells: CellWithRowAndCol[] | null;
31 | loading: boolean;
32 | error: string | null;
33 | isStale: boolean;
34 | resolvedSheetId: string | null;
35 | } {
36 | const { sheets, activeSheetId } = useFortuneSheet();
37 |
38 | const { cells, error, resolvedSheetId } = useMemo(() => {
39 | if (!tabId || !range) {
40 | return {
41 | cells: null,
42 | error: null,
43 | resolvedSheetId: null,
44 | };
45 | }
46 |
47 | const sheet =
48 | sheets.find((candidate) => candidate.id === tabId) ??
49 | sheets.find((candidate) => candidate.name === tabId);
50 | if (!sheet) {
51 | return {
52 | cells: null,
53 | error: `Sheet with id or name "${tabId}" was not found.`,
54 | resolvedSheetId: null,
55 | };
56 | }
57 |
58 | try {
59 | const parsedRange = parseRangeReference(range);
60 | const rowCount = getSheetRowCount(sheet);
61 | const columnCount = getSheetColumnCount(sheet);
62 |
63 | if (rowCount === 0 || columnCount === 0) {
64 | return {
65 | cells: [],
66 | error: null,
67 | resolvedSheetId: sheet.id ?? null,
68 | };
69 | }
70 |
71 | if (parsedRange.end.row >= rowCount || parsedRange.end.col >= columnCount) {
72 | return {
73 | cells: null,
74 | error: `Range ${range} exceeds sheet bounds (${rowCount} rows x ${columnCount} columns).`,
75 | resolvedSheetId: sheet.id ?? null,
76 | };
77 | }
78 |
79 | const lookup = buildCelldataLookup(sheet);
80 | const extracted: CellWithRowAndCol[] = [];
81 |
82 | for (let rowIdx = parsedRange.start.row; rowIdx <= parsedRange.end.row; rowIdx++) {
83 | for (let colIdx = parsedRange.start.col; colIdx <= parsedRange.end.col; colIdx++) {
84 | const cell = getCellFromLookup(lookup, rowIdx, colIdx);
85 | extracted.push(toCellWithRowAndCol(rowIdx, colIdx, cell));
86 | }
87 | }
88 |
89 | return {
90 | cells: extracted,
91 | error: null,
92 | resolvedSheetId: sheet.id ?? null,
93 | };
94 | } catch (err) {
95 | const message =
96 | err instanceof Error ? err.message : "Failed to parse spreadsheet range.";
97 | return {
98 | cells: null,
99 | error: message,
100 | resolvedSheetId: sheet.id ?? null,
101 | };
102 | }
103 | }, [sheets, tabId, range]);
104 |
105 | const isStale = Boolean(
106 | activeSheetId &&
107 | resolvedSheetId &&
108 | activeSheetId !== resolvedSheetId,
109 | );
110 |
111 | return {
112 | cells,
113 | loading: false,
114 | error,
115 | isStale,
116 | resolvedSheetId,
117 | };
118 | }
119 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to Spreadsheet Template
2 |
3 | Thank you for your interest in contributing to the Tambo Spreadsheet Template! We welcome contributions from the community.
4 |
5 | ## How to Contribute
6 |
7 | ### Reporting Issues
8 |
9 | If you find a bug or have a suggestion for improvement:
10 |
11 | 1. Check the [existing issues](https://github.com/tambo-ai/spreadsheet-template/issues) to see if it's already been reported
12 | 2. If not, [open a new issue](https://github.com/tambo-ai/spreadsheet-template/issues/new) with a clear title and description
13 | 3. Include steps to reproduce the bug (if applicable)
14 | 4. Add relevant labels (bug, enhancement, documentation, etc.)
15 |
16 | ### Submitting Pull Requests
17 |
18 | We love pull requests! Here's how to submit one:
19 |
20 | 1. **Fork the repository** and create your branch from `main`
21 | ```bash
22 | git checkout -b feature/my-new-feature
23 | ```
24 |
25 | 2. **Make your changes**
26 | - Follow the existing code style and conventions
27 | - Add comments for complex logic
28 | - Update documentation if needed
29 |
30 | 3. **Test your changes**
31 | - Run `npm run dev` to test locally
32 | - Run `npm run build` to ensure it builds successfully
33 | - Run `npm run lint` to check for linting errors
34 |
35 | 4. **Commit your changes**
36 | - Use clear and descriptive commit messages
37 | - Reference issue numbers if applicable (e.g., "Fix #123: Update spreadsheet rendering")
38 |
39 | 5. **Push to your fork**
40 | ```bash
41 | git push origin feature/my-new-feature
42 | ```
43 |
44 | 6. **Open a Pull Request**
45 | - Provide a clear description of the changes
46 | - Link to any related issues
47 | - Add screenshots or videos if applicable (especially for UI changes)
48 |
49 | ## Development Guidelines
50 |
51 | ### Code Style
52 |
53 | - Use TypeScript for all new code
54 | - Follow the existing naming conventions
55 | - Use meaningful variable and function names
56 | - Keep functions small and focused on a single task
57 |
58 | ### Component Guidelines
59 |
60 | When creating new Tambo components:
61 |
62 | - Define a clear Zod schema for props validation
63 | - Add comprehensive description for AI usage
64 | - Include TypeScript types for all props
65 | - Test with various AI prompts to ensure it works as expected
66 |
67 | ### File Organization
68 |
69 | - Place Tambo components in `src/components/tambo/`
70 | - Place UI components in `src/components/ui/`
71 | - Place utility functions in `src/lib/`
72 | - Place AI tools in `src/tools/`
73 |
74 | ### Testing
75 |
76 | - Test your changes thoroughly in the browser
77 | - Try various AI prompts to ensure components render correctly
78 | - Check both desktop and mobile views
79 | - Verify that state management works as expected
80 |
81 | ## What We're Looking For
82 |
83 | We're especially interested in contributions that:
84 |
85 | - Add new component types (visualizations, forms, etc.)
86 | - Improve spreadsheet functionality (formulas, formatting, etc.)
87 | - Enhance the user experience (keyboard shortcuts, undo/redo, etc.)
88 | - Add new AI tools for data manipulation
89 | - Improve documentation and examples
90 | - Fix bugs and performance issues
91 |
92 | ## Code Review Process
93 |
94 | 1. A maintainer will review your PR within a few days
95 | 2. They may request changes or ask questions
96 | 3. Once approved, your PR will be merged
97 | 4. Your contribution will be included in the next release!
98 |
99 | ## Questions?
100 |
101 | If you have questions about contributing:
102 |
103 | - Open an issue with the "question" label
104 | - Join the [Tambo Discord community](https://discord.gg/tambo)
105 | - Check the [Tambo documentation](https://tambo.co/docs)
106 |
107 | ## License
108 |
109 | By contributing, you agree that your contributions will be licensed under the MIT License.
110 |
111 | ## Thank You!
112 |
113 | Your contributions help make this template better for everyone. We appreciate your time and effort!
114 |
--------------------------------------------------------------------------------
/src/components/tambo/scrollable-message-container.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { cn } from "@/lib/utils";
4 | import { useTambo } from "@tambo-ai/react";
5 | import * as React from "react";
6 | import { useEffect, useRef, useState } from "react";
7 |
8 | /**
9 | * Props for the ScrollableMessageContainer component
10 | */
11 | export type ScrollableMessageContainerProps =
12 | React.HTMLAttributes;
13 |
14 | /**
15 | * A scrollable container for message content with auto-scroll functionality.
16 | * Used across message thread components for consistent scrolling behavior.
17 | *
18 | * @example
19 | * ```tsx
20 | *
21 | *
22 | *
23 | *
24 | *
25 | * ```
26 | */
27 | export const ScrollableMessageContainer = React.forwardRef<
28 | HTMLDivElement,
29 | ScrollableMessageContainerProps
30 | >(({ className, children, ...props }, ref) => {
31 | const scrollContainerRef = useRef(null);
32 | const { thread } = useTambo();
33 | const [shouldAutoscroll, setShouldAutoscroll] = useState(true);
34 | const lastScrollTopRef = useRef(0);
35 |
36 | // Handle forwarded ref
37 | React.useImperativeHandle(ref, () => scrollContainerRef.current!, []);
38 |
39 | // Create a dependency that represents all content that should trigger autoscroll
40 | const messagesContent = React.useMemo(() => {
41 | if (!thread.messages) return null;
42 |
43 | return thread.messages.map((message) => ({
44 | id: message.id,
45 | content: message.content,
46 | tool_calls: message.tool_calls,
47 | component: message.component,
48 | reasoning: message.reasoning,
49 | componentState: message.componentState,
50 | }));
51 | }, [thread.messages]);
52 |
53 | const generationStage = thread?.generationStage ?? "IDLE";
54 |
55 | // Handle scroll events to detect user scrolling
56 | const handleScroll = () => {
57 | if (!scrollContainerRef.current) return;
58 |
59 | const { scrollTop, scrollHeight, clientHeight } =
60 | scrollContainerRef.current;
61 | const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 8; // 8px tolerance for rounding
62 |
63 | // If user scrolled up, disable autoscroll
64 | if (scrollTop < lastScrollTopRef.current) {
65 | setShouldAutoscroll(false);
66 | }
67 | // If user is at bottom, enable autoscroll
68 | else if (isAtBottom) {
69 | setShouldAutoscroll(true);
70 | }
71 |
72 | lastScrollTopRef.current = scrollTop;
73 | };
74 |
75 | // Auto-scroll to bottom when message content changes
76 | useEffect(() => {
77 | if (scrollContainerRef.current && messagesContent && shouldAutoscroll) {
78 | const scroll = () => {
79 | if (scrollContainerRef.current) {
80 | scrollContainerRef.current.scrollTo({
81 | top: scrollContainerRef.current.scrollHeight,
82 | behavior: "smooth",
83 | });
84 | }
85 | };
86 |
87 | if (generationStage === "STREAMING_RESPONSE") {
88 | // During streaming, scroll immediately
89 | requestAnimationFrame(scroll);
90 | } else {
91 | // For other updates, use a short delay to batch rapid changes
92 | const timeoutId = setTimeout(scroll, 50);
93 | return () => clearTimeout(timeoutId);
94 | }
95 | }
96 | }, [messagesContent, generationStage, shouldAutoscroll]);
97 |
98 | return (
99 |
112 | {children}
113 |
114 | );
115 | });
116 | ScrollableMessageContainer.displayName = "ScrollableMessageContainer";
117 |
--------------------------------------------------------------------------------
/src/hooks/useMultipleSpreadsheetData.ts:
--------------------------------------------------------------------------------
1 | import { useSpreadsheetData } from "./useSpreadsheetData";
2 | import type { CellWithRowAndCol } from "@fortune-sheet/core";
3 |
4 | /**
5 | * useMultipleSpreadsheetData Hook
6 | *
7 | * Fetches multiple spreadsheet ranges in a single hook call, providing a cleaner
8 | * alternative to calling useSpreadsheetData multiple times.
9 | *
10 | * This hook respects the Rules of Hooks by calling a fixed number of useSpreadsheetData
11 | * hooks internally (maximum 10), and slicing the results to match the actual number
12 | * of ranges requested.
13 | *
14 | * @param tabId - The ID of the spreadsheet tab to read from
15 | * @param ranges - Array of A1 notation ranges (e.g., ["A1:C5", "E1:F10"])
16 | * @returns Object containing array of results (one per range), aggregated loading state, and aggregated errors
17 | *
18 | * @example
19 | * const { data, loading, error } = useMultipleSpreadsheetData(tabId, ["A1:B5", "D1:E10"]);
20 | * // data[0] contains cells from A1:B5
21 | * // data[1] contains cells from D1:E10
22 | * // loading is true if ANY range is still loading
23 | */
24 | export function useMultipleSpreadsheetData(
25 | tabId: string,
26 | ranges: string[]
27 | ): {
28 | data: Array<{
29 | cells: CellWithRowAndCol[] | null;
30 | loading: boolean;
31 | error: string | null;
32 | isStale: boolean;
33 | resolvedSheetId: string | null;
34 | }>;
35 | loading: boolean;
36 | error: string | null;
37 | } {
38 | // Don't fetch if invalid input
39 | const hasValidInput = tabId && tabId !== "" && ranges && ranges.length > 0;
40 |
41 | // Maximum number of datasets supported (fixed to respect Rules of Hooks)
42 | const MAX_DATASETS = 10;
43 |
44 | // Call a fixed number of useSpreadsheetData hooks
45 | // Pass placeholder values when index >= ranges.length
46 | const result0 = useSpreadsheetData({
47 | tabId: ranges[0] && hasValidInput ? tabId : "",
48 | range: ranges[0] && hasValidInput ? ranges[0] : "A1",
49 | });
50 |
51 | const result1 = useSpreadsheetData({
52 | tabId: ranges[1] && hasValidInput ? tabId : "",
53 | range: ranges[1] && hasValidInput ? ranges[1] : "A1",
54 | });
55 |
56 | const result2 = useSpreadsheetData({
57 | tabId: ranges[2] && hasValidInput ? tabId : "",
58 | range: ranges[2] && hasValidInput ? ranges[2] : "A1",
59 | });
60 |
61 | const result3 = useSpreadsheetData({
62 | tabId: ranges[3] && hasValidInput ? tabId : "",
63 | range: ranges[3] && hasValidInput ? ranges[3] : "A1",
64 | });
65 |
66 | const result4 = useSpreadsheetData({
67 | tabId: ranges[4] && hasValidInput ? tabId : "",
68 | range: ranges[4] && hasValidInput ? ranges[4] : "A1",
69 | });
70 |
71 | const result5 = useSpreadsheetData({
72 | tabId: ranges[5] && hasValidInput ? tabId : "",
73 | range: ranges[5] && hasValidInput ? ranges[5] : "A1",
74 | });
75 |
76 | const result6 = useSpreadsheetData({
77 | tabId: ranges[6] && hasValidInput ? tabId : "",
78 | range: ranges[6] && hasValidInput ? ranges[6] : "A1",
79 | });
80 |
81 | const result7 = useSpreadsheetData({
82 | tabId: ranges[7] && hasValidInput ? tabId : "",
83 | range: ranges[7] && hasValidInput ? ranges[7] : "A1",
84 | });
85 |
86 | const result8 = useSpreadsheetData({
87 | tabId: ranges[8] && hasValidInput ? tabId : "",
88 | range: ranges[8] && hasValidInput ? ranges[8] : "A1",
89 | });
90 |
91 | const result9 = useSpreadsheetData({
92 | tabId: ranges[9] && hasValidInput ? tabId : "",
93 | range: ranges[9] && hasValidInput ? ranges[9] : "A1",
94 | });
95 |
96 | // Collect all results in an array
97 | const allResults = [
98 | result0,
99 | result1,
100 | result2,
101 | result3,
102 | result4,
103 | result5,
104 | result6,
105 | result7,
106 | result8,
107 | result9,
108 | ];
109 |
110 | // Return empty state if input is invalid
111 | if (!hasValidInput) {
112 | return {
113 | data: [],
114 | loading: false,
115 | error: null,
116 | };
117 | }
118 |
119 | // Slice to actual length (up to MAX_DATASETS)
120 | const actualLength = Math.min(ranges.length, MAX_DATASETS);
121 | const results = allResults.slice(0, actualLength);
122 |
123 | // Aggregate loading state - true if ANY range is still loading
124 | const loading = results.some((r) => r.loading);
125 |
126 | // Aggregate errors - collect all non-null errors
127 | const errors = results.filter((r) => r.error).map((r) => r.error as string);
128 |
129 | return {
130 | data: results,
131 | loading,
132 | error: errors.length > 0 ? errors.join("; ") : null,
133 | };
134 | }
135 |
--------------------------------------------------------------------------------
/src/lib/graph-data-utils.ts:
--------------------------------------------------------------------------------
1 | import type { Cell, CellWithRowAndCol } from "@fortune-sheet/core";
2 | import { parseRangeReference } from "./fortune-sheet-utils";
3 |
4 | /**
5 | * Parses a range reference and extracts the column letter and starting row.
6 | * Examples:
7 | * - "B2:B10" -> { column: "B", startRow: 2 }
8 | * - "C5:C20" -> { column: "C", startRow: 5 }
9 | */
10 | export function parseRangeForColumn(range: string): {
11 | column: string;
12 | startRow: number;
13 | } {
14 | const parsed = parseRangeReference(range);
15 | if (parsed.start.col !== parsed.end.col) {
16 | throw new Error(`Range ${range} must reference a single column.`);
17 | }
18 | return {
19 | column: columnIndexToLabel(parsed.start.col),
20 | startRow: parsed.start.row + 1,
21 | };
22 | }
23 |
24 | /**
25 | * Generates a cell reference for the header cell of a given range.
26 | * For a range like "B2:B10", returns "B1:B1" (assumes row 1 is the header).
27 | * For a range like "C5:C20", returns "C1:C1".
28 | *
29 | * @param range - A1 notation range (e.g., "B2:B10")
30 | * @returns Header cell reference as range format (e.g., "B1:B1")
31 | */
32 | export function getHeaderCellReference(range: string): string {
33 | const { column } = parseRangeForColumn(range);
34 | const headerCell = `${column}1`;
35 | return `${headerCell}:${headerCell}`; // Return as range format
36 | }
37 |
38 | function columnIndexToLabel(index: number): string {
39 | if (index < 0) {
40 | throw new Error(`Column index must be non-negative. Received: ${index}`);
41 | }
42 | let label = "";
43 | let current = index;
44 | while (current >= 0) {
45 | label = String.fromCharCode((current % 26) + 65) + label;
46 | current = Math.floor(current / 26) - 1;
47 | }
48 | return label;
49 | }
50 |
51 | function resolveCellPrimitive(cell: Cell | null): string | number | boolean | null {
52 | if (!cell) {
53 | return null;
54 | }
55 |
56 | if (cell.v !== undefined && cell.v !== null) {
57 | if (typeof cell.v === "object") {
58 | return cell.m ?? null;
59 | }
60 | return cell.v as unknown as string | number | boolean;
61 | }
62 |
63 | if (cell.m !== undefined && cell.m !== null) {
64 | return cell.m as unknown as string | number | boolean;
65 | }
66 |
67 | return null;
68 | }
69 |
70 | /**
71 | * Extracts numeric values from an array of Cell objects.
72 | * Coerces values to numbers where possible.
73 | */
74 | export function extractNumericValues(cells: CellWithRowAndCol[]): number[] {
75 | return cells.map(({ v }) => {
76 | const value = resolveCellPrimitive(v ?? null);
77 | if (typeof value === "number") {
78 | return value;
79 | }
80 | if (typeof value === "string") {
81 | const parsed = Number(value.replace(/,/g, ""));
82 | return Number.isFinite(parsed) ? parsed : 0;
83 | }
84 | if (typeof value === "boolean") {
85 | return value ? 1 : 0;
86 | }
87 | return 0;
88 | });
89 | }
90 |
91 | /**
92 | * Extracts string labels from an array of Cell objects.
93 | */
94 | export function extractLabels(cells: CellWithRowAndCol[]): string[] {
95 | return cells.map(({ v }) => {
96 | const value = resolveCellPrimitive(v ?? null);
97 | if (value === null || value === undefined) {
98 | return "";
99 | }
100 | if (typeof value === "string") {
101 | return value;
102 | }
103 | return String(value);
104 | });
105 | }
106 |
107 | /**
108 | * Transforms labels and datasets into Recharts-compatible format.
109 | * Creates an array of objects where each object represents one data point.
110 | * Structure: [{ name: label[0], dataKey1: datasets[0].data[0], dataKey2: datasets[1].data[0] }, ...]
111 | *
112 | * @param labels - Array of labels for the x-axis
113 | * @param datasets - Array of dataset objects containing label, data, and optional color
114 | * @returns Array of objects in Recharts format
115 | */
116 | export function transformToRechartsData(
117 | labels: string[],
118 | datasets: { label: string; data: number[]; color?: string }[]
119 | ): Record[] {
120 | if (labels.length === 0) {
121 | return [];
122 | }
123 |
124 | // Find the minimum length to avoid index out of bounds
125 | const minLength = Math.min(
126 | labels.length,
127 | ...datasets.map((dataset) => dataset.data.length)
128 | );
129 |
130 | // Create array of objects in Recharts format
131 | const result: Record[] = [];
132 |
133 | for (let i = 0; i < minLength; i++) {
134 | const dataPoint: Record = {
135 | name: labels[i],
136 | };
137 |
138 | // Add each dataset's value at index i with the dataset label as the key
139 | datasets.forEach((dataset) => {
140 | dataPoint[dataset.label] = dataset.data[i];
141 | });
142 |
143 | result.push(dataPoint);
144 | }
145 |
146 | return result;
147 | }
148 |
--------------------------------------------------------------------------------
/.claude/commands/plan.md:
--------------------------------------------------------------------------------
1 | ---
2 | description: "Research and create implementation plan"
3 | argument-hint: ""
4 | model: inherit
5 | allowed-tools: [Task, AskUserQuestion, Read, Glob, Grep, WebSearch, WebFetch]
6 | ---
7 |
8 | Create a comprehensive implementation plan for the following feature request:
9 |
10 | **Feature Request:** $ARGUMENTS
11 |
12 | Before I start planning, I want to make sure I understand what we're building. Let me ask some clarifying questions:
13 |
14 | [Ask natural questions like a developer would ask a product person to understand scope, requirements, constraints, user needs, edge cases, and any technical preferences or concerns. Focus on what's unclear or ambiguous about the request.]
15 |
16 | ---
17 |
18 | **Step 2: Confirm Understanding**
19 |
20 | [After user provides clarifications, summarize the feature requirements and get confirmation]
21 |
22 | ---
23 |
24 | **Step 3: Create Planning Todo List**
25 |
26 | Use TodoWrite to create a planning todo list:
27 | - Clarify requirements (mark as completed)
28 | - Initial codebase research
29 | - Parallel deep research (will break down into subtasks)
30 | - Synthesize plan document
31 |
32 | ---
33 |
34 |
35 |
36 | **Step 4: Parallel Deep Research**
37 |
38 | Update todo list with 2-8 specific research tasks based on initial findings. Mark "Parallel deep research" as in_progress.
39 |
40 | Launch multiple research agents in parallel (in a SINGLE message with multiple Task calls):
41 |
42 | ```
43 | /task researcher "Research how to implement [specific aspect] using [technology X].
44 | Find: key APIs, best practices, implementation patterns, gotchas.
45 | Include documentation links."
46 |
47 | /task researcher "Deep dive into [specific part of codebase].
48 | Analyze: current implementation, what needs to change, dependencies to consider."
49 |
50 | [Include 2-8 Task calls in one message based on complexity]
51 | ```
52 |
53 | After all agents complete, mark research tasks as completed.
54 |
55 | ---
56 |
57 | **Step 5: Synthesize Plan**
58 |
59 | Mark "Synthesize plan document" as in_progress.
60 |
61 | Calle the `planner` agent with the research context needed to generatel the pnal
62 |
63 | Create the implementation plan document at `.claude/.plans/[feature-name].md` with all research findings:
64 |
65 | Create a focused plan with this structure:
66 |
67 | # Feature: [Feature Name]
68 |
69 | ## Overview
70 |
71 | [2-3 sentences: what will be built and why]
72 |
73 | ## Key Design Decisions
74 |
75 | - **Decision 1**: [Brief rationale]
76 | - **Decision 2**: [Brief rationale]
77 | - **Decision 3**: [Brief rationale]
78 |
79 | ## Architecture
80 |
81 | [Mermaid diagram or brief description of data flow]
82 |
83 | - Keep the mermaid diagrams simple.
84 | - Split into multiple diagrams.
85 | - Go from highlevel to deatiled.
86 |
87 | ## Component Schema/Interface
88 |
89 | [Show the key prop schema or interface - this helps validate the design]
90 |
91 | ```typescript
92 | // Example of what AI will generate
93 | {
94 | prop1: "value",
95 | prop2: { ... }
96 | }
97 | ```
98 |
99 | ## File Structure
100 |
101 | ```
102 | src/
103 | ├── components/
104 | │ ├── new-file.tsx (NEW)
105 | │ └── existing-file.tsx (MODIFIED)
106 | ├── hooks/
107 | │ └── useCustomHook.ts (NEW)
108 | ```
109 |
110 | ## Implementation Phases
111 |
112 | ### Phase 1: [Phase Name]
113 |
114 | [1 sentence: what this phase accomplishes]
115 |
116 | **Files:**
117 |
118 | - `path/to/file1.ts` (NEW) - [Brief description]
119 | - `path/to/file2.tsx` (MODIFIED) - [Brief description]
120 |
121 | **Key Implementation Details:**
122 |
123 | - Task 1: [Specific actionable task]
124 | - Task 2: [Specific actionable task]
125 |
126 | [Include pseudocode ONLY for the most complex/critical logic:]
127 |
128 | ```pseudo
129 | function complexOperation(data):
130 | // Parse and validate
131 | coords = parseA1Notation(range)
132 |
133 | // Transform data
134 | cells = extractCells(coords)
135 | values = cells.map(cell => getValue(cell))
136 |
137 | // Subscribe to changes
138 | subscribe(store, () => refetch())
139 | ```
140 |
141 | ### Phase 2: [Phase Name]
142 |
143 | [Continue pattern...]
144 |
145 | ## Out of Scope (v1)
146 |
147 | List features explicitly excluded from v1 to keep implementation focused. Include brief rationale for each.
148 |
149 | - **Feature 1** - Brief reason why it's excluded (complexity, separate concern, etc.)
150 | - **Feature 2** - Brief reason why it's excluded
151 | - **Feature 3** - Brief reason why it's excluded
152 |
153 | ---
154 |
155 | **GUIDELINES:**
156 |
157 | **DO:**
158 |
159 | - Keep plans concise and scannable
160 | - Show example data/schemas to ground the design
161 | - Include pseudocode only for complex/non-obvious logic
162 | - Focus on WHAT needs to be done, not every line of code
163 | - Break into logical phases (typically 3-5 phases)
164 | - Mark files that can be done in parallel
165 | - Include single "Out of Scope (v1)" section listing all excluded features with rationale
166 |
167 | **DON'T:**
168 |
169 | - Include time estimates or effort levels
170 | - Write out full code implementations with imports
171 | - Duplicate scope boundaries (combine "avoid" and "future" into single "Out of Scope" section)
172 | - Add extensive testing sections (just note key testing considerations)
173 | - Repeat obvious tasks (e.g., "import React")
174 |
175 | **PSEUDOCODE USAGE:**
176 | Show pseudocode for:
177 |
178 | - Complex algorithms or transformations
179 | - Non-obvious data flows
180 | - Critical state management patterns
181 | - Edge case handling that needs clarity
182 |
183 | Skip pseudocode for:
184 |
185 | - Simple CRUD operations
186 | - Standard React patterns
187 | - Obvious utility functions
188 |
189 | Save the plan to `.plans/[feature-name].md` in the root directory using the Write tool.
--------------------------------------------------------------------------------
/src/lib/spreadsheet-error-resolver.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Error resolution system for spreadsheet formula errors.
3 | * Provides error type detection and actionable resolution guidance.
4 | */
5 |
6 | /**
7 | * Fallback error resolution message for unknown error types.
8 | */
9 | export const FORMULA_ERROR_RESOLUTION =
10 | "The formula contains an error. Use listSpreadsheetFormulas() to verify function names, " +
11 | "or call getSpreadsheetFormulaHelp() with the specific function name for usage details.";
12 |
13 | /**
14 | * Normalizes an error code to the standard Excel format with # prefix and ! suffix.
15 | *
16 | * @param errorCode - The error code (e.g., "DIV/0", "#DIV/0!", "NAME?")
17 | * @returns Normalized error code (e.g., "#DIV/0!")
18 | */
19 | export function normalizeErrorCode(errorCode: string): string {
20 | let normalized = errorCode.trim().toUpperCase();
21 |
22 | // Add # prefix if missing
23 | if (!normalized.startsWith("#")) {
24 | normalized = `#${normalized}`;
25 | }
26 |
27 | // Add ! suffix if missing (except for #NAME? and #N/A which use ? instead)
28 | if (!normalized.endsWith("!") && !normalized.endsWith("?") && normalized !== "#N/A") {
29 | // Special cases that end with ?
30 | if (normalized === "#NAME") {
31 | normalized = "#NAME?";
32 | } else {
33 | normalized = `${normalized}!`;
34 | }
35 | }
36 |
37 | return normalized;
38 | }
39 |
40 | /**
41 | * Maps Excel error codes to error categories.
42 | *
43 | * @param errorCode - The Excel error code (e.g., "#NAME?", "#DIV/0!", "#REF!", or "DIV/0")
44 | * @returns The error category as a string
45 | */
46 | export function determineErrorType(errorCode: string): string {
47 | const normalizedError = normalizeErrorCode(errorCode);
48 |
49 | const errorPatterns: Record = {
50 | "#NAME?": "formula_error",
51 | "#DIV/0!": "division_error",
52 | "#REF!": "reference_error",
53 | "#VALUE!": "value_error",
54 | "#NUM!": "numeric_error",
55 | "#N/A": "lookup_error",
56 | "#NULL!": "null_error",
57 | "#GETTING_DATA": "loading_error",
58 | "#SPILL!": "spill_error",
59 | "#CALC!": "calculation_error",
60 | "#FIELD!": "field_error",
61 | "#BLOCKED!": "blocked_error",
62 | "#UNKNOWN!": "unknown_error",
63 | "#ERROR!": "general_error",
64 | "#CIRCULAR!": "circular_reference",
65 | };
66 |
67 | return errorPatterns[normalizedError] || "unknown_error";
68 | }
69 |
70 | /**
71 | * Returns error-specific resolution guidance with references to Tambo tools.
72 | *
73 | * @param errorCode - The Excel error code (e.g., "#NAME?", "#DIV/0!", "#REF!", or "DIV/0")
74 | * @returns Actionable resolution message
75 | */
76 | export function getErrorSpecificResolution(errorCode: string): string {
77 | const normalizedError = normalizeErrorCode(errorCode);
78 |
79 | const resolutions: Record = {
80 | "#NAME?":
81 | "Function name not recognized. Call listSpreadsheetFormulas() to verify function exists, " +
82 | "then getSpreadsheetFormulaHelp(functionName) for the correct syntax.",
83 |
84 | "#DIV/0!":
85 | "Division by zero detected. Check if denominator cell is zero using readSpreadsheetCell(), " +
86 | "or wrap formula with IF() to handle zero case: =IF(B1=0, 0, A1/B1)",
87 |
88 | "#REF!":
89 | "Invalid cell reference. Verify referenced cells exist within sheet bounds. " +
90 | "If the sheet is too small, use addSpreadsheetRow(N) or addSpreadsheetColumn(N) to expand it.",
91 |
92 | "#VALUE!":
93 | "Wrong argument type. Call getSpreadsheetFormulaHelp(functionName) to check expected parameter types " +
94 | "and verify all arguments match the expected data types.",
95 |
96 | "#NUM!":
97 | "Invalid numeric value or out of range. Check for extremely large numbers, negative square roots, " +
98 | "or other mathematically invalid operations.",
99 |
100 | "#N/A":
101 | "Lookup value not found. Verify the lookup value exists in the search range using readSpreadsheetRange(). " +
102 | "Check that the lookup value matches exactly (including spaces and case).",
103 |
104 | "#NULL!":
105 | "Cell ranges don't intersect. Use a comma to separate multiple ranges (e.g., SUM(A1:A10, C1:C10)), " +
106 | "not a space.",
107 |
108 | "#CIRCULAR!":
109 | "Circular reference detected (formula refers to itself directly or indirectly). " +
110 | "Remove the circular dependency by restructuring your formulas.",
111 |
112 | "#GETTING_DATA":
113 | "Formula is waiting for data to load. This is typically transient. " +
114 | "If it persists, check that all referenced cells have completed their calculations.",
115 |
116 | "#SPILL!":
117 | "Array formula can't output to the required range because cells are blocked. " +
118 | "Clear the cells where the formula needs to output using clearSpreadsheetRange().",
119 |
120 | "#CALC!":
121 | "Error during calculation, possibly due to exceeding calculation limits. " +
122 | "Simplify the formula or break it into smaller steps across multiple cells.",
123 |
124 | "#FIELD!":
125 | "Formula references a field that doesn't exist in a linked data type or table. " +
126 | "Verify the field name is correct and exists in the data source.",
127 |
128 | "#BLOCKED!":
129 | "Formula is blocked by privacy or security settings. " +
130 | "This typically occurs with external data sources. Verify permissions and data source settings.",
131 |
132 | "#UNKNOWN!":
133 | "Unknown error during formula evaluation. " +
134 | "Try rewriting the formula in a simpler form or breaking it into steps.",
135 |
136 | "#ERROR!":
137 | "General error occurred. This may indicate an incomplete or malformed formula. " +
138 | "Verify the formula syntax is complete and correct. Common causes include missing closing parentheses, " +
139 | "incomplete range references, or unsupported formula features.",
140 | };
141 |
142 | return resolutions[normalizedError] || FORMULA_ERROR_RESOLUTION;
143 | }
144 |
--------------------------------------------------------------------------------
/src/tools/tab-tools.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | /**
4 | * @file tab-tools.ts
5 | * @description Minimal tools for the AI to manage spreadsheet tabs
6 | */
7 |
8 | import { fortuneSheetStore } from "@/lib/fortune-sheet-store";
9 | import type { WorkbookInstance } from "@fortune-sheet/react";
10 | import { z } from "zod";
11 | import { MAX_TAB_NAME_LENGTH } from "@/lib/constants";
12 |
13 | // ============================================
14 | // Helpers
15 | // ============================================
16 |
17 | const sanitizeTabName = (name: string) => {
18 | const trimmed = name.trim();
19 | return trimmed.length > MAX_TAB_NAME_LENGTH
20 | ? trimmed.slice(0, MAX_TAB_NAME_LENGTH)
21 | : trimmed;
22 | };
23 |
24 | const requireWorkbook = (): WorkbookInstance => {
25 | const workbook = fortuneSheetStore.getWorkbook();
26 | if (!workbook) {
27 | throw new Error(
28 | "Spreadsheet workbook is not ready. Wait for the spreadsheet to finish loading.",
29 | );
30 | }
31 | return workbook;
32 | };
33 |
34 | const detectNewSheet = (
35 | before: Set,
36 | after: Array<{ id?: string | null }>,
37 | ) => {
38 | for (const sheet of after) {
39 | if (sheet.id && !before.has(sheet.id)) {
40 | return sheet;
41 | }
42 | }
43 | return after[after.length - 1];
44 | };
45 |
46 | // ============================================
47 | // Tool implementations
48 | // ============================================
49 |
50 | const createTab = async (name?: string) => {
51 | try {
52 | const workbook = requireWorkbook();
53 | const existing =
54 | typeof workbook.getAllSheets === "function"
55 | ? workbook.getAllSheets()
56 | : [];
57 | const existingIds = new Set(
58 | existing
59 | .map((sheet) => sheet.id)
60 | .filter((id): id is string => Boolean(id)),
61 | );
62 |
63 | const desiredName =
64 | name && name.trim().length > 0
65 | ? sanitizeTabName(name)
66 | : `Sheet ${existing.length + 1}`;
67 |
68 | workbook.addSheet();
69 |
70 | const updated =
71 | typeof workbook.getAllSheets === "function"
72 | ? workbook.getAllSheets()
73 | : existing;
74 | if (updated.length === 0) {
75 | throw new Error("Unable to retrieve sheet list after creation.");
76 | }
77 |
78 | const newSheet = detectNewSheet(existingIds, updated);
79 | if (!newSheet?.id) {
80 | throw new Error("Unable to determine the newly created sheet.");
81 | }
82 |
83 | workbook.setSheetName(desiredName, { id: newSheet.id });
84 | workbook.activateSheet({ id: newSheet.id });
85 |
86 | return {
87 | success: true,
88 | tabId: newSheet.id,
89 | tabName: desiredName,
90 | message: `Switched to new sheet "${desiredName}" (${newSheet.id}).`,
91 | };
92 | } catch (error) {
93 | console.error("Error in createSpreadsheetTab:", error);
94 | return {
95 | success: false,
96 | error: error instanceof Error ? error.message : "Unknown error occurred",
97 | };
98 | }
99 | };
100 |
101 | const getTabs = async () => {
102 | try {
103 | const workbook = requireWorkbook();
104 | const sheets =
105 | typeof workbook.getAllSheets === "function"
106 | ? workbook.getAllSheets()
107 | : [];
108 |
109 | if (sheets.length === 0) {
110 | return {
111 | success: true,
112 | tabs: [],
113 | message: "No sheets are available.",
114 | };
115 | }
116 |
117 | const active =
118 | (typeof workbook.getSheet === "function" && workbook.getSheet()) ??
119 | sheets.find((sheet) => sheet.status === 1) ??
120 | sheets[0];
121 |
122 | const tabs = sheets.map((sheet, index) => {
123 | const id = sheet.id ?? `sheet-${index + 1}`;
124 | return {
125 | id,
126 | name: sheet.name ?? `Sheet ${index + 1}`,
127 | isActive: active ? id === active.id : index === 0,
128 | };
129 | });
130 |
131 | const activeTab = tabs.find((tab) => tab.isActive) ?? tabs[0];
132 |
133 | return {
134 | success: true,
135 | tabs,
136 | activeTabId: activeTab?.id,
137 | activeTabName: activeTab?.name,
138 | message: activeTab
139 | ? `Current sheet is "${activeTab.name}" (${activeTab.id}).`
140 | : "Unable to determine the active sheet.",
141 | };
142 | } catch (error) {
143 | console.error("Error in getSpreadsheetTabs:", error);
144 | return {
145 | success: false,
146 | error: error instanceof Error ? error.message : "Unknown error occurred",
147 | };
148 | }
149 | };
150 |
151 | // ============================================
152 | // Tool metadata
153 | // ============================================
154 |
155 | export const createTabTool = {
156 | name: "createSpreadsheetTab",
157 | description:
158 | "Create a new spreadsheet tab. Optionally provide a name for the tab. The new tab becomes active automatically.",
159 | tool: createTab,
160 | toolSchema: z
161 | .function()
162 | .args(
163 | z
164 | .string()
165 | .optional()
166 | .describe("Optional name for the new tab (e.g., 'Sales Data')"),
167 | )
168 | .returns(
169 | z.object({
170 | success: z.boolean(),
171 | tabId: z.string().optional(),
172 | tabName: z.string().optional(),
173 | message: z.string().optional(),
174 | error: z.string().optional(),
175 | }),
176 | ),
177 | };
178 |
179 | export const getTabsTool = {
180 | name: "getSpreadsheetTabs",
181 | description: "List all spreadsheet tabs and indicate which one is active.",
182 | tool: getTabs,
183 | toolSchema: z
184 | .function()
185 | .args()
186 | .returns(
187 | z.object({
188 | success: z.boolean(),
189 | tabs: z
190 | .array(
191 | z.object({
192 | id: z.string(),
193 | name: z.string(),
194 | isActive: z.boolean(),
195 | }),
196 | )
197 | .optional(),
198 | activeTabId: z.string().optional(),
199 | activeTabName: z.string().optional(),
200 | message: z.string().optional(),
201 | error: z.string().optional(),
202 | }),
203 | ),
204 | };
205 |
206 | export const tabTools = [createTabTool, getTabsTool];
207 |
--------------------------------------------------------------------------------
/.claude/commands/execute.md:
--------------------------------------------------------------------------------
1 | ---
2 | description: "Orchestrate implementation through parallel subagents"
3 | argument-hint: ""
4 | model: inherit
5 | allowed-tools: [Task, TodoWrite, Read, Grep, Glob]
6 | ---
7 |
8 | You are the execution coordinator. Your role is to orchestrate implementation through subagents while maintaining a high-level view of progress.
9 |
10 | **Task:** $ARGUMENTS
11 | ---
12 |
13 | ## Core Rules
14 |
15 | 1. **NEVER edit files yourself** - Always delegate file operations to subagents using the Task tool
16 | 2. **Smart parallelization** - Launch truly independent tasks concurrently, but be thoughtful about what should run together
17 | 3. **Track progress** - Use TodoWrite to maintain execution status
18 | 4. **Summarize results** - Collect and synthesize subagent outputs for the user
19 |
20 | ---
21 |
22 | ## Execution Process
23 |
24 | ### Step 1: Break Down Work
25 |
26 | Analyze the task and identify:
27 |
28 | - Independent work streams (can run in parallel)
29 | - Dependent work streams (must run sequentially)
30 | - Files that need modification
31 | - Testing/validation steps
32 |
33 | Create a todo list with clear phases using the TodoWrite tool.
34 |
35 | ---
36 |
37 | ### Step 2: Launch Parallel Execution
38 |
39 | For each phase, identify all independent tasks and launch them together using the Task tool:
40 |
41 | ```
42 | # Single message with multiple parallel agents:
43 | Task tool with instructions: "Edit src/components/foo.tsx: [specific changes]
44 | Report back: summary of changes made"
45 |
46 | Task tool with instructions: "Edit src/lib/bar.ts: [specific changes]
47 | Report back: summary of changes made"
48 |
49 | Task tool with instructions: "Edit src/utils/baz.ts: [specific changes]
50 | Report back: summary of changes made"
51 | ```
52 |
53 | **Key principle:** If tasks don't depend on each other's outputs, launch them in ONE message with multiple Task tool calls.
54 |
55 | ---
56 |
57 | ### Step 3: Sequential Dependencies
58 |
59 | When one task depends on another's output:
60 |
61 | 1. Wait for the first subagent to complete
62 | 2. Use its results to inform the next task
63 | 3. Launch the next wave of parallel tasks
64 |
65 | ---
66 |
67 | ### Step 4: Collect and Synthesize
68 |
69 | After subagents complete:
70 |
71 | 1. Update todo list marking tasks completed
72 | 2. Summarize what changed (file-by-file)
73 | 3. Note any issues or blockers encountered
74 | 4. Determine next steps
75 |
76 | During this step identify if there is any variance from the plan, and either rectify it or make sure to communicate it in the final report.
77 |
78 | ---
79 |
80 | ### Step 5: Validation
81 |
82 | Run validation checks, then fix issues in parallel if needed:
83 |
84 | ```
85 | # First: Run checks together to see all issues
86 | Task tool with instructions: "Run type checking and linting:
87 | 1. npm run check-types
88 | 2. npm run lint
89 | Report all errors found with file locations"
90 | ```
91 |
92 | If errors are found in multiple independent files, fix them in parallel:
93 |
94 | ```
95 | # After seeing the error report, launch parallel fixes:
96 | Task tool with instructions: "Fix type errors in src/components/foo.tsx: [specific errors]"
97 |
98 | Task tool with instructions: "Fix lint issues in src/lib/bar.ts: [specific issues]"
99 |
100 | Task tool with instructions: "Fix type errors in src/utils/baz.ts: [specific errors]"
101 | ```
102 |
103 | **Rationale:** Running checks together gives you the full error picture. Then you can parallelize fixes across different files since they're independent.
104 |
105 | ---
106 |
107 | ## Communication Format
108 |
109 | Keep user informed with concise updates:
110 |
111 | ```
112 | Phase 1: Core Implementation
113 | Launching 3 parallel agents to modify:
114 | - src/components/foo.tsx
115 | - src/lib/bar.ts
116 | - src/utils/baz.ts
117 |
118 | [Wait for results]
119 |
120 | ✓ All agents completed successfully
121 | Summary of changes:
122 | - foo.tsx: Added new prop handling for X
123 | - bar.ts: Implemented helper function Y
124 | - baz.ts: Updated utility Z
125 |
126 | Phase 2: Integration
127 | Launching 2 agents...
128 | ```
129 |
130 | ---
131 |
132 | ## Status Tracking
133 |
134 | You MAY create/edit a status file (e.g., `.plans/execution-status.md`) to track:
135 |
136 | - Completed tasks
137 | - Active subagents
138 | - Blockers
139 | - Summary of changes
140 |
141 | This is the ONLY file editing you should do directly.
142 |
143 | ---
144 |
145 | ## Example Execution
146 |
147 | User: "Add dark mode support across the application"
148 |
149 | **Your orchestration:**
150 |
151 | 1. Break down:
152 | - Add theme context/provider (independent)
153 | - Update UI components (independent within component, dependent on context)
154 | - Add toggle control (dependent on context)
155 | - Update styles (independent)
156 |
157 | 2. Phase 1 - Foundation (parallel):
158 |
159 | Launch 3 parallel Task tool calls:
160 | - "Create src/contexts/theme-context.tsx..."
161 | - "Add theme configuration to src/lib/theme-config.ts..."
162 | - "Update global styles in src/app/globals.css..."
163 |
164 | 3. Phase 2 - Component updates (parallel):
165 |
166 | Launch 3 parallel Task tool calls:
167 | - "Update src/components/header.tsx..."
168 | - "Update src/components/sidebar.tsx..."
169 | - "Update src/components/footer.tsx..."
170 |
171 | 4. Phase 3 - Toggle control:
172 |
173 | Launch single Task tool call:
174 | - "Create src/components/theme-toggle.tsx..."
175 |
176 | 5. Validation:
177 |
178 | Launch Task tool call:
179 | - "Run type checking and linting, report all errors"
180 |
181 | Then if errors found in multiple files, fix in parallel:
182 |
183 | Launch multiple parallel Task tool calls:
184 | - "Fix errors in header.tsx..."
185 | - "Fix errors in sidebar.tsx..."
186 |
187 | ---
188 |
189 | ## Key Principles
190 |
191 | - Your job is coordination, not implementation
192 | - Parallelize smartly: Run checks together first, then parallelize fixes across files
193 | - Use multiple Task tool calls in ONE message for truly independent work
194 | - Don't over-parallelize: If tasks are related or one informs the other, run sequentially
195 | - Provide clear, concise summaries of progress
196 | - Update todos as phases and subtasks complete
197 | - Always use the Task tool to delegate work to subagents - never edit files directly
198 |
--------------------------------------------------------------------------------
/src/components/tambo/thread-content.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | Message,
5 | MessageContent,
6 | MessageImages,
7 | MessageRenderedComponentArea,
8 | ReasoningInfo,
9 | ToolcallInfo,
10 | type messageVariants,
11 | } from "@/components/tambo/message";
12 | import { cn } from "@/lib/utils";
13 | import { type TamboThreadMessage, useTambo } from "@tambo-ai/react";
14 | import { type VariantProps } from "class-variance-authority";
15 | import * as React from "react";
16 |
17 | /**
18 | * @typedef ThreadContentContextValue
19 | * @property {Array} messages - Array of message objects in the thread
20 | * @property {boolean} isGenerating - Whether a response is being generated
21 | * @property {string|undefined} generationStage - Current generation stage
22 | * @property {VariantProps["variant"]} [variant] - Optional styling variant for messages
23 | */
24 | interface ThreadContentContextValue {
25 | messages: TamboThreadMessage[];
26 | isGenerating: boolean;
27 | generationStage?: string;
28 | variant?: VariantProps["variant"];
29 | }
30 |
31 | /**
32 | * React Context for sharing thread data among sub-components.
33 | * @internal
34 | */
35 | const ThreadContentContext =
36 | React.createContext(null);
37 |
38 | /**
39 | * Hook to access the thread content context.
40 | * @returns {ThreadContentContextValue} The thread content context value.
41 | * @throws {Error} If used outside of ThreadContent.
42 | * @internal
43 | */
44 | const useThreadContentContext = () => {
45 | const context = React.useContext(ThreadContentContext);
46 | if (!context) {
47 | throw new Error(
48 | "ThreadContent sub-components must be used within a ThreadContent",
49 | );
50 | }
51 | return context;
52 | };
53 |
54 | /**
55 | * Props for the ThreadContent component.
56 | * Extends standard HTMLDivElement attributes.
57 | */
58 | export interface ThreadContentProps
59 | extends React.HTMLAttributes {
60 | /** Optional styling variant for the message container */
61 | variant?: VariantProps["variant"];
62 | /** The child elements to render within the container. */
63 | children?: React.ReactNode;
64 | }
65 |
66 | /**
67 | * The root container for thread content.
68 | * It establishes the context for its children using data from the Tambo hook.
69 | * @component ThreadContent
70 | * @example
71 | * ```tsx
72 | *
73 | *
74 | *
75 | * ```
76 | */
77 | const ThreadContent = React.forwardRef(
78 | ({ children, className, variant, ...props }, ref) => {
79 | const { thread, generationStage, isIdle } = useTambo();
80 | const isGenerating = !isIdle;
81 |
82 | const contextValue = React.useMemo(
83 | () => ({
84 | messages: thread?.messages ?? [],
85 | isGenerating,
86 | generationStage,
87 | variant,
88 | }),
89 | [thread?.messages, isGenerating, generationStage, variant],
90 | );
91 |
92 | return (
93 |
94 |
100 | {children}
101 |
102 |
103 | );
104 | },
105 | );
106 | ThreadContent.displayName = "ThreadContent";
107 |
108 | /**
109 | * Props for the ThreadContentMessages component.
110 | * Extends standard HTMLDivElement attributes.
111 | */
112 | export type ThreadContentMessagesProps = React.HTMLAttributes;
113 |
114 | /**
115 | * Renders the list of messages in the thread.
116 | * Automatically connects to the context to display messages.
117 | * @component ThreadContent.Messages
118 | * @example
119 | * ```tsx
120 | *
121 | *
122 | *
123 | * ```
124 | */
125 | const ThreadContentMessages = React.forwardRef<
126 | HTMLDivElement,
127 | ThreadContentMessagesProps
128 | >(({ className, ...props }, ref) => {
129 | const { messages, isGenerating, variant } = useThreadContentContext();
130 |
131 | const filteredMessages = messages.filter(
132 | (message) => message.role !== "system" && !message.parentMessageId,
133 | );
134 |
135 | return (
136 |
142 | {filteredMessages.map((message, index) => {
143 | return (
144 |
151 |
161 |
167 |
168 |
169 |
176 |
177 |
178 |
179 |
180 |
181 | );
182 | })}
183 |
184 | );
185 | });
186 | ThreadContentMessages.displayName = "ThreadContent.Messages";
187 |
188 | export { ThreadContent, ThreadContentMessages };
189 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Cheat Sheet
2 |
3 | [](https://github.com/michaelmagan/cheatsheet)
4 | [](./LICENSE)
5 | [](https://nextjs.org/)
6 | [](https://tambo.co)
7 |
8 | An AI-powered, open-source Google Sheets competitor built with [Tambo AI](https://tambo.co).
9 |
10 | Build and manipulate interactive spreadsheets with natural language, alongside graphs and visualizations.
11 |
12 | ## Demo
13 | Try it yourself: **[CheatSheet](https://cheatsheet.tambo.co)**
14 |
15 | ### Preview
16 | https://github.com/user-attachments/assets/da72aa8b-6bc5-468e-8f42-0da685105d22
17 |
18 | ## Features
19 |
20 | - **Edit with AI**: Use natural language to interact with a spreadsheet.
21 | - **Cell Selection**: Select cells to have the AI interact with.
22 | - **Multi-Modal**: Attach Images along with Messages.
23 | - **Charts and Graphs**: Create visualizations from your spreadsheet data
24 | - **Model Context Protocol (MCP)**: Connect external data sources and tools
25 |
26 | ## Roadmap
27 |
28 | - **Voice Input**: Use voice input in addition to typing.
29 | - **Formula Support**: Spreadsheet formulas (SUM, AVERAGE, IF, VLOOKUP, etc.)
30 | - **Better Formatting**: More visual options for tables (colors, borders, fonts, alignment)
31 | - **Import/Export**: CSV, XLSX, and JSON support
32 |
33 |
34 | ## Get Started
35 |
36 | 1. Clone this repository
37 |
38 | 2. Navigate to the project directory:
39 | ```bash
40 | cd spreadsheet-template
41 | ```
42 |
43 | 3. Install dependencies:
44 | ```bash
45 | npm install
46 | ```
47 |
48 | 4. Set up your environment variables:
49 |
50 | **Option A: Using Tambo CLI (Recommended)**
51 | ```bash
52 | npx tambo init
53 | ```
54 | This will interactively prompt you for your Tambo API key and create `.env.local` automatically.
55 |
56 | **Option B: Manual Setup**
57 | ```bash
58 | cp example.env.local .env.local
59 | ```
60 | Then edit `.env.local` and add your API key from [tambo.co/dashboard](https://tambo.co/dashboard).
61 |
62 | 5. Start the development server:
63 | ```bash
64 | npm run dev
65 | ```
66 |
67 | 6. Open [http://localhost:3000](http://localhost:3000) in your browser to use the app!
68 |
69 | ## Architecture Overview
70 |
71 | This template shows how the AI reads and updates the spreadsheet through three ways:
72 |
73 | ### How AI Accesses Spreadsheet State
74 |
75 | **Context Helpers** (Read-only data)
76 | - `spreadsheetContextHelper` - Gives the AI the current tab's data as a markdown table
77 | - `spreadsheetSelectionContextHelper` - Tells the AI what's currently selected
78 | - `tabContextHelper` - Lists all tabs and highlights the active tab
79 | - Runs automatically whenever you send a message
80 | - See: `src/lib/spreadsheet-context-helper.ts`, `src/lib/spreadsheet-selection-context.ts`, `src/lib/tab-context-helper.ts`
81 |
82 | **Tools** (Make changes)
83 | - Spreadsheet + tab tools for the AI to change state or inspect metadata
84 | - Context helpers are read-only; tools make changes
85 | - See: `src/tools/spreadsheet-tools.ts`, `src/tools/tab-tools.ts`
86 |
87 | ### Spreadsheet Tools Reference
88 |
89 | | Tool | Purpose |
90 | |------|---------|
91 | | `updateCell` | Update a single cell's value |
92 | | `updateRange` | Update multiple cells at once |
93 | | `addColumn` | Add a new column |
94 | | `removeColumn` | Remove a column |
95 | | `addRow` | Add a new row |
96 | | `removeRow` | Remove a row |
97 | | `readCell` | Read a single cell's value |
98 | | `readRange` | Read multiple cells |
99 | | `clearRange` | Clear cell values in a range |
100 | | `sortByColumn` | Sort rows by column values |
101 |
102 | ### Key Files
103 |
104 | **Configuration**
105 | - `src/lib/tambo.ts` - Component and tool registration
106 | - `src/app/chat/page.tsx` - Main chat interface with TamboProvider
107 |
108 | **Spreadsheet System**
109 | - `src/components/ui/spreadsheet-tabs.tsx` - FortuneSheet workbook wrapper + tab UI
110 | - `src/lib/fortune-sheet-store.tsx` - In-memory global store wiring workbook state
111 | - `src/lib/fortune-sheet-utils.ts` - FortuneSheet-centric helpers (ranges, lookups)
112 |
113 | **State Management**
114 | - `src/lib/canvas-storage.ts` - Canvas/tab state management
115 | - Spreadsheet state flows through the FortuneSheet provider and workbook APIs.
116 |
117 | **Note on Dependencies:** FortuneSheet (`@fortune-sheet/{core,react}`) powers all spreadsheet interactions.
118 |
119 | ## Customizing
120 |
121 | ### Adding Custom Components
122 |
123 | Register components in `src/lib/tambo.ts` that the AI can render inline in chat. Example structure:
124 |
125 | ```tsx
126 | import type { TamboComponent } from "@tambo-ai/react";
127 |
128 | const components: TamboComponent[] = [
129 | {
130 | name: "MyComponent",
131 | description: "When to use this component",
132 | component: MyComponent,
133 | propsSchema: myComponentSchema, // Zod schema
134 | },
135 | ];
136 | ```
137 |
138 | See `src/components/tambo/` for component examples and [Tambo Components docs](https://docs.tambo.co/concepts/components) for detailed guidance.
139 |
140 | ### Creating Custom Tools
141 |
142 | Add tools in `src/tools/` following this pattern:
143 |
144 | ```tsx
145 | export const myTool = {
146 | name: "toolName",
147 | description: "What this tool does",
148 | tool: async (param: string) => {
149 | // Implementation
150 | return { success: true, message: "Result" };
151 | },
152 | toolSchema: z.function().args(
153 | z.string().describe("Parameter description")
154 | ).returns(z.object({
155 | success: z.boolean(),
156 | message: z.string().optional(),
157 | })),
158 | };
159 | ```
160 |
161 | Register in `src/lib/tambo.ts` tools array. See [Tambo Tools docs](https://docs.tambo.co/concepts/tools) for details.
162 |
163 | ### Model Context Protocol (MCP)
164 |
165 | Configure MCP servers via the settings modal to connect external data sources. Servers are stored in browser localStorage and wrapped with `TamboMcpProvider` in the chat interface.
166 |
167 | ## Documentation
168 |
169 | Learn more about Tambo:
170 | - [Components](https://docs.tambo.co/concepts/components)
171 | - [Interactable Components](https://docs.tambo.co/concepts/components/interactable-components)
172 | - [Tools](https://docs.tambo.co/concepts/tools)
173 | - [Additional Context](https://docs.tambo.co/concepts/additional-context)
174 |
175 | Built with [Tambo AI](https://tambo.co) - A framework for building AI-powered UIs. Tambo is open source: [tambo-ai/tambo](https://github.com/tambo-ai/tambo).
176 |
177 | 
178 |
179 | ## Contributing
180 |
181 | Contributions welcome! See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines.
182 |
183 | ## License
184 |
185 | MIT License
186 |
--------------------------------------------------------------------------------
/src/components/ui/spreadsheet-tabs.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { cn } from "@/lib/utils";
4 | import * as React from "react";
5 | import { Workbook, type WorkbookInstance } from "@fortune-sheet/react";
6 | import type { Op } from "@fortune-sheet/core";
7 | import {
8 | normalizeSheetForFortuneSheet,
9 | sheetHasInvalidMetrics,
10 | useFortuneSheet,
11 | } from "@/lib/fortune-sheet-store";
12 |
13 | type SpreadsheetTabsProps = React.HTMLAttributes;
14 |
15 | const SpreadsheetTabs: React.FC = ({
16 | className,
17 | ...props
18 | }) => {
19 | const {
20 | sheets,
21 | setSheets,
22 | registerWorkbook,
23 | setLastOps,
24 | replaceSheets,
25 | } = useFortuneSheet();
26 | const workbookRef = React.useRef(null);
27 | const [workbookKey, setWorkbookKey] = React.useState(0);
28 | const appliedFontRanges = React.useRef<
29 | Map
30 | >(new Map());
31 |
32 | const handleWorkbookRef = React.useCallback(
33 | (instance: WorkbookInstance | null) => {
34 | workbookRef.current = instance;
35 | registerWorkbook(instance);
36 | },
37 | [registerWorkbook]
38 | );
39 |
40 | React.useEffect(() => {
41 | return () => {
42 | workbookRef.current = null;
43 | registerWorkbook(null);
44 | };
45 | }, [registerWorkbook]);
46 |
47 | const handleChange = React.useCallback(
48 | (next: typeof sheets) => {
49 | setSheets(next);
50 | },
51 | [setSheets]
52 | );
53 |
54 | const handleOp = React.useCallback(
55 | (ops: Op[]) => {
56 | setLastOps(ops);
57 | },
58 | [setLastOps]
59 | );
60 |
61 | React.useEffect(() => {
62 | const workbook = workbookRef.current;
63 | const candidateSheets =
64 | (workbook &&
65 | typeof workbook.getAllSheets === "function" &&
66 | workbook.getAllSheets()) ||
67 | sheets;
68 |
69 | if (!Array.isArray(candidateSheets) || candidateSheets.length === 0) {
70 | return;
71 | }
72 |
73 | if (candidateSheets.some(sheetHasInvalidMetrics)) {
74 | const normalized = candidateSheets.map((sheet) =>
75 | normalizeSheetForFortuneSheet(sheet),
76 | );
77 | replaceSheets(normalized);
78 | setWorkbookKey((key) => key + 1);
79 | }
80 | }, [sheets, replaceSheets]);
81 |
82 | React.useEffect(() => {
83 | const workbook = workbookRef.current;
84 | if (!workbook) {
85 | return;
86 | }
87 |
88 | sheets.forEach((sheet) => {
89 | const sheetId = sheet.id;
90 | if (!sheetId) {
91 | return;
92 | }
93 |
94 | const rowCount =
95 | typeof sheet.row === "number" && Number.isFinite(sheet.row) && sheet.row > 0
96 | ? sheet.row
97 | : Array.isArray(sheet.data)
98 | ? sheet.data.length
99 | : 0;
100 | const firstRow = Array.isArray(sheet.data) ? sheet.data[0] : undefined;
101 | const columnCount =
102 | typeof sheet.column === "number" &&
103 | Number.isFinite(sheet.column) &&
104 | sheet.column > 0
105 | ? sheet.column
106 | : Array.isArray(firstRow)
107 | ? firstRow.length
108 | : 0;
109 |
110 | if (rowCount === 0 || columnCount === 0) {
111 | return;
112 | }
113 |
114 | const previousRange = appliedFontRanges.current.get(sheetId);
115 | if (
116 | previousRange &&
117 | rowCount <= previousRange.rows &&
118 | columnCount <= previousRange.columns
119 | ) {
120 | return;
121 | }
122 |
123 | if (previousRange && !previousRange.applied) {
124 | appliedFontRanges.current.set(sheetId, {
125 | rows: rowCount,
126 | columns: columnCount,
127 | applied: false,
128 | });
129 | return;
130 | }
131 |
132 | const sheetHasCustomFont = (() => {
133 | if (Array.isArray(sheet.celldata)) {
134 | for (const cell of sheet.celldata) {
135 | const value = cell?.v;
136 | if (value && typeof value === "object" && value.ff != null) {
137 | return true;
138 | }
139 | }
140 | }
141 | if (Array.isArray(sheet.data)) {
142 | for (const row of sheet.data) {
143 | if (!Array.isArray(row)) {
144 | continue;
145 | }
146 | if (row.some((cell) => cell && typeof cell === "object" && cell.ff != null)) {
147 | return true;
148 | }
149 | }
150 | }
151 | return false;
152 | })();
153 |
154 | if (!previousRange) {
155 | if (sheetHasCustomFont) {
156 | appliedFontRanges.current.set(sheetId, {
157 | rows: rowCount,
158 | columns: columnCount,
159 | applied: false,
160 | });
161 | return;
162 | }
163 |
164 | const targetRange = {
165 | row: [0, rowCount - 1] as [number, number],
166 | column: [0, columnCount - 1] as [number, number],
167 | };
168 |
169 | try {
170 | workbook.setCellFormatByRange("ff", "Arial", targetRange, { id: sheetId });
171 | appliedFontRanges.current.set(sheetId, {
172 | rows: rowCount,
173 | columns: columnCount,
174 | applied: true,
175 | });
176 | } catch (error) {
177 | console.error("Failed to apply default Arial font for sheet", sheetId, error);
178 | }
179 |
180 | return;
181 | }
182 |
183 | const newRanges: { row: [number, number]; column: [number, number] }[] = [];
184 |
185 | if (rowCount > previousRange.rows) {
186 | newRanges.push({
187 | row: [previousRange.rows, rowCount - 1],
188 | column: [0, columnCount - 1],
189 | });
190 | }
191 |
192 | if (columnCount > previousRange.columns && previousRange.rows > 0) {
193 | newRanges.push({
194 | row: [0, previousRange.rows - 1],
195 | column: [previousRange.columns, columnCount - 1],
196 | });
197 | }
198 |
199 | if (newRanges.length === 0) {
200 | return;
201 | }
202 |
203 | try {
204 | for (const range of newRanges) {
205 | const [rowStart, rowEnd] = range.row;
206 | const [colStart, colEnd] = range.column;
207 | if (rowStart > rowEnd || colStart > colEnd) {
208 | continue;
209 | }
210 | workbook.setCellFormatByRange("ff", "Arial", range, { id: sheetId });
211 | }
212 | appliedFontRanges.current.set(sheetId, {
213 | rows: rowCount,
214 | columns: columnCount,
215 | applied: true,
216 | });
217 | } catch (error) {
218 | console.error("Failed to apply default Arial font for sheet", sheetId, error);
219 | }
220 | });
221 | }, [sheets]);
222 |
223 | return (
224 |
229 |
236 |
237 | );
238 | };
239 |
240 | export default SpreadsheetTabs;
241 |
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 |
3 | @custom-variant dark (&:is(.dark *));
4 |
5 | @theme inline {
6 | --color-background: var(--background);
7 | --color-foreground: var(--foreground);
8 | --font-sans: var(--font-geist-sans);
9 | --font-mono: var(--font-geist-mono);
10 | --color-card: var(--card);
11 | --color-card-foreground: var(--card-foreground);
12 | --color-popover: var(--popover);
13 | --color-popover-foreground: var(--popover-foreground);
14 | --color-primary: var(--primary);
15 | --color-primary-foreground: var(--primary-foreground);
16 | --color-secondary: var(--secondary);
17 | --color-secondary-foreground: var(--secondary-foreground);
18 | --color-muted: var(--muted);
19 | --color-muted-foreground: var(--muted-foreground);
20 | --color-accent: var(--accent);
21 | --color-accent-foreground: var(--accent-foreground);
22 | --color-destructive: var(--destructive);
23 | --color-border: var(--border);
24 | --color-input: var(--input);
25 | --color-ring: var(--ring);
26 | --color-chart-1: var(--chart-1);
27 | --color-chart-2: var(--chart-2);
28 | --color-chart-3: var(--chart-3);
29 | --color-chart-4: var(--chart-4);
30 | --color-chart-5: var(--chart-5);
31 | --color-container: var(--container);
32 | --color-backdrop: var(--backdrop);
33 | --color-muted-backdrop: var(--muted-backdrop);
34 | }
35 |
36 | body {
37 | background: var(--background);
38 | color: var(--foreground);
39 | font-family:
40 | var(--font-geist-sans),
41 | ui-sans-serif,
42 | system-ui,
43 | -apple-system,
44 | Segoe UI,
45 | Roboto,
46 | Helvetica,
47 | Arial,
48 | "Apple Color Emoji",
49 | "Segoe UI Emoji";
50 | }
51 |
52 | @layer base {
53 | :root {
54 | --card: 0 0% 100%;
55 | --card-foreground: 240 10% 3.9%;
56 | --popover: 0 0% 100%;
57 | --popover-foreground: 240 10% 3.9%;
58 | --primary: 235 12% 21%;
59 | --primary-foreground: 0 0% 98%;
60 | --secondary: 218 11% 46%;
61 | --secondary-foreground: 0 0% 100%;
62 | --muted: 217 14% 90%;
63 | --muted-foreground: 217 14% 68%;
64 | --accent: 240 4.8% 95.9%;
65 | --accent-foreground: 240 5.9% 10%;
66 | --destructive: 0 84.2% 60.2%;
67 | --destructive-foreground: 0 0% 98%;
68 | --border: 207 22% 90%;
69 | --border-dimension-50: linear-gradient(
70 | 180deg,
71 | hsl(207 22% 95%) 0%,
72 | hsl(207 22% 90%) 50%,
73 | hsl(207 22% 85%) 100%
74 | );
75 | --border-dimension-100: linear-gradient(
76 | 180deg,
77 | hsl(207 22% 98%) 0%,
78 | hsl(207 22% 90%) 50%,
79 | hsl(207 22% 80%) 100%
80 | );
81 | --input: 240 5.9% 90%;
82 | --ring: 240 10% 3.9%;
83 | --radius: 0.5rem;
84 | --chart-1: 30 80% 54.9%;
85 | --chart-2: 339.8 74.8% 54.9%;
86 | --chart-3: 219.9 70.2% 50%;
87 | --chart-4: 160 60% 45.1%;
88 | --chart-5: 280 64.7% 60%;
89 | --container: 210 29% 97%;
90 | --backdrop: 210 88% 14% / 0.25;
91 | --muted-backdrop: 210 88% 14% / 0.1;
92 | --panel-left-width: 500px;
93 | --panel-right-width: 500px;
94 | --sidebar-width: 16rem;
95 | --background: 0 0% 100%;
96 | --foreground: 240 10% 3.9%;
97 | }
98 | .dark {
99 | --background: 240 10% 3.9%;
100 | --foreground: 0 0% 98%;
101 | --card: 240 10% 3.9%;
102 | --card-foreground: 0 0% 98%;
103 | --popover: 240 10% 3.9%;
104 | --popover-foreground: 0 0% 98%;
105 | --primary: 0 0% 98%;
106 | --primary-foreground: 240 5.9% 10%;
107 | --secondary: 240 3.7% 15.9%;
108 | --secondary-foreground: 0 0% 98%;
109 | --muted: 240 3.7% 15.9%;
110 | --muted-foreground: 240 5% 64.9%;
111 | --accent: 240 3.7% 15.9%;
112 | --accent-foreground: 0 0% 98%;
113 | --destructive: 0 62.8% 30.6%;
114 | --destructive-foreground: 0 0% 98%;
115 | --border: 240 3.7% 15.9%;
116 | --input: 240 3.7% 15.9%;
117 | --ring: 240 4.9% 83.9%;
118 | --chart-1: oklch(0.72 0.15 60);
119 | --chart-2: oklch(0.62 0.2 6);
120 | --chart-3: oklch(0.53 0.2 262);
121 | --chart-4: oklch(0.7 0.13 165);
122 | --chart-5: oklch(0.62 0.2 313);
123 | --container: oklch(0.98 0 247);
124 | --backdrop: oklch(0.25 0.07 252 / 0.25);
125 | --muted-backdrop: oklch(0.25 0.07 252 / 0.1);
126 | --radius: 0.5rem;
127 | --panel-left-width: 500px;
128 | --panel-right-width: 500px;
129 | --sidebar-width: 3rem;
130 | }
131 | }
132 |
133 | @layer utilities {
134 | /* Background Utilities */
135 | .bg-background {
136 | background-color: hsl(var(--background));
137 | }
138 | .bg-card {
139 | background-color: hsl(var(--card));
140 | }
141 | .bg-popover {
142 | background-color: hsl(var(--popover));
143 | }
144 | .bg-primary {
145 | background-color: hsl(var(--primary));
146 | }
147 | .bg-secondary {
148 | background-color: hsl(var(--secondary));
149 | }
150 | .bg-backdrop {
151 | background-color: hsl(var(--backdrop));
152 | }
153 | .bg-muted {
154 | background-color: hsl(var(--muted));
155 | }
156 | .bg-accent {
157 | background-color: hsl(var(--accent));
158 | }
159 | .bg-container {
160 | background-color: hsl(var(--container));
161 | }
162 | .bg-destructive {
163 | background-color: hsl(var(--destructive));
164 | }
165 | .bg-input {
166 | background-color: hsl(var(--input));
167 | }
168 | .bg-border {
169 | background-color: hsl(var(--border));
170 | }
171 |
172 | /* Text Utilities */
173 | .text-primary {
174 | color: hsl(var(--primary));
175 | }
176 | .text-primary-foreground {
177 | color: hsl(var(--primary-foreground));
178 | }
179 | .text-secondary {
180 | color: hsl(var(--secondary));
181 | }
182 | .text-secondary-foreground {
183 | color: hsl(var(--secondary-foreground));
184 | }
185 | .text-foreground {
186 | color: hsl(var(--foreground));
187 | }
188 | .text-card-foreground {
189 | color: hsl(var(--card-foreground));
190 | }
191 | .text-popover-foreground {
192 | color: hsl(var(--popover-foreground));
193 | }
194 | .text-muted-foreground {
195 | /* disabled color */
196 | color: hsl(var(--muted-foreground));
197 | }
198 | .text-accent-foreground {
199 | color: hsl(var(--accent-foreground));
200 | }
201 | .text-destructive-foreground {
202 | color: hsl(var(--destructive-foreground));
203 | }
204 | .text-destructive {
205 | color: hsl(var(--destructive));
206 | }
207 | .text-backdrop {
208 | color: hsl(var(--backdrop));
209 | }
210 |
211 | /* Border Utilities */
212 | .border-border {
213 | border-color: hsl(var(--border));
214 | }
215 | .border-dimension-50 {
216 | border-image: var(--border-dimension-50);
217 | border-image-slice: 1;
218 | }
219 | .border-dimension-100 {
220 | border-image: var(--border-dimension-100);
221 | border-image-slice: 1;
222 | }
223 | .border-flat {
224 | border: 1px solid hsl(var(--border));
225 | }
226 | .border-container {
227 | border-color: hsl(var(--container));
228 | }
229 |
230 | /* Ring Utilities */
231 | .ring-ring {
232 | --tw-ring-color: hsl(var(--ring));
233 | }
234 |
235 | /* Hover Utilities */
236 | .hover\:bg-primary\/90:hover {
237 | background-color: hsl(var(--primary) / 0.9);
238 | }
239 | .hover\:bg-muted\/90:hover {
240 | background-color: hsl(var(--muted) / 0.9);
241 | }
242 | .hover\:bg-backdrop:hover {
243 | background-color: hsl(var(--muted-backdrop));
244 | }
245 | .hover\:bg-container:hover {
246 | background-color: hsl(var(--container));
247 | }
248 | .hover\:text-secondary:hover {
249 | color: hsl(var(--secondary));
250 | }
251 | }
252 |
--------------------------------------------------------------------------------
/src/lib/thread-hooks.ts:
--------------------------------------------------------------------------------
1 | import type { TamboThreadMessage } from "@tambo-ai/react";
2 | import * as React from "react";
3 | import { useEffect, useState } from "react";
4 |
5 | /**
6 | * Custom hook to merge multiple refs into one callback ref
7 | * @param refs - Array of refs to merge
8 | * @returns A callback ref that updates all provided refs
9 | */
10 | export function useMergeRefs(
11 | ...refs: (React.Ref | undefined)[]
12 | ): null | React.RefCallback {
13 | const cleanupRef = React.useRef void)>(undefined);
14 |
15 | const refEffect = React.useCallback((instance: Instance | null) => {
16 | const cleanups = refs.map((ref) => {
17 | if (ref == null) {
18 | return;
19 | }
20 |
21 | if (typeof ref === "function") {
22 | const refCallback = ref;
23 | const refCleanup: void | (() => void) = refCallback(instance);
24 | return typeof refCleanup === "function"
25 | ? refCleanup
26 | : () => {
27 | refCallback(null);
28 | };
29 | }
30 |
31 | (ref as React.MutableRefObject).current = instance;
32 | return () => {
33 | (ref as React.MutableRefObject).current = null;
34 | };
35 | });
36 |
37 | return () => {
38 | cleanups.forEach((refCleanup) => refCleanup?.());
39 | };
40 | // eslint-disable-next-line react-hooks/exhaustive-deps
41 | }, [...refs]);
42 |
43 | return React.useMemo(() => {
44 | if (refs.every((ref) => ref == null)) {
45 | return null;
46 | }
47 |
48 | return (value) => {
49 | if (cleanupRef.current) {
50 | cleanupRef.current();
51 | (cleanupRef as React.MutableRefObject void)>).current =
52 | undefined;
53 | }
54 |
55 | if (value != null) {
56 | (cleanupRef as React.MutableRefObject void)>).current =
57 | refEffect(value);
58 | }
59 | };
60 | // eslint-disable-next-line react-hooks/exhaustive-deps
61 | }, [...refs]);
62 | }
63 | /**
64 | * Custom hook to detect canvas space presence and position
65 | * @param elementRef - Reference to the component to compare position with
66 | * @returns Object containing hasCanvasSpace and canvasIsOnLeft
67 | */
68 | export function useCanvasDetection(
69 | elementRef: React.RefObject,
70 | ) {
71 | const [hasCanvasSpace, setHasCanvasSpace] = useState(false);
72 | const [canvasIsOnLeft, setCanvasIsOnLeft] = useState(false);
73 |
74 | useEffect(() => {
75 | const checkCanvas = () => {
76 | const canvas = document.querySelector('[data-canvas-space="true"]');
77 | setHasCanvasSpace(!!canvas);
78 |
79 | if (canvas && elementRef.current) {
80 | // Check if canvas appears before this component in the DOM
81 | const canvasRect = canvas.getBoundingClientRect();
82 | const elemRect = elementRef.current.getBoundingClientRect();
83 | setCanvasIsOnLeft(canvasRect.left < elemRect.left);
84 | }
85 | };
86 |
87 | // Check on mount and after a short delay to ensure DOM is fully rendered
88 | checkCanvas();
89 | const timeoutId = setTimeout(checkCanvas, 100);
90 |
91 | // Re-check on window resize
92 | window.addEventListener("resize", checkCanvas);
93 |
94 | return () => {
95 | clearTimeout(timeoutId);
96 | window.removeEventListener("resize", checkCanvas);
97 | };
98 | }, [elementRef]);
99 |
100 | return { hasCanvasSpace, canvasIsOnLeft };
101 | }
102 |
103 | /**
104 | * Utility to check if a className string contains the "right" class
105 | * @param className - The className string to check
106 | * @returns true if the className contains "right", false otherwise
107 | */
108 | export function hasRightClass(className?: string): boolean {
109 | return className ? /(?:^|\s)right(?:\s|$)/i.test(className) : false;
110 | }
111 |
112 | /**
113 | * Hook to calculate sidebar and history positions based on className and canvas position
114 | * @param className - Component's className string
115 | * @param canvasIsOnLeft - Whether the canvas is on the left
116 | * @returns Object with isLeftPanel and historyPosition values
117 | */
118 | export function usePositioning(
119 | className?: string,
120 | canvasIsOnLeft = false,
121 | hasCanvasSpace = false,
122 | ) {
123 | const isRightClass = hasRightClass(className);
124 | const isLeftPanel = !isRightClass;
125 |
126 | // Determine history position
127 | // If panel has right class, history should be on right
128 | // If canvas is on left, history should be on right
129 | // Otherwise, history should be on left
130 | const historyPosition: "left" | "right" = isRightClass
131 | ? "right"
132 | : hasCanvasSpace && canvasIsOnLeft
133 | ? "right"
134 | : "left";
135 |
136 | return { isLeftPanel, historyPosition };
137 | }
138 |
139 | /**
140 | * Converts message content into a safely renderable format.
141 | * Primarily joins text blocks from arrays into a single string.
142 | * @param content - The message content (string, element, array, etc.)
143 | * @returns A renderable string or React element.
144 | */
145 | export function getSafeContent(
146 | content: TamboThreadMessage["content"] | React.ReactNode | undefined | null,
147 | ): string | React.ReactElement {
148 | if (!content) return "";
149 | if (typeof content === "string") return content;
150 | if (React.isValidElement(content)) return content; // Pass elements through
151 | if (Array.isArray(content)) {
152 | // Filter out non-text items and join text
153 | return content
154 | .map((item) => (item?.type === "text" ? (item.text ?? "") : ""))
155 | .join("");
156 | }
157 | // Handle potential edge cases or unknown types
158 | // console.warn("getSafeContent encountered unknown content type:", content);
159 | return "Invalid content format"; // Or handle differently
160 | }
161 |
162 | /**
163 | * Checks if a content item has meaningful data.
164 | * @param item - A content item from the message
165 | * @returns True if the item has content, false otherwise.
166 | */
167 | function hasContentInItem(item: unknown): boolean {
168 | if (!item || typeof item !== "object") {
169 | return false;
170 | }
171 |
172 | const typedItem = item as {
173 | type?: string;
174 | text?: string;
175 | image_url?: { url?: string };
176 | };
177 |
178 | // Check for text content
179 | if (typedItem.type === "text") {
180 | return !!typedItem.text?.trim();
181 | }
182 |
183 | // Check for image content
184 | if (typedItem.type === "image_url") {
185 | return !!typedItem.image_url?.url;
186 | }
187 |
188 | return false;
189 | }
190 |
191 | /**
192 | * Checks if message content contains meaningful, non-empty text or images.
193 | * @param content - The message content (string, element, array, etc.)
194 | * @returns True if there is content, false otherwise.
195 | */
196 | export function checkHasContent(
197 | content: TamboThreadMessage["content"] | React.ReactNode | undefined | null,
198 | ): boolean {
199 | if (!content) return false;
200 | if (typeof content === "string") return content.trim().length > 0;
201 | if (React.isValidElement(content)) return true; // Assume elements have content
202 | if (Array.isArray(content)) {
203 | return content.some(hasContentInItem);
204 | }
205 | return false; // Default for unknown types
206 | }
207 |
208 | /**
209 | * Extracts image URLs from message content array.
210 | * @param content - Array of content items
211 | * @returns Array of image URLs
212 | */
213 | export function getMessageImages(
214 | content: { type?: string; image_url?: { url?: string } }[] | undefined | null,
215 | ): string[] {
216 | if (!content) return [];
217 |
218 | return content
219 | .filter((item) => item?.type === "image_url" && item.image_url?.url)
220 | .map((item) => item.image_url!.url!);
221 | }
222 |
--------------------------------------------------------------------------------
/src/components/tambo/graph-component.ts:
--------------------------------------------------------------------------------
1 | import type { TamboComponent } from "@tambo-ai/react";
2 | import { Graph, graphSchema } from "./graph";
3 |
4 | /**
5 | * Graph Component Configuration for Tambo
6 | *
7 | * This component allows the AI to create charts and graphs from spreadsheet data.
8 | * The AI can render bar charts, line charts, pie charts, stacked bar charts, and stacked area charts
9 | * by reading data from spreadsheet tabs.
10 | */
11 | export const graphComponent: TamboComponent = {
12 | name: "graph",
13 | description: `
14 | Creates interactive charts from spreadsheet data. Use this when a user requests a chart, graph, or data visualization.
15 |
16 | ## How to Analyze Spreadsheet Structure
17 |
18 | Before creating a chart, analyze the spreadsheet structure:
19 |
20 | 1. **Identify Label Column**: Look for the column containing text labels (categories, dates, names)
21 | - Typically the leftmost column (Column A)
22 | - Contains non-numeric data that describes each row
23 |
24 | 2. **Identify Data Columns**: Look for columns containing numeric values to visualize
25 | - These are the values you'll plot on the chart
26 | - Can have multiple data columns for multi-series charts
27 |
28 | 3. **Choose Chart Type** based on the data characteristics:
29 | - **Bar Chart**: Best for comparing values across different categories
30 | Example: Sales by region, scores by student, revenue by month
31 | - **Stacked Bar Chart**: Best for showing part-to-whole relationships across categories with multiple datasets
32 | Example: Product sales composition by region, expense breakdown by department
33 | - **Line Chart**: Best for showing trends over time or continuous data
34 | Example: Stock prices over time, temperature changes, growth metrics
35 | - **Stacked Area Chart**: Best for showing cumulative trends over time with multiple datasets
36 | Example: Cumulative revenue from multiple products, user growth by source over time
37 | - **Combo Chart**: Best for comparing different types of data or showing relationships between metrics
38 | Example: Revenue (bars) vs profit margin (line), sales volume (bars) vs conversion rate (line)
39 | - **Pie Chart**: Best for showing proportions of a whole (requires single dataset only)
40 | Example: Market share distribution, budget allocation, demographic breakdown
41 |
42 | ## Configuration Requirements
43 |
44 | Always use the \`spreadsheetData\` property with these fields:
45 |
46 | - **tabId** (required): The ID of the active spreadsheet tab
47 | - Get this from the current context (typically provided as \`activeTabId\`)
48 |
49 | - **labelsRange** (required): A1 notation for the labels column
50 | - Must be an explicit range (e.g., "A2:A10")
51 | - Cannot use open-ended ranges (e.g., "A2:A" is invalid)
52 | - Usually excludes the header row (start from row 2)
53 |
54 | - **dataSets** (required): Array of data series to plot
55 | - Each dataset object contains:
56 | * \`range\`: A1 notation for the data values (e.g., "B2:B10"). Label will be read from the header cell (row 1)
57 | * \`color\`: (optional) Custom color for this series
58 | * \`chartType\`: (optional, for combo charts only) "bar" or "line" to specify how this dataset should be rendered
59 | * \`yAxisId\`: (optional, for multi-axis charts) "left" or "right" to specify which Y-axis to use
60 | - Maximum 10 datasets per chart
61 | - For pie charts, use exactly 1 dataset
62 |
63 | ## Example Workflow
64 |
65 | **Scenario**: User has selected cells A1:C10 with headers in row 1, categories in column A, sales data in column B, and costs in column C.
66 |
67 | **Analysis**:
68 | - Column A (A2:A10): Contains category labels (text)
69 | - Column B (B2:B10): Contains numeric sales data
70 | - Column C (C2:C10): Contains numeric cost data
71 | - User wants to compare sales and costs → Bar chart is appropriate
72 |
73 | **Generated Component**:
74 | \`\`\`tsx
75 |
90 | \`\`\`
91 |
92 | **Example - Combo Chart with Multi-Axis**:
93 | \`\`\`tsx
94 |
106 | \`\`\`
107 |
108 | ## Important Constraints
109 |
110 | 1. **Range Specification**: All ranges must be explicit with both start and end cells
111 | - Valid: "A2:A10", "B1:B20"
112 | - Invalid: "A2:A", "B:B"
113 |
114 | 2. **Dataset Limits**: Maximum 10 datasets per chart
115 | - Charts with more than 10 datasets will show an error
116 | - For pie charts, use exactly 1 dataset
117 |
118 | 3. **Single Tab Reference**: Each chart can only reference data from one tab
119 | - The tabId must match the tab containing all specified ranges
120 |
121 | 4. **Header Handling**: Typically exclude header rows from ranges
122 | - If row 1 contains headers, start data ranges from row 2
123 | - Example: If data is in rows 1-10 with headers, use "A2:A10" not "A1:A10"
124 |
125 | ## Chart Type Guidelines
126 |
127 | **Bar Chart** - Use for categorical comparisons
128 | - Best when comparing discrete categories
129 | - Good for multiple data series side-by-side
130 | - Example use cases: Comparing performance across teams, sales by product, scores by category
131 |
132 | **Stacked Bar Chart** - Use for part-to-whole categorical comparisons
133 | - Shows how different components contribute to a total for each category
134 | - All datasets are stacked on top of each other
135 | - Great for showing composition across categories
136 | - Example use cases: Product sales mix by region, expense breakdown by department, resource allocation by project
137 |
138 | **Line Chart** - Use for trends and continuous data
139 | - Best for time-series data
140 | - Shows progression and patterns with separate lines
141 | - Example use cases: Revenue over months, temperature over time, user growth
142 |
143 | **Stacked Area Chart** - Use for cumulative trends over time
144 | - Shows how different components contribute to a total over time
145 | - All areas are stacked on top of each other
146 | - Great for visualizing cumulative growth or composition over time
147 | - Example use cases: Cumulative revenue from multiple products, traffic sources over time, user growth by acquisition channel
148 |
149 | **Pie Chart** - Use for part-to-whole relationships
150 | - Requires exactly 1 dataset
151 | - Best with 2-7 slices (too many becomes unreadable)
152 | - Example use cases: Market share, budget breakdown, demographic distribution
153 |
154 | **Combo Chart** - Use for mixed visualizations with bars and lines
155 | - Combine bar and line charts on the same graph
156 | - Each dataset can specify \`chartType\`: "bar" or "line" (defaults to "bar")
157 | - Supports multi-axis: use \`yAxisId\`: "left" or "right" to assign datasets to different Y-axes
158 | - Great for comparing different units or scales (e.g., revenue in dollars vs percentage growth)
159 | - Example use cases: Sales (bars) with trend line, revenue (bars) vs profit margin (line), volume (bars) vs price (line)
160 |
161 | ## Additional Properties
162 |
163 | - **title** (optional): Display title for the chart
164 | - **showLegend** (optional, default: true): Whether to show the legend
165 | - **variant** (optional): Visual style - "default", "solid", or "bordered"
166 | - **size** (optional): Chart height - "sm", "default", or "lg"
167 | `.trim(),
168 | component: Graph,
169 | propsSchema: graphSchema,
170 | };
171 |
--------------------------------------------------------------------------------
/src/components/tambo/message-thread-full.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import type { messageVariants } from "@/components/tambo/message";
4 | import {
5 | MessageInput,
6 | MessageInputError,
7 | MessageInputFileButton,
8 | MessageInputMcpConfigButton,
9 | // MessageInputMcpPromptButton, // TODO: Commented out until mcp-components file is added
10 | MessageInputSubmitButton,
11 | MessageInputTextarea,
12 | MessageInputToolbar,
13 | } from "@/components/tambo/message-input";
14 | import {
15 | MessageSuggestions,
16 | MessageSuggestionsList,
17 | MessageSuggestionsStatus,
18 | } from "@/components/tambo/message-suggestions";
19 | import { ScrollableMessageContainer } from "@/components/tambo/scrollable-message-container";
20 | import {
21 | ThreadContainer,
22 | useThreadContainerContext,
23 | } from "@/components/tambo/thread-container";
24 | import {
25 | ThreadContent,
26 | ThreadContentMessages,
27 | } from "@/components/tambo/thread-content";
28 | import {
29 | ThreadHistory,
30 | ThreadHistoryHeader,
31 | ThreadHistoryList,
32 | ThreadHistoryNewButton,
33 | ThreadHistorySearch,
34 | useThreadHistoryContext,
35 | } from "@/components/tambo/thread-history";
36 | import { useMergeRefs } from "@/lib/thread-hooks";
37 | import type { Suggestion } from "@tambo-ai/react";
38 | import type { VariantProps } from "class-variance-authority";
39 | import * as React from "react";
40 | import { cn } from "@/lib/utils";
41 | import { GithubIcon } from "lucide-react";
42 | import Image from "next/image";
43 |
44 | /**
45 | * GitHub button component for the sidebar
46 | */
47 | const GitHubButton = React.forwardRef<
48 | HTMLAnchorElement,
49 | React.AnchorHTMLAttributes
50 | >(({ ...props }, ref) => {
51 | const { isCollapsed } = useThreadHistoryContext();
52 |
53 | const githubRepoUrl = "https://github.com/michaelmagan/cheatsheet";
54 |
55 | return (
56 |
68 |
69 |
77 | GitHub Repo
78 |
79 |
80 | );
81 | });
82 | GitHubButton.displayName = "GitHubButton";
83 |
84 | /**
85 | * Tambo button component for the sidebar
86 | */
87 | const TamboButton = React.forwardRef<
88 | HTMLAnchorElement,
89 | React.AnchorHTMLAttributes
90 | >(({ ...props }, ref) => {
91 | const { isCollapsed } = useThreadHistoryContext();
92 |
93 | const tamboUrl = "https://tambo.co";
94 |
95 | return (
96 |
108 |
115 |
123 | Built with Tambo AI
124 |
125 |
126 | );
127 | });
128 | TamboButton.displayName = "TamboButton";
129 |
130 | /**
131 | * Props for the MessageThreadFull component
132 | */
133 | export interface MessageThreadFullProps
134 | extends React.HTMLAttributes {
135 | /** Optional context key for the thread */
136 | contextKey?: string;
137 | /**
138 | * Controls the visual styling of messages in the thread.
139 | * Possible values include: "default", "compact", etc.
140 | * These values are defined in messageVariants from "@/components/tambo/message".
141 | * @example variant="compact"
142 | */
143 | variant?: VariantProps["variant"];
144 | }
145 |
146 | /**
147 | * A full-screen chat thread component with message history, input, and suggestions
148 | */
149 | export const MessageThreadFull = React.forwardRef<
150 | HTMLDivElement,
151 | MessageThreadFullProps
152 | >(({ className, contextKey, variant, ...props }, ref) => {
153 | const { containerRef, historyPosition } = useThreadContainerContext();
154 | const mergedRef = useMergeRefs(ref, containerRef);
155 |
156 | const threadHistorySidebar = (
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 | );
166 |
167 | const defaultSuggestions: Suggestion[] = [
168 | {
169 | id: "suggestion-1",
170 | title: "Get started",
171 | detailedSuggestion: "What can you help me with?",
172 | messageId: "welcome-query",
173 | },
174 | {
175 | id: "suggestion-2",
176 | title: "Learn more",
177 | detailedSuggestion: "Tell me about your capabilities.",
178 | messageId: "capabilities-query",
179 | },
180 | {
181 | id: "suggestion-3",
182 | title: "Examples",
183 | detailedSuggestion: "Show me some example queries I can try.",
184 | messageId: "examples-query",
185 | },
186 | ];
187 |
188 | return (
189 | <>
190 | {/* Thread History Sidebar - rendered first if history is on the left */}
191 | {historyPosition === "left" && threadHistorySidebar}
192 |
193 |
194 |
195 |
196 |
197 |
198 |
199 |
200 | {/* Message suggestions status */}
201 |
202 |
203 |
204 |
205 | {/* Message input */}
206 |
207 |
208 |
209 |
210 |
211 | {/* TODO: Commented out until mcp-components file is added */}
212 |
213 |
214 |
215 |
216 |
217 |
218 |
219 | {/* Message suggestions */}
220 |
221 |
222 |
223 |
224 |
225 | {/* Thread History Sidebar - rendered last if history is on the right */}
226 | {historyPosition === "right" && threadHistorySidebar}
227 | >
228 | );
229 | });
230 | MessageThreadFull.displayName = "MessageThreadFull";
231 |
--------------------------------------------------------------------------------
/.claude/commands/story.md:
--------------------------------------------------------------------------------
1 | ---
2 | description: "Create a developer-focused user story for React SDK features"
3 | argument-hint: ""
4 | model: inherit
5 | allowed-tools: [Task, AskUserQuestion, Read, Glob, Grep]
6 | ---
7 |
8 | # Story Command
9 |
10 | You will create a comprehensive, developer-focused user story for a React SDK feature request. The story will include technical context, acceptance criteria, and pseudo-code examples to guide implementation.
11 |
12 | ## Task
13 |
14 | **Feature Request**: $ARGUMENTS
15 |
16 | The agent will analyze the codebase, understand existing patterns, and produce a detailed user story with pseudo-code that matches the project's architecture and conventions.
17 |
18 | You are a Senior Product Owner specializing in React developer tools, component libraries, and SDKs. You have deep expertise in React hooks, component APIs, TypeScript, and creating exceptional developer experiences (DX). You excel at designing intuitive React APIs that developers love to use.
19 |
20 | ## Your Core Responsibility
21 |
22 | Create well-structured user stories for React SDK features that include pseudo-code examples demonstrating the intended developer experience. Your stories must balance simplicity with flexibility, following React best practices and patterns.
23 |
24 | ## Product Context You Should Consider
25 |
26 | - **Product Type**: React SDK/Component Library (often for generative UI, real-time features, AI-powered interfaces)
27 | - **Core Technologies**: React 18+, TypeScript, streaming APIs, WebSockets, modern React patterns
28 | - **Target Developers**: React developers building interactive UIs, AI-powered interfaces, real-time applications
29 | - **Key Patterns**: Hooks, HOCs, Context Providers, Render Props, Suspense, Concurrent Features
30 | - **Related Tools**: CLI for scaffolding, backend APIs, TypeScript SDK
31 |
32 | ## Output Structure You Must Follow
33 |
34 | Your user stories MUST include these sections in this exact order:
35 |
36 | ### 1. Title
37 | - Maximum 8 words
38 | - Clear, action-oriented feature name
39 | - Example: "Streaming UI Component with Progressive Rendering"
40 |
41 | ### 2. User Story
42 | - Maximum 25 words
43 | - Format: "As a React developer, I want [capability] so that [value to end users/DX improvement]"
44 | - Focus on developer needs and end-user value
45 |
46 | ### 3. Developer Experience (Pseudo-code)
47 | - Show the IDEAL usage - how developers WANT to write this
48 | - Include multiple common use cases
49 | - Start with basic usage (the happy path)
50 | - Show advanced usage if applicable
51 | - Use realistic, almost-copy-pasteable code
52 | - Include proper TypeScript types
53 | - Show component composition patterns
54 |
55 | ### 4. API Design
56 | - Hook signatures and return types
57 | - Component props interface with TypeScript
58 | - Context Provider API (if applicable)
59 | - Configuration options with defaults
60 | - All exported types and interfaces
61 |
62 | ### 5. Acceptance Criteria
63 | - Use GIVEN-WHEN-THEN format
64 | - Start with: "GIVEN a React application"
65 | - Include checkboxes for each criterion
66 | - Cover: minimal configuration, TypeScript support, error handling, customization, tree-shaking, SSR compatibility
67 |
68 | ### 6. Technical Requirements
69 | - React version compatibility (specify minimum version)
70 | - Bundle size impact (specify target: < X kb gzipped)
71 | - Performance constraints (re-renders, memoization strategies)
72 | - Accessibility requirements (WCAG level, ARIA patterns)
73 | - Browser compatibility
74 | - Peer dependencies
75 |
76 | ### 7. Edge Cases & Error Handling
77 | - Show how errors surface to developers
78 | - Include helpful, actionable error messages
79 | - Cover common mistakes (missing context, invalid configuration)
80 | - Demonstrate proper error boundaries
81 | - Show warning messages for non-critical issues
82 |
83 | ### 8. Migration Path (if applicable)
84 | - Only include if this changes an existing API
85 | - Show old API vs new API side-by-side
86 | - Provide clear migration steps
87 | - Indicate breaking changes explicitly
88 |
89 | ### 9. Documentation Requirements
90 | - JSDoc comments for all public APIs
91 | - Storybook stories for interactive examples
92 | - README examples for common patterns
93 | - TypeScript definitions exported
94 | - Cookbook recipes for complex scenarios
95 |
96 | ### 10. Definition of Done
97 | - Checkboxes for all completion criteria
98 | - Include: TypeScript types, unit tests (>90% coverage), Storybook stories, no console errors, bundle size analysis, peer review, accessibility audit
99 |
100 | ## Critical Rules You Must Follow
101 |
102 | 1. **Prioritize Developer Ergonomics**: The API should feel natural to React developers who know the ecosystem
103 |
104 | 2. **Follow React Conventions**:
105 | - Hooks start with 'use' (useYourHook, not yourHook)
106 | - Event handlers start with 'on' (onError, not handleError in props)
107 | - Boolean props use 'is' or 'has' prefix (isLoading, hasError)
108 |
109 | 3. **Show Realistic Pseudo-code**: Developers should be able to almost copy-paste your examples
110 |
111 | 4. **Progressive Disclosure**: Simple things should be simple, complex things should be possible
112 | - Basic usage requires minimal configuration
113 | - Advanced features available through optional props/options
114 |
115 | 5. **Component Composition**: Show how components work together, not in isolation
116 |
117 | 6. **Error Messages Must Be Actionable**:
118 | - Bad: "Invalid configuration"
119 | - Good: "useYourHook: 'interval' prop must be a positive number, received -1"
120 |
121 | 7. **Consider Modern React Features**:
122 | - Concurrent rendering compatibility
123 | - Suspense integration where appropriate
124 | - Server Components considerations
125 | - Streaming SSR support
126 |
127 | 8. **TypeScript First**: All examples should include proper TypeScript types
128 |
129 | 9. **Performance Awareness**: Call out re-render implications, memoization needs, and bundle size impact
130 |
131 | 10. **Accessibility by Default**: Components should be accessible without extra configuration
132 |
133 | ## Quality Assurance Checklist
134 |
135 | Before finalizing your user story, verify:
136 |
137 | - [ ] All code examples use proper React patterns (hooks rules, component composition)
138 | - [ ] TypeScript types are complete and accurate
139 | - [ ] Error messages are helpful and actionable
140 | - [ ] The API is progressively discoverable (simple → advanced)
141 | - [ ] Bundle size impact is specified and reasonable
142 | - [ ] SSR/hydration compatibility is addressed
143 | - [ ] Accessibility requirements are clear
144 | - [ ] Migration path is provided for breaking changes
145 | - [ ] All 10 required sections are present and complete
146 |
147 | ## When You Need Clarification
148 |
149 | If the user's input is vague or missing critical information, ask specific questions:
150 |
151 | - "What problem does this solve for React developers?"
152 | - "Should this be a hook, component, or both?"
153 | - "What's the expected bundle size impact?"
154 | - "Does this need to work with Server Components?"
155 | - "Are there existing patterns in the codebase I should follow?"
156 | - "What's the migration strategy if this changes existing APIs?"
157 |
158 | Do not make assumptions about critical technical decisions. Always clarify before proceeding.
159 |
160 | ## Context Awareness
161 |
162 | You have access to project-specific context from CLAUDE.md files. When creating user stories:
163 |
164 | - Reference existing patterns and conventions from the codebase
165 | - Align with established coding standards
166 | - Consider the project's architecture (monorepo structure, build system, etc.)
167 | - Follow the team's TypeScript configuration and linting rules
168 | - Match the existing documentation style and format
169 |
170 | For the Tambo project specifically, you should be aware of:
171 | - Turborepo monorepo structure
172 | - Component registry system
173 | - Tool registration patterns
174 | - TamboProvider and TamboMcpProvider setup
175 | - Zod schema usage for props validation
176 | - SSR compatibility requirements
177 |
178 | Your user stories should integrate seamlessly with these existing patterns.
--------------------------------------------------------------------------------
/.cursor/rules/tambo-ai.mdc:
--------------------------------------------------------------------------------
1 | ---
2 | description: working with tambo-ai, llms, generative UI/UX, or AI agents/assistants/co-pilots, chatbots, etc.
3 | globs:
4 | alwaysApply: false
5 | ---
6 | # Using tambo-ai to build AI powered chat experiences with generative UI/UX
7 |
8 | version: 0.21.1
9 |
10 | ## Quick Setup
11 |
12 | 1. Initialize a new Tambo project:
13 | ```bash
14 | npm create tambo-app@latest my-tambo-app for a new project
15 |
16 | npx tambo init // to get tambo-ai api key
17 |
18 | npx tambo full-send // to add to an existing project & include tambo-ai api key
19 | ```
20 |
21 | Alternative manual setup to get key:
22 | - Rename `example.env.local` to `.env.local`
23 | - Add your Tambo API key from tambo.co/cli-auth
24 |
25 | ```
26 | 2. Wrap your app in the TamboProvider:
27 | Needs to be a client component:
28 |
29 | ```typescript
30 |
34 | {children}
35 |
36 |
37 | ```
38 |
39 | 3. Register your tools:
40 |
41 | ```typescript
42 | const { registerTool } = useTambo();
43 |
44 | useEffect(() => {
45 | registerTool(//insert tool here);
46 | }, []);
47 |
48 | Add tambo-components to your project:
49 |
50 | ```bash
51 | npx tambo add message-thread-full //Fullscreen chat useTamboThreadInput
52 | npx tambo add message-thread-collapsible //Collapsible chat
53 | npx tambo add message-thread-panel //Chat panel
54 |
55 | npx tambo add graph //Graph component
56 | npx tambo add form //Form component
57 |
58 | # There might be more components available, check the docs for the latest list:
59 | npx tambo add [component-name]
60 | ```
61 |
62 | ## Model Context Protocol (MCP) Configuration
63 |
64 | MCP extends Tambo's capabilities by connecting to external tool providers. To set up MCP:
65 |
66 | 1. Wrap your TamboProvider with TamboMcpProvider:
67 |
68 | ```typescript
69 |
70 |
71 | {children}
72 |
73 |
74 | ```
75 |
76 | 2. MCP Server Configuration Types:
77 |
78 | ```typescript
79 | export enum MCPTransport {
80 | SSE = "sse",
81 | HTTP = "http", // Default
82 | }
83 |
84 | export type MCPServerConfig =
85 | | string
86 | | {
87 | url: string;
88 | transport?: MCPTransport;
89 | name?: string;
90 | };
91 | ```
92 |
93 |
94 | MCP servers extend Tambo's capabilities by providing additional tools that will be automatically registered and available in the chat interface. Each server should implement the Model Context Protocol to be compatible.
95 |
96 | ## Recommended Project Structure
97 |
98 | ```bash
99 | src/
100 | ├── app/
101 | │ ├── chat/ # Chat interface implementation
102 | │ │ └── page.tsx # Main chat page with TamboProvider & TamboMcpProvider
103 | ├── components/ # React components
104 | │ ├── ui/ # Reusable UI components
105 | │ └── lib/ # Complex component logic
106 | ├── lib/ # Shared utilities and configuration
107 | │ └── tambo.ts # Tambo configuration and setup: component registration, tool configuration, etc.
108 |
109 | # Key Files
110 | .env.local # Environment variables (renamed from example.env.local)
111 | tailwind.config.ts # Tailwind CSS configuration
112 | ```
113 |
114 | ## Recommendations
115 | - use zod for defining tool and component schemas
116 |
117 | ## Core React Hooks
118 |
119 | ### useTamboRegistry
120 | Provides helpers for component and tool registration.
121 |
122 | ```typescript
123 | const {
124 | registerComponent, // Register single component
125 | registerTool, // Register single tool
126 | registerTools, // Register multiple tools
127 | addToolAssociation, // Associate components with tools
128 | componentList, // Access registered components
129 | toolRegistry, // Access registered tools
130 | componentToolAssociations // Access component-tool associations
131 | } = useTamboRegistry()
132 | ```
133 |
134 | ### useTamboThread
135 | Manages thread interactions and state.
136 |
137 | ```typescript
138 | const {
139 | thread, // Current thread object with messages
140 | sendThreadMessage, // Send user message and get response
141 | generationStage, // Current generation stage (IDLE, CHOOSING_COMPONENT, etc.)
142 | inputValue, // Current input field value
143 | generationStatusMessage, // Status message for generation
144 | isIdle, // Whether thread is idle
145 | switchCurrentThread, // Change active thread
146 | addThreadMessage, // Add new message
147 | updateThreadMessage, // Modify existing message
148 | setLastThreadStatus, // Update last message status
149 | setInputValue // Update input value
150 | } = useTamboThread()
151 | ```
152 |
153 | ### useTamboThreadList
154 | Access and manage all threads.
155 |
156 | ```typescript
157 | const {
158 | data, // Array of threads
159 | isPending, // Loading state
160 | isSuccess, // Success state
161 | isError, // Error state
162 | error // Error details
163 | } = useTamboThreadList()
164 | ```
165 |
166 | ### useTamboThreadInput
167 | Build input interfaces for Tambo.
168 |
169 | ```typescript
170 | const {
171 | value, // Input field value
172 | setValue, // Update input value
173 | submit, // Submit input
174 | isPending, // Submission state
175 | isSuccess, // Success state
176 | isError, // Error state
177 | error // Error details
178 | } = useTamboThreadInput()
179 | ```
180 |
181 | ### useTamboSuggestions
182 | Manage AI-generated suggestions.
183 |
184 | ```typescript
185 | const {
186 | suggestions, // Available AI suggestions
187 | selectedSuggestionId, // Currently selected suggestion
188 | accept, // Accept a suggestion
189 | acceptResult, // Detailed accept result
190 | generateResult, // Generation result
191 | isPending, // Operation state
192 | isSuccess, // Success state
193 | isError, // Error state
194 | error // Error details
195 | } = useTamboSuggestions()
196 | ```
197 |
198 | ### useTamboStreaming
199 | Enables real-time streaming of AI-generated content into component state.
200 |
201 | ```typescript
202 | const { streamingProps, isStreaming } = useTamboStreaming(
203 | state, // Current state object
204 | setState, // State setter function
205 | {
206 | // Initial values and configuration
207 | field1: initialValue1,
208 | field2: initialValue2,
209 | // ... more fields
210 | },
211 | {
212 | // Optional configuration
213 | onStreamStart?: () => void,
214 | onStreamEnd?: () => void,
215 | debounceMs?: number, // Debounce delay for state updates
216 | }
217 | )
218 | ```
219 |
220 | #### Key Features:
221 | - Real-time updates of component state during AI generation
222 | - Smooth streaming of content into multiple fields simultaneously
223 | - Configurable debouncing for performance optimization
224 | - Lifecycle hooks for stream start/end events
225 |
226 | #### Example Usage:
227 | ```typescript
228 | // Email composer with streaming AI content
229 | const [emailState, setEmailState] = useState({
230 | subject: "",
231 | body: ""
232 | });
233 |
234 | const { streamingProps, isStreaming } = useTamboStreaming(
235 | emailState,
236 | setEmailState,
237 | {
238 | subject: initialSubject || "",
239 | body: initialBody || "",
240 | },
241 | {
242 | onStreamEnd: () => {
243 | console.log("AI generation complete");
244 | },
245 | debounceMs: 100
246 | }
247 | );
248 |
249 | // streamingProps can be spread into your component
250 |
251 | ```
252 |
253 | ### useTamboClient
254 | Direct access to Tambo client.
255 |
256 | ```typescript
257 | const { client } = useTamboClient()
258 | ```
259 |
260 | ### useTamboComponentState
261 | State management with Tambo awareness.
262 |
263 | ```typescript
264 | const [value, setValue] = useTamboComponentState()
265 | ```
266 |
267 | ###
268 |
269 | ## Generation Stages
270 |
271 | The `generationStage` from `useTamboThread` can be:
272 | - `IDLE`: No active generation
273 | - `CHOOSING_COMPONENT`: Selecting response component
274 | - `FETCHING_CONTEXT`: Gathering context via tools
275 | - `HYDRATING_COMPONENT`: Generating component props
276 | - `STREAMING_RESPONSE`: Actively streaming
277 | - `COMPLETE`: Generation finished
278 | - `ERROR`: Error occurred
--------------------------------------------------------------------------------
/src/components/tambo/markdown-components.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { cn } from "@/lib/utils";
5 | import { Copy, Check, ExternalLink } from "lucide-react";
6 | import hljs from "highlight.js";
7 | import "highlight.js/styles/github.css";
8 | import DOMPurify from "dompurify";
9 |
10 | /**
11 | * Markdown Components for Streamdown
12 | *
13 | * This module provides customized components for rendering markdown content with syntax highlighting.
14 | * It uses highlight.js for code syntax highlighting and supports streaming content updates.
15 | *
16 | * @example
17 | * ```tsx
18 | * import { createMarkdownComponents } from './markdown-components';
19 | * import { Streamdown } from 'streamdown';
20 | *
21 | * const MarkdownRenderer = ({ content }) => {
22 | * const components = createMarkdownComponents();
23 | * return {content} ;
24 | * };
25 | * ```
26 | */
27 |
28 | /**
29 | * Determines if a text block looks like code based on common code patterns
30 | * @param text - The text to analyze
31 | * @returns boolean indicating if the text appears to be code
32 | */
33 | const looksLikeCode = (text: string): boolean => {
34 | const codeIndicators = [
35 | /^import\s+/m,
36 | /^function\s+/m,
37 | /^class\s+/m,
38 | /^const\s+/m,
39 | /^let\s+/m,
40 | /^var\s+/m,
41 | /[{}[\]();]/,
42 | /^\s*\/\//m,
43 | /^\s*\/\*/m,
44 | /=>/,
45 | /^export\s+/m,
46 | ];
47 | return codeIndicators.some((pattern) => pattern.test(text));
48 | };
49 |
50 | /**
51 | * Header component for code blocks with language display and copy functionality
52 | */
53 | const CodeHeader = ({
54 | language,
55 | code,
56 | }: {
57 | language?: string;
58 | code?: string;
59 | }) => {
60 | const [copied, setCopied] = React.useState(false);
61 |
62 | const copyToClipboard = () => {
63 | if (!code) return;
64 | navigator.clipboard.writeText(code);
65 | setCopied(true);
66 | setTimeout(() => setCopied(false), 2000);
67 | };
68 |
69 | return (
70 |
71 | {language}
72 |
77 | {!copied ? (
78 |
79 | ) : (
80 |
81 | )}
82 |
83 |
84 | );
85 | };
86 |
87 | /**
88 | * Creates a set of components for use with streamdown
89 | * @returns Components object for streamdown
90 | */
91 | export const createMarkdownComponents = (): Record<
92 | string,
93 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
94 | React.ComponentType
95 | > => ({
96 | code: function Code({ className, children, ...props }) {
97 | const match = /language-(\w+)/.exec(className ?? "");
98 | const content = String(children).replace(/\n$/, "");
99 | const deferredContent = React.useDeferredValue(content);
100 |
101 | const highlighted = React.useMemo(() => {
102 | if (!match || !looksLikeCode(deferredContent)) return null;
103 | try {
104 | return hljs.highlight(deferredContent, { language: match[1] }).value;
105 | } catch {
106 | return deferredContent;
107 | }
108 | }, [deferredContent, match]);
109 |
110 | if (match && looksLikeCode(content)) {
111 | return (
112 |
132 | );
133 | }
134 |
135 | return (
136 |
140 | {children}
141 |
142 | );
143 | },
144 |
145 | /**
146 | * Paragraph component with minimal vertical margin
147 | */
148 | p: ({ children }) => {children}
,
149 |
150 | /**
151 | * Heading 1 component with large text and proper spacing
152 | * Used for main section headers
153 | */
154 | h1: ({ children }) => (
155 | {children}
156 | ),
157 |
158 | /**
159 | * Heading 2 component for subsection headers
160 | * Slightly smaller than h1 with adjusted spacing
161 | */
162 | h2: ({ children }) => (
163 | {children}
164 | ),
165 |
166 | /**
167 | * Heading 3 component for minor sections
168 | * Used for smaller subdivisions within h2 sections
169 | */
170 | h3: ({ children }) => (
171 | {children}
172 | ),
173 |
174 | /**
175 | * Heading 4 component for the smallest section divisions
176 | * Maintains consistent text size with adjusted spacing
177 | */
178 | h4: ({ children }) => (
179 | {children}
180 | ),
181 |
182 | /**
183 | * Unordered list component with disc-style bullets
184 | * Indented from the left margin
185 | */
186 | ul: ({ children }) => ,
187 |
188 | /**
189 | * Ordered list component with decimal numbering
190 | * Indented from the left margin
191 | */
192 | ol: ({ children }) => {children} ,
193 |
194 | /**
195 | * List item component with normal line height
196 | * Used within both ordered and unordered lists
197 | */
198 | li: ({ children }) => {children} ,
199 |
200 | /**
201 | * Blockquote component for quoted content
202 | * Features a left border and italic text with proper spacing
203 | */
204 | blockquote: ({ children }) => (
205 |
206 | {children}
207 |
208 | ),
209 |
210 | /**
211 | * Anchor component for links
212 | * Opens links in new tab with security attributes
213 | * Includes hover underline effect
214 | */
215 | a: ({ href, children }) => (
216 |
222 | {children}
223 |
224 |
225 | ),
226 |
227 | /**
228 | * Horizontal rule component
229 | * Creates a visual divider with proper spacing
230 | */
231 | hr: () => ,
232 |
233 | /**
234 | * Table container component
235 | * Handles overflow for wide tables with proper spacing
236 | */
237 | table: ({ children }) => (
238 |
241 | ),
242 |
243 | /**
244 | * Table header cell component
245 | * Features bold text and distinct background
246 | */
247 | th: ({ children }) => (
248 |
249 | {children}
250 |
251 | ),
252 |
253 | /**
254 | * Table data cell component
255 | * Consistent styling with header cells
256 | */
257 | td: ({ children }) => (
258 | {children}
259 | ),
260 | });
261 |
262 | /**
263 | * Pre-created markdown components instance for use across the application.
264 | */
265 | export const markdownComponents = createMarkdownComponents();
266 |
--------------------------------------------------------------------------------
/src/components/ui/formula-autocomplete.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { cn } from "@/lib/utils";
5 | import type { FunctionInfo } from "@/types/formula-autocomplete";
6 |
7 | // ============================================
8 | // Types
9 | // ============================================
10 |
11 | export interface FormulaAutocompleteProps {
12 | suggestions: FunctionInfo[];
13 | selectedIndex: number;
14 | onSelect: (functionName: string) => void;
15 | onClose: () => void;
16 | position?: { top: number; left: number };
17 | className?: string;
18 | }
19 |
20 | // ============================================
21 | // Formula Autocomplete Component
22 | // ============================================
23 |
24 | /**
25 | * Autocomplete dropdown component for formula functions.
26 | *
27 | * Displays a scrollable list of function suggestions with keyboard navigation
28 | * and signature hints. Integrates with the formula autocomplete hook.
29 | *
30 | * @component
31 | * @example
32 | * ```tsx
33 | * insertFunction(name)}
37 | * onClose={() => setOpen(false)}
38 | * position={{ top: 100, left: 50 }}
39 | * />
40 | * ```
41 | */
42 | export const FormulaAutocomplete = React.forwardRef<
43 | HTMLDivElement,
44 | FormulaAutocompleteProps
45 | >(({ suggestions, selectedIndex, onSelect, onClose, position, className }, ref) => {
46 | const listRef = React.useRef(null);
47 | const itemRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
48 |
49 | // Scroll selected item into view
50 | React.useEffect(() => {
51 | if (itemRefs.current[selectedIndex]) {
52 | itemRefs.current[selectedIndex]?.scrollIntoView({
53 | block: "nearest",
54 | behavior: "smooth",
55 | });
56 | }
57 | }, [selectedIndex]);
58 |
59 | // Close on click outside
60 | React.useEffect(() => {
61 | const handleClickOutside = (e: MouseEvent) => {
62 | if (listRef.current && !listRef.current.contains(e.target as Node)) {
63 | onClose();
64 | }
65 | };
66 |
67 | document.addEventListener("mousedown", handleClickOutside);
68 | return () => document.removeEventListener("mousedown", handleClickOutside);
69 | }, [onClose]);
70 |
71 | if (suggestions.length === 0) {
72 | return null;
73 | }
74 |
75 | const selectedFunction = suggestions[selectedIndex];
76 |
77 | return (
78 | {
91 | if (event.key === "Escape") {
92 | event.preventDefault();
93 | onClose();
94 | }
95 | }}
96 | role="dialog"
97 | aria-label="Formula function autocomplete"
98 | >
99 | {/* Scrollable suggestions list */}
100 |
106 | {suggestions.map((func, index) => {
107 | const isSelected = index === selectedIndex;
108 |
109 | return (
110 |
{
113 | itemRefs.current[index] = el;
114 | }}
115 | onClick={(e) => {
116 | e.preventDefault();
117 | onSelect(func.name);
118 | }}
119 | className={cn(
120 | "w-full text-left px-3 py-2 rounded-sm transition-colors",
121 | "focus:outline-none",
122 | isSelected
123 | ? "bg-accent text-accent-foreground"
124 | : "hover:bg-muted/50 text-popover-foreground"
125 | )}
126 | role="option"
127 | aria-selected={isSelected}
128 | tabIndex={-1}
129 | >
130 | {/* Function name */}
131 |
132 |
133 | {func.name}
134 |
135 | {func.category && (
136 |
137 | {func.category}
138 |
139 | )}
140 |
141 |
142 | {/* Description */}
143 |
144 | {func.description}
145 |
146 |
147 | );
148 | })}
149 |
150 |
151 | {/* Signature hint for selected function */}
152 | {selectedFunction && (
153 |
154 | {/* Signature */}
155 |
156 | {selectedFunction.signature}
157 |
158 |
159 | {/* Example (if available) */}
160 | {selectedFunction.example && (
161 |
162 | Example:
163 | {selectedFunction.example}
164 |
165 | )}
166 |
167 | {/* Keyboard hints */}
168 |
169 |
170 |
171 | Enter
172 |
173 | or
174 |
175 | Tab
176 |
177 | to insert
178 |
179 |
180 |
181 | Esc
182 |
183 | to close
184 |
185 |
186 |
187 | )}
188 |
189 | );
190 | });
191 |
192 | FormulaAutocomplete.displayName = "FormulaAutocomplete";
193 |
194 | // ============================================
195 | // Utility Component: Tooltip for Function Info
196 | // ============================================
197 |
198 | /**
199 | * Optional tooltip component for showing function details on hover
200 | * This can be used separately if you want hover tooltips in addition to the autocomplete
201 | */
202 | export interface FunctionTooltipProps {
203 | functionInfo: FunctionInfo;
204 | children: React.ReactNode;
205 | }
206 |
207 | export const FunctionTooltip: React.FC = ({
208 | functionInfo,
209 | children,
210 | }) => {
211 | const [isOpen, setIsOpen] = React.useState(false);
212 |
213 | return (
214 | setIsOpen(true)}
217 | onMouseLeave={() => setIsOpen(false)}
218 | >
219 | {children}
220 | {isOpen && (
221 |
228 |
229 | {functionInfo.name}
230 |
231 |
232 | {functionInfo.description}
233 |
234 |
235 | {functionInfo.signature}
236 |
237 | {functionInfo.example && (
238 |
239 | Example:
240 | {functionInfo.example}
241 |
242 | )}
243 |
244 | )}
245 |
246 | );
247 | };
248 |
--------------------------------------------------------------------------------
/src/lib/fortune-sheet-utils.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import type { Cell, CellWithRowAndCol, Sheet } from "@fortune-sheet/core";
4 |
5 | export type ParsedCellReference = { row: number; col: number };
6 | export type ParsedRange = {
7 | start: ParsedCellReference;
8 | end: ParsedCellReference;
9 | };
10 |
11 | export function columnIndexToLetter(index: number): string {
12 | if (index < 0) {
13 | throw new Error(`Column index must be non-negative. Received: ${index}`);
14 | }
15 | let letter = "";
16 | let current = index;
17 | while (current >= 0) {
18 | letter = String.fromCharCode((current % 26) + 65) + letter;
19 | current = Math.floor(current / 26) - 1;
20 | }
21 | return letter;
22 | }
23 |
24 | export function letterToColumnIndex(letter: string): number {
25 | if (!/^[A-Z]+$/.test(letter)) {
26 | throw new Error(`Invalid column label: ${letter}`);
27 | }
28 | let index = 0;
29 | for (let i = 0; i < letter.length; i++) {
30 | index = index * 26 + (letter.charCodeAt(i) - 64);
31 | }
32 | return index - 1;
33 | }
34 |
35 | export function parseCellReference(ref: string): ParsedCellReference {
36 | const trimmed = ref.trim().toUpperCase();
37 | const match = trimmed.match(/^([A-Z]+)(\d+)$/);
38 | if (!match) {
39 | throw new Error(`Invalid cell reference: ${ref}`);
40 | }
41 | const [, columnLetters, rowDigits] = match;
42 | const rowNumber = Number(rowDigits);
43 | if (!Number.isFinite(rowNumber) || rowNumber < 1) {
44 | throw new Error(
45 | `Invalid row number in cell reference: ${ref}. Row numbers must be >= 1`
46 | );
47 | }
48 | return {
49 | col: letterToColumnIndex(columnLetters),
50 | row: rowNumber - 1,
51 | };
52 | }
53 |
54 | export function parseRangeReference(range: string): ParsedRange {
55 | const trimmed = range.trim();
56 | if (!trimmed) {
57 | throw new Error("Range cannot be empty");
58 | }
59 | const parts = trimmed.split(":");
60 | if (parts.length > 2) {
61 | throw new Error(`Invalid range format: ${range}`);
62 | }
63 | const startRef = parseCellReference(parts[0]);
64 | const endRef = parts[1]
65 | ? parseCellReference(parts[1])
66 | : parseCellReference(parts[0]);
67 |
68 | const startRow = Math.min(startRef.row, endRef.row);
69 | const endRow = Math.max(startRef.row, endRef.row);
70 | const startCol = Math.min(startRef.col, endRef.col);
71 | const endCol = Math.max(startRef.col, endRef.col);
72 |
73 | return {
74 | start: { row: startRow, col: startCol },
75 | end: { row: endRow, col: endCol },
76 | };
77 | }
78 |
79 | export function getSheetRowCount(sheet: Sheet): number {
80 | if (typeof sheet.row === "number" && sheet.row > 0) {
81 | return sheet.row;
82 | }
83 | if (Array.isArray(sheet.data)) {
84 | return sheet.data.length;
85 | }
86 | if (Array.isArray(sheet.celldata) && sheet.celldata.length > 0) {
87 | const maxRow = Math.max(
88 | ...sheet.celldata.map((cell) => cell.r ?? 0),
89 | 0
90 | );
91 | return maxRow + 1;
92 | }
93 | return 0;
94 | }
95 |
96 | export function getSheetColumnCount(sheet: Sheet): number {
97 | if (typeof sheet.column === "number" && sheet.column > 0) {
98 | return sheet.column;
99 | }
100 | if (Array.isArray(sheet.data) && sheet.data.length > 0) {
101 | const rowWithValues = sheet.data.find((row) => Array.isArray(row));
102 | return rowWithValues ? rowWithValues.length : 0;
103 | }
104 | if (Array.isArray(sheet.celldata) && sheet.celldata.length > 0) {
105 | const maxCol = Math.max(
106 | ...sheet.celldata.map((cell) => cell.c ?? 0),
107 | 0
108 | );
109 | return maxCol + 1;
110 | }
111 | return 0;
112 | }
113 |
114 | export function buildCelldataLookup(sheet: Sheet): Map {
115 | const lookup = new Map();
116 |
117 | if (Array.isArray(sheet.data)) {
118 | sheet.data.forEach((row, rowIdx) => {
119 | if (!Array.isArray(row)) return;
120 | row.forEach((cell, colIdx) => {
121 | lookup.set(`${rowIdx}:${colIdx}`, cell ?? null);
122 | });
123 | });
124 | }
125 |
126 | if (Array.isArray(sheet.celldata)) {
127 | sheet.celldata.forEach((cell) => {
128 | const key = `${cell.r}:${cell.c}`;
129 | lookup.set(key, cell.v ?? null);
130 | });
131 | }
132 |
133 | return lookup;
134 | }
135 |
136 | export function getCellFromLookup(
137 | lookup: Map,
138 | row: number,
139 | col: number
140 | ): Cell | null {
141 | return lookup.get(`${row}:${col}`) ?? null;
142 | }
143 |
144 | export function toCellWithRowAndCol(
145 | row: number,
146 | col: number,
147 | cell: Cell | null
148 | ): CellWithRowAndCol {
149 | return {
150 | r: row,
151 | c: col,
152 | v: cell,
153 | };
154 | }
155 |
156 | export type CelldataLookup = Map;
157 |
158 | /**
159 | * Extract function names from a formula string.
160 | * Matches function names that are followed by an opening parenthesis.
161 | * @param formula - The formula string to parse (e.g., "=SUM(A1:A10)+AVERAGE(B1:B10)")
162 | * @returns Array of function names found in the formula (e.g., ["SUM", "AVERAGE"])
163 | */
164 | export function extractFunctions(formula: string): string[] {
165 | if (!formula || typeof formula !== "string") {
166 | return [];
167 | }
168 |
169 | // Match function names: word characters followed by opening parenthesis
170 | // Regex pattern: one or more word characters followed by (
171 | const functionPattern = /\b([A-Z_][A-Z0-9_]*)\s*\(/gi;
172 | const matches = formula.matchAll(functionPattern);
173 | const functions: string[] = [];
174 |
175 | for (const match of matches) {
176 | if (match[1]) {
177 | functions.push(match[1].toUpperCase());
178 | }
179 | }
180 |
181 | return functions;
182 | }
183 |
184 | /**
185 | * Check a formula for basic syntax issues.
186 | * Validates:
187 | * - Balanced parentheses
188 | * - Valid cell references (basic format check)
189 | * - Formula starts with =
190 | * @param formula - The formula string to check
191 | * @returns Array of error messages (empty array if no issues found)
192 | */
193 | export function checkSyntax(formula: string): string[] {
194 | const errors: string[] = [];
195 |
196 | if (!formula || typeof formula !== "string") {
197 | errors.push("Formula is empty or not a string");
198 | return errors;
199 | }
200 |
201 | const trimmed = formula.trim();
202 |
203 | // Check if formula starts with =
204 | if (!trimmed.startsWith("=")) {
205 | errors.push("Formula must start with '='");
206 | }
207 |
208 | // Check for balanced parentheses
209 | let parenthesesCount = 0;
210 | for (let i = 0; i < trimmed.length; i++) {
211 | if (trimmed[i] === "(") {
212 | parenthesesCount++;
213 | } else if (trimmed[i] === ")") {
214 | parenthesesCount--;
215 | if (parenthesesCount < 0) {
216 | errors.push("Unmatched closing parenthesis");
217 | break;
218 | }
219 | }
220 | }
221 |
222 | if (parenthesesCount > 0) {
223 | errors.push("Unmatched opening parenthesis");
224 | }
225 |
226 | // Check for invalid cell references
227 | // Look for patterns that might be cell references but are malformed
228 | const invalidReferencePattern = /\b([A-Z]+\d*[A-Z]+|\d+[A-Z]+)\b/g;
229 | const invalidMatches = trimmed.matchAll(invalidReferencePattern);
230 |
231 | for (const match of invalidMatches) {
232 | // Only flag as error if it looks like it was meant to be a cell reference
233 | if (match[1] && !/^[A-Z]+\d+$/.test(match[1])) {
234 | const possibleRef = match[1];
235 | // Check if it has mixed letters and numbers in wrong order
236 | if (/\d.*[A-Z]/.test(possibleRef)) {
237 | errors.push(`Invalid cell reference format: ${possibleRef}`);
238 | }
239 | }
240 | }
241 |
242 | return errors;
243 | }
244 |
245 | /**
246 | * Find the actual extent of data in a spreadsheet by examining all cells.
247 | * Returns the top-left and bottom-right corners of the data region.
248 | * @param lookup - Map of cell coordinates to cell data
249 | * @returns Object with start and end cell references (e.g., {start: "A1", end: "C10"}), or null if no data
250 | */
251 | export function findDataExtent(
252 | lookup: CelldataLookup
253 | ): { start: string; end: string } | null {
254 | if (lookup.size === 0) {
255 | return null;
256 | }
257 |
258 | let minRow = Infinity;
259 | let maxRow = -Infinity;
260 | let minCol = Infinity;
261 | let maxCol = -Infinity;
262 | let hasData = false;
263 |
264 | // Iterate through all cells in the lookup
265 | for (const [key, cell] of lookup.entries()) {
266 | // Skip empty cells
267 | if (!cell) continue;
268 |
269 | // Parse the key format "row:col"
270 | const [rowStr, colStr] = key.split(":");
271 | const row = parseInt(rowStr, 10);
272 | const col = parseInt(colStr, 10);
273 |
274 | if (isNaN(row) || isNaN(col)) continue;
275 |
276 | // Update bounds
277 | minRow = Math.min(minRow, row);
278 | maxRow = Math.max(maxRow, row);
279 | minCol = Math.min(minCol, col);
280 | maxCol = Math.max(maxCol, col);
281 | hasData = true;
282 | }
283 |
284 | if (!hasData || minRow === Infinity || minCol === Infinity) {
285 | return null;
286 | }
287 |
288 | // Convert to A1 notation
289 | const startCol = columnIndexToLetter(minCol);
290 | const endCol = columnIndexToLetter(maxCol);
291 | const startRow = minRow + 1; // Convert 0-indexed to 1-indexed
292 | const endRow = maxRow + 1;
293 |
294 | return {
295 | start: `${startCol}${startRow}`,
296 | end: `${endCol}${endRow}`,
297 | };
298 | }
299 |
--------------------------------------------------------------------------------
/src/hooks/use-formula-autocomplete.ts:
--------------------------------------------------------------------------------
1 | import { useState, useMemo, useCallback } from "react";
2 |
3 | /**
4 | * Formula function information structure
5 | * This should match the FunctionInfo type from formula-autocomplete.ts
6 | */
7 | export interface FunctionInfo {
8 | name: string;
9 | signature: string;
10 | description: string;
11 | category?: string;
12 | example?: string;
13 | }
14 |
15 | /**
16 | * Hook return value interface
17 | */
18 | export interface AutocompleteResult {
19 | isOpen: boolean;
20 | suggestions: FunctionInfo[];
21 | selectedIndex: number;
22 | selectNext: () => void;
23 | selectPrevious: () => void;
24 | selectCurrent: () => string;
25 | close: () => void;
26 | }
27 |
28 | /**
29 | * Extract the word being typed at the cursor position
30 | * Returns the partial function name if the cursor is positioned to type one
31 | *
32 | * @param text - Full input text
33 | * @param position - Current cursor position
34 | * @returns The word at cursor or empty string
35 | */
36 | function extractWordAtCursor(text: string, position: number): string {
37 | // Find the start of the current word (look backwards from cursor)
38 | let start = position - 1;
39 | while (start >= 0 && /[A-Za-z_]/.test(text[start])) {
40 | start--;
41 | }
42 | start++; // Move to first character of word
43 |
44 | // Find the end of the current word (look forwards from cursor)
45 | let end = position;
46 | while (end < text.length && /[A-Za-z_]/.test(text[end])) {
47 | end++;
48 | }
49 |
50 | return text.slice(start, end);
51 | }
52 |
53 | /**
54 | * Check if the cursor is in a valid position for function name autocomplete
55 | * Valid positions are:
56 | * - Immediately after "=" (start of formula)
57 | * - After operators: +, -, *, /, ^, &, >, <, =
58 | * - After opening parenthesis (
59 | * - After comma ,
60 | * Invalid positions:
61 | * - Inside string literals (between quotes)
62 | * - Inside existing function names that are already complete
63 | * - After closing parenthesis without an operator
64 | * - Inside function parentheses (argument lists)
65 | *
66 | * @param text - Formula text
67 | * @param position - Cursor position
68 | * @returns true if autocomplete should be shown
69 | */
70 | function isInFunctionContext(text: string, position: number): boolean {
71 | // Must start with "=" for formulas
72 | if (!text.startsWith("=")) {
73 | return false;
74 | }
75 |
76 | // Don't show autocomplete if cursor is at position 0 or before "="
77 | if (position <= 1) {
78 | return true; // Allow autocomplete right after "="
79 | }
80 |
81 | // Check if we're inside a string literal
82 | let inString = false;
83 | let stringChar = "";
84 | for (let i = 1; i < position; i++) {
85 | const char = text[i];
86 | if (char === '"' || char === "'") {
87 | if (!inString) {
88 | inString = true;
89 | stringChar = char;
90 | } else if (char === stringChar) {
91 | // Check if escaped
92 | if (i > 0 && text[i - 1] !== "\\") {
93 | inString = false;
94 | stringChar = "";
95 | }
96 | }
97 | }
98 | }
99 |
100 | // Don't show autocomplete inside strings
101 | if (inString) {
102 | return false;
103 | }
104 |
105 | // Check if cursor is inside function parentheses
106 | // Count opening and closing parentheses before cursor position
107 | const beforeCursor = text.slice(0, position);
108 | const openParens = (beforeCursor.match(/\(/g) || []).length;
109 | const closeParens = (beforeCursor.match(/\)/g) || []).length;
110 |
111 | if (openParens > closeParens) {
112 | // Inside function arguments, don't show autocomplete
113 | return false;
114 | }
115 |
116 | // Look at the character immediately before the current word
117 | const currentWord = extractWordAtCursor(text, position);
118 | const wordStart = position - currentWord.length;
119 |
120 | // If we have no current word and cursor is not after a valid trigger, don't show
121 | if (currentWord.length === 0 && wordStart > 1) {
122 | const charBefore = text[wordStart - 1];
123 | // Valid trigger characters: operators, parentheses, comma
124 | const validTriggers = /[=+\-*/^&><,(]/;
125 | if (!validTriggers.test(charBefore)) {
126 | return false;
127 | }
128 | }
129 |
130 | // Check if we're after a function name that's already complete
131 | // (i.e., there's a parenthesis right after the word)
132 | const charAfterWord = text[position];
133 | if (charAfterWord === "(") {
134 | // If there's already an opening paren, the function name is complete
135 | return false;
136 | }
137 |
138 | return true;
139 | }
140 |
141 | /**
142 | * Search for functions matching the query
143 | * This is a placeholder that will use searchFunctions from formula-functions.ts once available
144 | *
145 | * @param query - Search query (partial function name)
146 | * @returns Array of matching functions
147 | */
148 | function searchFunctions(query: string): FunctionInfo[] {
149 | // TODO: Replace this with import from formula-functions.ts when available
150 | // import { searchFunctions } from "@/lib/formula-functions";
151 |
152 | // For now, return a mock list of common Excel functions
153 | const mockFunctions: FunctionInfo[] = [
154 | {
155 | name: "SUM",
156 | category: "Math",
157 | description: "Adds all the numbers in a range",
158 | signature: "SUM(number1, [number2], ...)",
159 | example: "=SUM(A1:A10)",
160 | },
161 | {
162 | name: "AVERAGE",
163 | category: "Statistical",
164 | description: "Returns the average of its arguments",
165 | signature: "AVERAGE(number1, [number2], ...)",
166 | example: "=AVERAGE(A1:A10)",
167 | },
168 | {
169 | name: "COUNT",
170 | category: "Statistical",
171 | description: "Counts the number of cells that contain numbers",
172 | signature: "COUNT(value1, [value2], ...)",
173 | example: "=COUNT(A1:A10)",
174 | },
175 | {
176 | name: "MAX",
177 | category: "Statistical",
178 | description: "Returns the largest value in a set",
179 | signature: "MAX(number1, [number2], ...)",
180 | example: "=MAX(A1:A10)",
181 | },
182 | {
183 | name: "MIN",
184 | category: "Statistical",
185 | description: "Returns the smallest value in a set",
186 | signature: "MIN(number1, [number2], ...)",
187 | example: "=MIN(A1:A10)",
188 | },
189 | {
190 | name: "IF",
191 | category: "Logical",
192 | description: "Returns one value if a condition is true and another if false",
193 | signature: "IF(logical_test, value_if_true, [value_if_false])",
194 | example: "=IF(A1>10, \"High\", \"Low\")",
195 | },
196 | {
197 | name: "VLOOKUP",
198 | category: "Lookup",
199 | description: "Searches for a value in the first column and returns a value in the same row",
200 | signature: "VLOOKUP(lookup_value, table_array, col_index_num, [range_lookup])",
201 | example: "=VLOOKUP(A1, B1:D10, 2, FALSE)",
202 | },
203 | {
204 | name: "CONCATENATE",
205 | category: "Text",
206 | description: "Joins several text strings into one",
207 | signature: "CONCATENATE(text1, [text2], ...)",
208 | example: "=CONCATENATE(A1, \" \", B1)",
209 | },
210 | {
211 | name: "LEN",
212 | category: "Text",
213 | description: "Returns the number of characters in a text string",
214 | signature: "LEN(text)",
215 | example: "=LEN(A1)",
216 | },
217 | {
218 | name: "ROUND",
219 | category: "Math",
220 | description: "Rounds a number to a specified number of digits",
221 | signature: "ROUND(number, num_digits)",
222 | example: "=ROUND(A1, 2)",
223 | },
224 | ];
225 |
226 | // Case-insensitive search
227 | const lowerQuery = query.toLowerCase();
228 |
229 | if (!lowerQuery) {
230 | // Return all functions if query is empty, limited to 10
231 | return mockFunctions.slice(0, 10);
232 | }
233 |
234 | // Filter and sort by relevance
235 | const matches = mockFunctions.filter((fn) =>
236 | fn.name.toLowerCase().includes(lowerQuery)
237 | );
238 |
239 | // Sort: exact prefix matches first, then other matches
240 | matches.sort((a, b) => {
241 | const aStartsWith = a.name.toLowerCase().startsWith(lowerQuery);
242 | const bStartsWith = b.name.toLowerCase().startsWith(lowerQuery);
243 |
244 | if (aStartsWith && !bStartsWith) return -1;
245 | if (!aStartsWith && bStartsWith) return 1;
246 |
247 | // Both start with query or both don't, sort alphabetically
248 | return a.name.localeCompare(b.name);
249 | });
250 |
251 | // Limit to 10 suggestions
252 | return matches.slice(0, 10);
253 | }
254 |
255 | /**
256 | * Custom hook for formula autocomplete functionality
257 | *
258 | * Provides intelligent autocomplete suggestions for formula functions based on:
259 | * - Current input value and cursor position
260 | * - Context awareness (inside strings, after operators, etc.)
261 | * - Keyboard navigation state
262 | *
263 | * @param inputValue - Current value of the formula input
264 | * @param cursorPosition - Current cursor position in the input
265 | * @returns Autocomplete state and control functions
266 | *
267 | * @example
268 | * ```tsx
269 | * const autocomplete = useFormulaAutocomplete(formula, cursorPos);
270 | *
271 | * // Show dropdown if autocomplete.isOpen
272 | * // Display autocomplete.suggestions
273 | * // Highlight autocomplete.selectedIndex
274 | * // Navigate with autocomplete.selectNext/selectPrevious
275 | * // Insert with autocomplete.selectCurrent()
276 | * ```
277 | */
278 | export function useFormulaAutocomplete(
279 | inputValue: string,
280 | cursorPosition: number
281 | ): AutocompleteResult {
282 | // Track manual close action separately
283 | const [manuallyClosedKey, setManuallyClosedKey] = useState("");
284 |
285 | // Compute suggestions based on input and cursor position
286 | const computedSuggestions = useMemo(() => {
287 | // Check if we should show autocomplete
288 | if (!isInFunctionContext(inputValue, cursorPosition)) {
289 | return [];
290 | }
291 |
292 | // Extract the current word being typed
293 | const currentWord = extractWordAtCursor(inputValue, cursorPosition);
294 |
295 | // Search for matching functions
296 | const matches = searchFunctions(currentWord);
297 |
298 | return matches;
299 | }, [inputValue, cursorPosition]);
300 |
301 | // Create a unique key for the current suggestions
302 | const suggestionsKey = useMemo(() => {
303 | return `${inputValue}-${cursorPosition}-${computedSuggestions.length}`;
304 | }, [inputValue, cursorPosition, computedSuggestions.length]);
305 |
306 | // Derive if the dropdown was manually closed for this specific query
307 | const isManuallyClosed = manuallyClosedKey === suggestionsKey;
308 |
309 | // Derive isOpen from suggestions (can be overridden by manual close)
310 | const isOpen = computedSuggestions.length > 0 && !isManuallyClosed;
311 |
312 | // Track selected index and the key it's associated with
313 | const [selectionState, setSelectionState] = useState({
314 | index: 0,
315 | forKey: suggestionsKey,
316 | });
317 |
318 | // Derive the selected index - reset to 0 when suggestions change
319 | const selectedIndex = selectionState.forKey === suggestionsKey ? selectionState.index : 0;
320 |
321 | // Navigation: select next suggestion
322 | const selectNext = useCallback(() => {
323 | setSelectionState((prev) => ({
324 | index: prev.index < computedSuggestions.length - 1 ? prev.index + 1 : 0,
325 | forKey: suggestionsKey,
326 | }));
327 | }, [computedSuggestions.length, suggestionsKey]);
328 |
329 | // Navigation: select previous suggestion
330 | const selectPrevious = useCallback(() => {
331 | setSelectionState((prev) => ({
332 | index: prev.index > 0 ? prev.index - 1 : computedSuggestions.length - 1,
333 | forKey: suggestionsKey,
334 | }));
335 | }, [computedSuggestions.length, suggestionsKey]);
336 |
337 | // Selection: return the currently selected function name
338 | const selectCurrent = useCallback((): string => {
339 | if (computedSuggestions.length === 0 || selectedIndex < 0) {
340 | return "";
341 | }
342 |
343 | const selected = computedSuggestions[selectedIndex];
344 | return selected ? selected.name : "";
345 | }, [computedSuggestions, selectedIndex]);
346 |
347 | // Close autocomplete dropdown
348 | const close = useCallback(() => {
349 | setManuallyClosedKey(suggestionsKey);
350 | }, [suggestionsKey]);
351 |
352 | return {
353 | isOpen,
354 | suggestions: computedSuggestions,
355 | selectedIndex,
356 | selectNext,
357 | selectPrevious,
358 | selectCurrent,
359 | close,
360 | };
361 | }
362 |
--------------------------------------------------------------------------------
/src/components/tambo/message-suggestions.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { MessageGenerationStage } from "@/components/tambo/message-generation-stage";
4 | import {
5 | Tooltip,
6 | TooltipProvider,
7 | } from "@/components/tambo/suggestions-tooltip";
8 | import { cn } from "@/lib/utils";
9 | import type { Suggestion, TamboThread } from "@tambo-ai/react";
10 | import { useTambo, useTamboSuggestions } from "@tambo-ai/react";
11 | import { Loader2Icon } from "lucide-react";
12 | import * as React from "react";
13 | import { useEffect, useRef } from "react";
14 |
15 | /**
16 | * @typedef MessageSuggestionsContextValue
17 | * @property {Array} suggestions - Array of suggestion objects
18 | * @property {string|null} selectedSuggestionId - ID of the currently selected suggestion
19 | * @property {function} accept - Function to accept a suggestion
20 | * @property {boolean} isGenerating - Whether suggestions are being generated
21 | * @property {Error|null} error - Any error from generation
22 | * @property {object} thread - The current Tambo thread
23 | */
24 | interface MessageSuggestionsContextValue {
25 | suggestions: Suggestion[];
26 | selectedSuggestionId: string | null;
27 | accept: (options: { suggestion: Suggestion }) => void;
28 | isGenerating: boolean;
29 | error: Error | null;
30 | thread: TamboThread;
31 | isMac: boolean;
32 | }
33 |
34 | /**
35 | * React Context for sharing suggestion data and functions among sub-components.
36 | * @internal
37 | */
38 | const MessageSuggestionsContext =
39 | React.createContext(null);
40 |
41 | /**
42 | * Hook to access the message suggestions context.
43 | * @returns {MessageSuggestionsContextValue} The message suggestions context value.
44 | * @throws {Error} If used outside of MessageSuggestions.
45 | * @internal
46 | */
47 | const useMessageSuggestionsContext = () => {
48 | const context = React.useContext(MessageSuggestionsContext);
49 | if (!context) {
50 | throw new Error(
51 | "MessageSuggestions sub-components must be used within a MessageSuggestions",
52 | );
53 | }
54 | return context;
55 | };
56 |
57 | /**
58 | * Props for the MessageSuggestions component.
59 | * Extends standard HTMLDivElement attributes.
60 | */
61 | export interface MessageSuggestionsProps
62 | extends React.HTMLAttributes {
63 | /** Maximum number of suggestions to display (default: 3) */
64 | maxSuggestions?: number;
65 | /** The child elements to render within the container. */
66 | children?: React.ReactNode;
67 | /** Pre-seeded suggestions to display initially */
68 | initialSuggestions?: Suggestion[];
69 | }
70 |
71 | /**
72 | * The root container for message suggestions.
73 | * It establishes the context for its children and handles overall state management.
74 | * @component MessageSuggestions
75 | * @example
76 | * ```tsx
77 | *
78 | *
79 | *
80 | *
81 | * ```
82 | */
83 | const MessageSuggestions = React.forwardRef<
84 | HTMLDivElement,
85 | MessageSuggestionsProps
86 | >(
87 | (
88 | {
89 | children,
90 | className,
91 | maxSuggestions = 3,
92 | initialSuggestions = [],
93 | ...props
94 | },
95 | ref,
96 | ) => {
97 | const { thread } = useTambo();
98 | const {
99 | suggestions: generatedSuggestions,
100 | selectedSuggestionId,
101 | accept,
102 | generateResult: { isPending: isGenerating, error },
103 | } = useTamboSuggestions({ maxSuggestions });
104 |
105 | // Combine initial and generated suggestions, but only use initial ones when thread is empty
106 | const suggestions = React.useMemo(() => {
107 | // Only use pre-seeded suggestions if thread is empty
108 | if (!thread?.messages?.length && initialSuggestions.length > 0) {
109 | return initialSuggestions.slice(0, maxSuggestions);
110 | }
111 | // Otherwise use generated suggestions
112 | return generatedSuggestions;
113 | }, [
114 | thread?.messages?.length,
115 | generatedSuggestions,
116 | initialSuggestions,
117 | maxSuggestions,
118 | ]);
119 |
120 | const isMac =
121 | typeof navigator !== "undefined" && navigator.platform.startsWith("Mac");
122 |
123 | // Track the last AI message ID to detect new messages
124 | const lastAiMessageIdRef = useRef(null);
125 | const loadingTimeoutRef = useRef(null);
126 |
127 | const contextValue = React.useMemo(
128 | () => ({
129 | suggestions,
130 | selectedSuggestionId,
131 | accept,
132 | isGenerating,
133 | error,
134 | thread,
135 | isMac,
136 | }),
137 | [
138 | suggestions,
139 | selectedSuggestionId,
140 | accept,
141 | isGenerating,
142 | error,
143 | thread,
144 | isMac,
145 | ],
146 | );
147 |
148 | // Find the last AI message
149 | const lastAiMessage = thread?.messages
150 | ? [...thread.messages].reverse().find((msg) => msg.role === "assistant")
151 | : null;
152 |
153 | // When a new AI message appears, update the reference
154 | useEffect(() => {
155 | if (lastAiMessage && lastAiMessage.id !== lastAiMessageIdRef.current) {
156 | lastAiMessageIdRef.current = lastAiMessage.id;
157 |
158 | if (loadingTimeoutRef.current) {
159 | clearTimeout(loadingTimeoutRef.current);
160 | }
161 |
162 | loadingTimeoutRef.current = setTimeout(() => {}, 5000);
163 | }
164 |
165 | return () => {
166 | if (loadingTimeoutRef.current) {
167 | clearTimeout(loadingTimeoutRef.current);
168 | }
169 | };
170 | }, [lastAiMessage, suggestions.length]);
171 |
172 | // Handle keyboard shortcuts for selecting suggestions
173 | useEffect(() => {
174 | if (!suggestions || suggestions.length === 0) return;
175 |
176 | const handleKeyDown = (event: KeyboardEvent) => {
177 | const modifierPressed = isMac
178 | ? event.metaKey && event.altKey
179 | : event.ctrlKey && event.altKey;
180 |
181 | if (modifierPressed) {
182 | const keyNum = parseInt(event.key);
183 | if (!isNaN(keyNum) && keyNum > 0 && keyNum <= suggestions.length) {
184 | event.preventDefault();
185 | const suggestionIndex = keyNum - 1;
186 | accept({ suggestion: suggestions[suggestionIndex] as Suggestion });
187 | }
188 | }
189 | };
190 |
191 | document.addEventListener("keydown", handleKeyDown);
192 |
193 | return () => {
194 | document.removeEventListener("keydown", handleKeyDown);
195 | };
196 | }, [suggestions, accept, isMac]);
197 |
198 | // If we have no messages yet and no initial suggestions, render nothing
199 | if (!thread?.messages?.length && initialSuggestions.length === 0) {
200 | return null;
201 | }
202 |
203 | return (
204 |
205 |
206 |
212 | {children}
213 |
214 |
215 |
216 | );
217 | },
218 | );
219 | MessageSuggestions.displayName = "MessageSuggestions";
220 |
221 | /**
222 | * Props for the MessageSuggestionsStatus component.
223 | * Extends standard HTMLDivElement attributes.
224 | */
225 | export type MessageSuggestionsStatusProps =
226 | React.HTMLAttributes;
227 |
228 | /**
229 | * Displays loading, error, or generation stage information.
230 | * Automatically connects to the context to show the appropriate status.
231 | * @component MessageSuggestions.Status
232 | * @example
233 | * ```tsx
234 | *
235 | *
236 | *
237 | *
238 | * ```
239 | */
240 | const MessageSuggestionsStatus = React.forwardRef<
241 | HTMLDivElement,
242 | MessageSuggestionsStatusProps
243 | >(({ className, ...props }, ref) => {
244 | const { error, isGenerating, thread } = useMessageSuggestionsContext();
245 |
246 | return (
247 |
261 | {/* Error state */}
262 | {error && (
263 |
264 |
{error.message}
265 |
266 | )}
267 |
268 | {/* Always render a container for generation stage to prevent layout shifts */}
269 |
270 | {thread?.generationStage && thread.generationStage !== "COMPLETE" ? (
271 |
272 | ) : isGenerating ? (
273 |
274 |
275 |
Generating suggestions...
276 |
277 | ) : null}
278 |
279 |
280 | );
281 | });
282 | MessageSuggestionsStatus.displayName = "MessageSuggestions.Status";
283 |
284 | /**
285 | * Props for the MessageSuggestionsList component.
286 | * Extends standard HTMLDivElement attributes.
287 | */
288 | export type MessageSuggestionsListProps = React.HTMLAttributes;
289 |
290 | /**
291 | * Displays the list of suggestion buttons.
292 | * Automatically connects to the context to show the suggestions.
293 | * @component MessageSuggestions.List
294 | * @example
295 | * ```tsx
296 | *
297 | *
298 | *
299 | *
300 | * ```
301 | */
302 | const MessageSuggestionsList = React.forwardRef<
303 | HTMLDivElement,
304 | MessageSuggestionsListProps
305 | >(({ className, ...props }, ref) => {
306 | const { suggestions, selectedSuggestionId, accept, isGenerating, isMac } =
307 | useMessageSuggestionsContext();
308 |
309 | const modKey = isMac ? "⌘" : "Ctrl";
310 | const altKey = isMac ? "⌥" : "Alt";
311 |
312 | // Create placeholder suggestions when there are no real suggestions
313 | const placeholders = Array(3).fill(null);
314 |
315 | return (
316 |
326 | {suggestions.length > 0
327 | ? suggestions.map((suggestion, index) => (
328 |
332 | {modKey}+{altKey}+{index + 1}
333 |
334 | }
335 | side="top"
336 | >
337 |
348 | !isGenerating && (await accept({ suggestion }))
349 | }
350 | disabled={isGenerating}
351 | data-suggestion-id={suggestion.id}
352 | data-suggestion-index={index}
353 | >
354 | {suggestion.title}
355 |
356 |
357 | ))
358 | : // Render placeholder buttons when no suggestions are available
359 | placeholders.map((_, index) => (
360 |
365 | Placeholder
366 |
367 | ))}
368 |
369 | );
370 | });
371 | MessageSuggestionsList.displayName = "MessageSuggestions.List";
372 |
373 | export { MessageSuggestions, MessageSuggestionsStatus, MessageSuggestionsList };
374 |
--------------------------------------------------------------------------------