mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 13:53:15 +00:00
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:
parent
9259a52b2f
commit
73b3ba7930
7 changed files with 137 additions and 16 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
43
ui/src/components/agents/messages/WorkSummary.tsx
Normal file
43
ui/src/components/agents/messages/WorkSummary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue