"use client";
import { useCallback, useState } from "react";
import type { Message } from "@/hooks/useChat";
interface MessageListProps {
messages: Message[];
isLoading: boolean;
loadingQuip?: string | null;
}
/**
* Parse thinking content from message.
* Extracts ... or ... blocks.
*/
function parseThinking(content: string): { thinking: string | null; response: string } {
// Match ... or ... blocks
const thinkingRegex = /<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi;
const matches = content.match(thinkingRegex);
if (!matches) {
return { thinking: null, response: content };
}
// Extract thinking content
let thinking = "";
for (const match of matches) {
const innerContent = match.replace(/<\/?(?:thinking|think)>/gi, "");
thinking += innerContent.trim() + "\n";
}
// Remove thinking blocks from response
const response = content.replace(thinkingRegex, "").trim();
const trimmedThinking = thinking.trim();
return {
thinking: trimmedThinking.length > 0 ? trimmedThinking : null,
response,
};
}
export function MessageList({
messages,
isLoading,
loadingQuip,
}: MessageListProps): React.JSX.Element {
return (
{messages.map((message) => (
))}
{isLoading && }
);
}
function MessageBubble({ message }: { message: Message }): React.JSX.Element {
const isUser = message.role === "user";
const [copied, setCopied] = useState(false);
const [thinkingExpanded, setThinkingExpanded] = useState(false);
// Parse thinking from content (or use pre-parsed thinking field)
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) {
// Silently fail - clipboard copy is non-critical
void err;
}
}, [response]);
return (
{/* Avatar */}
{isUser ? "You" : "AI"}
{/* Message Content */}
{/* Message Header */}
{isUser ? "You" : "AI Assistant"}
{/* Model indicator for assistant messages */}
{!isUser && message.model && (
{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}
{/* Copy Button - appears on hover */}
);
}
function LoadingIndicator({ quip }: { quip?: string | null }): React.JSX.Element {
return (
{/* Avatar */}
AI
{/* Loading Content */}
);
}
function formatTime(isoString: string): string {
try {
const date = new Date(isoString);
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
} catch {
return "";
}
}
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();
}