"use client"; import { ChevronDown, Circle, File, FileAudio, FileCode, FileImage, FileSpreadsheet, FileText, FileVideo, } from "lucide-react"; import React, { useEffect, useState } from "react"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { cn } from "@/lib/utils"; // ============================================================================ // Constants // ============================================================================ /** Animation timing constants (in milliseconds) */ const ANIMATION = { /** Delay between each step appearing */ STAGGER_DELAY_MS: 50, /** Additional delay for connection line animation */ CONNECTION_LINE_DELAY_MS: 150, } as const; /** File extension categories for icon mapping */ const FILE_EXTENSIONS = { DOCUMENT: ["pdf", "doc", "docx"] as const, SPREADSHEET: ["xls", "xlsx", "csv"] as const, IMAGE: ["png", "jpg", "jpeg", "gif", "webp", "svg"] as const, AUDIO: ["mp3", "wav", "m4a", "ogg", "webm"] as const, VIDEO: ["mp4", "mov", "avi", "mkv"] as const, CODE: ["js", "ts", "tsx", "jsx", "py", "html", "css", "json", "md"] as const, } as const; /** Type for file extension categories */ type FileExtensionCategory = keyof typeof FILE_EXTENSIONS; /** Icon size class for file icons */ const FILE_ICON_SIZE_CLASS = "size-3.5" as const; // ============================================================================ // Hooks // ============================================================================ /** * Custom hook for entrance animation * Returns true after mount to trigger CSS transitions */ function useEntranceAnimation(delay = 0): boolean { const [isVisible, setIsVisible] = useState(false); useEffect(() => { const timer = setTimeout(() => setIsVisible(true), delay); return () => clearTimeout(timer); }, [delay]); return isVisible; } // ============================================================================ // File Icon Utilities // ============================================================================ /** * Check if an extension belongs to a specific category */ function isExtensionInCategory(ext: string, category: FileExtensionCategory): boolean { return (FILE_EXTENSIONS[category] as readonly string[]).includes(ext); } /** * Get file icon based on file extension (all icons are muted/gray) */ function getFileIcon(name: string): React.ReactNode { const ext = name.split(".").pop()?.toLowerCase() ?? ""; if (isExtensionInCategory(ext, "DOCUMENT")) { return ; } if (isExtensionInCategory(ext, "SPREADSHEET")) { return ; } if (isExtensionInCategory(ext, "IMAGE")) { return ; } if (isExtensionInCategory(ext, "AUDIO")) { return ; } if (isExtensionInCategory(ext, "VIDEO")) { return ; } if (isExtensionInCategory(ext, "CODE")) { return ; } return ; } // ============================================================================ // Attachment Components // ============================================================================ interface AttachmentTileProps { /** File name to display */ name: string; } /** * Compact attachment tile component - matches the chat UI style */ const AttachmentTile: React.FC = ({ name }) => { const icon = getFileIcon(name); return ( {icon} {name} ); }; /** * Parse text and render bracketed items (like [filename.pdf]) as styled tiles */ function parseAndRenderWithBadges(text: string): React.ReactNode { // Match patterns like [filename.ext] or [N files] or [N documents] const regex = /\[([^\]]+)\]/g; const matches = Array.from(text.matchAll(regex)); if (matches.length === 0) { return text; } const parts: React.ReactNode[] = []; let lastIndex = 0; for (const match of matches) { const matchIndex = match.index ?? 0; // Add text before the match if (matchIndex > lastIndex) { parts.push(text.slice(lastIndex, matchIndex)); } const content = match[1]; // Render as a compact tile matching chat UI style with file-type colors parts.push(); lastIndex = matchIndex + match[0].length; } // Add remaining text if (lastIndex < text.length) { parts.push(text.slice(lastIndex)); } return parts; } // ============================================================================ // Chain of Thought Components // ============================================================================ export interface ChainOfThoughtItemProps extends React.HTMLAttributes { children: React.ReactNode; } export const ChainOfThoughtItem: React.FC = ({ children, className, ...props }) => ( {typeof children === "string" ? parseAndRenderWithBadges(children) : children} ); export interface ChainOfThoughtTriggerProps extends React.ComponentProps { /** Optional icon to display on the left side */ leftIcon?: React.ReactNode; /** Whether to swap the icon with chevron on hover */ swapIconOnHover?: boolean; } export const ChainOfThoughtTrigger: React.FC = ({ children, className, leftIcon, swapIconOnHover = true, ...props }) => ( {leftIcon ? ( {leftIcon} {swapIconOnHover && ( )} ) : ( )} {children} {!leftIcon && ( )} ); export interface ChainOfThoughtContentProps extends React.ComponentProps {} export const ChainOfThoughtContent: React.FC = ({ children, className, ...props }) => { return ( {/* Animated vertical connection line */} {React.Children.map(children, (child, index) => { const key = React.isValidElement(child) ? child.key : `cot-item-${index}`; return ( {child} ); })} ); }; export interface ChainOfThoughtProps { children: React.ReactNode; className?: string; } export const ChainOfThought: React.FC = ({ children, className }) => { const childrenArray = React.Children.toArray(children); return ( {childrenArray.map((child, index) => { // React.Children.toArray assigns stable keys to each child const key = React.isValidElement(child) ? child.key : `cot-step-${index}`; return ( {React.isValidElement(child) && React.cloneElement(child as React.ReactElement, { isLast: index === childrenArray.length - 1, stepIndex: index, })} ); })} ); }; export interface ChainOfThoughtStepProps extends Omit, "children"> { children: React.ReactNode; className?: string; /** Whether this is the last step (hides connection line) */ isLast?: boolean; /** Index of the step for staggered animation timing */ stepIndex?: number; } export const ChainOfThoughtStep: React.FC = ({ children, className, isLast = false, stepIndex = 0, ...props }) => { // Staggered entrance animation based on step index const isVisible = useEntranceAnimation(stepIndex * ANIMATION.STAGGER_DELAY_MS); // Calculate connection line delay: step delay + additional offset const connectionLineDelay = stepIndex * ANIMATION.STAGGER_DELAY_MS + ANIMATION.CONNECTION_LINE_DELAY_MS; return ( {children} {/* Animated connection line to next step */} ); };