From e9d811e609d76f829f14df7be41c2945a5673380 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:51:57 +0800 Subject: [PATCH] fix(webui): preserve file edit activity order --- .../thread/AgentActivityCluster.tsx | 93 +++++++++++++------ .../src/tests/agent-activity-cluster.test.tsx | 63 +++++++++++++ 2 files changed, 128 insertions(+), 28 deletions(-) diff --git a/webui/src/components/thread/AgentActivityCluster.tsx b/webui/src/components/thread/AgentActivityCluster.tsx index 2e0cc4fb2..5ea173916 100644 --- a/webui/src/components/thread/AgentActivityCluster.tsx +++ b/webui/src/components/thread/AgentActivityCluster.tsx @@ -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 ? ( - - ) : null} ); @@ -414,12 +423,16 @@ function ActivityMessageTimeline({ active, cliAppsByName, mcpPresetsByName, + fileEditsByMessage, + fileEditDisplayMode, onOpenFilePreview, }: { messages: UIMessage[]; active: boolean; cliAppsByName: Map; mcpPresetsByName: Map; + fileEditsByMessage: Map; + 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( - , + + + + , ); } }); @@ -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(); @@ -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 { + const messageByEdit = new Map(); + 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(); + 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"; diff --git a/webui/src/tests/agent-activity-cluster.test.tsx b/webui/src/tests/agent-activity-cluster.test.tsx index 54319f4d0..c7c690e53 100644 --- a/webui/src/tests/agent-activity-cluster.test.tsx +++ b/webui/src/tests/agent-activity-cluster.test.tsx @@ -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( + , + ); + + assertBetween(screen.getByText("Editing")); + + rerender( + , + ); + + assertBetween(screen.getByText("Edited")); + }); + it("renders file edit diffs and responds to preference changes", () => { localStorage.setItem( "nanobot-webui.settings-preferences",