import { useEffect, useMemo, useRef, useState } from "react"; import { ChunkRenderer } from "./ChunkRenderer"; import { MessageTimestamp } from "./MessageTimestamp"; import { ReasoningBlock } from "./ReasoningBlock"; import { buildRenderItems, summarizeExploration } from "./renderItems"; import { summarizeChangedFiles } from "./summarizeChangedFiles"; import { TurnChangedFilesCard } from "./TurnChangedFilesCard"; import { WorkSummary } from "./WorkSummary"; import type { ReactNode } from "react"; import type { RenderItem } from "./renderItems"; import type { Message } from "@/lib/agents/types"; import type { ApprovalCallbacks, ChangedFileSummaryItem } from "./types"; import { formatHoverTimestamp } from "@/lib/agents/messageTimestamps"; import { SubagentGroup } from "@/components/agents/subagents"; import { ToolExecution } from "@/components/agents/ported/ToolExecution"; import { ShellCommand } from "@/components/agents/ported/ShellCommand"; import { ReplyCard } from "@/components/agents/ported/ReplyCard"; /** * Render-item types kept visible (not collapsed) when a turn finishes — the * agent's actual reply to the user. Everything else is "work". */ const REPLY_ITEM_TYPES = new Set(["text-chunk", "reply-item"]); /** * Wraps a tool render item so a dim timestamp chip fades in on hover, anchored * to the row's top-right corner. */ function ToolRow({ timestamp, className, children, }: { timestamp?: string; className?: string; children: ReactNode; }) { const label = formatHoverTimestamp(timestamp); return (
{children} {label && ( )}
); } /** * Split a finished turn's items into collapsible work and the trailing reply, * where the reply is the maximal suffix made up solely of reply/text items. */ function splitWorkAndReply(items: Array): { workItems: Array; replyItems: Array; } { let splitIndex = items.length; while (splitIndex > 0) { const prev = items[splitIndex - 1]; if (!prev || !REPLY_ITEM_TYPES.has(prev.type)) break; splitIndex -= 1; } return { workItems: items.slice(0, splitIndex), replyItems: items.slice(splitIndex) }; } export function AgentMessage({ message, isStreaming, isMarkdownLive, projectPath, ...callbacks }: { message: Message; isStreaming?: boolean; isMarkdownLive?: boolean; projectPath?: string; } & ApprovalCallbacks) { const renderItems = useMemo( () => buildRenderItems(message.chunks, message.id), [message.chunks, message.id], ); const changedFiles = useMemo(() => summarizeChangedFiles(message.chunks), [message.chunks]); const changedFilesTotals = useMemo(() => { let additions = 0; let deletions = 0; for (const item of changedFiles) { additions += item.additions; deletions += item.deletions; } return { additions, deletions }; }, [changedFiles]); const changedFilesByPath = useMemo(() => { const byPath = new Map(); for (const file of changedFiles) { byPath.set(file.filePath, file); } return byPath; }, [changedFiles]); const exploredGroupIds = useMemo( () => renderItems .filter((item): item is Extract => item.type === "explored-group") .map((item) => item.id), [renderItems], ); const hasExploredGroups = exploredGroupIds.length > 0; const [expandedExploredGroups, setExpandedExploredGroups] = useState>({}); const wasExplorationLiveRef = useRef(false); useEffect(() => { const isLive = !!isStreaming; if (!hasExploredGroups) { setExpandedExploredGroups({}); wasExplorationLiveRef.current = isLive; return; } if (isLive) { const next: Record = {}; for (const id of exploredGroupIds) { next[id] = true; } setExpandedExploredGroups(next); wasExplorationLiveRef.current = true; return; } const shouldAutoCollapse = wasExplorationLiveRef.current; setExpandedExploredGroups((prev) => { const next: Record = {}; for (const id of exploredGroupIds) { next[id] = shouldAutoCollapse ? false : (prev[id] ?? false); } const prevKeys = Object.keys(prev); const nextKeys = Object.keys(next); if (prevKeys.length !== nextKeys.length) return next; for (const key of nextKeys) { if (prev[key] !== next[key]) return next; } return prev; }); wasExplorationLiveRef.current = false; }, [hasExploredGroups, isStreaming, exploredGroupIds, message.id]); // Measure wall-clock work time for live runs (most accurate); fall back to // the turn's first→last message timestamps for transcripts loaded from state. const [measuredDurationMs, setMeasuredDurationMs] = useState(null); const workStartRef = useRef(null); const wasStreamingRef = useRef(false); useEffect(() => { if (isStreaming) { if (workStartRef.current === null) workStartRef.current = Date.now(); wasStreamingRef.current = true; return; } if (wasStreamingRef.current && workStartRef.current !== null) { setMeasuredDurationMs(Date.now() - workStartRef.current); wasStreamingRef.current = false; } }, [isStreaming]); const workDurationMs = useMemo(() => { if (measuredDurationMs !== null) return measuredDurationMs; if (!message.startedAt || message.timestampIsFallback) return null; const start = Date.parse(message.startedAt); const end = Date.parse(message.timestamp); if (!Number.isFinite(start) || !Number.isFinite(end)) return null; const delta = end - start; return delta > 0 ? delta : null; }, [ measuredDurationMs, message.startedAt, message.timestamp, message.timestampIsFallback, ]); const { workItems, replyItems } = useMemo(() => splitWorkAndReply(renderItems), [renderItems]); const collapseWork = !isStreaming && workItems.length > 0; const renderItem = (item: RenderItem, index: number, total: number) => { switch (item.type) { case "reasoning-item": { const reasoningChunk = item.chunk.kind === "reasoning" ? item.chunk : null; const isLastItem = index === total - 1; return (
); } case "explored-group": { const summary = summarizeExploration(item.chunks); const isExpanded = expandedExploredGroups[item.id] ?? false; return (
{isExpanded && (
{item.chunks.map((chunk, chunkIndex) => ( ))}
)}
); } case "subagent-group": return ; case "edit-item": { const fullFileDiff = item.chunk.diffData ? changedFilesByPath.get(item.chunk.diffData.filePath) : undefined; return ( ); } case "shell-item": return ( ); case "reply-item": return ( ); case "tool-item": return ( ); case "text-chunk": return (
); } }; return (
{collapseWork ? ( <> {workItems.map((item, index) => renderItem(item, index, workItems.length))} {replyItems.map((item, index) => renderItem(item, workItems.length + index, renderItems.length), )} ) : ( renderItems.map((item, index) => renderItem(item, index, renderItems.length)) )} {changedFiles.length > 0 && !isStreaming && ( )} {!message.timestampIsFallback && ( )}
); }