├── 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 |
19 |
20 | 47 |
48 |
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 | 57 | 58 | ) : ( 59 | 60 | 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 | 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 | 33 |

Tambo API Key Required

34 |

35 | To get started, you need to initialize Tambo: 36 |

37 |
38 | npx tambo init 39 | 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 | 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 | [![GitHub](https://img.shields.io/badge/github-michaelmagan/cheatsheet-blue?logo=github)](https://github.com/michaelmagan/cheatsheet) 4 | [![License](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE) 5 | [![Next.js](https://img.shields.io/badge/Next.js-15-black?logo=next.js)](https://nextjs.org/) 6 | [![Tambo AI](https://img.shields.io/badge/Tambo-AI-purple)](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 | ![Tambo Template Demo](https://raw.githubusercontent.com/tambo-ai/tambo/main/assets/template.gif) 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 | Tambo 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 | 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 |
113 | 114 |
122 |
123 |               
129 |             
130 |
131 |
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 }) =>
    {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 |
    239 | {children}
    240 |
    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 | 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 | 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 | --------------------------------------------------------------------------------