Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions web/packages/superagent-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ negative legacy timing is omitted. Running durations update once per second but
are excluded from live-region announcements. Stream timestamps remain ordering
metadata and are never rendered as stream duration.

Collapsed Work logs show separate succeeded and failed operation counts. Active
and unknown counts appear when those states are present, so one failed operation
does not make the entire log look unsuccessful.

Set `isExecutionLive={false}` for terminal history. Unpaired legacy calls then
show `unknown` and do not start the elapsed-time interval. Collapsed Work logs
do not mount tool result bodies or call `renderToolCall` until expanded.
Expand Down
84 changes: 83 additions & 1 deletion web/packages/superagent-ui/src/ConversationView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";

import { ConversationView } from "./ConversationView.js";
import {
ConversationView,
type ConversationViewProps,
} from "./ConversationView.js";

const base = Date.parse("2026-09-23T12:00:00Z");

Expand All @@ -16,6 +19,41 @@ afterEach(() => {
});

describe("ConversationView", () => {
it("summarizes successful and failed operations in a collapsed work log", () => {
const { container } = render(
<ConversationView
messages={toolTurn([
{ id: "success", name: "read_file", content: "done" },
{
id: "failure",
name: "apply_patch",
content: '{"isError":true}',
},
])}
/>,
);

expect(container).toHaveTextContent("3 operations");
expect(container).toHaveTextContent("2 succeeded");
expect(container).toHaveTextContent("1 failed");
expect(screen.getByText("Work log").closest("details")).not.toHaveAttribute(
"open",
);
});

it("keeps a zero failure count visible for successful work", () => {
const { container } = render(
<ConversationView
messages={toolTurn([
{ id: "success", name: "read_file", content: "done" },
])}
/>,
);

expect(container).toHaveTextContent("2 succeeded");
expect(container).toHaveTextContent("0 failed");
});

it("updates a running tool duration without announcing every tick", () => {
vi.useFakeTimers();
vi.setSystemTime(base + 2_000);
Expand Down Expand Up @@ -203,3 +241,47 @@ function toggleWorkLog(open: boolean) {
details.open = open;
fireEvent(details, new Event("toggle"));
}

function toolTurn(
tools: readonly { id: string; name: string; content: string }[],
): ConversationViewProps["messages"] {
return [
{
sequence: 1,
message: {
role: "user",
content: "run tools",
toolCalls: [],
toolCallId: null,
toolName: null,
createdAt: new Date(base).toISOString(),
},
},
{
sequence: 2,
message: {
role: "assistant",
content: "",
toolCalls: tools.map(({ id, name }) => ({
id,
name,
argumentsJson: "{}",
})),
toolCallId: null,
toolName: null,
createdAt: new Date(base + 500).toISOString(),
},
},
...tools.map(({ id, name, content }, index) => ({
sequence: index + 3,
message: {
role: "tool" as const,
content,
toolCalls: [],
toolCallId: id,
toolName: name,
createdAt: new Date(base + 1_000 + index * 500).toISOString(),
},
})),
];
}
45 changes: 32 additions & 13 deletions web/packages/superagent-ui/src/ConversationView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,18 +160,20 @@ function WorkLog({
0,
);
if (operationCount === 0) return null;
const failed =
turn.models.some((item) => item.status === "failed") ||
turn.tools.some((group) =>
group.calls.some((item) => item.status === "failed"),
);
const running =
turn.models.some((item) => item.status === "running") ||
turn.tools.some((group) =>
group.calls.some(
(item) => item.status === "running" || item.status === "waiting",
),
);
const statuses = [
...turn.models.map((item) => item.status),
...turn.tools.flatMap((group) => group.calls.map((item) => item.status)),
];
const succeededCount = statuses.filter(
(status) => status === "complete",
).length;
const failedCount = statuses.filter((status) => status === "failed").length;
const activeCount = statuses.filter(
(status) => status === "running" || status === "waiting",
).length;
const unknownCount = statuses.filter((status) => status === "unknown").length;
const failed = failedCount > 0;
const running = activeCount > 0;
return (
<details
className={`sa-work-log${failed ? " sa-work-log--failed" : ""}`}
Expand All @@ -187,7 +189,24 @@ function WorkLog({
<span>
{operationCount} operation{operationCount === 1 ? "" : "s"}
</span>
{failed ? <Status value="failed" /> : null}
<span className="sa-work-log__summary-count">
{succeededCount} succeeded
</span>
<span
className={`sa-work-log__summary-count${failed ? " sa-work-log__summary-count--failed" : ""}`}
>
{failedCount} failed
</span>
{activeCount > 0 ? (
<span className="sa-work-log__summary-count">
{activeCount} active
</span>
) : null}
{unknownCount > 0 ? (
<span className="sa-work-log__summary-count">
{unknownCount} unknown
</span>
) : null}
<span className="sa-work-log__durations">
<Duration value={turn.waitDurationMs} prefix="wait " />
<Duration value={turn.workDurationMs} prefix="work " />
Expand Down
9 changes: 9 additions & 0 deletions web/packages/superagent-ui/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,15 @@
gap: 10px;
}

.sa-work-log__summary-count {
color: var(--sa-muted-color, #667066);
font-size: 11px;
}

.sa-work-log__summary-count--failed {
color: var(--sa-danger-color, #9b2c25);
}

.sa-work-log__body {
display: flex;
flex-direction: column;
Expand Down