diff --git a/src/components/tambo/diagram-utils.ts b/src/components/tambo/diagram-utils.ts
new file mode 100644
index 0000000..48a4291
--- /dev/null
+++ b/src/components/tambo/diagram-utils.ts
@@ -0,0 +1,107 @@
+import { Edge, MarkerType } from 'reactflow';
+import { Table, TableColumn } from '@/lib/types';
+
+/** Default stroke style applied to all relationship edges. */
+export const EDGE_STYLE = {
+ stroke: '#3b82f6',
+ strokeWidth: 1,
+ strokeDasharray: '4 3',
+};
+
+/** Arrow marker displayed at the target end of each edge. */
+export const EDGE_MARKER = {
+ type: MarkerType.ArrowClosed as const,
+ color: '#3b82f6',
+ width: 8,
+ height: 8,
+};
+
+/**
+ * Creates a stable string fingerprint from a column list.
+ * Used to detect whether a node's data actually changed between
+ * streaming ticks, so unchanged nodes can keep the same object
+ * reference and avoid a ReactFlow re-render.
+ */
+export function fingerprint(columns: TableColumn[]): string {
+ return columns
+ .map(
+ (c) =>
+ `${c.name}:${c.type}:${c.nullable}:${c.isPrimaryKey}:${c.isUnique}:${c.defaultValue ?? ''}:${c.foreignKey ? `${c.foreignKey.table}.${c.foreignKey.column}` : ''}`,
+ )
+ .join('|');
+}
+
+/**
+ * Computes a grid position for a table node.
+ * Arranges nodes in a roughly square grid with consistent spacing.
+ *
+ * @param index - The table's index in the list
+ * @param total - Total number of tables (determines grid dimensions)
+ */
+export function gridPosition(index: number, total: number) {
+ if (total <= 0) return { x: 0, y: 120 };
+ const cols = Math.ceil(Math.sqrt(total));
+ const cellW = 1400 / cols;
+ const cellH = 1000 / Math.ceil(total / cols);
+ return {
+ x: (index % cols) * cellW + (cellW - 200) / 2,
+ y: Math.floor(index / cols) * cellH + 120,
+ };
+}
+
+/**
+ * Creates a stable fingerprint of all foreign key relationships.
+ * Used to skip rebuilding edges when relationships haven't changed.
+ */
+export function edgeFingerprint(tables: Table[]): string {
+ const parts: string[] = [];
+ for (const table of tables) {
+ for (const col of table.columns ?? []) {
+ if (col.foreignKey) {
+ parts.push(`${table.name}.${col.name}->${col.foreignKey.table}.${col.foreignKey.column}`);
+ }
+ }
+ }
+ return parts.sort().join('|');
+}
+
+/**
+ * Builds ReactFlow edges from foreign key relationships.
+ * Only creates edges where both the source and target table exist
+ * in the current table list.
+ */
+export function buildEdges(tables: Table[]): Edge[] {
+ const tableNames = new Set(tables.map((t) => t.name));
+ const edges: Edge[] = [];
+
+ for (const table of tables) {
+ for (const col of table.columns ?? []) {
+ if (!col.foreignKey || !tableNames.has(col.foreignKey.table)) continue;
+
+ edges.push({
+ id: `${table.name}-${col.name}-${col.foreignKey.table}-${col.foreignKey.column}`,
+ source: table.name,
+ target: col.foreignKey.table,
+ sourceHandle: `${table.name}-${col.name}-source`,
+ targetHandle: `${col.foreignKey.table}-${col.foreignKey.column}-target`,
+ type: 'default',
+ animated: true,
+ style: EDGE_STYLE,
+ markerEnd: EDGE_MARKER,
+ label: `${col.name} โ ${col.foreignKey.column}`,
+ labelStyle: {
+ fill: '#3b82f6',
+ fontSize: 10,
+ fontFamily: 'monospace',
+ fontWeight: 600,
+ },
+ labelBgPadding: [4, 2] as [number, number],
+ labelBgBorderRadius: 4,
+ labelBgStyle: { fill: '#f0f7ff', fillOpacity: 0.8 },
+ labelShowBg: true,
+ });
+ }
+ }
+
+ return edges;
+}
diff --git a/src/components/tambo/dictation-button.tsx b/src/components/tambo/dictation-button.tsx
new file mode 100644
index 0000000..1c2e37e
--- /dev/null
+++ b/src/components/tambo/dictation-button.tsx
@@ -0,0 +1,73 @@
+import { Tooltip } from "@/components/tambo/message-suggestions";
+import { useTamboThreadInput, useTamboVoice } from "@tambo-ai/react";
+import { Loader2Icon, Mic, Square } from "lucide-react";
+import React, { useEffect, useRef } from "react";
+
+/**
+ * Button for dictating speech into the message input.
+ */
+export default function DictationButton() {
+ const {
+ startRecording,
+ stopRecording,
+ isRecording,
+ isTranscribing,
+ transcript,
+ transcriptionError,
+ } = useTamboVoice();
+ const { setValue } = useTamboThreadInput();
+ const lastProcessedTranscriptRef = useRef("");
+
+ const handleStartRecording = () => {
+ lastProcessedTranscriptRef.current = "";
+ startRecording();
+ };
+
+ const handleStopRecording = () => {
+ stopRecording();
+ };
+
+ useEffect(() => {
+ if (transcript && transcript !== lastProcessedTranscriptRef.current) {
+ lastProcessedTranscriptRef.current = transcript;
+ setValue((prev) => prev + " " + transcript);
+ }
+ }, [transcript, setValue]);
+
+ if (isTranscribing) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {transcriptionError}
+ {isRecording ? (
+
+
+
+
+
+ ) : (
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/tambo/elicitation-ui.tsx b/src/components/tambo/elicitation-ui.tsx
new file mode 100644
index 0000000..f62fcf6
--- /dev/null
+++ b/src/components/tambo/elicitation-ui.tsx
@@ -0,0 +1,624 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import {
+ type TamboElicitationRequest,
+ type TamboElicitationResponse,
+} from "@tambo-ai/react/mcp";
+import * as React from "react";
+import { useMemo, useState } from "react";
+
+type FieldSchema =
+ TamboElicitationRequest["requestedSchema"]["properties"][string];
+
+/**
+ * Props for individual field components
+ */
+interface FieldProps {
+ name: string;
+ schema: FieldSchema;
+ value: unknown;
+ onChange: (value: unknown) => void;
+ required: boolean;
+ autoFocus?: boolean;
+ validationError?: string | null;
+}
+
+/**
+ * Boolean field component - renders yes/no buttons
+ */
+const BooleanField: React.FC = ({
+ name,
+ schema,
+ value,
+ onChange,
+ required,
+ autoFocus,
+}) => {
+ const boolValue = value as boolean | undefined;
+
+ return (
+
+
+ {schema.description ?? name}
+ {required && * }
+
+
+ onChange(true)}
+ className={cn(
+ "flex-1 px-4 py-2 rounded-lg border transition-colors",
+ boolValue === true
+ ? "bg-accent text-accent-foreground border-accent"
+ : "bg-background border-border hover:bg-muted",
+ )}
+ >
+ Yes
+
+ onChange(false)}
+ className={cn(
+ "flex-1 px-4 py-2 rounded-lg border transition-colors",
+ boolValue === false
+ ? "bg-accent text-accent-foreground border-accent"
+ : "bg-background border-border hover:bg-muted",
+ )}
+ >
+ No
+
+
+
+ );
+};
+
+/**
+ * Enum field component - renders button for each choice
+ */
+const EnumField: React.FC = ({
+ name,
+ schema,
+ value,
+ onChange,
+ required,
+ autoFocus,
+}) => {
+ if (schema.type !== "string" || !("enum" in schema)) {
+ return null;
+ }
+ const options = schema.enum ?? [];
+ const optionNames =
+ "enumNames" in schema ? (schema.enumNames ?? []) : options;
+ const stringValue = value as string | undefined;
+
+ return (
+
+
+ {schema.description ?? name}
+ {required && * }
+
+
+ {options.map((option, index) => (
+ onChange(option)}
+ className={cn(
+ "px-4 py-2 rounded-lg border transition-colors",
+ stringValue === option
+ ? "bg-accent text-accent-foreground border-accent"
+ : "bg-background border-border hover:bg-muted",
+ )}
+ >
+ {optionNames[index] ?? option}
+
+ ))}
+
+
+ );
+};
+
+/**
+ * String field component - renders text input with validation
+ */
+const StringField: React.FC = ({
+ name,
+ schema,
+ value,
+ onChange,
+ required,
+ autoFocus,
+ validationError,
+}) => {
+ const inputId = React.useId();
+
+ if (schema.type !== "string") {
+ return null;
+ }
+ const stringValue = (value as string | undefined) ?? "";
+
+ // Map JSON Schema format to HTML5 input type
+ const getInputType = (): string => {
+ const format = "format" in schema ? schema.format : undefined;
+ switch (format) {
+ case "email":
+ return "email";
+ case "uri":
+ return "url";
+ case "date":
+ return "date";
+ case "date-time":
+ return "datetime-local";
+ default:
+ return "text";
+ }
+ };
+
+ const inputType = getInputType();
+ const hasError = !!validationError;
+ const errorId = `${inputId}-error`;
+
+ return (
+
+
+ {schema.description ?? name}
+ {required && * }
+
+
onChange(e.target.value)}
+ className={cn(
+ "w-full px-3 py-2 rounded-lg border bg-background text-foreground focus:outline-none focus:ring-2",
+ hasError
+ ? "border-destructive focus:ring-destructive"
+ : "border-border focus:ring-accent",
+ )}
+ placeholder={schema.description ?? name}
+ minLength={"minLength" in schema ? schema.minLength : undefined}
+ maxLength={"maxLength" in schema ? schema.maxLength : undefined}
+ required={required}
+ aria-invalid={hasError || undefined}
+ aria-describedby={hasError ? errorId : undefined}
+ />
+ {validationError && (
+
+ {validationError}
+
+ )}
+
+ );
+};
+
+/**
+ * Number field component - renders number input with validation
+ */
+const NumberField: React.FC = ({
+ name,
+ schema,
+ value,
+ onChange,
+ required,
+ autoFocus,
+ validationError,
+}) => {
+ const inputId = React.useId();
+
+ if (schema.type !== "number" && schema.type !== "integer") {
+ return null;
+ }
+ const numberSchema = schema;
+ const numberValue = value as number | undefined;
+ const hasError = !!validationError;
+ const errorId = `${inputId}-error`;
+
+ return (
+
+
+ {schema.description ?? name}
+ {required && * }
+
+
{
+ const { value, valueAsNumber } = e.currentTarget;
+ onChange(
+ value === "" || Number.isNaN(valueAsNumber)
+ ? undefined
+ : valueAsNumber,
+ );
+ }}
+ className={cn(
+ "w-full px-3 py-2 rounded-lg border bg-background text-foreground focus:outline-none focus:ring-2",
+ hasError
+ ? "border-destructive focus:ring-destructive"
+ : "border-border focus:ring-accent",
+ )}
+ placeholder={schema.description ?? name}
+ min={numberSchema.minimum}
+ max={numberSchema.maximum}
+ step={numberSchema.type === "integer" ? 1 : "any"}
+ required={required}
+ aria-invalid={hasError || undefined}
+ aria-describedby={hasError ? errorId : undefined}
+ />
+ {validationError && (
+
+ {validationError}
+
+ )}
+
+ );
+};
+
+/**
+ * Generic field component that renders the appropriate input based on schema type
+ */
+const Field: React.FC = (props) => {
+ const { schema } = props;
+
+ if (schema.type === "boolean") {
+ return ;
+ }
+
+ if (schema.type === "string" && "enum" in schema) {
+ return ;
+ }
+
+ if (schema.type === "string") {
+ return ;
+ }
+
+ if (schema.type === "number" || schema.type === "integer") {
+ return ;
+ }
+
+ return null;
+};
+
+/**
+ * Determines if the elicitation should use single-entry mode
+ * (one field that is boolean or enum)
+ */
+function isSingleEntryMode(request: TamboElicitationRequest): boolean {
+ const fields = Object.entries(request.requestedSchema.properties);
+
+ if (fields.length !== 1) {
+ return false;
+ }
+
+ const [, schema] = fields[0];
+
+ return (
+ schema.type === "boolean" || (schema.type === "string" && "enum" in schema)
+ );
+}
+
+/**
+ * Unified validation function that returns both validity and a user-facing message.
+ * Avoids drift between boolean validation and error computation.
+ */
+function validateField(
+ value: unknown,
+ schema: FieldSchema,
+ required: boolean,
+): { valid: boolean; error: string | null } {
+ // Required
+ if (required && (value === undefined || value === "" || value === null)) {
+ return { valid: false, error: "This field is required" };
+ }
+
+ // If empty and not required, it's valid
+ if (!required && (value === undefined || value === "" || value === null)) {
+ return { valid: true, error: null };
+ }
+
+ // String validation
+ if (schema.type === "string") {
+ const stringSchema = schema;
+ const stringValue = String(value);
+
+ if (
+ "minLength" in stringSchema &&
+ stringSchema.minLength !== undefined &&
+ stringValue.length < stringSchema.minLength
+ ) {
+ return {
+ valid: false,
+ error: `Minimum length is ${stringSchema.minLength} characters`,
+ };
+ }
+
+ if (
+ "maxLength" in stringSchema &&
+ stringSchema.maxLength !== undefined &&
+ stringValue.length > stringSchema.maxLength
+ ) {
+ return {
+ valid: false,
+ error: `Maximum length is ${stringSchema.maxLength} characters`,
+ };
+ }
+
+ if ("pattern" in stringSchema && stringSchema.pattern) {
+ try {
+ const regex = new RegExp(stringSchema.pattern as string);
+ if (!regex.test(stringValue)) {
+ return {
+ valid: false,
+ error: "Value does not match required pattern",
+ };
+ }
+ } catch {
+ // Invalid regex pattern, skip validation
+ }
+ }
+
+ // Format validation
+ if ("format" in stringSchema && stringSchema.format) {
+ switch (stringSchema.format) {
+ case "email":
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(stringValue)) {
+ return {
+ valid: false,
+ error: "Please enter a valid email address",
+ };
+ }
+ break;
+ case "uri":
+ try {
+ new URL(stringValue);
+ } catch {
+ return { valid: false, error: "Please enter a valid URL" };
+ }
+ break;
+ }
+ }
+ }
+
+ // Number validation
+ if (schema.type === "number" || schema.type === "integer") {
+ const numberSchema = schema;
+ const numberValue = Number(value);
+
+ if (Number.isNaN(numberValue)) {
+ return { valid: false, error: "Please enter a valid number" };
+ }
+
+ if (
+ numberSchema.minimum !== undefined &&
+ numberValue < numberSchema.minimum
+ ) {
+ return {
+ valid: false,
+ error: `Minimum value is ${numberSchema.minimum}`,
+ };
+ }
+
+ if (
+ numberSchema.maximum !== undefined &&
+ numberValue > numberSchema.maximum
+ ) {
+ return {
+ valid: false,
+ error: `Maximum value is ${numberSchema.maximum}`,
+ };
+ }
+
+ if (schema.type === "integer" && !Number.isInteger(numberValue)) {
+ return { valid: false, error: "Please enter a whole number" };
+ }
+ }
+
+ return { valid: true, error: null };
+}
+
+// Backwards-compatible helpers that delegate to the unified validator
+function getValidationError(
+ value: unknown,
+ schema: FieldSchema,
+ required: boolean,
+): string | null {
+ return validateField(value, schema, required).error;
+}
+
+/**
+ * Props for the ElicitationUI component
+ */
+export interface ElicitationUIProps {
+ request: TamboElicitationRequest;
+ onResponse: (response: TamboElicitationResponse) => void;
+ className?: string;
+}
+
+/**
+ * Main elicitation UI component
+ * Handles both single-entry and multiple-entry modes
+ */
+export const ElicitationUI: React.FC = ({
+ request,
+ onResponse,
+ className,
+}) => {
+ const singleEntry = isSingleEntryMode(request);
+ const fields = useMemo(
+ () => Object.entries(request.requestedSchema.properties),
+ [request.requestedSchema.properties],
+ );
+ const requiredFields = useMemo(
+ () => request.requestedSchema.required ?? [],
+ [request.requestedSchema.required],
+ );
+ const [formData, setFormData] = useState>(() => {
+ const initial: Record = {};
+ fields.forEach(([name, schema]) => {
+ if (schema.default !== undefined) {
+ initial[name] = schema.default;
+ }
+ });
+ return initial;
+ });
+
+ // Initialize form data with defaults
+ const [touchedFields, setTouchedFields] = useState>(new Set());
+
+ const handleFieldChange = (name: string, value: unknown) => {
+ setFormData((prev) => ({ ...prev, [name]: value }));
+ // Mark field as touched so we can show validation errors
+ setTouchedFields((prev) => new Set(prev).add(name));
+ };
+
+ const handleAccept = () => {
+ // Check if valid before submitting
+ if (!isValid) {
+ // Mark all fields as touched to show validation errors
+ setTouchedFields(new Set(fields.map(([name]) => name)));
+ return;
+ }
+ onResponse({ action: "accept", content: formData });
+ };
+
+ const handleDecline = () => {
+ onResponse({ action: "decline" });
+ };
+
+ const handleCancel = () => {
+ onResponse({ action: "cancel" });
+ };
+
+ // For single-entry mode with boolean/enum, clicking the option submits immediately
+ const handleSingleEntryChange = (name: string, value: unknown) => {
+ const updatedData = { ...formData, [name]: value };
+ setFormData(updatedData);
+ // Mark as touched for consistency/future-proofing
+ setTouchedFields((prev) => new Set(prev).add(name));
+ // Submit immediately
+ onResponse({ action: "accept", content: updatedData });
+ };
+
+ // Check if form is valid (all fields pass validation)
+ const isValid = fields.every(([fieldName, fieldSchema]) => {
+ const value = formData[fieldName];
+ const isRequired = requiredFields.includes(fieldName);
+ return validateField(value, fieldSchema, isRequired).valid;
+ });
+
+ if (singleEntry) {
+ const [fieldName, fieldSchema] = fields[0];
+ const validationError = touchedFields.has(fieldName)
+ ? getValidationError(
+ formData[fieldName],
+ fieldSchema,
+ requiredFields.includes(fieldName),
+ )
+ : null;
+
+ return (
+
+
+ {request.message}
+
+
handleSingleEntryChange(fieldName, value)}
+ required={requiredFields.includes(fieldName)}
+ autoFocus
+ validationError={validationError}
+ />
+
+
+ Cancel
+
+
+ Decline
+
+
+
+ );
+ }
+
+ // Multiple-entry mode
+ return (
+
+
+ {request.message}
+
+
+ {fields.map(([name, schema], index) => {
+ const validationError = touchedFields.has(name)
+ ? getValidationError(
+ formData[name],
+ schema,
+ requiredFields.includes(name),
+ )
+ : null;
+
+ return (
+ handleFieldChange(name, value)}
+ required={requiredFields.includes(name)}
+ autoFocus={index === 0}
+ validationError={validationError}
+ />
+ );
+ })}
+
+
+
+ Cancel
+
+
+ Decline
+
+
+ Submit
+
+
+
+ );
+};
diff --git a/src/components/tambo/graph.tsx b/src/components/tambo/graph.tsx
index 53b5ed4..0cc148b 100644
--- a/src/components/tambo/graph.tsx
+++ b/src/components/tambo/graph.tsx
@@ -1,18 +1,110 @@
"use client";
import { cn } from "@/lib/utils";
-import { cva, type VariantProps } from "class-variance-authority";
+import { cva } from "class-variance-authority";
import * as React from "react";
import * as RechartsCore from "recharts";
-import { z } from "zod";
+import { z } from "zod/v3";
/**
- * Represents a graph data object
- * @property {string} type - Type of graph to render
- * @property {string[]} labels - Labels for the graph
- * @property {Object[]} datasets - Data for the graph
+ * Type for graph variant
*/
+type GraphVariant = "default" | "solid" | "bordered";
+/**
+ * Type for graph size
+ */
+type GraphSize = "default" | "sm" | "lg";
+
+/**
+ * Variants for the Graph component
+ */
+export const graphVariants = cva(
+ "w-full rounded-lg overflow-hidden transition-all duration-200",
+ {
+ variants: {
+ variant: {
+ default: "bg-background",
+ solid: [
+ "shadow-lg shadow-zinc-900/10 dark:shadow-zinc-900/20",
+ "bg-muted",
+ ].join(" "),
+ bordered: ["border-2", "border-border"].join(" "),
+ },
+ size: {
+ default: "h-64",
+ sm: "h-48",
+ lg: "h-96",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+/**
+ * Props for the error boundary
+ */
+interface GraphErrorBoundaryProps {
+ children: React.ReactNode;
+ className?: string;
+ variant?: GraphVariant;
+ size?: GraphSize;
+}
+
+/**
+ * Error boundary for catching rendering errors in the Graph component
+ */
+class GraphErrorBoundary extends React.Component<
+ GraphErrorBoundaryProps,
+ { hasError: boolean; error?: Error }
+> {
+ constructor(props: GraphErrorBoundaryProps) {
+ super(props);
+ this.state = { hasError: false };
+ }
+
+ static getDerivedStateFromError(error: Error) {
+ return { hasError: true, error };
+ }
+
+ componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
+ console.error("Error rendering chart:", error, errorInfo);
+ }
+
+ render(): React.ReactNode {
+ if (this.state.hasError) {
+ return (
+
+
+
+
Error loading chart
+
+ An error occurred while rendering. Please try again.
+
+
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
+/**
+ * Zod schema for GraphData
+ */
export const graphDataSchema = z.object({
type: z.enum(["bar", "line", "pie"]).describe("Type of graph to render"),
labels: z.array(z.string()).describe("Labels for the graph"),
@@ -27,6 +119,9 @@ export const graphDataSchema = z.object({
.describe("Data for the graph"),
});
+/**
+ * Zod schema for Graph
+ */
export const graphSchema = z.object({
data: graphDataSchema.describe(
"Data object containing chart configuration and values",
@@ -44,52 +139,30 @@ export const graphSchema = z.object({
.enum(["default", "sm", "lg"])
.optional()
.describe("Size of the graph"),
+ className: z
+ .string()
+ .optional()
+ .describe("Additional CSS classes for styling"),
});
-// Define the base type from the Zod schema
-export type GraphDataType = z.infer;
-
-// Extend the GraphProps with additional tambo properties
-export interface GraphProps
- extends Omit, "data" | "title" | "size">,
- Omit, "size" | "variant"> {
- /** Data object containing chart configuration and values */
- data?: GraphDataType;
- /** Optional title for the chart */
- title?: string;
- /** Whether to show the legend (default: true) */
- showLegend?: boolean;
- /** Visual style variant of the graph */
- variant?: "default" | "solid" | "bordered";
- /** Size of the graph */
- size?: "default" | "sm" | "lg";
-}
+/**
+ * TypeScript type inferred from the Zod schema
+ */
+export type GraphProps = z.infer;
-const graphVariants = cva(
- "w-full rounded-lg overflow-hidden transition-all duration-200",
- {
- variants: {
- variant: {
- default: "bg-background",
- solid: [
- "shadow-lg shadow-zinc-900/10 dark:shadow-zinc-900/20",
- "bg-muted",
- ].join(" "),
- bordered: ["border-2", "border-border"].join(" "),
- },
- size: {
- default: "h-64",
- sm: "h-48",
- lg: "h-96",
- },
- },
- defaultVariants: {
- variant: "default",
- size: "default",
- },
- },
-);
+/**
+ * TypeScript type inferred from the Zod schema
+ */
+export type GraphDataType = z.infer;
+/**
+ * Default colors for the Graph component.
+ *
+ * Color handling: our v4 theme defines CSS variables like `--border`,
+ * `--muted-foreground`, and `--chart-1` as full OKLCH color values in
+ * `globals-v4.css`, so we pass them directly as `var(--token)` to
+ * Recharts/SVG props instead of wrapping them in `hsl()`/`oklch()`.
+ */
const defaultColors = [
"hsl(220, 100%, 62%)", // Blue
"hsl(160, 82%, 47%)", // Green
@@ -171,230 +244,228 @@ export const Graph = React.forwardRef(
);
}
- try {
- // Filter datasets to only include those with valid data
- const validDatasets = data.datasets.filter(
- (dataset) =>
- dataset.label &&
- dataset.data &&
- Array.isArray(dataset.data) &&
- dataset.data.length > 0,
+ // Filter datasets to only include those with valid data
+ const validDatasets = data.datasets.filter(
+ (dataset) =>
+ dataset.label &&
+ dataset.data &&
+ Array.isArray(dataset.data) &&
+ dataset.data.length > 0,
+ );
+
+ if (validDatasets.length === 0) {
+ return (
+
+
+
+
Preparing datasets...
+
+
+
);
+ }
+
+ // Use the minimum length between labels and the shortest dataset
+ const maxDataPoints = Math.min(
+ data.labels.length,
+ Math.min(...validDatasets.map((d) => d.data.length)),
+ );
- if (validDatasets.length === 0) {
+ // Transform data for Recharts using only available data points
+ const chartData = data.labels
+ .slice(0, maxDataPoints)
+ .map((label, index) => ({
+ name: label,
+ ...Object.fromEntries(
+ validDatasets.map((dataset) => [
+ dataset.label,
+ dataset.data[index] ?? 0,
+ ]),
+ ),
+ }));
+
+ const renderChart = () => {
+ if (!["bar", "line", "pie"].includes(data.type)) {
return (
-
-
-
-
Preparing datasets...
-
+
+
+
Unsupported chart type: {data.type}
);
}
- // Use the minimum length between labels and the shortest dataset
- const maxDataPoints = Math.min(
- data.labels.length,
- Math.min(...validDatasets.map((d) => d.data.length)),
- );
-
- // Transform data for Recharts using only available data points
- const chartData = data.labels
- .slice(0, maxDataPoints)
- .map((label, index) => ({
- name: label,
- ...Object.fromEntries(
- validDatasets.map((dataset) => [
- dataset.label,
- dataset.data[index] ?? 0,
- ]),
- ),
- }));
-
- const renderChart = () => {
- if (!["bar", "line", "pie"].includes(data.type)) {
+ switch (data.type) {
+ case "bar":
return (
-
-
-
Unsupported chart type: {data.type}
-
-
- );
- }
-
- switch (data.type) {
- case "bar":
- return (
-
-
-
-
-
+
+
+
+
+ {showLegend && (
+
- {showLegend && (
-
- )}
- {validDatasets.map((dataset, index) => (
-
- ))}
-
- );
-
- case "line":
- return (
-
-
-
- (
+
-
+ );
+
+ case "line":
+ return (
+
+
+
+
+
+ {showLegend && (
+
- {showLegend && (
-
- )}
- {validDatasets.map((dataset, index) => (
-
- ))}
-
- );
+ )}
+ {validDatasets.map((dataset, index) => (
+
+ ))}
+
+ );
- case "pie": {
- // For pie charts, use the first valid dataset
- const pieDataset = validDatasets[0];
- if (!pieDataset) {
- return (
-
-
-
No valid dataset for pie chart
-
+ case "pie": {
+ // For pie charts, use the first valid dataset
+ const pieDataset = validDatasets[0];
+ if (!pieDataset) {
+ return (
+
+
+
No valid dataset for pie chart
- );
- }
+
+ );
+ }
- return (
-
- ({
- name: data.labels[index],
- value,
- fill: defaultColors[index % defaultColors.length],
- }))}
- dataKey="value"
- nameKey="name"
- cx="50%"
- cy="50%"
- labelLine={false}
- outerRadius={80}
- fill="#8884d8"
- />
-
+ ({
+ name: data.labels[index],
+ value,
+ fill: defaultColors[index % defaultColors.length],
+ }))}
+ dataKey="value"
+ nameKey="name"
+ cx="50%"
+ cy="50%"
+ labelLine={false}
+ outerRadius={80}
+ fill="#8884d8"
+ />
+
+ {showLegend && (
+
- {showLegend && (
-
- )}
-
- );
- }
+ )}
+
+ );
}
- };
+ }
+ };
- return (
+ return (
+
(
- );
- } catch (error) {
- console.error("Error rendering chart:", error);
- return (
-
-
-
-
Error loading chart
-
- An error occurred while rendering. Please try again.
-
-
-
-
- );
- }
+
+ );
},
);
Graph.displayName = "Graph";
diff --git a/src/components/tambo/markdown-components.tsx b/src/components/tambo/markdown-components.tsx
index 85631af..ca31df3 100644
--- a/src/components/tambo/markdown-components.tsx
+++ b/src/components/tambo/markdown-components.tsx
@@ -1,15 +1,14 @@
"use client";
-import * as React from "react";
import { cn } from "@/lib/utils";
-import type { Components } from "react-markdown";
-import { Copy, Check, ExternalLink } from "lucide-react";
+import DOMPurify from "dompurify";
import hljs from "highlight.js";
import "highlight.js/styles/github.css";
-import DOMPurify from "dompurify";
+import { Check, Copy, ExternalLink, X } from "lucide-react";
+import * as React from "react";
/**
- * Markdown Components for React-Markdown
+ * Markdown Components for Streamdown
*
* This module provides customized components for rendering markdown content with syntax highlighting.
* It uses highlight.js for code syntax highlighting and supports streaming content updates.
@@ -17,11 +16,11 @@ import DOMPurify from "dompurify";
* @example
* ```tsx
* import { createMarkdownComponents } from './markdown-components';
- * import ReactMarkdown from 'react-markdown';
+ * import { Streamdown } from 'streamdown';
*
* const MarkdownRenderer = ({ content }) => {
- * const components = createMarkdownComponents('light');
- * return
{content} ;
+ * const components = createMarkdownComponents();
+ * return
{content} ;
* };
* ```
*/
@@ -48,6 +47,22 @@ const looksLikeCode = (text: string): boolean => {
return codeIndicators.some((pattern) => pattern.test(text));
};
+/**
+ * Resource mention component that displays resource names.
+ * Matches the styling from text-editor.tsx mention components.
+ */
+function ResourceMention({ name, uri }: { name: string; uri: string }) {
+ return (
+
+ @{name}
+
+ );
+}
+
/**
* Header component for code blocks with language display and copy functionality
*/
@@ -59,38 +74,62 @@ const CodeHeader = ({
code?: string;
}) => {
const [copied, setCopied] = React.useState(false);
+ const [error, setError] = React.useState(false);
+ const timeoutRef = React.useRef
| null>(null);
- const copyToClipboard = () => {
+ const copyToClipboard = async () => {
if (!code) return;
- navigator.clipboard.writeText(code);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
+
+ // Clear any existing timeout to prevent race conditions
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = null;
+ }
+
+ try {
+ await navigator.clipboard.writeText(code);
+ setCopied(true);
+ setError(false);
+ } catch (err) {
+ console.error("Failed to copy code to clipboard:", err);
+ setError(true);
+ }
+ timeoutRef.current = setTimeout(() => setError(false), 2000);
};
+ const Icon = React.useMemo(() => {
+ if (error) {
+ return ;
+ }
+ if (copied) {
+ return ;
+ }
+ return ;
+ }, [copied, error]);
+
return (
-
-
{language}
+
+ {language}
- {!copied ? (
-
- ) : (
-
- )}
+ {Icon}
);
};
/**
- * Creates a set of components for use with react-markdown
- * @param theme - The theme to use ('light' or 'dark')
- * @returns Components object for react-markdown
+ * Creates a set of components for use with streamdown
+ * @returns Components object for streamdown
*/
-export const createMarkdownComponents = (): Components => ({
+export const createMarkdownComponents = (): Record<
+ string,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ React.ComponentType
+> => ({
code: function Code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className ?? "");
const content = String(children).replace(/\n$/, "");
@@ -113,7 +152,7 @@ export const createMarkdownComponents = (): Components => ({
className={cn(
"overflow-x-auto rounded-b-md bg-background",
"[&::-webkit-scrollbar]:w-[6px]",
- "[&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-thumb]:rounded-md",
+ "[&::-webkit-scrollbar-thumb]:bg-muted-foreground/30 [&::-webkit-scrollbar-thumb]:rounded-md",
"[&::-webkit-scrollbar:horizontal]:h-[4px]",
)}
>
@@ -207,20 +246,55 @@ export const createMarkdownComponents = (): Components => ({
/**
* Anchor component for links
- * Opens links in new tab with security attributes
- * Includes hover underline effect
+ * Detects tambo-resource:// URIs and renders them as ResourceMention components.
+ * Regular links open in new tab with security attributes.
*/
- a: ({ href, children }) => (
-
- {children}
-
-
- ),
+ a: ({ href, children }) => {
+ // Check if href uses tambo-resource:// protocol to signal it's a resource
+ if (href?.startsWith("tambo-resource://")) {
+ // Extract encoded URI (everything after tambo-resource://)
+ const encodedUri = href.slice("tambo-resource://".length);
+ // Decode the URI
+ let uri: string;
+ try {
+ uri = decodeURIComponent(encodedUri);
+ } catch {
+ // If decoding fails, use the encoded version as fallback
+ uri = encodedUri;
+ }
+ // Extract name from children (link text)
+ // Handle different children types (string, number, array, etc.)
+ let name: string;
+ if (typeof children === "string") {
+ name = children;
+ } else if (typeof children === "number") {
+ name = String(children);
+ } else if (Array.isArray(children)) {
+ // If children is an array, join string elements
+ name = children
+ .map((child) =>
+ typeof child === "string" ? child : String(child ?? ""),
+ )
+ .join("");
+ } else {
+ name = String(children ?? uri);
+ }
+ return ;
+ }
+
+ // Regular link rendering
+ return (
+
+ {children}
+
+
+ );
+ },
/**
* Horizontal rule component
@@ -256,3 +330,8 @@ export const createMarkdownComponents = (): Components => ({
{children}
),
});
+
+/**
+ * Pre-created markdown components instance for use across the application.
+ */
+export const markdownComponents = createMarkdownComponents();
diff --git a/src/components/tambo/mcp-components.tsx b/src/components/tambo/mcp-components.tsx
new file mode 100644
index 0000000..dff03dc
--- /dev/null
+++ b/src/components/tambo/mcp-components.tsx
@@ -0,0 +1,517 @@
+"use client";
+
+import {
+ Tooltip,
+ TooltipProvider,
+} from "@/components/tambo/message-suggestions";
+import { cn } from "@/lib/utils";
+import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
+import {
+ useTamboMcpPrompt,
+ useTamboMcpPromptList,
+ useTamboMcpResourceList,
+} from "@tambo-ai/react/mcp";
+import { AlertCircle, AtSign, FileText, Search } from "lucide-react";
+import * as React from "react";
+
+/**
+ * Represents a single message content item from an MCP prompt.
+ */
+interface PromptMessageContent {
+ type?: string;
+ text?: string;
+}
+
+/**
+ * Represents a single message from an MCP prompt.
+ */
+interface PromptMessage {
+ content?: PromptMessageContent;
+}
+
+/**
+ * Validates that prompt data has a valid messages array structure.
+ * @param promptData - The prompt data to validate
+ * @returns true if the prompt data has valid messages, false otherwise
+ */
+function isValidPromptData(
+ promptData: unknown,
+): promptData is { messages: PromptMessage[] } {
+ if (!promptData || typeof promptData !== "object") {
+ return false;
+ }
+
+ const data = promptData as { messages?: unknown };
+ if (!Array.isArray(data.messages)) {
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * Safely extracts text content from prompt messages.
+ * Handles malformed or missing content gracefully.
+ * @param messages - Array of prompt messages
+ * @returns Extracted text content joined by newlines
+ */
+function extractPromptText(messages: PromptMessage[]): string {
+ return messages
+ .map((msg) => {
+ // Safely access nested properties
+ if (
+ msg?.content?.type === "text" &&
+ typeof msg.content.text === "string"
+ ) {
+ return msg.content.text;
+ }
+ return "";
+ })
+ .filter(Boolean)
+ .join("\n");
+}
+
+/**
+ * Props for the McpPromptButton component.
+ */
+export interface McpPromptButtonProps extends React.ButtonHTMLAttributes {
+ /** Callback to insert text into the input */
+ onInsertText: (text: string) => void;
+ /** Current input value */
+ value: string;
+ /** Optional custom className */
+ className?: string;
+}
+
+/**
+ * MCP Prompt picker button component for inserting prompts from MCP servers.
+ * @component McpPromptButton
+ * @example
+ * ```tsx
+ * setInputValue(text)}
+ * />
+ * ```
+ */
+export const McpPromptButton = React.forwardRef<
+ HTMLButtonElement,
+ McpPromptButtonProps
+>(({ className, onInsertText, value, ...props }, ref) => {
+ const { data: promptList, isLoading } = useTamboMcpPromptList();
+ const [selectedPromptName, setSelectedPromptName] = React.useState<
+ string | null
+ >(null);
+ const [promptError, setPromptError] = React.useState(null);
+ const { data: promptData, error: fetchError } = useTamboMcpPrompt(
+ selectedPromptName ?? "",
+ );
+
+ // When prompt data is fetched, validate and insert it into the input
+ React.useEffect(() => {
+ if (selectedPromptName && promptData) {
+ // Validate prompt data structure
+ if (!isValidPromptData(promptData)) {
+ setPromptError("Invalid prompt format received");
+ setSelectedPromptName(null);
+ return;
+ }
+
+ // Extract text with safe access
+ const promptText = extractPromptText(promptData.messages);
+
+ if (!promptText) {
+ setPromptError("Prompt contains no text content");
+ setSelectedPromptName(null);
+ return;
+ }
+
+ // Clear any previous errors
+ setPromptError(null);
+
+ // Insert the prompt text, appending to existing value if any
+ const newValue = value ? `${value}\n\n${promptText}` : promptText;
+ onInsertText(newValue);
+
+ // Reset the selected prompt
+ setSelectedPromptName(null);
+ }
+ }, [promptData, selectedPromptName, onInsertText, value]);
+
+ // Handle fetch errors
+ React.useEffect(() => {
+ if (fetchError) {
+ setPromptError("Failed to load prompt");
+ setSelectedPromptName(null);
+ }
+ }, [fetchError]);
+
+ // Clear error after a delay
+ React.useEffect(() => {
+ if (promptError) {
+ const timer = setTimeout(() => setPromptError(null), 3000);
+ return () => clearTimeout(timer);
+ }
+ }, [promptError]);
+
+ // Only show button if prompts are available (hide during loading and when no prompts)
+ if (!promptList || promptList.length === 0) {
+ return null;
+ }
+
+ const buttonClasses = cn(
+ "w-10 h-10 rounded-lg border border-border bg-background text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:pointer-events-none flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
+ className,
+ );
+
+ return (
+
+
+
+
+
+ {promptError ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ );
+});
+McpPromptButton.displayName = "McpPromptButton";
+
+/**
+ * Internal component to render prompt list content
+ */
+function PromptListContent({
+ isLoading,
+ promptList,
+ onSelectPrompt,
+}: {
+ isLoading: boolean;
+ promptList:
+ | {
+ server: { url: string };
+ prompt: { name: string; description?: string };
+ }[]
+ | undefined;
+ onSelectPrompt: (name: string) => void;
+}) {
+ if (isLoading) {
+ return (
+
+ Loading prompts...
+
+ );
+ }
+ if (!promptList || promptList.length === 0) {
+ return (
+
+ No prompts available
+
+ );
+ }
+ return (
+ <>
+ {promptList.map((promptEntry) => (
+ {
+ onSelectPrompt(promptEntry.prompt.name);
+ }}
+ >
+
+ {promptEntry.prompt.name}
+
+ {promptEntry.prompt.description && (
+
+ {promptEntry.prompt.description}
+
+ )}
+
+ ))}
+ >
+ );
+}
+
+/**
+ * Props for the ResourceCombobox internal component
+ */
+interface ResourceComboboxProps {
+ setIsOpen: (open: boolean) => void;
+ searchQuery: string;
+ setSearchQuery: (query: string) => void;
+ filteredResources: ReturnType["data"];
+ isLoading: boolean;
+ onSelectResource: (id: string, label: string) => void;
+}
+
+/**
+ * Internal combobox component for MCP resource selection with search functionality.
+ * Not exported - only used within McpResourceButton.
+ */
+const ResourceCombobox: React.FC = ({
+ searchQuery,
+ setSearchQuery,
+ filteredResources,
+ isLoading,
+ onSelectResource,
+ setIsOpen,
+}) => {
+ return (
+
+ {
+ // Prevent focus from moving when closing
+ e.preventDefault();
+ }}
+ >
+ {/* Search input */}
+
+
+
+ setSearchQuery(e.target.value)}
+ className="w-full pl-8 pr-3 py-1.5 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent"
+ onClick={(e) => e.stopPropagation()}
+ onKeyDown={(e) => {
+ // Prevent dropdown from closing on key events
+ e.stopPropagation();
+ if (e.key === "Escape") {
+ setIsOpen(false);
+ }
+ }}
+ />
+
+
+
+ {/* Resource list */}
+
+
+
+
+
+ );
+};
+
+/**
+ * Internal component to render resource list content
+ */
+function ResourceListContent({
+ isLoading,
+ filteredResources,
+ searchQuery,
+ onSelectResource,
+}: {
+ isLoading: boolean;
+ filteredResources: ReturnType["data"];
+ searchQuery: string;
+ onSelectResource: (id: string, label: string) => void;
+}) {
+ if (isLoading) {
+ return (
+
+ Loading resources...
+
+ );
+ }
+ if (!filteredResources || filteredResources.length === 0) {
+ return (
+
+ {searchQuery
+ ? `No resources matching "${searchQuery}"`
+ : "No resources available"}
+
+ );
+ }
+ return (
+ <>
+ {filteredResources.map((resourceEntry) => (
+ {
+ onSelectResource(
+ resourceEntry.resource.uri,
+ resourceEntry.resource.name || resourceEntry.resource.uri,
+ );
+ }}
+ >
+
+
+
+ {resourceEntry.resource.name ?? "Unnamed Resource"}
+
+
+ {resourceEntry.resource.uri}
+
+ {resourceEntry.resource.description && (
+
+ {resourceEntry.resource.description}
+
+ )}
+
+
+
+ ))}
+ >
+ );
+}
+
+/**
+ * Props for the McpResourceButton component.
+ */
+export interface McpResourceButtonProps extends React.ButtonHTMLAttributes {
+ /** Callback to insert text into the input */
+ onInsertResource: (id: string, label: string) => void;
+ /** Current input value */
+ value: string;
+ /** Optional custom className */
+ className?: string;
+}
+
+/**
+ * MCP Resource picker button component for inserting resource references from MCP servers.
+ * Uses a combobox with search for easy filtering of potentially many resources.
+ * @component McpResourceButton
+ * @example
+ * ```tsx
+ * setInputValue(text)}
+ * />
+ * ```
+ */
+export const McpResourceButton = React.forwardRef<
+ HTMLButtonElement,
+ McpResourceButtonProps
+>(({ className, onInsertResource, value: _value, ...props }, ref) => {
+ const { data: resourceList, isLoading } = useTamboMcpResourceList();
+ const [isOpen, setIsOpen] = React.useState(false);
+ const [searchQuery, setSearchQuery] = React.useState("");
+
+ // Filter resources based on search query
+ const filteredResources = React.useMemo(() => {
+ if (!resourceList) return [];
+ if (!searchQuery) return resourceList;
+
+ const query = searchQuery.toLowerCase();
+ return resourceList.filter((entry) => {
+ const uri = entry.resource.uri.toLowerCase();
+ const name = entry.resource.name?.toLowerCase() ?? "";
+ const description = entry.resource.description?.toLowerCase() ?? "";
+ // Combine predicates without `||` to satisfy lint rule preferring `??` for fallbacks
+ // Ensure correct boolean semantics by using Array.prototype.some
+ return [
+ uri.includes(query),
+ name.includes(query),
+ description.includes(query),
+ ].some(Boolean);
+ });
+ }, [resourceList, searchQuery]);
+
+ const handleSelectResource = (id: string, label: string) => {
+ // Pass raw resource string to caller; caller decides how to insert
+ onInsertResource(id, label);
+ setIsOpen(false);
+ setSearchQuery("");
+ };
+
+ // Only show button if resources are available
+ if (!resourceList || resourceList.length === 0) {
+ return null;
+ }
+
+ const buttonClasses = cn(
+ "w-10 h-10 rounded-lg border border-border bg-background text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:pointer-events-none flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
+ className,
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+});
+McpResourceButton.displayName = "McpResourceButton";
diff --git a/src/components/tambo/mcp-config-modal.tsx b/src/components/tambo/mcp-config-modal.tsx
index 135868f..fdb3a82 100644
--- a/src/components/tambo/mcp-config-modal.tsx
+++ b/src/components/tambo/mcp-config-modal.tsx
@@ -1,19 +1,19 @@
"use client";
-import { type McpServerInfo, MCPTransport } from "@tambo-ai/react/mcp";
-import { ChevronDown, X, Trash2 } from "lucide-react";
-import React from "react";
-import { createPortal } from "react-dom";
-import { motion } from "framer-motion";
+import { createMarkdownComponents } from "@/components/tambo/markdown-components";
+import { cn } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@radix-ui/react-dropdown-menu";
-import { createMarkdownComponents } from "@/components/tambo/markdown-components";
-import Markdown from "react-markdown";
-import { cn } from "@/lib/utils";
+import { type McpServerInfo, MCPTransport } from "@tambo-ai/react";
+import { motion } from "framer-motion";
+import { ChevronDown, Trash2, X } from "lucide-react";
+import React from "react";
+import { createPortal } from "react-dom";
+import { Streamdown } from "streamdown";
/**
* Modal component for configuring client-side MCP (Model Context Protocol) servers.
@@ -114,11 +114,11 @@ export const McpConfigModal = ({
// Helper function to get server display information
const getServerInfo = (server: McpServerInfo) => {
if (typeof server === "string") {
- return { url: server, transport: "SSE (default)", name: null };
+ return { url: server, transport: "HTTP (default)", name: null };
} else {
return {
url: server.url,
- transport: server.transport ?? "SSE (default)",
+ transport: server.transport ?? "HTTP (default)",
name: server.name ?? null,
};
}
@@ -142,14 +142,13 @@ export const McpConfigModal = ({
After configuring your MCP servers below, integrate them into your application.
-#### 1. Import the required components
+#### 1. Import the required hook
\`\`\`tsx
import { useMcpServers } from "@/components/tambo/mcp-config-modal";
-import { TamboMcpProvider } from "@tambo-ai/react/mcp";
\`\`\`
-#### 2. Load MCP servers and wrap your components:
+#### 2. Load MCP servers and pass to TamboProvider:
\`\`\`tsx
const mcpServers = useMcpServers();
@@ -162,10 +161,13 @@ function MyApp() {
const mcpServers = useMcpServers(); // Reactive - updates when servers change
return (
-
-
- {/* Your app components */}
-
+
+ {/* Your app components */}
);
}
@@ -219,9 +221,9 @@ function MyApp() {
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
-
+
{instructions}
-
+
)}
@@ -229,7 +231,7 @@ function MyApp() {
Configure{" "}
- client-side {" "}
+ client-side {" "}
MCP servers to extend the capabilities of your tambo application.
These servers will be connected{" "}
@@ -249,7 +251,7 @@ function MyApp() {
className="block text-sm font-semibold text-foreground mb-2"
>
Server URL
-
+
(must be accessible from the browser)
@@ -271,7 +273,7 @@ function MyApp() {
className="block text-sm font-semibold text-foreground mb-2"
>
Server Name
-
+
(optional)
@@ -362,12 +364,12 @@ function MyApp() {
{serverInfo.name && (
-
+
Name: {" "}
{serverInfo.name}
)}
-
+
Transport: {" "}
{serverInfo.transport}
@@ -386,10 +388,10 @@ function MyApp() {
) : (
-
+
No MCP servers configured yet
-
+
Add your first server above to get started
@@ -415,13 +417,13 @@ function MyApp() {
-
+
Learn more: {" "}
client-side
{" "}
@@ -430,7 +432,7 @@ function MyApp() {
href="https://docs.tambo.co/concepts/model-context-protocol/serverside-mcp-connection"
target="_blank"
rel="noopener noreferrer"
- className="text-secondary hover:text-foreground underline underline-offset-2"
+ className="text-muted-foreground hover:text-foreground underline underline-offset-2"
>
server-side
{" "}
@@ -469,9 +471,9 @@ export type McpServer = string | { url: string };
* // Returns: [{ url: "https://api.example.com" }, "https://api2.example.com"]
*
* return (
- *
+ *
* {children}
- *
+ *
* );
* }
* ```
diff --git a/src/components/tambo/message-generation-stage.tsx b/src/components/tambo/message-generation-stage.tsx
index 4e4d291..9dd83e3 100644
--- a/src/components/tambo/message-generation-stage.tsx
+++ b/src/components/tambo/message-generation-stage.tsx
@@ -1,7 +1,7 @@
"use client";
import { cn } from "@/lib/utils";
-import { type GenerationStage, useTambo } from "@tambo-ai/react";
+import { useTambo } from "@tambo-ai/react";
import { Loader2Icon } from "lucide-react";
import * as React from "react";
@@ -11,8 +11,7 @@ import * as React from "react";
* @property {boolean} showLabel - Whether to show the label
*/
-export interface GenerationStageProps
- extends React.HTMLAttributes {
+export interface GenerationStageProps extends React.HTMLAttributes {
showLabel?: boolean;
}
@@ -21,30 +20,20 @@ export function MessageGenerationStage({
showLabel = true,
...props
}: GenerationStageProps) {
- const { thread, isIdle } = useTambo();
- const stage = thread?.generationStage;
+ const { isStreaming, isWaiting, isIdle } = useTambo();
- // Only render if we have a generation stage
- if (!stage) {
+ if (isIdle) {
return null;
}
- // Map stage names to more user-friendly labels
- const stageLabels: Record = {
- IDLE: "Idle",
- CHOOSING_COMPONENT: "Choosing component",
- FETCHING_CONTEXT: "Fetching context",
- HYDRATING_COMPONENT: "Preparing component",
- STREAMING_RESPONSE: "Generating response",
- COMPLETE: "Complete",
- ERROR: "Error",
- CANCELLED: "Cancelled",
- };
-
- const label =
- stageLabels[stage] || `${stage.charAt(0).toUpperCase() + stage.slice(1)}`;
+ let label = "";
+ if (isWaiting) {
+ label = "Preparing response";
+ } else if (isStreaming) {
+ label = "Generating response";
+ }
- if (isIdle) {
+ if (!label) {
return null;
}
diff --git a/src/components/tambo/message-input.tsx b/src/components/tambo/message-input.tsx
index 31a4e2f..a42d61f 100644
--- a/src/components/tambo/message-input.tsx
+++ b/src/components/tambo/message-input.tsx
@@ -1,27 +1,79 @@
"use client";
-import { McpConfigModal } from "@/components/tambo/mcp-config-modal";
+import { useTamboMcpPrompt } from "@tambo-ai/react/mcp";
+import { ElicitationUI } from "@/components/tambo/elicitation-ui";
+import {
+ McpPromptButton,
+ McpResourceButton,
+} from "@/components/tambo/mcp-components";
import {
Tooltip,
TooltipProvider,
-} from "@/components/tambo/suggestions-tooltip";
+} from "@/components/tambo/message-suggestions";
import { cn } from "@/lib/utils";
-import {
- useIsTamboTokenUpdating,
- useTamboThread,
- useTamboThreadInput,
-} from "@tambo-ai/react";
import { cva, type VariantProps } from "class-variance-authority";
-import { ArrowUp, Square } from "lucide-react";
+import {
+ ArrowUp,
+ AtSign,
+ FileText,
+ Image as ImageIcon,
+ Paperclip,
+ Square,
+ X,
+} from "lucide-react";
import * as React from "react";
+import { McpConfigModal } from "./mcp-config-modal";
+import {
+ getImageItems,
+ TextEditor,
+ type PromptItem,
+ type ResourceItem,
+ type TamboEditor,
+} from "./text-editor";
+
+// Import base compound components and constants
+import {
+ IS_PASTED_IMAGE,
+ MAX_IMAGES,
+ MessageInput as MessageInputBase,
+ type PromptProvider,
+ type ResourceProvider,
+ type StagedImageRenderProps,
+} from "@tambo-ai/react-ui-base/message-input";
+
+// Lazy load DictationButton for code splitting (framework-agnostic alternative to next/dynamic)
+// eslint-disable-next-line @typescript-eslint/promise-function-async
+const LazyDictationButton = React.lazy(() => import("./dictation-button"));
/**
- * CSS variants for the message input container
- * @typedef {Object} MessageInputVariants
- * @property {string} default - Default styling
- * @property {string} solid - Solid styling with shadow effects
- * @property {string} bordered - Bordered styling with border emphasis
+ * Wrapper component that includes Suspense boundary for the lazy-loaded DictationButton.
+ * This ensures the component can be safely used without requiring consumers to add their own Suspense.
+ * Also handles SSR by only rendering on the client (DictationButton uses Web Audio APIs).
*/
+const DictationButton = () => {
+ const [isMounted, setIsMounted] = React.useState(false);
+
+ React.useEffect(() => {
+ setIsMounted(true);
+ }, []);
+
+ if (!isMounted) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+};
+
+const noop = () => {};
+
+// Re-export provider interfaces from base
+export type { PromptProvider, ResourceProvider };
+
+/** CSS variants for the message input container */
const messageInputVariants = cva("w-full", {
variants: {
variant: {
@@ -48,69 +100,15 @@ const messageInputVariants = cva("w-full", {
},
});
-/**
- * @typedef MessageInputContextValue
- * @property {string} value - The current input value
- * @property {function} setValue - Function to update the input value
- * @property {function} submit - Function to submit the message
- * @property {function} handleSubmit - Function to handle form submission
- * @property {boolean} isPending - Whether a submission is in progress
- * @property {Error|null} error - Any error from the submission
- * @property {string|undefined} contextKey - The thread context key
- * @property {HTMLTextAreaElement|null} textareaRef - Reference to the textarea element
- * @property {string | null} submitError - Error from the submission
- * @property {function} setSubmitError - Function to set the submission error
- */
-interface MessageInputContextValue {
- value: string;
- setValue: (value: string) => void;
- submit: (options: {
- contextKey?: string;
- streamResponse?: boolean;
- }) => Promise;
- handleSubmit: (e: React.FormEvent) => Promise;
- isPending: boolean;
- error: Error | null;
- contextKey?: string;
- textareaRef: React.RefObject;
- submitError: string | null;
- setSubmitError: React.Dispatch>;
-}
-
-/**
- * React Context for sharing message input data and functions among sub-components.
- * @internal
- */
-const MessageInputContext =
- React.createContext(null);
-
-/**
- * Hook to access the message input context.
- * Throws an error if used outside of a MessageInput component.
- * @returns {MessageInputContextValue} The message input context value.
- * @throws {Error} If used outside of MessageInput.
- * @internal
- */
-const useMessageInputContext = () => {
- const context = React.useContext(MessageInputContext);
- if (!context) {
- throw new Error(
- "MessageInput sub-components must be used within a MessageInput",
- );
- }
- return context;
-};
-
/**
* Props for the MessageInput component.
* Extends standard HTMLFormElement attributes.
*/
-export interface MessageInputProps
- extends React.HTMLAttributes {
- /** The context key identifying which thread to send messages to. */
- contextKey?: string;
+export interface MessageInputProps extends React.HTMLAttributes {
/** Optional styling variant for the input container. */
variant?: VariantProps["variant"];
+ /** Optional ref to forward to the TamboEditor instance. */
+ inputRef?: React.RefObject;
/** The child elements to render within the form container. */
children?: React.ReactNode;
}
@@ -118,10 +116,11 @@ export interface MessageInputProps
/**
* The root container for a message input component.
* It establishes the context for its children and handles the form submission.
+ * Composes the headless MessageInput.Root from base for all business logic.
* @component MessageInput
* @example
* ```tsx
- *
+ *
*
*
*
@@ -129,186 +128,384 @@ export interface MessageInputProps
* ```
*/
const MessageInput = React.forwardRef(
- ({ children, className, contextKey, variant, ...props }, ref) => {
- const { value, setValue, submit, isPending, error } = useTamboThreadInput();
- const { cancel } = useTamboThread();
- const [displayValue, setDisplayValue] = React.useState("");
- const [submitError, setSubmitError] = React.useState(null);
- const [isSubmitting, setIsSubmitting] = React.useState(false);
- const textareaRef = React.useRef(null);
-
- React.useEffect(() => {
- setDisplayValue(value);
- if (value && textareaRef.current) {
- textareaRef.current.focus();
- }
- }, [value]);
-
- const handleSubmit = React.useCallback(
- async (e: React.FormEvent) => {
- e.preventDefault();
- if (!value.trim() || isSubmitting) return;
-
- setSubmitError(null);
- setDisplayValue("");
- setIsSubmitting(true);
-
- try {
- await submit({
- contextKey,
- streamResponse: true,
- });
- setValue("");
- setTimeout(() => {
- textareaRef.current?.focus();
- }, 0);
- } catch (error) {
- console.error("Failed to submit message:", error);
- setDisplayValue(value);
- setSubmitError(
- error instanceof Error
- ? error.message
- : "Failed to send message. Please try again.",
- );
-
- // Cancel the thread to reset loading state
- cancel();
- } finally {
- setIsSubmitting(false);
- }
- },
- [
- value,
- submit,
- contextKey,
- setValue,
- setDisplayValue,
- setSubmitError,
- cancel,
- isSubmitting,
- ],
- );
-
- const contextValue = React.useMemo(
- () => ({
- value: displayValue,
- setValue: (newValue: string) => {
- setValue(newValue);
- setDisplayValue(newValue);
- },
- submit,
- handleSubmit,
- isPending: isPending ?? isSubmitting,
- error,
- contextKey,
- textareaRef,
- submitError,
- setSubmitError,
- }),
- [
- displayValue,
- setValue,
- submit,
- handleSubmit,
- isPending,
- isSubmitting,
- error,
- contextKey,
- submitError,
- ],
- );
+ ({ children, className, variant, inputRef, ...props }, ref) => {
return (
-
-
-
+
+ {/* Use data-* classes for styling, render props only for behavior changes */}
+
+ {/* Render props ONLY for behavior change (elicitation vs normal content) */}
+ {({ elicitation, resolveElicitation }) => (
+ <>
+ {/* Drop overlay styled with CSS, shown via group-data-[dragging] */}
+
+
+ Drop files here to add to conversation
+
+
+
+ {elicitation && resolveElicitation ? (
+
+ ) : (
+ <>
+
+ {children}
+ >
+ )}
+ >
+ )}
+
+
+
);
},
);
MessageInput.displayName = "MessageInput";
+// IS_PASTED_IMAGE and MAX_IMAGES imported from base
+
/**
* Props for the MessageInputTextarea component.
* Extends standard TextareaHTMLAttributes.
*/
-export interface MessageInputTextareaProps
- extends React.TextareaHTMLAttributes {
+export interface MessageInputTextareaProps extends React.HTMLAttributes {
/** Custom placeholder text. */
placeholder?: string;
+ /** Resource provider for @ mentions (optional - includes interactables by default) */
+ resourceProvider?: ResourceProvider;
+ /** Prompt provider for / commands (optional) */
+ promptProvider?: PromptProvider;
+ /** Callback when a resource is selected from @ mentions (optional) */
+ onResourceSelect?: (item: ResourceItem) => void;
}
/**
- * Textarea component for entering message text.
- * Automatically connects to the context to handle value changes and key presses.
+ * Rich-text textarea component for entering message text with @ mention support.
+ * Uses the TipTap-based TextEditor which supports:
+ * - @ mention autocomplete for interactables plus optional static items and async fetchers
+ * - Keyboard navigation (Enter to submit, Shift+Enter for newline)
+ * - Image paste handling via the thread input context
+ *
+ * **Note:** This component uses refs internally to ensure callbacks stay fresh,
+ * so consumers can pass updated providers on each render without worrying about
+ * closure issues with the TipTap editor.
+ *
* @component MessageInput.Textarea
* @example
* ```tsx
*
- *
+ * {
+ * // Return custom resources
+ * return [{ id: "foo", name: "Foo" }];
+ * }
+ * }}
+ * />
*
* ```
*/
const MessageInputTextarea = ({
className,
placeholder = "What do you want to do?",
+ resourceProvider,
+ promptProvider,
+ onResourceSelect,
...props
}: MessageInputTextareaProps) => {
- const { value, setValue, textareaRef, handleSubmit } =
- useMessageInputContext();
- const { isIdle } = useTamboThread();
- const isUpdatingToken = useIsTamboTokenUpdating();
- const isPending = !isIdle;
-
- const handleChange = (e: React.ChangeEvent) => {
- setValue(e.target.value);
- };
-
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
- if (value.trim()) {
- handleSubmit(e as unknown as React.FormEvent);
- }
+ // Resource names are extracted from editor at submit time, no need to track in state
+ const setResourceNames = React.useCallback(
+ (
+ _resourceNames:
+ | Record
+ | ((prev: Record) => Record),
+ ) => {
+ // No-op - we extract resource names directly from editor at submit time
+ },
+ [],
+ );
+
+ // Icon factories for MCP items (styled layer provides the icons)
+ const resourceFormatOptions = React.useMemo(
+ () => ({ createMcpIcon: () => }),
+ [],
+ );
+ const promptFormatOptions = React.useMemo(
+ () => ({ createMcpIcon: () => }),
+ [],
+ );
+
+ // State for MCP prompt fetching (since we can't call hooks inside get())
+ const [selectedMcpPromptName, setSelectedMcpPromptName] = React.useState<
+ string | null
+ >(null);
+ const { data: selectedMcpPromptData } = useTamboMcpPrompt(
+ selectedMcpPromptName ?? "",
+ );
+
+ // Handle prompt selection - check if it's an MCP prompt
+ const handlePromptSelect = React.useCallback((item: PromptItem) => {
+ if (item.id.startsWith("mcp-prompt:")) {
+ const promptName = item.id.replace("mcp-prompt:", "");
+ setSelectedMcpPromptName(promptName);
}
- };
+ }, []);
+
+ // Handle image paste - mark as pasted and add to thread
+ const pendingImagesRef = React.useRef(0);
return (
-
+ >
+ {({
+ value,
+ setValue,
+ handleSubmit,
+ editorRef,
+ disabled,
+ addImage,
+ images,
+ setImageError,
+ resourceItems,
+ setResourceSearch,
+ promptItems,
+ setPromptSearch,
+ }) => {
+ // Handle image paste - mark as pasted and add to thread
+ const handleAddImage = async (file: File) => {
+ if (images.length + pendingImagesRef.current >= MAX_IMAGES) {
+ setImageError(`Max ${MAX_IMAGES} uploads at a time`);
+ return;
+ }
+ setImageError(null);
+ pendingImagesRef.current += 1;
+ try {
+ file[IS_PASTED_IMAGE] = true;
+ await addImage(file);
+ } finally {
+ pendingImagesRef.current -= 1;
+ }
+ };
+
+ return (
+ <>
+ setSelectedMcpPromptName(null)}
+ />
+ }
+ value={value}
+ onChange={setValue}
+ onResourceNamesChange={setResourceNames}
+ onSubmit={handleSubmit}
+ onAddImage={handleAddImage}
+ placeholder={placeholder}
+ disabled={disabled}
+ className="bg-background text-foreground"
+ onSearchResources={setResourceSearch}
+ resources={resourceItems}
+ onSearchPrompts={setPromptSearch}
+ prompts={promptItems}
+ onResourceSelect={onResourceSelect ?? noop}
+ onPromptSelect={handlePromptSelect}
+ />
+ >
+ );
+ }}
+
);
};
MessageInputTextarea.displayName = "MessageInput.Textarea";
+/**
+ * Helper component to handle MCP prompt insertion effect.
+ * Extracted to avoid hooks-in-render-prop issues.
+ */
+interface McpPromptEffectProps {
+ selectedMcpPromptName: string | null;
+ selectedMcpPromptData:
+ | { messages?: Array<{ content?: { type: string; text?: string } }> }
+ | null
+ | undefined;
+ editorRef: React.RefObject;
+ setValue: (value: string) => void;
+ onComplete: () => void;
+}
+
+const McpPromptEffect: React.FC = ({
+ selectedMcpPromptName,
+ selectedMcpPromptData,
+ editorRef,
+ setValue,
+ onComplete,
+}) => {
+ React.useEffect(() => {
+ if (selectedMcpPromptData && selectedMcpPromptName) {
+ const promptMessages = selectedMcpPromptData?.messages;
+ if (promptMessages) {
+ const promptText = promptMessages
+ .map((msg) => {
+ if (msg.content?.type === "text") {
+ return msg.content.text;
+ }
+ return "";
+ })
+ .filter(Boolean)
+ .join("\n");
+
+ const editor = editorRef.current;
+ if (editor) {
+ editor.setContent(promptText);
+ setValue(promptText);
+ editor.focus("end");
+ }
+ }
+ onComplete();
+ }
+ }, [
+ selectedMcpPromptData,
+ selectedMcpPromptName,
+ editorRef,
+ setValue,
+ onComplete,
+ ]);
+
+ return null;
+};
+
+/**
+ * Props for the legacy plain textarea message input component.
+ * This preserves the original MessageInput.Textarea API for backward compatibility.
+ */
+export interface MessageInputPlainTextareaProps extends React.TextareaHTMLAttributes {
+ /** Custom placeholder text. */
+ placeholder?: string;
+}
+
+/**
+ * Legacy textarea-based message input component.
+ *
+ * This mirrors the previous MessageInput.Textarea implementation using a native
+ * `
+
{/* Right side - only submit button */}
{React.Children.map(children, (child): React.ReactNode => {
if (
@@ -550,11 +1006,21 @@ MessageInputToolbar.displayName = "MessageInput.Toolbar";
// --- Exports ---
export {
+ DictationButton,
MessageInput,
+ MessageInputContexts,
MessageInputError,
+ MessageInputFileButton,
MessageInputMcpConfigButton,
+ MessageInputMcpPromptButton,
+ MessageInputMcpResourceButton,
+ MessageInputPlainTextarea,
+ MessageInputStagedImages,
MessageInputSubmitButton,
MessageInputTextarea,
MessageInputToolbar,
messageInputVariants,
};
+
+// Re-export types from text-editor for convenience
+export type { PromptItem, ResourceItem } from "./text-editor";
diff --git a/src/components/tambo/message-suggestions.tsx b/src/components/tambo/message-suggestions.tsx
index d520b67..0e39550 100644
--- a/src/components/tambo/message-suggestions.tsx
+++ b/src/components/tambo/message-suggestions.tsx
@@ -1,14 +1,10 @@
"use client";
-import { MessageGenerationStage } from "@/components/tambo/message-generation-stage";
-import {
- Tooltip,
- TooltipProvider,
-} from "@/components/tambo/suggestions-tooltip";
+import { MessageGenerationStage } from "./message-generation-stage";
+import { Tooltip, TooltipProvider } from "./suggestions-tooltip";
import { cn } from "@/lib/utils";
-import type { Suggestion, TamboThread } from "@tambo-ai/react";
+import type { Suggestion, TamboThreadMessage } from "@tambo-ai/react";
import { useTambo, useTamboSuggestions } from "@tambo-ai/react";
-import { Loader2Icon } from "lucide-react";
import * as React from "react";
import { useEffect, useRef } from "react";
@@ -24,10 +20,11 @@ import { useEffect, useRef } from "react";
interface MessageSuggestionsContextValue {
suggestions: Suggestion[];
selectedSuggestionId: string | null;
- accept: (options: { suggestion: Suggestion }) => void;
+ accept: (options: { suggestion: Suggestion }) => Promise
;
isGenerating: boolean;
error: Error | null;
- thread: TamboThread;
+ messages: TamboThreadMessage[];
+ isStreaming: boolean;
isMac: boolean;
}
@@ -58,8 +55,7 @@ const useMessageSuggestionsContext = () => {
* Props for the MessageSuggestions component.
* Extends standard HTMLDivElement attributes.
*/
-export interface MessageSuggestionsProps
- extends React.HTMLAttributes {
+export interface MessageSuggestionsProps extends React.HTMLAttributes {
/** Maximum number of suggestions to display (default: 3) */
maxSuggestions?: number;
/** The child elements to render within the container. */
@@ -94,24 +90,25 @@ const MessageSuggestions = React.forwardRef<
},
ref,
) => {
- const { thread } = useTambo();
+ const { messages, isStreaming } = useTambo();
const {
suggestions: generatedSuggestions,
selectedSuggestionId,
accept,
- generateResult: { isPending: isGenerating, error },
+ isGenerating,
+ error,
} = useTamboSuggestions({ maxSuggestions });
// Combine initial and generated suggestions, but only use initial ones when thread is empty
const suggestions = React.useMemo(() => {
// Only use pre-seeded suggestions if thread is empty
- if (!thread?.messages?.length && initialSuggestions.length > 0) {
+ if (!messages.length && initialSuggestions.length > 0) {
return initialSuggestions.slice(0, maxSuggestions);
}
// Otherwise use generated suggestions
return generatedSuggestions;
}, [
- thread?.messages?.length,
+ messages.length,
generatedSuggestions,
initialSuggestions,
maxSuggestions,
@@ -131,7 +128,8 @@ const MessageSuggestions = React.forwardRef<
accept,
isGenerating,
error,
- thread,
+ messages,
+ isStreaming,
isMac,
}),
[
@@ -140,15 +138,18 @@ const MessageSuggestions = React.forwardRef<
accept,
isGenerating,
error,
- thread,
+ messages,
+ isStreaming,
isMac,
],
);
// Find the last AI message
- const lastAiMessage = thread?.messages
- ? [...thread.messages].reverse().find((msg) => msg.role === "assistant")
- : null;
+ const lastAiMessage =
+ messages.length > 0
+ ? (messages.toReversed().find((msg) => msg.role === "assistant") ??
+ null)
+ : null;
// When a new AI message appears, update the reference
useEffect(() => {
@@ -183,7 +184,7 @@ const MessageSuggestions = React.forwardRef<
if (!isNaN(keyNum) && keyNum > 0 && keyNum <= suggestions.length) {
event.preventDefault();
const suggestionIndex = keyNum - 1;
- accept({ suggestion: suggestions[suggestionIndex] as Suggestion });
+ void accept({ suggestion: suggestions[suggestionIndex] });
}
}
};
@@ -196,7 +197,7 @@ const MessageSuggestions = React.forwardRef<
}, [suggestions, accept, isMac]);
// If we have no messages yet and no initial suggestions, render nothing
- if (!thread?.messages?.length && initialSuggestions.length === 0) {
+ if (!messages.length && initialSuggestions.length === 0) {
return null;
}
@@ -241,18 +242,14 @@ const MessageSuggestionsStatus = React.forwardRef<
HTMLDivElement,
MessageSuggestionsStatusProps
>(({ className, ...props }, ref) => {
- const { error, isGenerating, thread } = useMessageSuggestionsContext();
+ const { error, isGenerating, isStreaming } = useMessageSuggestionsContext();
return (
- {thread?.generationStage && thread.generationStage !== "COMPLETE" ? (
-
- ) : isGenerating ? (
-
-
-
Generating suggestions...
-
- ) : null}
+ {isStreaming &&
}
);
@@ -338,11 +328,10 @@ const MessageSuggestionsList = React.forwardRef<
className={cn(
"py-2 px-2.5 rounded-2xl text-xs transition-colors",
"border border-flat",
- isGenerating
- ? "bg-muted/50 text-muted-foreground"
- : selectedSuggestionId === suggestion.id
- ? "bg-accent text-accent-foreground"
- : "bg-background hover:bg-accent hover:text-accent-foreground",
+ getSuggestionButtonClassName({
+ isGenerating,
+ isSelected: selectedSuggestionId === suggestion.id,
+ }),
)}
onClick={async () =>
!isGenerating && (await accept({ suggestion }))
@@ -370,4 +359,29 @@ const MessageSuggestionsList = React.forwardRef<
});
MessageSuggestionsList.displayName = "MessageSuggestions.List";
-export { MessageSuggestions, MessageSuggestionsStatus, MessageSuggestionsList };
+/**
+ * Internal function to get className for suggestion button based on state
+ */
+function getSuggestionButtonClassName({
+ isGenerating,
+ isSelected,
+}: {
+ isGenerating: boolean;
+ isSelected: boolean;
+}) {
+ if (isGenerating) {
+ return "bg-muted/50 text-muted-foreground";
+ }
+ if (isSelected) {
+ return "bg-accent text-accent-foreground";
+ }
+ return "bg-background hover:bg-accent hover:text-accent-foreground";
+}
+
+export {
+ MessageSuggestions,
+ MessageSuggestionsList,
+ MessageSuggestionsStatus,
+ Tooltip,
+ TooltipProvider,
+};
diff --git a/src/components/tambo/message-thread-full.tsx b/src/components/tambo/message-thread-full.tsx
index 2a8445d..43ba932 100644
--- a/src/components/tambo/message-thread-full.tsx
+++ b/src/components/tambo/message-thread-full.tsx
@@ -1,47 +1,50 @@
-'use client';
+"use client";
+import type { messageVariants } from "@/components/tambo/message";
import {
MessageInput,
+ MessageInputError,
+ MessageInputFileButton,
+ MessageInputMcpPromptButton,
+ MessageInputMcpResourceButton,
+ MessageInputSubmitButton,
MessageInputTextarea,
MessageInputToolbar,
- MessageInputSubmitButton,
- MessageInputError,
- MessageInputMcpConfigButton,
-} from '@/components/tambo/message-input';
+} from "@/components/tambo/message-input";
import {
MessageSuggestions,
- MessageSuggestionsStatus,
MessageSuggestionsList,
-} from '@/components/tambo/message-suggestions';
-import type { messageVariants } from '@/components/tambo/message';
+ MessageSuggestionsStatus,
+} from "@/components/tambo/message-suggestions";
+import { ScrollableMessageContainer } from "@/components/tambo/scrollable-message-container";
+import { ThreadContainer, useThreadContainerContext } from "./thread-container";
import {
ThreadContent,
ThreadContentMessages,
-} from '@/components/tambo/thread-content';
+} from "@/components/tambo/thread-content";
import {
- ThreadContainer,
- useThreadContainerContext,
-} from '@/components/tambo/thread-container';
-import { ScrollableMessageContainer } from '@/components/tambo/scrollable-message-container';
-import { useMergedRef } from '@/lib/thread-hooks';
-import type { Suggestion } from '@tambo-ai/react';
-import type { VariantProps } from 'class-variance-authority';
-import * as React from 'react';
+ ThreadHistory,
+ ThreadHistoryHeader,
+ ThreadHistoryList,
+ ThreadHistoryNewButton,
+ ThreadHistorySearch,
+} from "@/components/tambo/thread-history";
+import { useMergeRefs } from "@/lib/thread-hooks";
+import type { Suggestion } from "@tambo-ai/react";
+import type { VariantProps } from "class-variance-authority";
+import * as React from "react";
/**
* Props for the MessageThreadFull component
*/
-export interface MessageThreadFullProps
- extends React.HTMLAttributes
{
- /** Optional context key for the thread */
- contextKey?: string;
+export interface MessageThreadFullProps extends React.HTMLAttributes {
/**
* Controls the visual styling of messages in the thread.
* Possible values include: "default", "compact", etc.
* These values are defined in messageVariants from "@/components/tambo/message".
* @example variant="compact"
*/
- variant?: VariantProps['variant'];
+ variant?: VariantProps["variant"];
}
/**
@@ -50,62 +53,87 @@ export interface MessageThreadFullProps
export const MessageThreadFull = React.forwardRef<
HTMLDivElement,
MessageThreadFullProps
->(({ className, contextKey, variant, ...props }, ref) => {
- const { containerRef } = useThreadContainerContext();
- const mergedRef = useMergedRef(ref, containerRef);
+>(({ className, variant, ...props }, ref) => {
+ const { containerRef, historyPosition } = useThreadContainerContext();
+ const mergedRef = useMergeRefs(ref, containerRef);
+
+ const threadHistorySidebar = (
+
+
+
+
+
+
+ );
const defaultSuggestions: Suggestion[] = [
{
- id: 'suggestion-1',
- title: 'Get started',
- detailedSuggestion: 'What can you help me with?',
- messageId: 'welcome-query',
+ id: "suggestion-1",
+ title: "Get started",
+ detailedSuggestion: "What can you help me with?",
+ messageId: "welcome-query",
},
{
- id: 'suggestion-2',
- title: 'Learn more',
- detailedSuggestion: 'Tell me about your capabilities.',
- messageId: 'capabilities-query',
+ id: "suggestion-2",
+ title: "Learn more",
+ detailedSuggestion: "Tell me about your capabilities.",
+ messageId: "capabilities-query",
},
{
- id: 'suggestion-3',
- title: 'Examples',
- detailedSuggestion: 'Show me some example queries I can try.',
- messageId: 'examples-query',
+ id: "suggestion-3",
+ title: "Examples",
+ detailedSuggestion: "Show me some example queries I can try.",
+ messageId: "examples-query",
},
];
return (
-
-
-
-
-
-
- {/* Message suggestions status */}
-
-
-
- {/* Message input */}
-
-
-
-
-
-
-
-
-
-
- {/* Message suggestions */}
-
-
-
-
+
+ {/* Thread History Sidebar - rendered first if history is on the left */}
+ {historyPosition === "left" && threadHistorySidebar}
+
+
+
+
+
+
+
+
+ {/* Message suggestions status */}
+
+
+
+
+ {/* Message input */}
+
+
+
+
+
+
+
+ {/* Uncomment this to enable client-side MCP config modal button */}
+ {/* */}
+
+
+
+
+
+
+ {/* Message suggestions */}
+
+
+
+
+
+ {/* Thread History Sidebar - rendered last if history is on the right */}
+ {historyPosition === "right" && threadHistorySidebar}
+
);
});
-MessageThreadFull.displayName = 'MessageThreadFull';
+MessageThreadFull.displayName = "MessageThreadFull";
diff --git a/src/components/tambo/message.tsx b/src/components/tambo/message.tsx
index cc7207e..999f106 100644
--- a/src/components/tambo/message.tsx
+++ b/src/components/tambo/message.tsx
@@ -1,17 +1,33 @@
"use client";
-import { createMarkdownComponents } from "@/components/tambo/markdown-components";
-import { checkHasContent, getSafeContent } from "@/lib/thread-hooks";
+import { TamboThreadMessage, TamboToolUseContent } from "@tambo-ai/react";
+import {
+ Message as MessageBase,
+ type MessageContentProps as MessageBaseContentProps,
+ type MessageContentRenderProps as MessageBaseContentRenderProps,
+ type MessageImagesProps as MessageBaseImagesProps,
+ type MessageRenderedComponentProps as MessageBaseRenderedComponentProps,
+ type MessageLoadingIndicatorProps,
+ type MessageRootProps,
+} from "@tambo-ai/react-ui-base/message";
+import {
+ ReasoningInfo as ReasoningInfoBase,
+ type ReasoningInfoRootProps,
+} from "@tambo-ai/react-ui-base/reasoning-info";
+import {
+ ToolcallInfo as ToolcallInfoBase,
+ type ToolcallInfoRootProps as ToolcallInfoBaseRootProps,
+} from "@tambo-ai/react-ui-base/toolcall-info";
import { cn } from "@/lib/utils";
-import type { TamboThreadMessage } from "@tambo-ai/react";
-import { useTambo } from "@tambo-ai/react";
-import type TamboAI from "@tambo-ai/typescript-sdk";
import { cva, type VariantProps } from "class-variance-authority";
-import stringify from "json-stringify-pretty-compact";
import { Check, ChevronDown, ExternalLink, Loader2, X } from "lucide-react";
import * as React from "react";
-import { useState } from "react";
-import ReactMarkdown from "react-markdown";
+import { Streamdown } from "streamdown";
+import { getSafeContent } from "../../lib/thread-hooks";
+import {
+ createMarkdownComponents,
+ markdownComponents,
+} from "./markdown-components";
/**
* CSS variants for the message container
@@ -37,59 +53,12 @@ const messageVariants = cva("flex", {
},
});
-/**
- * @typedef MessageContextValue
- * @property {"user" | "assistant"} role - The role of the message sender.
- * @property {VariantProps["variant"]} [variant] - Optional styling variant for the message container.
- * @property {TamboThreadMessage} message - The full Tambo thread message object.
- * @property {boolean} [isLoading] - Optional flag to indicate if the message is in a loading state.
- */
-interface MessageContextValue {
- role: "user" | "assistant";
- variant?: VariantProps["variant"];
- message: TamboThreadMessage;
- isLoading?: boolean;
-}
-
-/**
- * React Context for sharing message data and settings among sub-components.
- * @internal
- */
-const MessageContext = React.createContext(null);
-
-/**
- * Hook to access the message context.
- * Throws an error if used outside of a Message component.
- * @returns {MessageContextValue} The message context value.
- * @throws {Error} If used outside of Message.
- * @internal
- */
-const useMessageContext = () => {
- const context = React.useContext(MessageContext);
- if (!context) {
- throw new Error("Message sub-components must be used within a Message");
- }
- return context;
-};
-
-// --- Sub-Components ---
-
/**
* Props for the Message component.
- * Extends standard HTMLDivElement attributes.
*/
-export interface MessageProps
- extends Omit, "content"> {
- /** The role of the message sender ('user' or 'assistant'). */
- role: "user" | "assistant";
- /** The full Tambo thread message object. */
- message: TamboThreadMessage;
+export interface MessageProps extends MessageRootProps {
/** Optional styling variant for the message container. */
variant?: VariantProps["variant"];
- /** Optional flag to indicate if the message is in a loading state. */
- isLoading?: boolean;
- /** The child elements to render within the root container. Typically includes Message.Bubble and Message.RenderedComponentArea. */
- children: React.ReactNode;
}
/**
@@ -105,138 +74,187 @@ export interface MessageProps
* ```
*/
const Message = React.forwardRef(
- (
- { children, className, role, variant, isLoading, message, ...props },
- ref,
- ) => {
- const contextValue = React.useMemo(
- () => ({ role, variant, isLoading, message }),
- [role, variant, isLoading, message],
- );
-
- // Don't render tool response messages as they're shown in tool call dropdowns
- if (message.actionType === "tool_response") {
- return null;
- }
+ ({ className, variant, message, children, role, ...props }, ref) => {
return (
-
-
- {children}
-
-
+
+ {children}
+
);
},
);
Message.displayName = "Message";
/**
- * Loading indicator with bouncing dots animation
+ * Loading indicator with bouncing dots animation.
*
* A reusable component that displays three animated dots for loading states.
* Used in message content and tool status areas.
*
* @component
- * @param {React.HTMLAttributes} props - Standard HTML div props
- * @param {string} [props.className] - Optional CSS classes to apply
- * @returns {JSX.Element} Animated loading indicator component
+ * @param props - Standard HTML div props
+ * @param props.className - Optional CSS classes to apply
+ * @returns Animated loading indicator component
*/
-const LoadingIndicator: React.FC> = ({
+const LoadingIndicator: React.FC = ({
className,
...props
}) => {
return (
-
-
-
-
-
+
);
};
LoadingIndicator.displayName = "LoadingIndicator";
/**
- * Props for the MessageContent component.
- * Extends standard HTMLDivElement attributes.
+ * Internal component to render message content based on its type
*/
-export interface MessageContentProps
- extends Omit, "content"> {
- /** Optional override for the message content. If not provided, uses the content from the message object in the context. */
- content?: string | { type: string; text?: string }[];
- /** Optional flag to render as Markdown. Default is true. */
- markdown?: boolean;
+function MessageContentRenderer({
+ contentToRender,
+ markdownContent,
+ markdown,
+}: {
+ contentToRender: unknown;
+ markdownContent: string;
+ markdown: boolean;
+}) {
+ if (!contentToRender) {
+ return Empty message ;
+ }
+ if (React.isValidElement(contentToRender)) {
+ return contentToRender;
+ }
+ if (markdown) {
+ return (
+ {markdownContent}
+ );
+ }
+ return markdownContent;
}
+/**
+ * Props for the MessageImages component.
+ */
+export type MessageImagesProps = Omit<
+ MessageBaseImagesProps,
+ "renderImage" | "children"
+>;
+
+/**
+ * Displays images from message content horizontally.
+ * @component MessageImages
+ */
+const MessageImages = React.forwardRef(
+ ({ className, ...props }, ref) => {
+ return (
+ (
+
+
+
+ )}
+ {...props}
+ />
+ );
+ },
+);
+MessageImages.displayName = "MessageImages";
+
+/**
+ * Props for the MessageContent component.
+ */
+export type MessageContentProps = Omit;
+
/**
* Displays the message content with optional markdown formatting.
* Only shows text content - tool calls are handled by ToolcallInfo component.
* @component MessageContent
*/
const MessageContent = React.forwardRef(
- (
- { className, children, content: contentProp, markdown = true, ...props },
- ref,
- ) => {
- const { message, isLoading } = useMessageContext();
- const contentToRender = children ?? contentProp ?? message.content;
-
- const safeContent = React.useMemo(
- () => getSafeContent(contentToRender as TamboThreadMessage["content"]),
- [contentToRender],
- );
- const hasContent = React.useMemo(
- () => checkHasContent(contentToRender as TamboThreadMessage["content"]),
- [contentToRender],
- );
-
- const showLoading = isLoading && !hasContent;
-
+ ({ className, content, markdown = true, ...props }, ref) => {
return (
- {
+ if (isLoading && !isReasoning) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {isCancelled && (
+ cancelled
+ )}
+
+ );
+ }}
{...props}
- >
- {showLoading ? (
-
-
-
- ) : (
-
- {!contentToRender ? (
-
- Empty message
-
- ) : React.isValidElement(contentToRender) ? (
- contentToRender
- ) : markdown ? (
-
- {typeof safeContent === "string" ? safeContent : ""}
-
- ) : (
- safeContent
- )}
- {message.isCancelled && (
- cancelled
- )}
-
- )}
-
+ />
);
},
);
@@ -244,270 +262,541 @@ MessageContent.displayName = "MessageContent";
/**
* Props for the ToolcallInfo component.
- * Extends standard HTMLDivElement attributes.
*/
-export interface ToolcallInfoProps
- extends Omit, "content"> {
+export interface ToolcallInfoProps extends Omit<
+ ToolcallInfoBaseRootProps,
+ "children" | "message"
+> {
/** Optional flag to render response content as Markdown. Default is true. */
markdown?: boolean;
+ /** Optional specific tool_use block to display. If not provided, uses the first from the message. */
+ toolUse?: TamboToolUseContent;
}
-function getToolStatusMessage(
- message: TamboThreadMessage,
- isLoading: boolean | undefined,
-) {
- const isToolCall = message.actionType === "tool_call";
- if (!isToolCall) return null;
-
- const toolCallMessage = isLoading
- ? `Calling ${message.toolCallRequest?.toolName ?? "tool"}`
- : `Called ${message.toolCallRequest?.toolName ?? "tool"}`;
- const toolStatusMessage = isLoading
- ? message.component?.statusMessage
- : message.component?.completionStatusMessage;
- return toolStatusMessage ?? toolCallMessage;
+const toolStatusIconClassName = cva("h-3 w-3 text-bold", {
+ variants: {
+ status: {
+ error: "text-red-500",
+ loading: "text-muted-foreground animate-spin",
+ success: "text-green-500",
+ },
+ },
+ defaultVariants: {
+ status: "success",
+ },
+});
+
+function ToolcallStatusIcon() {
+ return (
+ {
+ let Icon = Check;
+ if (status === "error") Icon = X;
+ if (status === "loading") Icon = Loader2;
+ return ;
+ }}
+ />
+ );
}
+function ToolResultDisplay({
+ content,
+ hasResult,
+ enableMarkdown,
+}: {
+ content: TamboThreadMessage["content"] | null;
+ hasResult: boolean;
+ enableMarkdown: boolean;
+}) {
+ if (!hasResult) {
+ return Empty response ;
+ }
+ if (!content) {
+ return null;
+ }
+ return (
+
+ );
+}
+
+function ToolcallInfoContent({ markdown }: { markdown: boolean }) {
+ return (
+
+ {({ message }) => (
+ <>
+ `tool: ${toolName}`}
+ />
+
+ `parameters:\n${parametersString}`
+ }
+ />
+
+
+ {({ content, hasResult }) => (
+ <>
+ result:
+
+
+
+ >
+ )}
+
+ >
+ )}
+
+ );
+}
+
+type ToolcallInfoTriggerProps = React.ComponentProps<
+ typeof ToolcallInfoBase.Trigger
+>;
+const ToolcallInfoTrigger = React.forwardRef<
+ HTMLButtonElement,
+ ToolcallInfoTriggerProps
+>(function ToolcallInfoTrigger({ children, className, ...props }, ref) {
+ return (
+
+ {children}
+
+ );
+});
+ToolcallInfoTrigger.displayName = "ToolcallInfoTrigger";
+
/**
* Displays tool call information in a collapsible dropdown.
* Shows tool name, parameters, and associated tool response.
* @component ToolcallInfo
*/
const ToolcallInfo = React.forwardRef(
- ({ className, ...props }, ref) => {
- const [isExpanded, setIsExpanded] = useState(false);
- const { message, isLoading } = useMessageContext();
- const { thread } = useTambo();
- const toolDetailsId = React.useId();
-
- const associatedToolResponse = React.useMemo(() => {
- if (!thread?.messages) return null;
- const currentMessageIndex = thread.messages.findIndex(
- (m: TamboThreadMessage) => m.id === message.id,
- );
- if (currentMessageIndex === -1) return null;
- for (let i = currentMessageIndex + 1; i < thread.messages.length; i++) {
- const nextMessage = thread.messages[i];
- if (nextMessage.actionType === "tool_response") {
- return nextMessage;
- }
- if (nextMessage.actionType === "tool_call") {
- break;
- }
- }
- return null;
- }, [message, thread?.messages]);
+ ({ className, markdown = true, toolUse, ...props }, ref) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ },
+);
+ToolcallInfo.displayName = "ToolcallInfo";
+/**
+ * Displays a message's child messages in a collapsible dropdown.
+ * Used for MCP sampling sub-threads.
+ */
+const SamplingSubThread = ({
+ parentMessageId: _parentMessageId,
+ titleText = "finished additional work",
+}: {
+ parentMessageId: string;
+ titleText?: string;
+}) => {
+ const [isExpanded, setIsExpanded] = React.useState(false);
+ const samplingDetailsId = React.useId();
- if (message.actionType !== "tool_call") {
- return null;
- }
+ // In V1, messages no longer have parentMessageId, so sub-threads are not supported.
+ // Render nothing until a V1 equivalent is available.
+ const childMessages: TamboThreadMessage[] = [];
- const toolCallRequest: TamboAI.ToolCallRequest | undefined =
- message.toolCallRequest ?? message.component?.toolCallRequest;
- const hasToolError = message.error;
+ if (!childMessages.length) return null;
+
+ return (
+
+
setIsExpanded(!isExpanded)}
+ className={cn(
+ "flex items-center gap-1 cursor-pointer hover:bg-muted-foreground/10 rounded-md p-2 select-none w-fit",
+ )}
+ >
+ {titleText}
+
+
+
+
+
+ {childMessages?.map((m: TamboThreadMessage) => (
+
+
+ {getSafeContent(m.content)}
+
+
+ ))}
+
+
+
+
+ );
+};
+SamplingSubThread.displayName = "SamplingSubThread";
- const toolStatusMessage = getToolStatusMessage(message, isLoading);
+/**
+ * Props for the ReasoningInfo component.
+ */
+export type ReasoningInfoProps = Omit<
+ ReasoningInfoRootProps,
+ "children" | "message"
+>;
+/**
+ * Displays reasoning information in a collapsible dropdown.
+ * Shows the reasoning strings provided by the LLM when available.
+ * @component ReasoningInfo
+ */
+const ReasoningInfo = React.forwardRef(
+ ({ className, ...props }, ref) => {
return (
-
-
setIsExpanded(!isExpanded)}
- className={cn(
- "flex items-center gap-1 cursor-pointer hover:bg-gray-100 rounded-md p-1 select-none w-fit",
- )}
+
- {hasToolError ? (
-
- ) : isLoading ? (
-
- ) : (
-
- )}
- {toolStatusMessage}
-
-
-
+
+
-
- tool: {toolCallRequest?.toolName}
-
-
- parameters:{"\n"}
- {stringify(keyifyParameters(toolCallRequest?.parameters))}
-
- {associatedToolResponse && (
- <>
- result:
-
- {!associatedToolResponse.content ? (
-
- Empty response
-
- ) : (
- formatToolResult(associatedToolResponse.content)
- )}
-
- >
- )}
-
+
+ steps.map((reasoningStep, index) => (
+
+ {showStepNumbers && (
+
+ Step {index + 1}:
+
+ )}
+ {reasoningStep && (
+
+
+
+ {reasoningStep}
+
+
+
+ )}
+
+ ))
+ }
+ />
+
-
+
);
},
);
+ReasoningInfo.displayName = "ReasoningInfo";
-ToolcallInfo.displayName = "ToolcallInfo";
-
-function keyifyParameters(
- parameters: TamboAI.ToolCallRequest["parameters"] | undefined,
-) {
- if (!parameters) return;
- return Object.fromEntries(
- parameters.map((p) => [p.parameterName, p.parameterValue]),
+/**
+ * Renders an image from a tool result.
+ */
+function ToolResultImage({ url, index }: { url: string; index: number }) {
+ return (
+
+
+
);
}
+interface ToolResultResourceProps {
+ resource: {
+ uri?: string;
+ text?: string;
+ blob?: string;
+ name?: string;
+ mimeType?: string;
+ };
+ index: number;
+}
+
/**
- * Helper function to detect if content is JSON and format it nicely
- * @param content - The content to check and format
- * @returns Formatted content or original content if not JSON
+ * Renders a resource from a tool result.
+ * Handles text, blob (images), and URI resources.
*/
-function formatToolResult(
- content: TamboThreadMessage["content"],
-): React.ReactNode {
- if (!content) return content;
+function ToolResultResource({ resource, index }: ToolResultResourceProps) {
+ // Handle blob content (e.g., base64-encoded images)
+ if (resource.blob && resource.mimeType?.startsWith("image/")) {
+ const dataUrl = `data:${resource.mimeType};base64,${resource.blob}`;
+ return (
+
+
+
+ );
+ }
+
+ // Handle text content
+ if (resource.text) {
+ return (
+
+ {resource.name && (
+
+ {resource.name}:{" "}
+
+ )}
+ {resource.text}
+
+ );
+ }
+
+ // Handle URI reference
+ if (resource.uri) {
+ return (
+
+
+ {resource.name ?? "Resource"}:
+
+ {resource.uri}
+
+ );
+ }
+
+ return null;
+}
- const safeContent = getSafeContent(content);
- if (typeof safeContent !== "string") return safeContent;
+/**
+ * Renders tool result content with appropriate formatting.
+ * Handles text (with JSON pretty-printing), images, and MCP resources.
+ */
+function ToolResultContent({
+ content,
+ enableMarkdown = true,
+}: {
+ content: TamboThreadMessage["content"];
+ enableMarkdown?: boolean;
+}) {
+ if (!content) return null;
+
+ // Handle string content directly
+ if (typeof content === "string") {
+ return ;
+ }
+
+ // Handle array content with mixed types
+ if (Array.isArray(content)) {
+ const textParts: string[] = [];
+ const nonTextItems: Array<{
+ type: "image" | "resource";
+ url?: string;
+ resource?: ToolResultResourceProps["resource"];
+ index: number;
+ }> = [];
+
+ content.forEach((item, index) => {
+ if (!item?.type) return;
+
+ if (item.type === "text" && item.text) {
+ textParts.push(item.text);
+ } else if (item.type === "resource" && item.resource) {
+ nonTextItems.push({ type: "resource", resource: item.resource, index });
+ }
+ });
+
+ const combinedText = textParts.join("");
+
+ // If we only have text, return it directly
+ if (nonTextItems.length === 0) {
+ return combinedText ? (
+
+ ) : null;
+ }
+
+ // If we have mixed content, render in a flex container
+ return (
+
+ {combinedText && (
+
+ )}
+
+ {nonTextItems.map((item) => {
+ switch (item.type) {
+ case "image":
+ return item.url ? (
+
+ ) : null;
+ case "resource":
+ return item.resource ? (
+
+ ) : null;
+ }
+ })}
+
+
+ );
+ }
+
+ // Fallback for unknown content types
+ return getSafeContent(content);
+}
+
+/**
+ * Renders text content, attempting JSON parsing for pretty-printing.
+ */
+function ToolResultText({
+ text,
+ enableMarkdown,
+}: {
+ text: string;
+ enableMarkdown: boolean;
+}) {
+ if (!text) return null;
- // Try to parse as JSON
try {
- const parsed = JSON.parse(safeContent);
+ const parsed = JSON.parse(text);
return (
-
-
+
+
{JSON.stringify(parsed, null, 2)}
);
} catch {
- return safeContent;
+ // JSON parsing failed, render as markdown or plain text
+ if (!enableMarkdown) return text;
+ return {text} ;
}
}
/**
- * Props for the MessageRenderedComponentArea component.
- * Extends standard HTMLDivElement attributes.
+ * Props for the MessageRenderedComponent component.
*/
-export type MessageRenderedComponentAreaProps =
- React.HTMLAttributes;
+export type MessageRenderedComponentAreaProps = Omit<
+ MessageBaseRenderedComponentProps,
+ "children"
+>;
/**
* Displays the `renderedComponent` associated with an assistant message.
* Shows a button to view in canvas if a canvas space exists, otherwise renders inline.
* Only renders if the message role is 'assistant' and `message.renderedComponent` exists.
- * @component Message.RenderedComponentArea
*/
const MessageRenderedComponentArea = React.forwardRef<
HTMLDivElement,
MessageRenderedComponentAreaProps
->(({ className, children, ...props }, ref) => {
- const { message, role } = useMessageContext();
- const [canvasExists, setCanvasExists] = React.useState(false);
-
- // Check if canvas exists on mount and window resize
- React.useEffect(() => {
- const checkCanvasExists = () => {
- const canvas = document.querySelector('[data-canvas-space="true"]');
- setCanvasExists(!!canvas);
- };
-
- // Check on mount
- checkCanvasExists();
-
- // Set up resize listener
- window.addEventListener("resize", checkCanvasExists);
-
- // Clean up
- return () => {
- window.removeEventListener("resize", checkCanvasExists);
- };
- }, []);
-
- if (
- !message.renderedComponent ||
- role !== "assistant" ||
- message.isCancelled
- ) {
- return null;
- }
-
+>(({ className, ...props }, ref) => {
return (
-
- {children ??
- (canvasExists ? (
-
- {
- if (typeof window !== "undefined") {
- window.dispatchEvent(
- new CustomEvent("tambo:showComponent", {
- detail: {
- messageId: message.id,
- component: message.renderedComponent,
- },
- }),
- );
- }
- }}
- className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-secondary transition-colors duration-200 cursor-pointer group"
- aria-label="View component in canvas"
- >
- View component
-
-
-
- ) : (
-
{message.renderedComponent}
- ))}
-
+
+
+ View component
+
+
+
+
+
);
});
MessageRenderedComponentArea.displayName = "Message.RenderedComponentArea";
-// --- Exports ---
export {
+ createMarkdownComponents,
LoadingIndicator,
+ markdownComponents,
Message,
MessageContent,
+ MessageImages,
MessageRenderedComponentArea,
messageVariants,
+ ReasoningInfo,
ToolcallInfo,
};
diff --git a/src/components/tambo/schema-canvas.tsx b/src/components/tambo/schema-canvas.tsx
new file mode 100644
index 0000000..8c87571
--- /dev/null
+++ b/src/components/tambo/schema-canvas.tsx
@@ -0,0 +1,153 @@
+'use client';
+
+import { useEffect } from 'react';
+import { Loader2, Check } from 'lucide-react';
+import { useTamboStreamStatus } from '@tambo-ai/react';
+import { useSchema } from '@/lib/schema-context';
+import type { Table } from '@/lib/types';
+import { z } from 'zod';
+
+interface SchemaCanvasProps {
+ tables?: Table[];
+ removedTables?: string[];
+ mode?: 'full' | 'update';
+}
+
+/**
+ * Merges incoming tables into the current schema.
+ * - Tables with matching names are replaced (updated).
+ * - New tables are appended.
+ * - Tables listed in `removed` are dropped.
+ * - Existing tables not in `incoming` or `removed` are kept as-is.
+ */
+function mergeTables(
+ existing: Table[],
+ incoming: Table[],
+ removed: string[] = [],
+): Table[] {
+ const removedSet = new Set(removed);
+ const merged = new Map();
+
+ for (const table of existing) {
+ if (!removedSet.has(table.name)) {
+ merged.set(table.name, table);
+ }
+ }
+
+ for (const table of incoming) {
+ if (table.name) {
+ merged.set(table.name, table);
+ }
+ }
+
+ return Array.from(merged.values());
+}
+
+export function SchemaCanvas({
+ tables,
+ removedTables,
+ mode = 'full',
+}: SchemaCanvasProps) {
+ const { streamStatus } = useTamboStreamStatus();
+ const { schemaData, setSchemaData, setIsStreaming } = useSchema();
+
+ /** Sync streaming tables to schema context. */
+ useEffect(() => {
+ const hasTables = tables && tables.length > 0;
+ const hasRemovals = removedTables && removedTables.length > 0;
+
+ if (!hasTables && !hasRemovals) return;
+
+ if (mode === 'update') {
+ setSchemaData((current) =>
+ mergeTables(current, tables ?? [], removedTables),
+ );
+ } else {
+ if (hasTables) setSchemaData(tables);
+ }
+ }, [tables, removedTables, mode, setSchemaData]);
+
+ /** Sync streaming state, resetting on unmount so it never gets stuck. */
+ useEffect(() => {
+ setIsStreaming(streamStatus.isStreaming || streamStatus.isPending);
+ return () => setIsStreaming(false);
+ }, [streamStatus.isStreaming, streamStatus.isPending, setIsStreaming]);
+
+ const isActive = streamStatus.isPending || streamStatus.isStreaming;
+ const totalTables = schemaData.length;
+
+ return (
+
+ {isActive ? (
+
+ ) : (
+
+ )}
+
+ {isActive
+ ? mode === 'update'
+ ? 'Updating schema...'
+ : 'Generating schema...'
+ : `Schema ${mode === 'update' ? 'updated' : 'ready'} โ ${totalTables} ${totalTables === 1 ? 'table' : 'tables'}`}
+
+
+ );
+}
+
+export const schemaCanvasSchema = z.object({
+ mode: z
+ .enum(['full', 'update'])
+ .optional()
+ .describe(
+ 'Set to "full" when creating a brand new schema from scratch. Set to "update" when adding, modifying, or removing tables from an existing schema โ this preserves all existing tables and only applies the changes.',
+ ),
+ tables: z
+ .array(
+ z.object({
+ name: z.string().describe('The name of the database table'),
+ columns: z
+ .array(
+ z.object({
+ name: z.string().describe('Column name'),
+ type: z
+ .string()
+ .describe(
+ 'Column data type (e.g. VARCHAR, INT, TIMESTAMP, UUID, TEXT, BOOLEAN)',
+ ),
+ nullable: z
+ .boolean()
+ .describe('Whether the column allows NULL values'),
+ defaultValue: z
+ .string()
+ .optional()
+ .describe('Default value for the column'),
+ isPrimaryKey: z
+ .boolean()
+ .describe('Whether this column is a primary key'),
+ isUnique: z
+ .boolean()
+ .describe('Whether this column has a unique constraint'),
+ foreignKey: z
+ .object({
+ table: z.string().describe('Referenced table name'),
+ column: z.string().describe('Referenced column name'),
+ })
+ .optional()
+ .describe('Foreign key reference, if any'),
+ }),
+ )
+ .optional()
+ .describe('The columns in this table'),
+ }),
+ )
+ .optional()
+ .describe(
+ 'The tables to render. In "full" mode, provide ALL tables for the complete schema. In "update" mode, provide ONLY the new or modified tables โ existing unchanged tables are preserved automatically.',
+ ),
+ removedTables: z
+ .array(z.string())
+ .optional()
+ .describe(
+ 'Table names to remove from the schema. Only used in "update" mode.',
+ ),
+});
diff --git a/src/components/tambo/schema-diagram.tsx b/src/components/tambo/schema-diagram.tsx
index 0404360..4854ca3 100644
--- a/src/components/tambo/schema-diagram.tsx
+++ b/src/components/tambo/schema-diagram.tsx
@@ -1,248 +1,156 @@
'use client';
-import { useEffect, useState } from 'react';
+import { useEffect, useState, useRef, useCallback } from 'react';
import ReactFlow, {
- Edge,
- NodeProps,
- Handle,
- Position,
useNodesState,
useEdgesState,
+ useReactFlow,
Controls,
Background,
BackgroundVariant,
- MarkerType,
ReactFlowProvider,
} from 'reactflow';
import 'reactflow/dist/style.css';
-import { Table, TableColumn } from '@/lib/types';
-import { z } from 'zod';
+import { Table } from '@/lib/types';
+import { TableNode } from './table-node';
+import {
+ EDGE_STYLE,
+ EDGE_MARKER,
+ fingerprint,
+ edgeFingerprint,
+ gridPosition,
+ buildEdges,
+} from './diagram-utils';
interface SchemaDiagramProps {
tables?: Table[];
- title?: string;
}
-const TableNode = ({ data }: NodeProps) => {
- return (
-
-
+const nodeTypes = { tableNode: TableNode };
-
- {data.columns.map((column: TableColumn, index: number) => {
- const sourceHandleId = `${data.label}-${column.name}-source`;
- const targetHandleId = `${data.label}-${column.name}-target`;
+/**
+ * Inner diagram component that must be wrapped in ReactFlowProvider.
+ * Handles node/edge state, streaming-safe updates, and viewport fitting.
+ */
+function SchemaDiagramInner({ tables = [] }: SchemaDiagramProps) {
+ const [nodes, setNodes, onNodesChange] = useNodesState([]);
+ const [edges, setEdges, onEdgesChange] = useEdgesState([]);
+ const [isMounted, setIsMounted] = useState(false);
+ const { fitView } = useReactFlow();
- return (
-
-
- {column.isPrimaryKey && (
-
- ๐
-
- )}
- {column.foreignKey && (
-
- ๐
-
- )}
-
- {column.name}
-
- {!column.nullable && (
-
- *
-
- )}
-
+ /** Cached node positions โ prevents nodes from jumping when new columns stream in. */
+ const positions = useRef(new Map
());
-
- {column.type}
-
+ /** Column fingerprints โ when unchanged, we reuse the same node reference so ReactFlow skips re-rendering. */
+ const fingerprints = useRef(new Map());
- {column.isPrimaryKey && (
-
- )}
+ /** Tracks the last-seen table count so fitView only fires when a new table appears. */
+ const prevCount = useRef(0);
- {column.foreignKey && (
-
- )}
-
- );
- })}
-
-
- );
-};
+ /** Stores the pending fitView RAF id so we can cancel it on unmount or re-trigger. */
+ const fitViewRaf = useRef(null);
-const nodeTypes = {
- tableNode: TableNode,
-};
-
-function SchemaDiagramInner({ tables = [] }: SchemaDiagramProps) {
- const [nodes, setNodes, onNodesChange] = useNodesState([]);
- const [edges, setEdges, onEdgesChange] = useEdgesState([]);
- const [isMounted, setIsMounted] = useState(false);
+ /** Last edge fingerprint โ skip rebuilding edges when FK relationships haven't changed. */
+ const prevEdgeFp = useRef('');
useEffect(() => {
setIsMounted(true);
+ return () => {
+ if (fitViewRaf.current != null) cancelAnimationFrame(fitViewRaf.current);
+ };
}, []);
- useEffect(() => {
- console.log('SchemaDiagram useEffect triggered with tables:', tables);
+ /**
+ * Wraps the default onNodesChange to also persist drag positions
+ * into the positions ref, so they survive streaming re-renders.
+ */
+ const handleNodesChange: typeof onNodesChange = useCallback(
+ (changes) => {
+ onNodesChange(changes);
+ for (const c of changes) {
+ if (c.type === 'position' && c.position && c.id) {
+ positions.current.set(c.id, c.position);
+ }
+ }
+ },
+ [onNodesChange],
+ );
- if (!tables || tables.length === 0) {
+ /**
+ * Main sync effect โ runs on every `tables` change (including streaming ticks).
+ * Uses fingerprinting to preserve node object references for unchanged tables,
+ * which prevents ReactFlow from re-rendering/flashing those nodes.
+ */
+ useEffect(() => {
+ if (tables.length === 0) {
setNodes([]);
setEdges([]);
+ positions.current.clear();
+ fingerprints.current.clear();
+ prevCount.current = 0;
+ prevEdgeFp.current = '';
return;
}
- const calculatePositions = () => {
- const nodeWidth = 200;
- const padding = 120;
- const containerWidth = 1400;
- const containerHeight = 1000;
+ const valid = tables.filter((t) => t.name);
+ const validNames = new Set(valid.map((t) => t.name));
- const cols = Math.ceil(Math.sqrt(tables.length));
- const cellWidth = containerWidth / cols;
- const cellHeight = containerHeight / Math.ceil(tables.length / cols);
+ // Clean up stale fingerprints for removed tables
+ for (const key of fingerprints.current.keys()) {
+ if (!validNames.has(key)) fingerprints.current.delete(key);
+ }
- return tables.map((table, index) => {
- const row = Math.floor(index / cols);
- const col = index % cols;
+ setNodes((current) => {
+ const byId = new Map(current.map((n) => [n.id, n]));
- const position = {
- x: col * cellWidth + (cellWidth - nodeWidth) / 2,
- y: row * cellHeight + padding,
- };
+ return valid.map((table, i) => {
+ const cols = table.columns ?? [];
+ const fp = fingerprint(cols);
+ const prev = byId.get(table.name);
+
+ if (prev && fingerprints.current.get(table.name) === fp) return prev;
+
+ fingerprints.current.set(table.name, fp);
return {
id: table.name,
- type: 'tableNode',
- position,
- data: {
- label: table.name,
- columns: table.columns,
- },
+ type: 'tableNode' as const,
+ position:
+ prev?.position ??
+ positions.current.get(table.name) ??
+ gridPosition(i, valid.length),
+ data: { label: table.name, columns: cols },
};
});
- };
-
- const generateRelationEdges = () => {
- const relationEdges: Edge[] = [];
+ });
- tables.forEach((table) => {
- table.columns.forEach((column) => {
- if (column.foreignKey) {
- const sourceTableId = table.name;
- const targetTableId = column.foreignKey.table;
-
- if (tables.some((t) => t.name === targetTableId)) {
- const edgeId = `${sourceTableId}-${column.name}-${targetTableId}`;
+ const eFp = edgeFingerprint(valid);
+ if (eFp !== prevEdgeFp.current) {
+ prevEdgeFp.current = eFp;
+ setEdges(buildEdges(valid));
+ }
- relationEdges.push({
- id: edgeId,
- source: sourceTableId,
- target: targetTableId,
- sourceHandle: `${sourceTableId}-${column.name}-source`,
- targetHandle: `${targetTableId}-${column.foreignKey.column}-target`,
- type: 'default',
- animated: true,
- style: {
- stroke: '#3b82f6',
- strokeWidth: 1,
- strokeDasharray: '4 3',
- },
- markerEnd: {
- type: MarkerType.ArrowClosed,
- color: '#3b82f6',
- width: 8,
- height: 8,
- },
- label: `${column.name} โ ${column.foreignKey.column}`,
- labelStyle: {
- fill: '#3b82f6',
- fontSize: 10,
- fontFamily: 'monospace',
- fontWeight: 600,
- },
- labelBgPadding: [4, 2],
- labelBgBorderRadius: 4,
- labelBgStyle: {
- fill: '#f0f7ff',
- fillOpacity: 0.8,
- },
- labelShowBg: true,
- });
- }
- }
- });
+ if (valid.length !== prevCount.current) {
+ prevCount.current = valid.length;
+ if (fitViewRaf.current != null) cancelAnimationFrame(fitViewRaf.current);
+ fitViewRaf.current = requestAnimationFrame(() => {
+ fitViewRaf.current = null;
+ fitView({ padding: 0.1, duration: 200 });
});
-
- return relationEdges;
- };
-
- const tableNodes = calculatePositions();
- const relationEdges = generateRelationEdges();
-
- console.log('Generated nodes:', tableNodes);
- console.log('Generated edges:', relationEdges);
-
- setNodes(tableNodes);
- setEdges(relationEdges);
- }, [tables, setNodes, setEdges]);
+ }
+ }, [tables, setNodes, setEdges, fitView]);
if (!isMounted) {
return (
-
-
Loading Diagram...
+
+ Loading Diagram...
);
}
- if (!tables || tables.length === 0) {
+ if (tables.length === 0) {
return (
@@ -255,53 +163,25 @@ function SchemaDiagramInner({ tables = [] }: SchemaDiagramProps) {
);
}
- console.log('SchemaDiagram rendering with:', {
- tablesLength: tables.length,
- nodesLength: nodes.length,
- edgesLength: edges.length,
- isMounted,
- });
-
return (
);
}
-
-export const schemaDiagramSchema = z.object({
- tables: z
- .array(
- z.object({
- name: z.string(),
- columns: z.array(
- z.object({
- name: z.string(),
- type: z.string(),
- nullable: z.boolean(),
- defaultValue: z.string().optional(),
- isPrimaryKey: z.boolean(),
- isUnique: z.boolean(),
- foreignKey: z
- .object({
- table: z.string(),
- column: z.string(),
- })
- .optional(),
- }),
- ),
- }),
- )
- .optional(),
- title: z.string().optional(),
-});
diff --git a/src/components/tambo/scrollable-message-container.tsx b/src/components/tambo/scrollable-message-container.tsx
index 53593fc..958bb04 100644
--- a/src/components/tambo/scrollable-message-container.tsx
+++ b/src/components/tambo/scrollable-message-container.tsx
@@ -1,9 +1,9 @@
"use client";
-import { cn } from "@/lib/utils";
import { useTambo } from "@tambo-ai/react";
+import { cn } from "@/lib/utils";
import * as React from "react";
-import { useEffect, useRef } from "react";
+import { useCallback, useEffect, useRef, useState, useMemo } from "react";
/**
* Props for the ScrollableMessageContainer component
@@ -29,34 +29,75 @@ export const ScrollableMessageContainer = React.forwardRef<
ScrollableMessageContainerProps
>(({ className, children, ...props }, ref) => {
const scrollContainerRef = useRef(null);
- const { thread } = useTambo();
+ const { messages, isStreaming } = useTambo();
+ const [shouldAutoscroll, setShouldAutoscroll] = useState(true);
+ const lastScrollTopRef = useRef(0);
// Handle forwarded ref
React.useImperativeHandle(ref, () => scrollContainerRef.current!, []);
- // Auto-scroll to bottom when messages change
+ // Create a dependency that represents all content that should trigger autoscroll
+ const messagesContent = useMemo(() => {
+ if (!messages.length) return null;
+
+ return messages.map((message) => ({
+ id: message.id,
+ content: message.content,
+ reasoning: message.reasoning,
+ }));
+ }, [messages]);
+
+ // Handle scroll events to detect user scrolling
+ const handleScroll = useCallback(() => {
+ if (!scrollContainerRef.current) return;
+
+ const { scrollTop, scrollHeight, clientHeight } =
+ scrollContainerRef.current;
+ const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 8; // 8px tolerance for rounding
+
+ // If user scrolled up, disable autoscroll
+ if (scrollTop < lastScrollTopRef.current) {
+ setShouldAutoscroll(false);
+ }
+ // If user is at bottom, enable autoscroll
+ else if (isAtBottom) {
+ setShouldAutoscroll(true);
+ }
+
+ lastScrollTopRef.current = scrollTop;
+ }, []);
+
+ // Auto-scroll to bottom when message content changes
useEffect(() => {
- if (scrollContainerRef.current && thread?.messages?.length) {
- const timeoutId = setTimeout(() => {
+ if (scrollContainerRef.current && messagesContent && shouldAutoscroll) {
+ const scroll = () => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
top: scrollContainerRef.current.scrollHeight,
behavior: "smooth",
});
}
- }, 250);
+ };
- return () => clearTimeout(timeoutId);
+ if (isStreaming) {
+ // During streaming, scroll immediately
+ requestAnimationFrame(scroll);
+ } else {
+ // For other updates, use a short delay to batch rapid changes
+ const timeoutId = setTimeout(scroll, 50);
+ return () => clearTimeout(timeoutId);
+ }
}
- }, [thread?.messages]);
+ }, [messagesContent, isStreaming, shouldAutoscroll]);
return (
(
+
+
+
+
+ {(data.columns ?? []).map((column: TableColumn, i: number) => (
+
+
+ {column.isPrimaryKey && (
+
+ ๐
+
+ )}
+ {column.foreignKey && (
+
+ ๐
+
+ )}
+
+ {column.name}
+
+ {!column.nullable && (
+
+ *
+
+ )}
+
+
+
+ {column.type}
+
+
+ {column.isPrimaryKey && (
+
+ )}
+ {column.foreignKey && (
+
+ )}
+
+ ))}
+
+
+);
diff --git a/src/components/tambo/text-editor.tsx b/src/components/tambo/text-editor.tsx
new file mode 100644
index 0000000..563635d
--- /dev/null
+++ b/src/components/tambo/text-editor.tsx
@@ -0,0 +1,849 @@
+"use client";
+
+import * as Popover from "@radix-ui/react-popover";
+import { cn } from "@/lib/utils";
+import Document from "@tiptap/extension-document";
+import HardBreak from "@tiptap/extension-hard-break";
+import Mention from "@tiptap/extension-mention";
+import Paragraph from "@tiptap/extension-paragraph";
+import Placeholder from "@tiptap/extension-placeholder";
+import Text from "@tiptap/extension-text";
+import {
+ EditorContent,
+ Extension,
+ useEditor,
+ type Editor,
+} from "@tiptap/react";
+import type { SuggestionOptions } from "@tiptap/suggestion";
+import Suggestion from "@tiptap/suggestion";
+import { Cuboid, FileText } from "lucide-react";
+import * as React from "react";
+import { useImperativeHandle, useState } from "react";
+
+/**
+ * Result of extracting images from clipboard data.
+ */
+export interface ImageItems {
+ imageItems: File[];
+ hasText: boolean;
+}
+
+/**
+ * Returns images array and hasText bool from clipboard data.
+ * @param clipboardData - The clipboard data from a paste event
+ * @returns Object containing extracted images array and whether text was present
+ */
+export function getImageItems(
+ clipboardData: DataTransfer | null | undefined,
+): ImageItems {
+ const items = Array.from(clipboardData?.items ?? []);
+ const imageItems: File[] = [];
+
+ for (const item of items) {
+ if (!item.type.startsWith("image/")) {
+ continue;
+ }
+
+ const image = item.getAsFile();
+ if (image) {
+ imageItems.push(image);
+ }
+ }
+
+ const text = clipboardData?.getData("text/plain") ?? "";
+
+ return {
+ imageItems,
+ hasText: text.length > 0 ? true : false,
+ };
+}
+
+/**
+ * Minimal editor interface exposed to parent components.
+ * Hides TipTap implementation details and exposes only necessary operations.
+ */
+export interface TamboEditor {
+ /** Focus the editor at a specific position */
+ focus(position?: "start" | "end"): void;
+ /** Set the editor content */
+ setContent(content: string): void;
+ /** Append text to the end of the editor content */
+ appendText(text: string): void;
+ /** Get the text and resource names */
+ getTextWithResourceURIs(): {
+ text: string;
+ resourceNames: Record
;
+ };
+ /** Check if a mention with the given id exists */
+ hasMention(id: string): boolean;
+ /** Insert a mention node with a following space */
+ insertMention(id: string, label: string): void;
+ /** Set whether the editor is editable */
+ setEditable(editable: boolean): void;
+}
+
+/**
+ * Base interface for suggestion items (resources and prompts).
+ */
+interface SuggestionItem {
+ id: string;
+ name: string;
+ icon?: React.ReactNode;
+}
+
+/**
+ * Represents a resource item that appears in the "@" mention dropdown.
+ * Resources are referenced by ID/URI and appear as visual mention nodes in the editor.
+ */
+export interface ResourceItem extends SuggestionItem {
+ componentData?: unknown;
+}
+
+/**
+ * Represents a prompt item that appears in the "/" command dropdown.
+ * Prompts contain text that gets inserted into the editor.
+ */
+export interface PromptItem extends SuggestionItem {
+ /** The actual prompt text to insert into the editor */
+ text: string;
+}
+
+export interface TextEditorProps {
+ value: string;
+ onChange: (text: string) => void;
+ onResourceNamesChange: (
+ resourceNames:
+ | Record
+ | ((prev: Record) => Record),
+ ) => void;
+ onKeyDown?: (event: React.KeyboardEvent) => void;
+ placeholder?: string;
+ disabled?: boolean;
+ className?: string;
+ /** Submit handler for Enter key behavior */
+ onSubmit: (e: React.FormEvent) => Promise;
+ /** Called when an image is pasted into the editor */
+ onAddImage: (file: File) => Promise;
+ /** Called when resource search query changes (for "@" mentions) - parent should update `resources` prop */
+ onSearchResources: (query: string) => void;
+ /** Current list of resources to show in the "@" suggestion menu (controlled) */
+ resources: ResourceItem[];
+ /** Called when prompt search query changes (for "/" commands) - parent should update `prompts` prop */
+ onSearchPrompts: (query: string) => void;
+ /** Current list of prompts to show in the "/" suggestion menu (controlled) */
+ prompts: PromptItem[];
+ /** Called when a resource is selected from the "@" menu */
+ onResourceSelect: (item: ResourceItem) => void;
+ /** Called when a prompt is selected from the "/" menu */
+ onPromptSelect: (item: PromptItem) => void;
+}
+
+/**
+ * State for a suggestion popover.
+ */
+interface SuggestionState {
+ isOpen: boolean;
+ items: T[];
+ selectedIndex: number;
+ position: { top: number; left: number; lineHeight: number } | null;
+ command: ((item: T) => void) | null;
+}
+
+/**
+ * Ref value for accessing suggestion state from TipTap callbacks.
+ */
+interface SuggestionRef {
+ state: SuggestionState;
+ setState: (update: Partial>) => void;
+}
+
+/**
+ * Utility function to convert TipTap clientRect to position coordinates.
+ * Includes line height for proper spacing when popup flips above cursor.
+ */
+function getPositionFromClientRect(
+ clientRect?: (() => DOMRect | null) | null,
+): { top: number; left: number; lineHeight: number } | null {
+ if (!clientRect) return null;
+ const rect = clientRect();
+ if (!rect) return null;
+ const lineHeight = rect.height || 20; // Fallback to 20px if height not available
+ return { top: rect.bottom, left: rect.left, lineHeight };
+}
+
+/**
+ * Props for the generic suggestion popover.
+ */
+interface SuggestionPopoverProps {
+ state: SuggestionState;
+ onClose: () => void;
+ defaultIcon: React.ReactNode;
+ emptyMessage: string;
+ /** Whether to use monospace font for the secondary text (id) */
+ monoSecondary?: boolean;
+}
+
+/**
+ * Generic popover component for suggestions (@resources and /prompts).
+ */
+function SuggestionPopover({
+ state,
+ onClose,
+ defaultIcon,
+ emptyMessage,
+ monoSecondary = false,
+}: SuggestionPopoverProps) {
+ if (!state.isOpen || !state.position) return null;
+
+ const sideOffset = state.position.lineHeight + 4;
+
+ return (
+ !open && onClose()}
+ >
+
+
+
+ e.preventDefault()}
+ onCloseAutoFocus={(e) => e.preventDefault()}
+ onEscapeKeyDown={(e) => {
+ e.preventDefault();
+ onClose();
+ }}
+ >
+ {state.items.length === 0 ? (
+
+ {emptyMessage}
+
+ ) : (
+
+ {state.items.map((item, index) => (
+
state.command?.(item)}
+ >
+ {item.icon ?? defaultIcon}
+
+
{item.name}
+
+ {item.id}
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+/**
+ * Internal helper to check if a mention exists in a raw TipTap Editor.
+ */
+function checkMentionExists(editor: Editor, label: string): boolean {
+ if (!editor.state?.doc) return false;
+ let exists = false;
+ editor.state.doc.descendants((node) => {
+ if (node.type.name === "mention") {
+ const mentionLabel = node.attrs.label as string;
+ if (mentionLabel === label) {
+ exists = true;
+ return false;
+ }
+ }
+ return true;
+ });
+ return exists;
+}
+
+/**
+ * Creates the resource mention configuration for TipTap Mention extension.
+ * The items() function triggers the search - actual items come from props via stateRef.
+ */
+function createResourceMentionConfig(
+ onSearchChange: (query: string) => void,
+ onSelect: (item: ResourceItem) => void,
+ stateRef: React.MutableRefObject>,
+): Omit {
+ return {
+ char: "@",
+ items: ({ query }) => {
+ onSearchChange(query);
+ return [];
+ },
+
+ render: () => {
+ const createWrapCommand =
+ (
+ editor: Editor,
+ tiptapCommand: (attrs: { id: string; label: string }) => void,
+ ) =>
+ (item: ResourceItem) => {
+ if (checkMentionExists(editor, item.name)) return;
+ tiptapCommand({ id: item.id, label: item.name });
+ onSelect(item);
+ };
+
+ return {
+ onStart: (props) => {
+ stateRef.current.setState({
+ isOpen: true,
+ selectedIndex: 0,
+ position: getPositionFromClientRect(props.clientRect),
+ command: createWrapCommand(props.editor, props.command),
+ });
+ },
+ onUpdate: (props) => {
+ stateRef.current.setState({
+ position: getPositionFromClientRect(props.clientRect),
+ command: createWrapCommand(props.editor, props.command),
+ selectedIndex: 0,
+ });
+ },
+ onKeyDown: ({ event }) => {
+ const { state, setState } = stateRef.current;
+ if (!state.isOpen) return false;
+
+ const handlers: Record boolean> = {
+ ArrowUp: () => {
+ if (state.items.length === 0) return false;
+ setState({
+ selectedIndex:
+ (state.selectedIndex - 1 + state.items.length) %
+ state.items.length,
+ });
+ return true;
+ },
+ ArrowDown: () => {
+ if (state.items.length === 0) return false;
+ setState({
+ selectedIndex: (state.selectedIndex + 1) % state.items.length,
+ });
+ return true;
+ },
+ Enter: () => {
+ const item = state.items[state.selectedIndex];
+ if (item && state.command) {
+ state.command(item);
+ return true;
+ }
+ return false;
+ },
+ Escape: () => {
+ setState({ isOpen: false });
+ return true;
+ },
+ };
+
+ const handler = handlers[event.key];
+ if (handler) {
+ event.preventDefault();
+ return handler();
+ }
+ return false;
+ },
+ onExit: () => {
+ stateRef.current.setState({ isOpen: false });
+ },
+ };
+ },
+ };
+}
+
+/**
+ * Creates a custom TipTap extension for prompt commands using the Suggestion plugin.
+ * The items() function triggers the search - actual items come from props via stateRef.
+ */
+function createPromptCommandExtension(
+ onSearchChange: (query: string) => void,
+ onSelect: (item: PromptItem) => void,
+ stateRef: React.MutableRefObject>,
+) {
+ return Extension.create({
+ name: "promptCommand",
+
+ addProseMirrorPlugins() {
+ return [
+ Suggestion({
+ editor: this.editor,
+ char: "/",
+ items: ({ query, editor }) => {
+ // Only show prompts when editor is empty (except for the "/" and query)
+ const editorValue = editor.getText().replace("/", "").trim();
+ if (editorValue.length > 0) {
+ stateRef.current.setState({ isOpen: false });
+ return [];
+ }
+ // Trigger search - actual items come from props via stateRef
+ onSearchChange(query);
+ return [];
+ },
+ render: () => {
+ // Store command creator that captures editor context
+ let createCommand: ((item: PromptItem) => void) | null = null;
+
+ return {
+ onStart: (props) => {
+ createCommand = (item: PromptItem) => {
+ props.editor.commands.deleteRange({
+ from: props.range.from,
+ to: props.range.to,
+ });
+ onSelect(item);
+ };
+ stateRef.current.setState({
+ isOpen: true,
+ selectedIndex: 0,
+ position: getPositionFromClientRect(props.clientRect),
+ command: createCommand,
+ });
+ },
+ onUpdate: (props) => {
+ createCommand = (item: PromptItem) => {
+ props.editor.commands.deleteRange({
+ from: props.range.from,
+ to: props.range.to,
+ });
+ onSelect(item);
+ };
+ stateRef.current.setState({
+ position: getPositionFromClientRect(props.clientRect),
+ command: createCommand,
+ selectedIndex: 0,
+ });
+ },
+ onKeyDown: ({ event }) => {
+ const { state, setState } = stateRef.current;
+ if (!state.isOpen) return false;
+
+ const handlers: Record boolean> = {
+ ArrowUp: () => {
+ if (state.items.length === 0) return false;
+ setState({
+ selectedIndex:
+ (state.selectedIndex - 1 + state.items.length) %
+ state.items.length,
+ });
+ return true;
+ },
+ ArrowDown: () => {
+ if (state.items.length === 0) return false;
+ setState({
+ selectedIndex:
+ (state.selectedIndex + 1) % state.items.length,
+ });
+ return true;
+ },
+ Enter: () => {
+ const item = state.items[state.selectedIndex];
+ if (item && state.command) {
+ state.command(item);
+ return true;
+ }
+ return false;
+ },
+ Escape: () => {
+ setState({ isOpen: false });
+ return true;
+ },
+ };
+
+ const handler = handlers[event.key];
+ if (handler) {
+ event.preventDefault();
+ return handler();
+ }
+ return false;
+ },
+ onExit: () => {
+ stateRef.current.setState({ isOpen: false });
+ },
+ };
+ },
+ }),
+ ];
+ },
+ });
+}
+
+/**
+ * Custom text extraction that serializes mention nodes with their ID (resource URI).
+ */
+function getTextWithResourceURIs(editor: Editor | null): {
+ text: string;
+ resourceNames: Record;
+} {
+ if (!editor?.state?.doc) return { text: "", resourceNames: {} };
+
+ let text = "";
+ const resourceNames: Record = {};
+
+ editor.state.doc.descendants((node) => {
+ if (node.type.name === "mention") {
+ const id = node.attrs.id ?? "";
+ const label = node.attrs.label ?? "";
+ text += `@${id}`;
+ if (label && id) {
+ resourceNames[id] = label;
+ }
+ } else if (node.type.name === "hardBreak") {
+ text += "\n";
+ } else if (node.isText) {
+ text += node.text;
+ }
+ return true;
+ });
+
+ return { text, resourceNames };
+}
+
+/**
+ * Hook to create suggestion state with a ref for TipTap access.
+ */
+function useSuggestionState(
+ externalItems?: T[],
+): [SuggestionState, React.MutableRefObject>] {
+ const [state, setStateInternal] = useState>({
+ isOpen: false,
+ items: externalItems ?? [],
+ selectedIndex: 0,
+ position: null,
+ command: null,
+ });
+
+ const setState = React.useCallback((update: Partial>) => {
+ setStateInternal((prev) => ({ ...prev, ...update }));
+ }, []);
+
+ const stateRef = React.useRef>({ state, setState });
+
+ // Keep ref in sync
+ React.useEffect(() => {
+ stateRef.current = { state, setState };
+ }, [state, setState]);
+
+ // Sync external items when provided
+ React.useEffect(() => {
+ if (externalItems !== undefined) {
+ setStateInternal((prev) => {
+ if (prev.items === externalItems) {
+ return prev;
+ }
+
+ const previousMaxIndex = Math.max(prev.items.length - 1, 0);
+ const safePrevIndex = Math.min(
+ Math.max(prev.selectedIndex, 0),
+ previousMaxIndex,
+ );
+
+ const selectedItem = prev.items[safePrevIndex];
+ const matchedIndex = selectedItem
+ ? externalItems.findIndex((item) => item.id === selectedItem.id)
+ : -1;
+
+ const maxIndex = Math.max(externalItems.length - 1, 0);
+ const nextSelectedIndex =
+ matchedIndex >= 0 ? matchedIndex : Math.min(safePrevIndex, maxIndex);
+
+ return {
+ ...prev,
+ items: externalItems,
+ selectedIndex: nextSelectedIndex,
+ };
+ });
+ }
+ }, [externalItems]);
+
+ return [state, stateRef];
+}
+
+/**
+ * Text editor component with resource ("@") and prompt ("/") support.
+ */
+export const TextEditor = React.forwardRef(
+ (
+ {
+ value,
+ onChange,
+ onResourceNamesChange,
+ onKeyDown,
+ placeholder = "What do you want to do?",
+ disabled = false,
+ className,
+ onSubmit,
+ onAddImage,
+ onSearchResources,
+ resources,
+ onSearchPrompts,
+ prompts,
+ onResourceSelect,
+ onPromptSelect,
+ },
+ ref,
+ ) => {
+ // Suggestion states with refs for TipTap access
+ const [resourceState, resourceRef] =
+ useSuggestionState(resources);
+ const [promptState, promptRef] = useSuggestionState(prompts);
+
+ // Consolidated ref for callbacks that TipTap needs to access
+ const callbacksRef = React.useRef({
+ onSearchResources,
+ onResourceSelect,
+ onSearchPrompts,
+ onPromptSelect,
+ });
+
+ React.useEffect(() => {
+ callbacksRef.current = {
+ onSearchResources,
+ onResourceSelect,
+ onSearchPrompts,
+ onPromptSelect,
+ };
+ }, [onSearchResources, onResourceSelect, onSearchPrompts, onPromptSelect]);
+
+ // Stable callbacks for TipTap
+ const stableSearchResources = React.useCallback(
+ (query: string) => callbacksRef.current.onSearchResources(query),
+ [],
+ );
+
+ const stableSearchPrompts = React.useCallback(
+ (query: string) => callbacksRef.current.onSearchPrompts(query),
+ [],
+ );
+
+ const handleResourceSelect = React.useCallback(
+ (item: ResourceItem) => callbacksRef.current.onResourceSelect(item),
+ [],
+ );
+
+ const handlePromptSelect = React.useCallback(
+ (item: PromptItem) => callbacksRef.current.onPromptSelect(item),
+ [],
+ );
+
+ const handleKeyDown = React.useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && !e.shiftKey && value.trim()) {
+ e.preventDefault();
+ void onSubmit(e as React.FormEvent);
+ return;
+ }
+ onKeyDown?.(e);
+ },
+ [onSubmit, value, onKeyDown],
+ );
+
+ const editor = useEditor({
+ immediatelyRender: false,
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ HardBreak,
+ Placeholder.configure({ placeholder }),
+ Mention.configure({
+ HTMLAttributes: {
+ class:
+ "mention resource inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground",
+ },
+ suggestion: createResourceMentionConfig(
+ stableSearchResources,
+ handleResourceSelect,
+ resourceRef,
+ ),
+ renderLabel: ({ node }) => `@${(node.attrs.label as string) ?? ""}`,
+ }),
+ createPromptCommandExtension(
+ stableSearchPrompts,
+ handlePromptSelect,
+ promptRef,
+ ),
+ ],
+ content: value,
+ editable: !disabled,
+ onUpdate: ({ editor }) => {
+ const { text, resourceNames } = getTextWithResourceURIs(editor);
+ if (text !== value) {
+ onChange(text);
+ }
+ if (onResourceNamesChange) {
+ onResourceNamesChange((prev) => ({ ...prev, ...resourceNames }));
+ }
+ },
+ editorProps: {
+ attributes: {
+ class: cn(
+ "tiptap",
+ "prose prose-sm max-w-none focus:outline-none",
+ "p-3 rounded-t-lg bg-transparent text-sm leading-relaxed",
+ "min-h-[82px] max-h-[40vh] overflow-y-auto",
+ "break-words whitespace-pre-wrap",
+ className,
+ ),
+ },
+ handlePaste: (_view, event) => {
+ const { imageItems, hasText } = getImageItems(event.clipboardData);
+
+ if (imageItems.length === 0) return false;
+
+ if (!hasText) {
+ event.preventDefault();
+ }
+
+ void (async () => {
+ for (const item of imageItems) {
+ try {
+ await onAddImage(item);
+ } catch (error) {
+ console.error("Failed to add pasted image:", error);
+ }
+ }
+ })();
+
+ return !hasText;
+ },
+ handleKeyDown: (_view, event) => {
+ const anyMenuOpen = resourceState.isOpen || promptState.isOpen;
+
+ if (anyMenuOpen) return false;
+
+ if (event.key === "Enter" && !event.shiftKey && editor) {
+ const reactEvent = event as unknown as React.KeyboardEvent;
+ handleKeyDown(reactEvent);
+ return reactEvent.defaultPrevented;
+ }
+
+ return false;
+ },
+ },
+ });
+
+ useImperativeHandle(ref, () => {
+ if (!editor) {
+ return {
+ focus: () => {},
+ setContent: () => {},
+ appendText: () => {},
+ getTextWithResourceURIs: () => ({ text: "", resourceNames: {} }),
+ hasMention: () => false,
+ insertMention: () => {},
+ setEditable: () => {},
+ };
+ }
+
+ return {
+ focus: (position?: "start" | "end") => {
+ if (position) {
+ editor.commands.focus(position);
+ } else {
+ editor.commands.focus();
+ }
+ },
+ setContent: (content: string) => {
+ editor.commands.setContent(content);
+ },
+ appendText: (text: string) => {
+ editor.chain().focus("end").insertContent(text).run();
+ },
+ getTextWithResourceURIs: () => getTextWithResourceURIs(editor),
+ hasMention: (id: string) => {
+ if (!editor.state?.doc) return false;
+ let exists = false;
+ editor.state.doc.descendants((node) => {
+ if (node.type.name === "mention") {
+ const mentionId = node.attrs.id as string;
+ if (mentionId === id) {
+ exists = true;
+ return false;
+ }
+ }
+ return true;
+ });
+ return exists;
+ },
+ insertMention: (id: string, label: string) => {
+ editor
+ .chain()
+ .focus()
+ .insertContent([
+ { type: "mention", attrs: { id, label } },
+ { type: "text", text: " " },
+ ])
+ .run();
+ },
+ setEditable: (editable: boolean) => {
+ editor.setEditable(editable);
+ },
+ };
+ }, [editor]);
+
+ const lastSyncedValueRef = React.useRef(value);
+
+ React.useEffect(() => {
+ if (!editor) return;
+
+ const { text: currentText } = getTextWithResourceURIs(editor);
+
+ if (value !== currentText && value !== lastSyncedValueRef.current) {
+ editor.commands.setContent(value);
+ lastSyncedValueRef.current = value;
+ } else if (value === currentText) {
+ lastSyncedValueRef.current = value;
+ }
+
+ editor.setEditable(!disabled);
+ }, [editor, value, disabled]);
+
+ return (
+
+ resourceRef.current.setState({ isOpen: false })}
+ defaultIcon={ }
+ emptyMessage="No results found"
+ monoSecondary
+ />
+ promptRef.current.setState({ isOpen: false })}
+ defaultIcon={ }
+ emptyMessage="No prompts found"
+ />
+
+
+ );
+ },
+);
+
+TextEditor.displayName = "TextEditor";
diff --git a/src/components/tambo/thread-container.tsx b/src/components/tambo/thread-container.tsx
index f2954a0..70acf0c 100644
--- a/src/components/tambo/thread-container.tsx
+++ b/src/components/tambo/thread-container.tsx
@@ -1,16 +1,24 @@
-import { cn } from '@/lib/utils';
+import { cn } from "@/lib/utils";
import {
useCanvasDetection,
usePositioning,
- useMergedRef,
-} from '@/lib/thread-hooks';
-import * as React from 'react';
-import { useRef } from 'react';
+ useMergeRefs,
+} from "@/lib/thread-hooks";
+import * as React from "react";
+import { useRef } from "react";
/**
* Props for the ThreadContainer component
*/
-export type ThreadContainerProps = React.HTMLAttributes;
+export interface ThreadContainerProps extends React.HTMLAttributes {
+ /**
+ * Whether to disable automatic sidebar spacing.
+ * When true, the component will not add margins for the sidebar.
+ * Useful when the sidebar is positioned externally (e.g., in a flex container).
+ * @default false
+ */
+ disableSidebarSpacing?: boolean;
+}
/**
* A responsive container component for message threads that handles
@@ -21,7 +29,7 @@ export type ThreadContainerProps = React.HTMLAttributes;
export const ThreadContainer = React.forwardRef<
HTMLDivElement,
ThreadContainerProps
->(({ className, children, ...props }, ref) => {
+>(({ className, children, disableSidebarSpacing = false, ...props }, ref) => {
const containerRef = useRef(null);
const { hasCanvasSpace, canvasIsOnLeft } = useCanvasDetection(containerRef);
const { isLeftPanel, historyPosition } = usePositioning(
@@ -29,42 +37,40 @@ export const ThreadContainer = React.forwardRef<
canvasIsOnLeft,
hasCanvasSpace,
);
- const mergedRef = useMergedRef(ref, containerRef);
+ const mergedRef = useMergeRefs(ref, containerRef);
return (
);
});
-ThreadContainer.displayName = 'ThreadContainer';
+ThreadContainer.displayName = "ThreadContainer";
/**
* Hook that provides positioning context for thread containers
@@ -89,7 +95,7 @@ export function useThreadContainerContext() {
const containerRef = useRef
(null);
const { hasCanvasSpace, canvasIsOnLeft } = useCanvasDetection(containerRef);
const { isLeftPanel, historyPosition } = usePositioning(
- '',
+ "",
canvasIsOnLeft,
hasCanvasSpace,
);
diff --git a/src/components/tambo/thread-content.tsx b/src/components/tambo/thread-content.tsx
index 4d04c1f..871f2aa 100644
--- a/src/components/tambo/thread-content.tsx
+++ b/src/components/tambo/thread-content.tsx
@@ -3,12 +3,18 @@
import {
Message,
MessageContent,
+ MessageImages,
MessageRenderedComponentArea,
+ ReasoningInfo,
ToolcallInfo,
type messageVariants,
} from "@/components/tambo/message";
import { cn } from "@/lib/utils";
-import { type TamboThreadMessage, useTambo } from "@tambo-ai/react";
+import {
+ type Content,
+ type TamboThreadMessage,
+ useTambo,
+} from "@tambo-ai/react";
import { type VariantProps } from "class-variance-authority";
import * as React from "react";
@@ -22,7 +28,6 @@ import * as React from "react";
interface ThreadContentContextValue {
messages: TamboThreadMessage[];
isGenerating: boolean;
- generationStage?: string;
variant?: VariantProps["variant"];
}
@@ -53,8 +58,7 @@ const useThreadContentContext = () => {
* Props for the ThreadContent component.
* Extends standard HTMLDivElement attributes.
*/
-export interface ThreadContentProps
- extends React.HTMLAttributes {
+export interface ThreadContentProps extends React.HTMLAttributes {
/** Optional styling variant for the message container */
variant?: VariantProps["variant"];
/** The child elements to render within the container. */
@@ -74,17 +78,16 @@ export interface ThreadContentProps
*/
const ThreadContent = React.forwardRef(
({ children, className, variant, ...props }, ref) => {
- const { thread, generationStage, isIdle } = useTambo();
+ const { messages, isIdle } = useTambo();
const isGenerating = !isIdle;
const contextValue = React.useMemo(
() => ({
- messages: thread?.messages ?? [],
+ messages,
isGenerating,
- generationStage,
variant,
}),
- [thread?.messages, isGenerating, generationStage, variant],
+ [messages, isGenerating, variant],
);
return (
@@ -126,6 +129,20 @@ const ThreadContentMessages = React.forwardRef<
>(({ className, ...props }, ref) => {
const { messages, isGenerating, variant } = useThreadContentContext();
+ const filteredMessages = messages.filter((message) => {
+ if (message.role === "system") return false;
+ // Hide messages that only contain tool_result content blocks.
+ // These are consumed by ToolcallInfo on the preceding tool_use message
+ // and shouldn't render as standalone message bubbles.
+ if (
+ message.content.length > 0 &&
+ message.content.every((block) => block.type === "tool_result")
+ ) {
+ return false;
+ }
+ return true;
+ });
+
return (
- {messages.map((message, index) => {
+ {filteredMessages.map((message, index) => {
+ const messageContentClassName =
+ message.role === "assistant"
+ ? "text-foreground font-sans"
+ : "text-foreground bg-container hover:bg-backdrop font-sans";
+
return (
@@ -148,7 +168,7 @@ const ThreadContentMessages = React.forwardRef<
role={message.role === "assistant" ? "assistant" : "user"}
message={message}
variant={variant}
- isLoading={isGenerating && index === messages.length - 1}
+ isLoading={isGenerating && index === filteredMessages.length - 1}
className={cn(
"flex w-full",
message.role === "assistant" ? "justify-start" : "justify-end",
@@ -160,14 +180,41 @@ const ThreadContentMessages = React.forwardRef<
message.role === "assistant" ? "w-full" : "max-w-3xl",
)}
>
-
+
+ {message.content.map((block, blockIndex) => {
+ switch (block.type) {
+ case "text":
+ case "resource":
+ return (
+
+ );
+ case "tool_use":
+ return (
+
+ );
+ case "tool_result":
+ case "component":
+ // tool_result is rendered by ToolcallInfo on the preceding assistant message.
+ // component is rendered by MessageRenderedComponentArea below.
+ return null;
+ default: {
+ const _exhaustive: never = block;
+ console.error(
+ "Unknown content block type:",
+ (_exhaustive as Content).type,
+ );
+ return null;
+ }
}
- />
-
+ })}
diff --git a/src/components/tambo/thread-history.tsx b/src/components/tambo/thread-history.tsx
index 40a10b8..1b61c8e 100644
--- a/src/components/tambo/thread-history.tsx
+++ b/src/components/tambo/thread-history.tsx
@@ -1,12 +1,12 @@
"use client";
-import { cn } from "@/lib/utils";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import {
- type TamboThread,
- useTamboThread,
+ type ThreadListResponse,
+ useTambo,
useTamboThreadList,
} from "@tambo-ai/react";
+import { cn } from "@/lib/utils";
import {
ArrowLeftToLine,
ArrowRightToLine,
@@ -14,30 +14,29 @@ import {
Pencil,
PlusIcon,
SearchIcon,
- Sparkles,
} from "lucide-react";
import React, { useMemo } from "react";
+/** Thread item from the thread list API */
+type ThreadListItem = ThreadListResponse["threads"][number];
+
/**
* Context for sharing thread history state and functions
*/
interface ThreadHistoryContextValue {
- threads: { items?: TamboThread[] } | null | undefined;
+ threads: ThreadListResponse | null | undefined;
isLoading: boolean;
error: Error | null;
refetch: () => Promise
;
- currentThread: TamboThread;
- switchCurrentThread: (threadId: string) => void;
- startNewThread: () => void;
+ currentThreadId: string;
+ switchThread: (threadId: string) => void;
+ startNewThread: () => string;
searchQuery: string;
setSearchQuery: React.Dispatch>;
isCollapsed: boolean;
setIsCollapsed: React.Dispatch>;
onThreadChange?: () => void;
- contextKey?: string;
position?: "left" | "right";
- updateThreadName: (newName: string, threadId?: string) => Promise;
- generateThreadName: (threadId: string) => Promise;
}
const ThreadHistoryContext =
@@ -57,7 +56,6 @@ const useThreadHistoryContext = () => {
* Root component that provides context for thread history
*/
interface ThreadHistoryProps extends React.HTMLAttributes {
- contextKey?: string;
onThreadChange?: () => void;
children?: React.ReactNode;
defaultCollapsed?: boolean;
@@ -68,7 +66,6 @@ const ThreadHistory = React.forwardRef(
(
{
className,
- contextKey,
onThreadChange,
defaultCollapsed = true,
position = "left",
@@ -81,20 +78,9 @@ const ThreadHistory = React.forwardRef(
const [isCollapsed, setIsCollapsed] = React.useState(defaultCollapsed);
const [shouldFocusSearch, setShouldFocusSearch] = React.useState(false);
- const {
- data: threads,
- isLoading,
- error,
- refetch,
- } = useTamboThreadList({ contextKey });
-
- const {
- switchCurrentThread,
- startNewThread,
- thread: currentThread,
- updateThreadName,
- generateThreadName,
- } = useTamboThread();
+ const { data: threads, isLoading, error, refetch } = useTamboThreadList();
+
+ const { switchThread, startNewThread, currentThreadId } = useTambo();
// Update CSS variable when sidebar collapses/expands
React.useEffect(() => {
@@ -118,53 +104,48 @@ const ThreadHistory = React.forwardRef(
isLoading,
error,
refetch,
- currentThread,
- switchCurrentThread,
+ currentThreadId,
+ switchThread,
startNewThread,
searchQuery,
setSearchQuery,
isCollapsed,
setIsCollapsed,
onThreadChange,
- contextKey,
position,
- updateThreadName,
- generateThreadName,
}),
[
threads,
isLoading,
error,
refetch,
- currentThread,
- switchCurrentThread,
+ currentThreadId,
+ switchThread,
startNewThread,
searchQuery,
isCollapsed,
onThreadChange,
- contextKey,
position,
- updateThreadName,
- generateThreadName,
],
);
return (
-
+
{children}
@@ -192,20 +173,28 @@ const ThreadHistoryHeader = React.forwardRef<
- {!isCollapsed && (
-
Tambo Conversations
- )}
+
+ Tambo Conversations
+
setIsCollapsed(!isCollapsed)}
className={cn(
- "bg-container transition-colors p-1 hover:bg-backdrop rounded-md cursor-pointer",
- position === "left" ? "ml-auto" : "",
+ `bg-container p-1 hover:bg-backdrop transition-colors rounded-md cursor-pointer absolute flex items-center justify-center`,
+ position === "left" ? "right-1" : "left-0",
)}
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
@@ -239,7 +228,7 @@ const ThreadHistoryNewButton = React.forwardRef<
if (e) e.stopPropagation();
try {
- await startNewThread();
+ startNewThread();
await refetch();
onThreadChange?.();
} catch (error) {
@@ -253,7 +242,7 @@ const ThreadHistoryNewButton = React.forwardRef<
const handleKeyDown = (event: KeyboardEvent) => {
if (event.altKey && event.shiftKey && event.key === "n") {
event.preventDefault();
- handleNewThread();
+ void handleNewThread();
}
};
@@ -266,14 +255,23 @@ const ThreadHistoryNewButton = React.forwardRef<
ref={ref}
onClick={handleNewThread}
className={cn(
- "flex items-center gap-2 rounded-md mb-4 hover:bg-backdrop transition-colors cursor-pointer",
- isCollapsed ? "p-1 justify-center" : "p-2",
+ "flex items-center rounded-md mb-4 hover:bg-backdrop transition-colors cursor-pointer relative",
+ isCollapsed ? "p-1 justify-center" : "p-2 gap-2",
)}
title="New thread"
{...props}
>
- {!isCollapsed && New thread }
+
+ New thread
+
);
});
@@ -300,38 +298,43 @@ const ThreadHistorySearch = React.forwardRef<
};
return (
-
- {isCollapsed ? (
-
+
+ {/*visible when collapsed */}
+
+
+
+
+ {/*visible when expanded with delay */}
+
+
+
-
- ) : (
- <>
-
-
-
-
setSearchQuery(e.target.value)}
- />
- >
- )}
+
+
setSearchQuery(e.target.value)}
+ />
+
);
});
@@ -350,17 +353,13 @@ const ThreadHistoryList = React.forwardRef<
error,
isCollapsed,
searchQuery,
- currentThread,
- switchCurrentThread,
+ currentThreadId,
+ switchThread,
onThreadChange,
- updateThreadName,
- generateThreadName,
- refetch,
} = useThreadHistoryContext();
- const [editingThread, setEditingThread] = React.useState(
- null,
- );
+ const [editingThread, setEditingThread] =
+ React.useState(null);
const [newName, setNewName] = React.useState("");
const inputRef = React.useRef(null);
@@ -403,14 +402,12 @@ const ThreadHistoryList = React.forwardRef<
// While collapsed we do not need the list, avoid extra work.
if (isCollapsed) return [];
- if (!threads?.items) return [];
+ if (!threads?.threads) return [];
const query = searchQuery.toLowerCase();
- return threads.items.filter((thread: TamboThread) => {
- const nameMatches = thread.name?.toLowerCase().includes(query) ?? false;
+ return threads.threads.filter((thread: ThreadListItem) => {
const idMatches = thread.id.toLowerCase().includes(query);
-
- return idMatches ? true : nameMatches;
+ return idMatches;
});
}, [isCollapsed, threads, searchQuery]);
@@ -418,38 +415,24 @@ const ThreadHistoryList = React.forwardRef<
if (e) e.stopPropagation();
try {
- switchCurrentThread(threadId);
+ switchThread(threadId);
onThreadChange?.();
} catch (error) {
console.error("Failed to switch thread:", error);
}
};
- const handleRename = (thread: TamboThread) => {
+ const handleRename = (thread: ThreadListItem) => {
setEditingThread(thread);
- setNewName(thread.name ?? "");
- };
-
- const handleGenerateName = async (thread: TamboThread) => {
- try {
- await generateThreadName(thread.id);
- await refetch();
- } catch (error) {
- console.error("Failed to generate name:", error);
- }
+ setNewName(`Thread ${thread.id.substring(0, 8)}`);
};
const handleNameSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!editingThread) return;
- try {
- await updateThreadName(newName, editingThread.id);
- await refetch();
- setEditingThread(null);
- } catch (error) {
- console.error("Failed to rename thread:", error);
- }
+ // Thread renaming is not supported in V1 API
+ setEditingThread(null);
};
// Content to show
@@ -468,7 +451,10 @@ const ThreadHistoryList = React.forwardRef<
content = (
Error loading threads
@@ -478,7 +464,10 @@ const ThreadHistoryList = React.forwardRef<
content = (
{searchQuery ? "No matching threads" : "No previous threads"}
@@ -487,13 +476,13 @@ const ThreadHistoryList = React.forwardRef<
} else {
content = (
- {filteredThreads.map((thread: TamboThread) => (
+ {filteredThreads.map((thread: ThreadListItem) => (
await handleSwitchThread(thread.id)}
className={cn(
"p-2 rounded-md hover:bg-backdrop cursor-pointer group flex items-center justify-between",
- currentThread?.id === thread.id ? "bg-muted" : "",
+ currentThreadId === thread.id ? "bg-muted" : "",
editingThread?.id === thread.id ? "bg-muted" : "",
)}
>
@@ -525,7 +514,7 @@ const ThreadHistoryList = React.forwardRef<
) : (
<>
- {thread.name ?? `Thread ${thread.id.substring(0, 8)}`}
+ {`Thread ${thread.id.substring(0, 8)}`}
{new Date(thread.createdAt).toLocaleString(undefined, {
@@ -538,11 +527,7 @@ const ThreadHistoryList = React.forwardRef<
>
)}
-
+
))}
@@ -573,11 +558,9 @@ ThreadHistoryList.displayName = "ThreadHistory.List";
const ThreadOptionsDropdown = ({
thread,
onRename,
- onGenerateName,
}: {
- thread: TamboThread;
- onRename: (thread: TamboThread) => void;
- onGenerateName: (thread: TamboThread) => void;
+ thread: ThreadListItem;
+ onRename: (thread: ThreadListItem) => void;
}) => {
return (
@@ -605,16 +588,6 @@ const ThreadOptionsDropdown = ({
Rename
- {
- e.stopPropagation();
- onGenerateName(thread);
- }}
- >
-
- Generate Name
-
diff --git a/src/lib/generators/drizzle.ts b/src/lib/generators/drizzle.ts
index e320a74..eecf4d8 100644
--- a/src/lib/generators/drizzle.ts
+++ b/src/lib/generators/drizzle.ts
@@ -14,9 +14,11 @@ const toCamelCase = (str: string) => {
};
export const generateDrizzleSchema = (tables: Table[]): string => {
+ if (!tables) return '';
const imports = new Set
(['pgTable']);
const tableCode = tables
+ .filter((table) => table.name && table.columns)
.map((table) => {
const tableNameCamel = toCamelCase(table.name);
const columnsCode = table.columns
diff --git a/src/lib/generators/prisma.ts b/src/lib/generators/prisma.ts
index 6871f83..db61e34 100644
--- a/src/lib/generators/prisma.ts
+++ b/src/lib/generators/prisma.ts
@@ -20,7 +20,9 @@ const toCamelCase = (str: string) => {
};
export const generatePrismaSchema = (tables: Table[]): string => {
+ if (!tables) return '';
const models = tables
+ .filter((table) => table.name && table.columns)
.map((table) => {
const modelName = toPascalCase(table.name);
diff --git a/src/lib/generators/sql.ts b/src/lib/generators/sql.ts
index b75a743..89b6052 100644
--- a/src/lib/generators/sql.ts
+++ b/src/lib/generators/sql.ts
@@ -1,7 +1,9 @@
import { Table } from '../types';
export const generateSqlCode = (tables: Table[]): string => {
+ if (!tables) return '';
return tables
+ .filter((table) => table.name && table.columns)
.map((table) => {
const columnDefinitions = table.columns
.map((col) => {
diff --git a/src/lib/schema-context.tsx b/src/lib/schema-context.tsx
index 4f52430..4348a36 100644
--- a/src/lib/schema-context.tsx
+++ b/src/lib/schema-context.tsx
@@ -1,14 +1,13 @@
'use client';
-import React, { createContext, useContext, useState, useCallback } from 'react';
+import React, { createContext, useContext, useState } from 'react';
import { Table } from './types';
-import { getDatabaseSchema } from '@/services/database-design';
interface SchemaContextType {
schemaData: Table[];
- isLoading: boolean;
- updateSchema: (description: string, currentSchema?: Table[]) => Promise;
- setSchemaData: (tables: Table[]) => void;
+ isStreaming: boolean;
+ setSchemaData: React.Dispatch>;
+ setIsStreaming: (streaming: boolean) => void;
}
const SchemaContext = createContext(undefined);
@@ -31,41 +30,11 @@ export function SchemaProvider({
initialSchema = [],
}: SchemaProviderProps) {
const [schemaData, setSchemaData] = useState(initialSchema);
- const [isLoading, setIsLoading] = useState(false);
-
- const updateSchema = useCallback(
- async (description: string, currentSchema?: Table[]) => {
- console.log('updateSchema called with description:', description);
- setIsLoading(true);
- try {
- const newSchema = await getDatabaseSchema(
- description,
- JSON.stringify(currentSchema || schemaData),
- );
- console.log('updateSchema received new schema:', newSchema);
- setSchemaData(newSchema);
- } catch (error) {
- console.error('Failed to update schema:', error);
- } finally {
- setIsLoading(false);
- }
- },
- [schemaData],
- );
-
- const setSchemaDataWithLogging = useCallback((tables: Table[]) => {
- console.log('setSchemaData called with tables:', tables);
- setSchemaData(tables);
- }, []);
-
- const value = {
- schemaData,
- isLoading,
- updateSchema,
- setSchemaData: setSchemaDataWithLogging,
- };
+ const [isStreaming, setIsStreaming] = useState(false);
return (
- {children}
+
+ {children}
+
);
}
diff --git a/src/lib/schema-tools.ts b/src/lib/schema-tools.ts
deleted file mode 100644
index f827fa2..0000000
--- a/src/lib/schema-tools.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-import { Table } from '@/lib/types';
-import { getDatabaseSchema as getSchemaFromAPI } from '@/services/database-design';
-
-// Global callback to update schema context from tools
-let updateSchemaCallback: ((tables: Table[]) => void) | null = null;
-
-export function setSchemaUpdateCallback(callback: (tables: Table[]) => void) {
- updateSchemaCallback = callback;
-}
-
-// These are wrapper functions that will be used by the Tambo tools
-// They call the API and can potentially trigger UI updates
-
-export async function getDatabaseSchemaForTool(args: {
- description: string;
- currentSchema?: string;
-}): Promise {
- console.log('getDatabaseSchemaForTool called with:', args);
- const { description, currentSchema } = args;
- const result = await getSchemaFromAPI(description, currentSchema);
-
- // Update the schema context if callback is available
- if (updateSchemaCallback && result) {
- console.log('Updating schema context with:', result);
- updateSchemaCallback(result);
- }
-
- return result;
-}
-
-export async function analyzeSchemaForTool(args: {
- tables: Table[];
-}): Promise {
- console.log('analyzeSchemaForTool called with:', args);
- const { tables } = args;
- const description = `Analyze and optimize this existing database schema: ${JSON.stringify(
- tables,
- )}`;
- const result = await getSchemaFromAPI(description);
-
- // Update the schema context if callback is available
- if (updateSchemaCallback && result) {
- console.log('Updating schema context with:', result);
- updateSchemaCallback(result);
- }
-
- return result;
-}
-
-export async function generateMigrationForTool(args: {
- currentTables: Table[];
- description: string;
-}): Promise {
- console.log('generateMigrationForTool called with:', args);
- const { currentTables, description } = args;
- const result = await getSchemaFromAPI(
- description,
- JSON.stringify(currentTables),
- );
-
- // Update the schema context if callback is available
- if (updateSchemaCallback && result) {
- console.log('Updating schema context with:', result);
- updateSchemaCallback(result);
- }
-
- return result;
-}
-
-export async function validateSchemaForTool(args: {
- tables: Table[];
-}): Promise {
- console.log('validateSchemaForTool called with:', args);
- const { tables } = args;
- const description = `Validate and fix any issues in this database schema: ${JSON.stringify(
- tables,
- )}`;
- const result = await getSchemaFromAPI(description);
-
- // Update the schema context if callback is available
- if (updateSchemaCallback && result) {
- console.log('Updating schema context with:', result);
- updateSchemaCallback(result);
- }
-
- return result;
-}
-
-export async function optimizeSchemaForTool(args: {
- tables: Table[];
-}): Promise {
- console.log('optimizeSchemaForTool called with:', args);
- const { tables } = args;
- const description = `Optimize this database schema for better performance and maintainability: ${JSON.stringify(
- tables,
- )}`;
- const result = await getSchemaFromAPI(description);
-
- // Update the schema context if callback is available
- if (updateSchemaCallback && result) {
- console.log('Updating schema context with:', result);
- updateSchemaCallback(result);
- }
-
- return result;
-}
diff --git a/src/lib/tambo.ts b/src/lib/tambo.ts
index ab9a340..9ee4824 100644
--- a/src/lib/tambo.ts
+++ b/src/lib/tambo.ts
@@ -11,301 +11,12 @@
import { Graph, graphSchema } from '@/components/tambo/graph';
import { DataCard, dataCardSchema } from '@/components/ui/card-data';
import {
- getDatabaseSchemaForTool,
- analyzeSchemaForTool,
- generateMigrationForTool,
- validateSchemaForTool,
- optimizeSchemaForTool,
-} from '@/lib/schema-tools';
-import type { TamboComponent } from '@tambo-ai/react';
-import { TamboTool } from '@tambo-ai/react';
-import { z } from 'zod';
+ SchemaCanvas,
+ schemaCanvasSchema,
+} from '@/components/tambo/schema-canvas';
+import type { TamboComponent, TamboTool } from '@tambo-ai/react';
-/**
- * tools
- *
- * This array contains all the database analysis and generation tools that are registered for use within the application.
- * Each tool is defined with its name, description, and expected props. The tools
- * can be controlled by AI to dynamically analyze schemas, generate migrations, and provide optimization insights.
- */
-
-export const tools: TamboTool[] = [
- {
- name: 'getDatabaseSchema',
- description:
- 'Generate a database schema from a natural language description. Provide a description of what the database should handle and it will create tables, columns, relationships, and constraints.',
- tool: getDatabaseSchemaForTool,
- toolSchema: z
- .function()
- .args(
- z.object({
- description: z
- .string()
- .describe('Description of the database requirements'),
- currentSchema: z
- .string()
- .optional()
- .describe('Current schema to modify/extend (JSON format)'),
- }),
- )
- .returns(
- z.array(
- z.object({
- name: z.string(),
- columns: z.array(
- z.object({
- name: z.string(),
- type: z.string(),
- nullable: z.boolean(),
- defaultValue: z.string().optional(),
- isPrimaryKey: z.boolean(),
- isUnique: z.boolean(),
- foreignKey: z
- .object({
- table: z.string(),
- column: z.string(),
- })
- .optional(),
- }),
- ),
- }),
- ),
- ),
- },
- {
- name: 'analyzeSchema',
- description:
- 'Analyze an existing database schema for potential issues, optimization opportunities, and best practices. Takes the current schema and provides an improved version.',
- tool: analyzeSchemaForTool,
- toolSchema: z
- .function()
- .args(
- z.object({
- tables: z
- .array(
- z.object({
- name: z.string(),
- columns: z.array(
- z.object({
- name: z.string(),
- type: z.string(),
- nullable: z.boolean(),
- defaultValue: z.string().optional(),
- isPrimaryKey: z.boolean(),
- isUnique: z.boolean(),
- foreignKey: z
- .object({
- table: z.string(),
- column: z.string(),
- })
- .optional(),
- }),
- ),
- }),
- )
- .describe('Current schema tables to analyze'),
- }),
- )
- .returns(
- z.array(
- z.object({
- name: z.string(),
- columns: z.array(
- z.object({
- name: z.string(),
- type: z.string(),
- nullable: z.boolean(),
- defaultValue: z.string().optional(),
- isPrimaryKey: z.boolean(),
- isUnique: z.boolean(),
- foreignKey: z
- .object({
- table: z.string(),
- column: z.string(),
- })
- .optional(),
- }),
- ),
- }),
- ),
- ),
- },
- {
- name: 'generateMigration',
- description:
- 'Generate database migration by modifying an existing schema based on new requirements. Takes current tables and a description of changes needed.',
- tool: generateMigrationForTool,
- toolSchema: z
- .function()
- .args(
- z.object({
- currentTables: z
- .array(
- z.object({
- name: z.string(),
- columns: z.array(
- z.object({
- name: z.string(),
- type: z.string(),
- nullable: z.boolean(),
- defaultValue: z.string().optional(),
- isPrimaryKey: z.boolean(),
- isUnique: z.boolean(),
- foreignKey: z
- .object({
- table: z.string(),
- column: z.string(),
- })
- .optional(),
- }),
- ),
- }),
- )
- .describe('Current schema tables'),
- description: z.string().describe('Description of the changes needed'),
- }),
- )
- .returns(
- z.array(
- z.object({
- name: z.string(),
- columns: z.array(
- z.object({
- name: z.string(),
- type: z.string(),
- nullable: z.boolean(),
- defaultValue: z.string().optional(),
- isPrimaryKey: z.boolean(),
- isUnique: z.boolean(),
- foreignKey: z
- .object({
- table: z.string(),
- column: z.string(),
- })
- .optional(),
- }),
- ),
- }),
- ),
- ),
- },
- {
- name: 'validateSchema',
- description:
- 'Validate and fix issues in a database schema for consistency, referential integrity, and design best practices.',
- tool: validateSchemaForTool,
- toolSchema: z
- .function()
- .args(
- z.object({
- tables: z
- .array(
- z.object({
- name: z.string(),
- columns: z.array(
- z.object({
- name: z.string(),
- type: z.string(),
- nullable: z.boolean(),
- defaultValue: z.string().optional(),
- isPrimaryKey: z.boolean(),
- isUnique: z.boolean(),
- foreignKey: z
- .object({
- table: z.string(),
- column: z.string(),
- })
- .optional(),
- }),
- ),
- }),
- )
- .describe('Schema tables to validate'),
- }),
- )
- .returns(
- z.array(
- z.object({
- name: z.string(),
- columns: z.array(
- z.object({
- name: z.string(),
- type: z.string(),
- nullable: z.boolean(),
- defaultValue: z.string().optional(),
- isPrimaryKey: z.boolean(),
- isUnique: z.boolean(),
- foreignKey: z
- .object({
- table: z.string(),
- column: z.string(),
- })
- .optional(),
- }),
- ),
- }),
- ),
- ),
- },
- {
- name: 'optimizeSchema',
- description:
- 'Optimize a database schema for better performance, normalization, and maintainability.',
- tool: optimizeSchemaForTool,
- toolSchema: z
- .function()
- .args(
- z.object({
- tables: z
- .array(
- z.object({
- name: z.string(),
- columns: z.array(
- z.object({
- name: z.string(),
- type: z.string(),
- nullable: z.boolean(),
- defaultValue: z.string().optional(),
- isPrimaryKey: z.boolean(),
- isUnique: z.boolean(),
- foreignKey: z
- .object({
- table: z.string(),
- column: z.string(),
- })
- .optional(),
- }),
- ),
- }),
- )
- .describe('Schema tables to optimize'),
- }),
- )
- .returns(
- z.array(
- z.object({
- name: z.string(),
- columns: z.array(
- z.object({
- name: z.string(),
- type: z.string(),
- nullable: z.boolean(),
- defaultValue: z.string().optional(),
- isPrimaryKey: z.boolean(),
- isUnique: z.boolean(),
- foreignKey: z
- .object({
- table: z.string(),
- column: z.string(),
- })
- .optional(),
- }),
- ),
- }),
- ),
- ),
- },
-];
+export const tools: TamboTool[] = [];
/**
* components
@@ -315,6 +26,13 @@ export const tools: TamboTool[] = [
* can be controlled by AI to dynamically render database schemas, ERDs, and analysis visualizations based on user interactions.
*/
export const components: TamboComponent[] = [
+ {
+ name: 'SchemaCanvas',
+ description:
+ 'Renders a database schema as an interactive Entity Relationship Diagram in the canvas. Use this component whenever the user asks to create, design, or modify a database schema. Supports two modes: Use mode="full" when creating a brand new schema โ provide ALL tables. Use mode="update" when the user asks to add, modify, or remove tables from an existing schema โ provide ONLY the new or changed tables in "tables", existing tables are preserved automatically. To DELETE tables, pass their names in the "removedTables" array (e.g. removedTables: ["users", "posts"]). Always prefer mode="update" when a schema already exists in the canvas.',
+ component: SchemaCanvas,
+ propsSchema: schemaCanvasSchema,
+ },
{
name: 'Graph',
description:
@@ -329,8 +47,4 @@ export const components: TamboComponent[] = [
component: DataCard,
propsSchema: dataCardSchema,
},
- // TODO: Add more database-specific components:
- // - TableSchema component for detailed table visualization
- // - SQLDisplay component for generated SQL code
- // - SchemaComparison component for before/after comparisons
];
diff --git a/src/lib/thread-hooks.ts b/src/lib/thread-hooks.ts
index 366b4cc..8f988fb 100644
--- a/src/lib/thread-hooks.ts
+++ b/src/lib/thread-hooks.ts
@@ -1,30 +1,109 @@
+import type { TamboThreadMessage } from "@tambo-ai/react";
import * as React from "react";
import { useEffect, useState } from "react";
-import type { TamboThreadMessage } from "@tambo-ai/react";
/**
- * Custom hook to merge multiple refs into one callback ref
- * @param refs - Array of refs to merge
- * @returns A callback ref that updates all provided refs
+ * Converts message content to markdown format for rendering with streamdown.
+ * Handles text and resource content parts, converting resources to markdown links
+ * with a custom URL scheme that will be rendered as Mention components.
+ *
+ * @param content - The message content (string, element, array, etc.)
+ * @returns A markdown string ready for streamdown rendering
*/
-export function useMergedRef(...refs: React.Ref[]) {
- return React.useCallback(
- (element: T) => {
- for (const ref of refs) {
- if (!ref) continue;
-
- if (typeof ref === "function") {
- ref(element);
- } else {
- // This cast is safe because we're just updating the .current property
- (ref as React.MutableRefObject).current = element;
+export function convertContentToMarkdown(
+ content: TamboThreadMessage["content"] | React.ReactNode | undefined | null,
+): string {
+ if (!content) return "";
+ if (typeof content === "string") return content;
+ if (React.isValidElement(content)) {
+ // For React elements, we can't convert to markdown - this shouldn't happen
+ // in normal flow, but keep backward compatibility
+ return "";
+ }
+ if (Array.isArray(content)) {
+ const parts: string[] = [];
+ for (const item of content) {
+ if (item?.type === "text") {
+ parts.push(item.text ?? "");
+ } else if (item?.type === "resource") {
+ const resource = item.resource;
+ const uri = resource?.uri;
+ if (uri) {
+ // Use resource name for display, fallback to URI if no name
+ const displayName = resource?.name ?? uri;
+ // Use a custom protocol that looks more standard to avoid blocking
+ // Format: tambo-resource://
+ // We'll detect this in the link component and decode the URI
+ const encodedUri = encodeURIComponent(uri);
+ parts.push(`[${displayName}](tambo-resource://${encodedUri})`);
}
}
- },
- [refs],
- );
+ }
+ return parts.join(" ");
+ }
+ return "";
}
+/**
+ * Merges multiple refs into a single callback ref.
+ *
+ * In React 19, callback refs may return cleanup functions; this hook fans out
+ * both assignments and cleanups to all provided refs and tracks the last
+ * cleanup so it runs when the instance changes.
+ */
+export function useMergeRefs(
+ ...refs: (React.Ref | undefined)[]
+): null | React.RefCallback {
+ const cleanupRef = React.useRef void)>(undefined);
+
+ const refEffect = React.useCallback((instance: Instance | null) => {
+ const cleanups = refs.map((ref) => {
+ if (ref == null) {
+ return;
+ }
+
+ if (typeof ref === "function") {
+ const refCallback = ref;
+ const refCleanup: void | (() => void) = refCallback(instance);
+ return typeof refCleanup === "function"
+ ? refCleanup
+ : () => {
+ refCallback(null);
+ };
+ }
+
+ (ref as React.MutableRefObject).current = instance;
+ return () => {
+ (ref as React.MutableRefObject).current = null;
+ };
+ });
+
+ return () => {
+ cleanups.forEach((refCleanup) => refCleanup?.());
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, refs);
+
+ return React.useMemo(() => {
+ if (refs.every((ref) => ref == null)) {
+ return null;
+ }
+
+ return (value) => {
+ if (cleanupRef.current) {
+ cleanupRef.current();
+ (cleanupRef as React.MutableRefObject void)>).current =
+ undefined;
+ }
+
+ if (value != null) {
+ (cleanupRef as React.MutableRefObject void)>).current =
+ refEffect(value);
+ }
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [refEffect, ...refs]);
+}
/**
* Custom hook to detect canvas space presence and position
* @param elementRef - Reference to the component to compare position with
@@ -92,18 +171,26 @@ export function usePositioning(
// If panel has right class, history should be on right
// If canvas is on left, history should be on right
// Otherwise, history should be on left
- const historyPosition: "left" | "right" = isRightClass
- ? "right"
- : hasCanvasSpace && canvasIsOnLeft
- ? "right"
- : "left";
+ let historyPosition: "left" | "right";
+ if (isRightClass) {
+ historyPosition = "right";
+ } else if (hasCanvasSpace && canvasIsOnLeft) {
+ historyPosition = "right";
+ } else {
+ historyPosition = "left";
+ }
return { isLeftPanel, historyPosition };
}
/**
* Converts message content into a safely renderable format.
- * Primarily joins text blocks from arrays into a single string.
+ * Handles text, resource references, and other content types.
+ *
+ * @deprecated This function is deprecated. Message rendering now uses a private
+ * `convertContentToMarkdown()` function within the message component. This function
+ * is kept for backward compatibility since it's exposed in the SDK.
+ *
* @param content - The message content (string, element, array, etc.)
* @returns A renderable string or React element.
*/
@@ -114,10 +201,20 @@ export function getSafeContent(
if (typeof content === "string") return content;
if (React.isValidElement(content)) return content; // Pass elements through
if (Array.isArray(content)) {
- // Filter out non-text items and join text
- return content
- .map((item) => (item && item.type === "text" ? (item.text ?? "") : ""))
- .join("");
+ // Map content parts to strings, including resource references
+ const parts: string[] = [];
+ for (const item of content) {
+ if (item?.type === "text") {
+ parts.push(item.text ?? "");
+ } else if (item?.type === "resource") {
+ // Format resource references as @uri (uri already contains serverKey prefix if applicable)
+ const uri = item.resource?.uri;
+ if (uri) {
+ parts.push(`@${uri}`);
+ }
+ }
+ }
+ return parts.join(" ");
}
// Handle potential edge cases or unknown types
// console.warn("getSafeContent encountered unknown content type:", content);
@@ -125,7 +222,36 @@ export function getSafeContent(
}
/**
- * Checks if message content contains meaningful, non-empty text.
+ * Checks if a content item has meaningful data.
+ * @param item - A content item from the message
+ * @returns True if the item has content, false otherwise.
+ */
+function hasContentInItem(item: unknown): boolean {
+ if (!item || typeof item !== "object") {
+ return false;
+ }
+
+ const typedItem = item as {
+ type?: string;
+ text?: string;
+ image_url?: { url?: string };
+ };
+
+ // Check for text content
+ if (typedItem.type === "text") {
+ return !!typedItem.text?.trim();
+ }
+
+ // Check for image content
+ if (typedItem.type === "image_url") {
+ return !!typedItem.image_url?.url;
+ }
+
+ return false;
+}
+
+/**
+ * Checks if message content contains meaningful, non-empty text or images.
* @param content - The message content (string, element, array, etc.)
* @returns True if there is content, false otherwise.
*/
@@ -136,13 +262,22 @@ export function checkHasContent(
if (typeof content === "string") return content.trim().length > 0;
if (React.isValidElement(content)) return true; // Assume elements have content
if (Array.isArray(content)) {
- return content.some(
- (item) =>
- item &&
- item.type === "text" &&
- typeof item.text === "string" &&
- item.text.trim().length > 0,
- );
+ return content.some(hasContentInItem);
}
return false; // Default for unknown types
}
+
+/**
+ * Extracts image URLs from message content array.
+ * @param content - Array of content items
+ * @returns Array of image URLs
+ */
+export function getMessageImages(
+ content: { type?: string; image_url?: { url?: string } }[] | undefined | null,
+): string[] {
+ if (!content) return [];
+
+ return content
+ .filter((item) => item?.type === "image_url" && item.image_url?.url)
+ .map((item) => item.image_url!.url!);
+}
diff --git a/src/lib/turso.ts b/src/lib/turso.ts
index ebb546a..f54b580 100644
--- a/src/lib/turso.ts
+++ b/src/lib/turso.ts
@@ -1,6 +1,16 @@
-import { createClient } from '@libsql/client';
+import { createClient, Client } from '@libsql/client';
-export const turso = createClient({
- url: process.env.TURSO_DATABASE_URL!,
- authToken: process.env.TURSO_AUTH_TOKEN,
-});
+let tursoClient: Client | null = null;
+
+export function getTurso(): Client {
+ if (!tursoClient) {
+ if (!process.env.TURSO_DATABASE_URL) {
+ throw new Error('TURSO_DATABASE_URL environment variable is not set');
+ }
+ tursoClient = createClient({
+ url: process.env.TURSO_DATABASE_URL,
+ authToken: process.env.TURSO_AUTH_TOKEN,
+ });
+ }
+ return tursoClient;
+}
diff --git a/src/services/database-design.ts b/src/services/database-design.ts
deleted file mode 100644
index 734acc1..0000000
--- a/src/services/database-design.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import { Table } from '../lib/types';
-
-const API_BASE_URL = '/api';
-
-export async function getDatabaseSchema(
- description?: string,
- currentSchema?: string,
-): Promise {
- try {
- if (!description) {
- // Return empty array if no description provided
- return [];
- }
-
- const response = await fetch(`${API_BASE_URL}/generate-schema`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- description,
- currentSchema: currentSchema,
- }),
- });
-
- if (!response.ok) {
- throw new Error(`API request failed: ${response.statusText}`);
- }
-
- const data = await response.json();
- return data.schema?.tables || [];
- } catch (error) {
- console.error('Error fetching database schema:', error);
- // Return empty array on error to prevent UI breakage
- return [];
- }
-}
-
-export async function analyzeSchema(tables: Table[]): Promise {
- try {
- const description = `Analyze and optimize this existing database schema: ${JSON.stringify(
- tables,
- )}`;
- return await getDatabaseSchema(description);
- } catch (error) {
- console.error('Error analyzing schema:', error);
- return tables; // Return original on error
- }
-}
-
-export async function generateMigration(
- currentTables: Table[],
- description: string,
-): Promise {
- try {
- return await getDatabaseSchema(description, JSON.stringify(currentTables));
- } catch (error) {
- console.error('Error generating migration:', error);
- return currentTables; // Return current on error
- }
-}
-
-export async function validateSchema(tables: Table[]): Promise {
- try {
- const description = `Validate and fix any issues in this database schema: ${JSON.stringify(
- tables,
- )}`;
- return await getDatabaseSchema(description);
- } catch (error) {
- console.error('Error validating schema:', error);
- return tables; // Return original on error
- }
-}
-
-export async function optimizeSchema(tables: Table[]): Promise {
- try {
- const description = `Optimize this database schema for better performance and maintainability: ${JSON.stringify(
- tables,
- )}`;
- return await getDatabaseSchema(description);
- } catch (error) {
- console.error('Error optimizing schema:', error);
- return tables; // Return original on error
- }
-}
diff --git a/tsconfig.json b/tsconfig.json
index 09522c1..47ffe7d 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2017",
- "lib": ["dom", "dom.iterable", "esnext"],
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -11,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
- "jsx": "preserve",
+ "jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -19,9 +23,20 @@
}
],
"paths": {
- "@/*": ["./src/*"]
+ "@/*": [
+ "./src/*"
+ ]
}
},
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
- "exclude": ["node_modules", "server"]
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules",
+ "server"
+ ]
}