feat: collapse finished agent work into a Worked for X dropdown (#1509)

Once an agent turn finishes, its reasoning/tool/exploration steps collapse
behind a single "Worked for …" toggle so the chat shows only the final reply,
mirroring Devin's transcript. Work stays expanded live and auto-collapses on
completion; the final reply text/cards remain visible.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Johannes du Plessis 2026-06-12 10:08:12 -07:00 • committed by GitHub
parent 9259a52b2f
commit 73b3ba7930
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 137 additions and 16 deletions

View file

@ -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<RenderItem["type"]>(["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 (
<div className="my-2 min-w-0 space-y-2">
{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<number | null>(null);
const workStartRef = useRef<number | null>(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 (
<div key={item.key} className="flex-1 min-w-0">
<ReasoningBlock
@ -216,7 +268,22 @@ export function AgentMessage({
</div>
);
}
})}
};
return (
<div className="my-2 min-w-0 space-y-2">
{collapseWork ? (
<>
<WorkSummary durationMs={workDurationMs}>
{workItems.map((item, index) => renderItem(item, index, workItems.length))}
</WorkSummary>
{replyItems.map((item, index) =>
renderItem(item, workItems.length + index, renderItems.length),
)}
</>
) : (
renderItems.map((item, index) => renderItem(item, index, renderItems.length))
)}
{changedFiles.length > 0 && !isStreaming && (
<TurnChangedFilesCard

View file

@ -1,10 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { ChevronRight } from "lucide-react";
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`;
}
import { formatElapsed } from "@/lib/utils";
function reasoningLabel(elapsedMs: number | null): string {
if (elapsedMs === null) return "Thought";

View file

@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { formatElapsed } from "@/lib/utils";
const BUSY_TEXTS: { present: string; past: string }[] = [
{ present: "vibing...", past: "Vibed" },
{ present: "noodling...", past: "Noodled" },
@ -14,11 +16,6 @@ const BUSY_TEXTS: { present: string; past: string }[] = [
{ present: "processing...", past: "Processed" },
];
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`;
}
const THINKING_SETTLE_MS = 300;
export function ThinkingSpinner({

View file

@ -0,0 +1,43 @@
import { useState, type ReactNode } from "react";
import { ChevronRight } from "lucide-react";
import { formatElapsed } from "@/lib/utils";
/**
* Collapses a finished agent turn's working steps (reasoning, tool calls,
* exploration, edits, …) behind a single "Worked for …" toggle so the
* transcript shows only the final reply by default.
*/
export function WorkSummary({
durationMs,
children,
}: {
durationMs: number | null;
children: ReactNode;
}) {
const [expanded, setExpanded] = useState(false);
const label =
durationMs && durationMs >= 1000 ? `Worked for ${formatElapsed(durationMs)}` : "Worked";
return (
<div className="my-1">
<button
type="button"
onClick={() => setExpanded((value) => !value)}
className="flex items-center gap-1 text-left transition-opacity hover:opacity-90"
aria-expanded={expanded}
>
<ChevronRight
className={`h-3 w-3 text-[color:var(--ui-text-dim)] shrink-0 transition-transform ${expanded ? "rotate-90" : ""}`}
aria-hidden
/>
<span className="text-xs text-[color:var(--ui-text-dim)]">{label}</span>
</button>
{expanded && (
<div className="mt-1 ml-1 space-y-2 border-l-2 border-[var(--ui-border)] pl-3">
{children}
</div>
)}
</div>
);
}

View file

@ -79,7 +79,13 @@ function mergeTextChunks(chunks: Array<Chunk>): Array<Chunk> {
return chunks.filter((c, i) => c.kind !== "text" || i === lastText);
}
type AgentTurn = { id: string; author: Message["author"]; timestamp: string; chunks: Array<Chunk> };
type AgentTurn = {
id: string;
author: Message["author"];
timestamp: string;
startedAt: string;
chunks: Array<Chunk>;
};
function messageTimestamp(raw: BaseMessage): string {
const msg = raw as unknown as Record<string, unknown>;
@ -304,6 +310,7 @@ export function streamMessagesToUi(
id: msgId,
author: "agent",
timestamp,
startedAt: timestamp,
chunks: [...chunks],
};
} else {

View file

@ -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<Chunk>
hidden?: boolean
}

View file

@ -14,6 +14,14 @@ export function cn(...inputs: Array<ClassValue>) {
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.
*/