fix(webui): avoid misleading file edit counters

This commit is contained in:
Xubin Ren 2026-05-22 13:26:31 +08:00
parent 9b2f452b6e
commit effc1efd92
2 changed files with 102 additions and 5 deletions

View File

@ -27,6 +27,7 @@ interface ActivityCounts {
fileCount: number;
added: number;
deleted: number;
hasDiffStats: boolean;
hasEditingFiles: boolean;
hasFailedFiles: boolean;
primaryFilePath?: string;
@ -61,6 +62,7 @@ function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): Act
}
let added = 0;
let deleted = 0;
let hasDiffStats = false;
let hasEditingFiles = false;
let failedFileCount = 0;
let primaryFilePath: string | undefined;
@ -77,6 +79,10 @@ function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): Act
if (edit.status === "error" || edit.binary) {
continue;
}
if (!hasVisibleDiffStats(edit)) {
continue;
}
hasDiffStats = true;
added += edit.added;
deleted += edit.deleted;
}
@ -86,6 +92,7 @@ function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): Act
fileCount: fileEdits.length,
added,
deleted,
hasDiffStats,
hasEditingFiles,
hasFailedFiles: fileEdits.length > 0 && failedFileCount === fileEdits.length,
primaryFilePath,
@ -120,6 +127,7 @@ export function AgentActivityCluster({
fileCount,
added,
deleted,
hasDiffStats,
hasEditingFiles,
hasFailedFiles,
primaryFilePath,
@ -140,6 +148,7 @@ export function AgentActivityCluster({
const headerBusy = fileCount > 0 ? hasEditingFiles : isTurnStreaming;
const singleFilePath = fileCount === 1 ? primaryFilePath : undefined;
const singleFileTooltipPath = fileCount === 1 ? primaryFileTooltipPath : undefined;
const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || fileCount > 0;
const fileActivitySummary = fileCount > 0
? hasPendingFileEdit && !singleFilePath
@ -243,6 +252,8 @@ export function AgentActivityCluster({
autoFollowActivityRef.current = distance < ACTIVITY_SCROLL_NEAR_BOTTOM_PX;
}, []);
if (!hasVisibleActivity) return null;
return (
<div className={cn("w-full", hasBodyBelow && "mb-2")}>
<button
@ -282,7 +293,7 @@ export function AgentActivityCluster({
{summary}
</StreamingLabelSheen>
)}
{fileCount > 0 && (
{fileCount > 0 && hasDiffStats && (
<span className="inline-flex min-w-0 items-center gap-1 text-muted-foreground/85">
<DiffPair added={added} deleted={deleted} />
</span>
@ -435,6 +446,17 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
summary.absolute_path = edit.absolute_path;
}
summary.pending = summary.pending || !!edit.pending || !edit.path;
if (!edit.path && edit.pending) {
if (active && edit.status === "editing") {
summary.hasActiveEditing = true;
summary.approximate = summary.approximate || !!edit.approximate;
if (!edit.binary) {
summary.added += edit.added;
summary.deleted += edit.deleted;
}
}
continue;
}
if (active && edit.status === "editing") {
summary.hasActiveEditing = true;
summary.binary = summary.binary || !!edit.binary;
@ -461,8 +483,16 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
}
}
return order.map((key) => {
return order.flatMap((key) => {
const summary = byPath.get(key)!;
if (
!summary.path
&& !summary.hasActiveEditing
&& !summary.hasSuccessfulChange
&& !summary.hasFailed
) {
return [];
}
const status: UIFileEdit["status"] = summary.hasActiveEditing
? "editing"
: summary.hasSuccessfulChange
@ -470,7 +500,7 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
: summary.hasFailed
? "error"
: "done";
return {
return [{
key: summary.key,
path: summary.path,
absolute_path: summary.absolute_path,
@ -481,10 +511,14 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
status,
pending: summary.pending && !summary.path,
error: summary.error,
};
}];
});
}
function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "deleted">): boolean {
return edit.added > 0 || edit.deleted > 0;
}
function FileEditGroup({ edits }: { edits: FileEditSummary[] }) {
if (edits.length === 0) return null;
return (
@ -500,7 +534,7 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
const { t } = useTranslation();
const editing = edit.status === "editing";
const failed = edit.status === "error";
const hasCountedDiff = !failed && !edit.binary;
const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit);
return (
<li className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-md px-2 py-1.5 text-xs">
<div className="flex min-w-0 items-center gap-2">

View File

@ -271,6 +271,69 @@ describe("AgentActivityCluster", () => {
}
});
it("does not render zero diff counters for completed edits", () => {
render(
<AgentActivityCluster
messages={activityMessages("", {
id: "t2",
role: "tool",
kind: "trace",
content: "edit_file()",
traces: ["edit_file()"],
fileEdits: [{
call_id: "call-edit",
tool: "edit_file",
path: "src/app.tsx",
phase: "end",
added: 0,
deleted: 0,
approximate: false,
status: "done",
}],
createdAt: 3,
})}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
expect(screen.getByRole("button", { name: /edited app\.tsx/i })).toBeInTheDocument();
expect(screen.queryByText("+0")).not.toBeInTheDocument();
expect(screen.queryByText("-0")).not.toBeInTheDocument();
});
it("drops stale pathless pending edits after the turn completes", () => {
render(
<AgentActivityCluster
messages={[{
id: "t1",
role: "tool",
kind: "trace",
content: "",
traces: [],
fileEdits: [{
call_id: "call-edit",
tool: "edit_file",
path: "",
phase: "start",
added: 98,
deleted: 0,
approximate: true,
status: "editing",
pending: true,
}],
createdAt: 1,
}]}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
expect(screen.queryByRole("button", { name: /preparing edit/i })).not.toBeInTheDocument();
expect(screen.queryByText("+98")).not.toBeInTheDocument();
expect(screen.queryByText("0 tool calls")).not.toBeInTheDocument();
});
it("renders pending file edit placeholders before the path is known", () => {
render(
<AgentActivityCluster