mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-04 21:52:14 +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 { SubagentGroup } from "@/components/agents/subagents";
|
||||||
import { summarizeChangedFiles } from "./summarizeChangedFiles";
|
import { summarizeChangedFiles } from "./summarizeChangedFiles";
|
||||||
import { TurnChangedFilesCard } from "./TurnChangedFilesCard";
|
import { TurnChangedFilesCard } from "./TurnChangedFilesCard";
|
||||||
|
import { WorkSummary } from "./WorkSummary";
|
||||||
import type { ApprovalCallbacks, ChangedFileSummaryItem } from "./types";
|
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({
|
export function AgentMessage({
|
||||||
message,
|
message,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
|
|
@ -95,13 +119,41 @@ export function AgentMessage({
|
||||||
wasExplorationLiveRef.current = false;
|
wasExplorationLiveRef.current = false;
|
||||||
}, [hasExploredGroups, isStreaming, exploredGroupIds, message.id]);
|
}, [hasExploredGroups, isStreaming, exploredGroupIds, message.id]);
|
||||||
|
|
||||||
return (
|
// Measure wall-clock work time for live runs (most accurate); fall back to
|
||||||
<div className="my-2 min-w-0 space-y-2">
|
// the turn's first→last message timestamps for transcripts loaded from state.
|
||||||
{renderItems.map((item, index) => {
|
const [measuredDurationMs, setMeasuredDurationMs] = useState<number | null>(null);
|
||||||
switch (item.type) {
|
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": {
|
case "reasoning-item": {
|
||||||
const reasoningChunk = item.chunk.kind === "reasoning" ? item.chunk : null;
|
const reasoningChunk = item.chunk.kind === "reasoning" ? item.chunk : null;
|
||||||
const isLastItem = index === renderItems.length - 1;
|
const isLastItem = index === total - 1;
|
||||||
return (
|
return (
|
||||||
<div key={item.key} className="flex-1 min-w-0">
|
<div key={item.key} className="flex-1 min-w-0">
|
||||||
<ReasoningBlock
|
<ReasoningBlock
|
||||||
|
|
@ -216,7 +268,22 @@ export function AgentMessage({
|
||||||
</div>
|
</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 && (
|
{changedFiles.length > 0 && !isStreaming && (
|
||||||
<TurnChangedFilesCard
|
<TurnChangedFilesCard
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
function formatElapsed(ms: number): string {
|
import { formatElapsed } from "@/lib/utils";
|
||||||
const secs = Math.max(1, Math.ceil(ms / 1000));
|
|
||||||
return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function reasoningLabel(elapsedMs: number | null): string {
|
function reasoningLabel(elapsedMs: number | null): string {
|
||||||
if (elapsedMs === null) return "Thought";
|
if (elapsedMs === null) return "Thought";
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { formatElapsed } from "@/lib/utils";
|
||||||
|
|
||||||
const BUSY_TEXTS: { present: string; past: string }[] = [
|
const BUSY_TEXTS: { present: string; past: string }[] = [
|
||||||
{ present: "vibing...", past: "Vibed" },
|
{ present: "vibing...", past: "Vibed" },
|
||||||
{ present: "noodling...", past: "Noodled" },
|
{ present: "noodling...", past: "Noodled" },
|
||||||
|
|
@ -14,11 +16,6 @@ const BUSY_TEXTS: { present: string; past: string }[] = [
|
||||||
{ present: "processing...", past: "Processed" },
|
{ 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;
|
const THINKING_SETTLE_MS = 300;
|
||||||
|
|
||||||
export function ThinkingSpinner({
|
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);
|
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 {
|
function messageTimestamp(raw: BaseMessage): string {
|
||||||
const msg = raw as unknown as Record<string, unknown>;
|
const msg = raw as unknown as Record<string, unknown>;
|
||||||
|
|
@ -304,6 +310,7 @@ export function streamMessagesToUi(
|
||||||
id: msgId,
|
id: msgId,
|
||||||
author: "agent",
|
author: "agent",
|
||||||
timestamp,
|
timestamp,
|
||||||
|
startedAt: timestamp,
|
||||||
chunks: [...chunks],
|
chunks: [...chunks],
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,8 @@ export interface Message {
|
||||||
id: string
|
id: string
|
||||||
author: Author
|
author: Author
|
||||||
timestamp: string
|
timestamp: string
|
||||||
|
/** Timestamp of the first message in an agent turn; used to derive work duration. */
|
||||||
|
startedAt?: string
|
||||||
chunks: Array<Chunk>
|
chunks: Array<Chunk>
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,14 @@ export function cn(...inputs: Array<ClassValue>) {
|
||||||
return twMerge(clsx(inputs))
|
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.
|
* Intl.RelativeTimeFormat instance for formatting relative times.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue