fix(webui): preserve file edit activity order

This commit is contained in:
Xubin Ren
2026-08-27 10:45:40 +08:00
parent bbbfacbc64
commit 701926eba1
2 changed files with 128 additions and 28 deletions
@@ -1,4 +1,13 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
Fragment,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import {
CheckCircle2,
Clock3,
@@ -43,10 +52,11 @@ import {
} from "@/lib/activity-timeline";
import { useFileEditDisplayMode } from "@/hooks/useFileEditDisplayMode";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import { usePageVisibility } from "@/hooks/usePageVisibility";
import type { FileEditDisplayMode } from "@/lib/local-preferences";
import { logoFallbackUrls } from "@/lib/provider-brand";
import { canonicalToolTrace, formatToolCallTrace } from "@/lib/tool-traces";
import { cn } from "@/lib/utils";
import { usePageVisibility } from "@/hooks/usePageVisibility";
import type { CliAppInfo, McpPresetInfo, ToolProgressEvent, UIFileEdit, UIMessage } from "@/lib/types";
const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24;
@@ -156,10 +166,14 @@ export function AgentActivityCluster({
const fileEditDisplayMode = useFileEditDisplayMode();
const pageVisible = usePageVisibility();
const activityMessages = useMemo(() => coalesceActivityMessages(messages), [messages]);
const fileEdits = useMemo(
() => summarizeFileEdits(collectFileEdits(activityMessages), isTurnStreaming),
const fileEditsByMessage = useMemo(
() => summarizeFileEditsByMessage(activityMessages, isTurnStreaming),
[activityMessages, isTurnStreaming],
);
const fileEdits = useMemo(
() => [...fileEditsByMessage.values()].flat(),
[fileEditsByMessage],
);
const cliRuns = useMemo(() => collectCliRuns(activityMessages), [activityMessages]);
const mcpRuns = useMemo(() => collectMcpRuns(activityMessages), [activityMessages]);
const cliAppsByName = useMemo(
@@ -348,15 +362,10 @@ export function AgentActivityCluster({
active={isTurnStreaming}
cliAppsByName={cliAppsByName}
mcpPresetsByName={mcpPresetsByName}
fileEditsByMessage={fileEditsByMessage}
fileEditDisplayMode={fileEditDisplayMode}
onOpenFilePreview={onOpenFilePreview}
/>
{fileEdits.length ? (
<FileEditGroup
edits={fileEdits}
displayMode={fileEditDisplayMode}
onOpenFilePreview={onOpenFilePreview}
/>
) : null}
</ThinkingReasoningShell>
</div>
);
@@ -414,12 +423,16 @@ function ActivityMessageTimeline({
active,
cliAppsByName,
mcpPresetsByName,
fileEditsByMessage,
fileEditDisplayMode,
onOpenFilePreview,
}: {
messages: UIMessage[];
active: boolean;
cliAppsByName: Map<string, CliAppInfo>;
mcpPresetsByName: Map<string, McpPresetInfo>;
fileEditsByMessage: Map<string, FileEditSummary[]>;
fileEditDisplayMode: FileEditDisplayMode;
onOpenFilePreview?: (path: string) => void;
}) {
const items: ReactNode[] = [];
@@ -447,14 +460,21 @@ function ActivityMessageTimeline({
return;
}
if (message.kind === "trace") {
const fileEdits = fileEditsByMessage.get(message.id) ?? [];
items.push(
<ActivityTraceTimeline
key={message.id}
message={message}
active={active && index === messages.length - 1}
cliAppsByName={cliAppsByName}
mcpPresetsByName={mcpPresetsByName}
/>,
<Fragment key={message.id}>
<ActivityTraceTimeline
message={message}
active={active && index === messages.length - 1}
cliAppsByName={cliAppsByName}
mcpPresetsByName={mcpPresetsByName}
/>
<FileEditGroup
edits={fileEdits}
displayMode={fileEditDisplayMode}
onOpenFilePreview={onOpenFilePreview}
/>
</Fragment>,
);
}
});
@@ -1061,16 +1081,6 @@ function fileEditCallKey(edit: UIFileEdit): string {
return `${edit.tool}|${edit.path}`;
}
function collectFileEdits(messages: UIMessage[]): UIFileEdit[] {
const edits: UIFileEdit[] = [];
for (const message of messages) {
if (message.kind === "trace" && message.fileEdits?.length) {
edits.push(...message.fileEdits);
}
}
return edits;
}
function latestFileEditEvents(edits: UIFileEdit[]): UIFileEdit[] {
const order: string[] = [];
const byKey = new Map<string, UIFileEdit>();
@@ -1082,6 +1092,33 @@ function latestFileEditEvents(edits: UIFileEdit[]): UIFileEdit[] {
return order.map((key) => byKey.get(key)).filter(Boolean) as UIFileEdit[];
}
/** Keep each edit at the point where its call first appeared. Later lifecycle
* events update that row in place instead of moving completed edits to the end. */
function summarizeFileEditsByMessage(
messages: UIMessage[],
active: boolean,
): Map<string, FileEditSummary[]> {
const messageByEdit = new Map<string, string>();
const edits: UIFileEdit[] = [];
for (const message of messages) {
for (const edit of message.fileEdits ?? []) {
const key = fileEditCallKey(edit);
if (!messageByEdit.has(key)) messageByEdit.set(key, message.id);
edits.push(edit);
}
}
const grouped = new Map<string, FileEditSummary[]>();
for (const edit of summarizeFileEdits(edits, active)) {
const messageId = messageByEdit.get(edit.key);
if (!messageId) continue;
const group = grouped.get(messageId) ?? [];
group.push(edit);
grouped.set(messageId, group);
}
return grouped;
}
function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSummary[] {
return latestFileEditEvents(edits).flatMap((edit) => {
const editing = active && edit.status === "editing";
@@ -561,6 +561,69 @@ describe("AgentActivityCluster", () => {
}
});
it("keeps a completed file edit at its original position in the turn", () => {
const before: UIMessage = {
id: "model-before-edit",
role: "assistant",
content: "Before the edit",
activityKind: "model",
createdAt: 1,
};
const after: UIMessage = {
id: "model-after-edit",
role: "assistant",
content: "After the edit",
activityKind: "model",
createdAt: 3,
};
const fileEdit = (status: "editing" | "done"): UIMessage => ({
id: "file-edit-in-place",
role: "tool",
kind: "trace",
content: "edit_file()",
traces: ["edit_file()"],
fileEdits: [{
call_id: "call-edit-in-place",
tool: "edit_file",
path: "src/app.tsx",
phase: status === "editing" ? "start" : "end",
added: status === "editing" ? 0 : 2,
deleted: 0,
approximate: false,
status,
}],
createdAt: 2,
});
const assertBetween = (middle: HTMLElement) => {
const beforeElement = screen.getByText("Before the edit");
const afterElement = screen.getByText("After the edit");
expect(beforeElement.compareDocumentPosition(middle) & Node.DOCUMENT_POSITION_FOLLOWING)
.toBeTruthy();
expect(middle.compareDocumentPosition(afterElement) & Node.DOCUMENT_POSITION_FOLLOWING)
.toBeTruthy();
};
const { rerender } = render(
<AgentActivityCluster
messages={[before, fileEdit("editing"), after]}
isTurnStreaming
hasBodyBelow={false}
/>,
);
assertBetween(screen.getByText("Editing"));
rerender(
<AgentActivityCluster
messages={[before, fileEdit("done"), after]}
isTurnStreaming
hasBodyBelow={false}
/>,
);
assertBetween(screen.getByText("Edited"));
});
it("renders file edit diffs and responds to preference changes", () => {
localStorage.setItem(
"nanobot-webui.settings-preferences",