"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import type { Message } from "@/hooks/useChat"; interface MessageListProps { messages: Message[]; isLoading: boolean; isStreaming?: boolean; streamingMessageId?: string; loadingQuip?: string | null; } /** * Parse thinking content from message. * Extracts ... or ... blocks. */ function parseThinking(content: string): { thinking: string | null; response: string } { const thinkingRegex = /<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi; const matches = content.match(thinkingRegex); if (!matches) { return { thinking: null, response: content }; } let thinking = ""; for (const match of matches) { const innerContent = match.replace(/<\/?(?:thinking|think)>/gi, ""); thinking += innerContent.trim() + "\n"; } const response = content.replace(thinkingRegex, "").trim(); const trimmedThinking = thinking.trim(); return { thinking: trimmedThinking.length > 0 ? trimmedThinking : null, response, }; } export function MessageList({ messages, isLoading, isStreaming = false, streamingMessageId, loadingQuip, }: MessageListProps): React.JSX.Element { const bottomRef = useRef(null); // Auto-scroll to bottom when messages change or streaming tokens arrive useEffect(() => { if (isStreaming || isLoading) { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); } }, [messages, isStreaming, isLoading]); return (
{messages.map((message) => ( ))} {isLoading && !isStreaming && ( )}
); } interface MessageBubbleProps { message: Message; isStreaming?: boolean; } function MessageBubble({ message, isStreaming = false }: MessageBubbleProps): React.JSX.Element { const isUser = message.role === "user"; const [copied, setCopied] = useState(false); const [thinkingExpanded, setThinkingExpanded] = useState(false); const { thinking, response } = message.thinking ? { thinking: message.thinking, response: message.content } : parseThinking(message.content); const handleCopy = useCallback(async () => { try { await navigator.clipboard.writeText(response); setCopied(true); setTimeout(() => { setCopied(false); }, 2000); } catch (err) { void err; } }, [response]); return (
{/* Avatar */} {/* Message Content */}
{/* Message Header */}
{isUser ? "You" : "AI Assistant"} {/* Streaming indicator in header */} {!isUser && isStreaming && ( streaming )} {/* Model indicator for assistant messages */} {!isUser && message.model && !isStreaming && ( {message.model} )} {/* Token usage indicator for assistant messages */} {!isUser && message.totalTokens !== undefined && message.totalTokens > 0 && ( {formatTokenCount(message.totalTokens)} tokens )} {formatTime(message.createdAt)}
{/* Thinking Section - Collapsible */} {thinking && !isUser && (
{thinkingExpanded && (
{thinking}
)}
)} {/* Message Body */}

{response} {/* Blinking cursor during streaming */} {isStreaming && !isUser && (

{/* Copy Button - hidden while streaming */} {!isStreaming && ( )}
); } function LoadingIndicator({ quip }: { quip?: string | null }): React.JSX.Element { return (
{/* Avatar */} {/* Loading Content */}
AI Assistant thinking...
{quip && ( {quip} )}
); } export function formatTime(isoString: string): string { try { const date = new Date(isoString); if (isNaN(date.getTime())) { return "Invalid date"; } return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } catch { return "Invalid date"; } } function formatTokenCount(tokens: number): string { if (tokens >= 1000000) { return `${(tokens / 1000000).toFixed(1)}M`; } else if (tokens >= 1000) { return `${(tokens / 1000).toFixed(1)}K`; } return tokens.toString(); }