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",