import { ReasoningMessage, Message } from "@ag-ui/core"; import { useState, useEffect, useRef } from "react"; import { ChevronRight } from "lucide-react"; import { twMerge } from "tailwind-merge"; import { Streamdown } from "streamdown"; import { WithSlots, renderSlot } from "../../lib/slots"; export type CopilotChatReasoningMessageProps = WithSlots< { header: typeof CopilotChatReasoningMessage.Header; contentView: typeof CopilotChatReasoningMessage.Content; toggle: typeof CopilotChatReasoningMessage.Toggle; }, { message: ReasoningMessage; messages?: Message[]; isRunning?: boolean; } & React.HTMLAttributes >; /** * Formats an elapsed duration (in seconds) to a human-readable string. */ function formatDuration(seconds: number): string { if (seconds < 1) return "a few seconds"; if (seconds < 60) return `${Math.round(seconds)} seconds`; const mins = Math.floor(seconds / 60); const secs = Math.round(seconds % 60); if (secs === 0) return `${mins} minute${mins > 1 ? "s" : ""}`; return `${mins}m ${secs}s`; } export function CopilotChatReasoningMessage({ message, messages, isRunning, header, contentView, toggle, children, className, ...props }: CopilotChatReasoningMessageProps) { const isLatest = messages?.[messages.length - 1]?.id === message.id; const isStreaming = !!(isRunning && isLatest); const hasContent = !!(message.content && message.content.length > 0); // Track elapsed time while streaming const startTimeRef = useRef(null); const [elapsed, setElapsed] = useState(0); useEffect(() => { if (isStreaming && startTimeRef.current === null) { startTimeRef.current = Date.now(); } if (!isStreaming && startTimeRef.current !== null) { // Final snapshot of elapsed time setElapsed((Date.now() - startTimeRef.current) / 1000); return; } if (!isStreaming) return; // Tick every second while streaming const timer = setInterval(() => { if (startTimeRef.current !== null) { setElapsed((Date.now() - startTimeRef.current) / 1000); } }, 1000); return () => clearInterval(timer); }, [isStreaming]); // Default to open while streaming, auto-collapse when streaming ends. // Track whether the user has manually toggled so auto-collapse doesn't // override their explicit intent (prevents flaky test failures on CI // where async forceUpdate timing can race with click handlers). const [isOpen, setIsOpen] = useState(isStreaming); const userToggledRef = useRef(false); useEffect(() => { if (isStreaming) { // Reset user-toggle tracking when a new streaming session starts userToggledRef.current = false; setIsOpen(true); } else if (!userToggledRef.current) { // Auto-collapse only if the user hasn't manually toggled setIsOpen(false); } }, [isStreaming]); const handleToggle = hasContent ? () => { userToggledRef.current = true; setIsOpen((prev) => !prev); } : undefined; const label = isStreaming ? "Thinking…" : `Thought for ${formatDuration(elapsed)}`; const boundHeader = renderSlot(header, CopilotChatReasoningMessage.Header, { isOpen, label, hasContent, isStreaming, onClick: handleToggle, }); const boundContent = renderSlot( contentView, CopilotChatReasoningMessage.Content, { isStreaming, hasContent, children: message.content, }, ); const boundToggle = renderSlot(toggle, CopilotChatReasoningMessage.Toggle, { isOpen, children: boundContent, }); if (children) { return (
{children({ header: boundHeader, contentView: boundContent, toggle: boundToggle, message, messages, isRunning, })}
); } return (
{boundHeader} {boundToggle}
); } export namespace CopilotChatReasoningMessage { export const Header: React.FC< React.ButtonHTMLAttributes & { isOpen?: boolean; label?: string; hasContent?: boolean; isStreaming?: boolean; } > = ({ isOpen, label = "Thoughts", hasContent, isStreaming, className, children: headerChildren, ...headerProps }) => { const isExpandable = !!hasContent; return ( ); }; export const Content: React.FC< React.HTMLAttributes & { isStreaming?: boolean; hasContent?: boolean; } > = ({ isStreaming, hasContent, className, children: contentChildren, ...contentProps }) => { // Don't render the content area at all when there's nothing to show if (!hasContent && !isStreaming) return null; return (
{typeof contentChildren === "string" ? contentChildren : ""} {isStreaming && hasContent && ( )}
); }; export const Toggle: React.FC< React.HTMLAttributes & { isOpen?: boolean; } > = ({ isOpen, className, children: toggleChildren, ...toggleProps }) => { return (
{toggleChildren}
); }; } CopilotChatReasoningMessage.Header.displayName = "CopilotChatReasoningMessage.Header"; CopilotChatReasoningMessage.Content.displayName = "CopilotChatReasoningMessage.Content"; CopilotChatReasoningMessage.Toggle.displayName = "CopilotChatReasoningMessage.Toggle"; export default CopilotChatReasoningMessage;