diff --git a/ui/src/components/agents/messages/AgentMessage.tsx b/ui/src/components/agents/messages/AgentMessage.tsx index 3023d64e..6ce555bf 100644 --- a/ui/src/components/agents/messages/AgentMessage.tsx +++ b/ui/src/components/agents/messages/AgentMessage.tsx @@ -11,8 +11,32 @@ import { buildRenderItems, summarizeExploration, type RenderItem } from "./rende import { SubagentGroup } from "@/components/agents/subagents"; import { summarizeChangedFiles } from "./summarizeChangedFiles"; import { TurnChangedFilesCard } from "./TurnChangedFilesCard"; +import { WorkSummary } from "./WorkSummary"; import type { ApprovalCallbacks, ChangedFileSummaryItem } from "./types"; +/** + * 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"]); + +/** + * 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: RenderItem[]): { + workItems: RenderItem[]; + replyItems: RenderItem[]; +} { + 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, @@ -95,13 +119,41 @@ export function AgentMessage({ wasExplorationLiveRef.current = false; }, [hasExploredGroups, isStreaming, exploredGroupIds, message.id]); - return ( -
- {renderItems.map((item, index) => { - switch (item.type) { + // 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) 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]); + + 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 === renderItems.length - 1; + const isLastItem = index === total - 1; 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 && ( = 1000 ? `Worked for ${formatElapsed(durationMs)}` : "Worked"; + + return ( +
+ + {expanded && ( +
+ {children} +
+ )} +
+ ); +} diff --git a/ui/src/lib/agents/streamMessagesToUi.ts b/ui/src/lib/agents/streamMessagesToUi.ts index 67e96420..b5ef16a5 100644 --- a/ui/src/lib/agents/streamMessagesToUi.ts +++ b/ui/src/lib/agents/streamMessagesToUi.ts @@ -79,7 +79,13 @@ function mergeTextChunks(chunks: Array): Array { return chunks.filter((c, i) => c.kind !== "text" || i === lastText); } -type AgentTurn = { id: string; author: Message["author"]; timestamp: string; chunks: Array }; +type AgentTurn = { + id: string; + author: Message["author"]; + timestamp: string; + startedAt: string; + chunks: Array; +}; function messageTimestamp(raw: BaseMessage): string { const msg = raw as unknown as Record; @@ -304,6 +310,7 @@ export function streamMessagesToUi( id: msgId, author: "agent", timestamp, + startedAt: timestamp, chunks: [...chunks], }; } else { diff --git a/ui/src/lib/agents/types.ts b/ui/src/lib/agents/types.ts index 1ba1c5f4..e5a10109 100644 --- a/ui/src/lib/agents/types.ts +++ b/ui/src/lib/agents/types.ts @@ -138,6 +138,8 @@ export interface Message { id: string author: Author timestamp: string + /** Timestamp of the first message in an agent turn; used to derive work duration. */ + startedAt?: string chunks: Array hidden?: boolean } diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index b3f0edd6..d5f3bcd1 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -14,6 +14,14 @@ export function cn(...inputs: Array) { return twMerge(clsx(inputs)) } +/** + * Format an elapsed duration in milliseconds as a compact string (e.g. "5s", "3m 20s"). + */ +export function formatElapsed(ms: number): string { + const secs = Math.max(1, Math.ceil(ms / 1000)); + return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`; +} + /** * Intl.RelativeTimeFormat instance for formatting relative times. */