"use client"; import { checkHasContent, getSafeContent } from "@/lib/thread-hooks"; 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 { Streamdown } from "streamdown"; import { createMarkdownComponents } from "@/components/ui/markdown-components"; /** * CSS variants for the message container * @typedef {Object} MessageVariants * @property {string} default - Default styling * @property {string} solid - Solid styling with shadow effects */ const messageVariants = cva("flex", { variants: { variant: { default: "", solid: [ "[&>div>div:first-child]:shadow-md", "[&>div>div:first-child]:bg-container/50", "[&>div>div:first-child]:hover:bg-container", "[&>div>div:first-child]:transition-all", "[&>div>div:first-child]:duration-200", ].join(" "), }, }, defaultVariants: { variant: "default", }, }); /** * @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; /** 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; } /** * The root container for a message component. * It establishes the context for its children and applies alignment styles based on the role. * @component Message * @example * ```tsx * * * * * ``` */ 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; } return (
{children}
); }, ); Message.displayName = "Message"; /** * 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 */ const LoadingIndicator: React.FC> = ({ className, ...props }) => { return (
); }; LoadingIndicator.displayName = "LoadingIndicator"; /** * Props for the MessageContent component. * Extends standard HTMLDivElement attributes. */ 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; } /** * 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; return (
{showLoading ? (
) : (
{!contentToRender ? ( Empty message ) : React.isValidElement(contentToRender) ? ( contentToRender ) : markdown ? ( {typeof safeContent === "string" ? safeContent : ""} ) : ( safeContent )} {message.isCancelled && ( cancelled )}
)}
); }, ); MessageContent.displayName = "MessageContent"; /** * Props for the ToolcallInfo component. * Extends standard HTMLDivElement attributes. */ export interface ToolcallInfoProps extends Omit, "content"> { /** Optional flag to render response content as Markdown. Default is true. */ markdown?: boolean; } 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; } /** * 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]); if (message.actionType !== "tool_call") { return null; } const toolCallRequest: TamboAI.ToolCallRequest | undefined = message.toolCallRequest ?? message.component?.toolCallRequest; const hasToolError = message.error; const toolStatusMessage = getToolStatusMessage(message, isLoading); return (
tool: {toolCallRequest?.toolName} parameters:{"\n"} {stringify(keyifyParameters(toolCallRequest?.parameters))} {associatedToolResponse && ( <> result:
{!associatedToolResponse.content ? ( Empty response ) : ( formatToolResult(associatedToolResponse.content) )}
)}
); }, ); ToolcallInfo.displayName = "ToolcallInfo"; function keyifyParameters( parameters: TamboAI.ToolCallRequest["parameters"] | undefined, ) { if (!parameters) return; return Object.fromEntries( parameters.map((p) => [p.parameterName, p.parameterValue]), ); } /** * 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 */ function formatToolResult( content: TamboThreadMessage["content"], ): React.ReactNode { if (!content) return content; const safeContent = getSafeContent(content); if (typeof safeContent !== "string") return safeContent; // Try to parse as JSON try { const parsed = JSON.parse(safeContent); return (
        
          {JSON.stringify(parsed, null, 2)}
        
      
); } catch { return safeContent; } } /** * Props for the MessageRenderedComponentArea component. * Extends standard HTMLDivElement attributes. */ export type MessageRenderedComponentAreaProps = React.HTMLAttributes; /** * 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; } return (
{children ?? (canvasExists ? (
) : (
{message.renderedComponent}
))}
); }); MessageRenderedComponentArea.displayName = "Message.RenderedComponentArea"; // --- Exports --- export { LoadingIndicator, Message, MessageContent, MessageRenderedComponentArea, messageVariants, ToolcallInfo, };