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 { import {
CheckCircle2, CheckCircle2,
Clock3, Clock3,
@@ -43,10 +52,11 @@ import {
} from "@/lib/activity-timeline"; } from "@/lib/activity-timeline";
import { useFileEditDisplayMode } from "@/hooks/useFileEditDisplayMode"; import { useFileEditDisplayMode } from "@/hooks/useFileEditDisplayMode";
import { useLogoFallback } from "@/hooks/useLogoFallback"; import { useLogoFallback } from "@/hooks/useLogoFallback";
import { usePageVisibility } from "@/hooks/usePageVisibility";
import type { FileEditDisplayMode } from "@/lib/local-preferences";
import { logoFallbackUrls } from "@/lib/provider-brand"; import { logoFallbackUrls } from "@/lib/provider-brand";
import { canonicalToolTrace, formatToolCallTrace } from "@/lib/tool-traces"; import { canonicalToolTrace, formatToolCallTrace } from "@/lib/tool-traces";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { usePageVisibility } from "@/hooks/usePageVisibility";
import type { CliAppInfo, McpPresetInfo, ToolProgressEvent, UIFileEdit, UIMessage } from "@/lib/types"; import type { CliAppInfo, McpPresetInfo, ToolProgressEvent, UIFileEdit, UIMessage } from "@/lib/types";
const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24; const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24;
@@ -156,10 +166,14 @@ export function AgentActivityCluster({
const fileEditDisplayMode = useFileEditDisplayMode(); const fileEditDisplayMode = useFileEditDisplayMode();
const pageVisible = usePageVisibility(); const pageVisible = usePageVisibility();
const activityMessages = useMemo(() => coalesceActivityMessages(messages), [messages]); const activityMessages = useMemo(() => coalesceActivityMessages(messages), [messages]);
const fileEdits = useMemo( const fileEditsByMessage = useMemo(
() => summarizeFileEdits(collectFileEdits(activityMessages), isTurnStreaming), () => summarizeFileEditsByMessage(activityMessages, isTurnStreaming),
[activityMessages, isTurnStreaming], [activityMessages, isTurnStreaming],
); );
const fileEdits = useMemo(
() => [...fileEditsByMessage.values()].flat(),
[fileEditsByMessage],
);
const cliRuns = useMemo(() => collectCliRuns(activityMessages), [activityMessages]); const cliRuns = useMemo(() => collectCliRuns(activityMessages), [activityMessages]);
const mcpRuns = useMemo(() => collectMcpRuns(activityMessages), [activityMessages]); const mcpRuns = useMemo(() => collectMcpRuns(activityMessages), [activityMessages]);
const cliAppsByName = useMemo( const cliAppsByName = useMemo(
@@ -348,15 +362,10 @@ export function AgentActivityCluster({
active={isTurnStreaming} active={isTurnStreaming}
cliAppsByName={cliAppsByName} cliAppsByName={cliAppsByName}
mcpPresetsByName={mcpPresetsByName} mcpPresetsByName={mcpPresetsByName}
fileEditsByMessage={fileEditsByMessage}
fileEditDisplayMode={fileEditDisplayMode}
onOpenFilePreview={onOpenFilePreview} onOpenFilePreview={onOpenFilePreview}
/> />
{fileEdits.length ? (
<FileEditGroup
edits={fileEdits}
displayMode={fileEditDisplayMode}
onOpenFilePreview={onOpenFilePreview}
/>
) : null}
</ThinkingReasoningShell> </ThinkingReasoningShell>
</div> </div>
); );
@@ -414,12 +423,16 @@ function ActivityMessageTimeline({
active, active,
cliAppsByName, cliAppsByName,
mcpPresetsByName, mcpPresetsByName,
fileEditsByMessage,
fileEditDisplayMode,
onOpenFilePreview, onOpenFilePreview,
}: { }: {
messages: UIMessage[]; messages: UIMessage[];
active: boolean; active: boolean;
cliAppsByName: Map<string, CliAppInfo>; cliAppsByName: Map<string, CliAppInfo>;
mcpPresetsByName: Map<string, McpPresetInfo>; mcpPresetsByName: Map<string, McpPresetInfo>;
fileEditsByMessage: Map<string, FileEditSummary[]>;
fileEditDisplayMode: FileEditDisplayMode;
onOpenFilePreview?: (path: string) => void; onOpenFilePreview?: (path: string) => void;
}) { }) {
const items: ReactNode[] = []; const items: ReactNode[] = [];
@@ -447,14 +460,21 @@ function ActivityMessageTimeline({
return; return;
} }
if (message.kind === "trace") { if (message.kind === "trace") {
const fileEdits = fileEditsByMessage.get(message.id) ?? [];
items.push( items.push(
<Fragment key={message.id}>
<ActivityTraceTimeline <ActivityTraceTimeline
key={message.id}
message={message} message={message}
active={active && index === messages.length - 1} active={active && index === messages.length - 1}
cliAppsByName={cliAppsByName} cliAppsByName={cliAppsByName}
mcpPresetsByName={mcpPresetsByName} mcpPresetsByName={mcpPresetsByName}
/>, />
<FileEditGroup
edits={fileEdits}
displayMode={fileEditDisplayMode}
onOpenFilePreview={onOpenFilePreview}
/>
</Fragment>,
); );
} }
}); });
@@ -1061,16 +1081,6 @@ function fileEditCallKey(edit: UIFileEdit): string {
return `${edit.tool}|${edit.path}`; 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[] { function latestFileEditEvents(edits: UIFileEdit[]): UIFileEdit[] {
const order: string[] = []; const order: string[] = [];
const byKey = new Map<string, UIFileEdit>(); 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[]; 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[] { function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSummary[] {
return latestFileEditEvents(edits).flatMap((edit) => { return latestFileEditEvents(edits).flatMap((edit) => {
const editing = active && edit.status === "editing"; 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", () => { it("renders file edit diffs and responds to preference changes", () => {
localStorage.setItem( localStorage.setItem(
"nanobot-webui.settings-preferences", "nanobot-webui.settings-preferences",