feat: add file edit diff progress view

Capture file edit snapshots through runner tool lifecycle hooks and render unified diffs in the WebUI with folding and truncation controls.
This commit is contained in:
chengyongru
2026-07-09 10:42:43 +08:00
committed by Xubin Ren
parent 207813d3b5
commit 7768672c5b
40 changed files with 2224 additions and 1753 deletions
+339 -13
View File
@@ -41,6 +41,15 @@ const BROWSERBASE_MCP: McpPresetInfo = {
connection_summary: "https://mcp.browserbase.com/mcp",
};
function unifiedFileDiff(lines: string[], truncated = false) {
return {
format: "unified" as const,
context: 3,
truncated,
text: lines.join("\n"),
};
}
function activityMessages(extraReasoning = "", extraTool?: UIMessage): UIMessage[] {
const rows: UIMessage[] = [
{
@@ -447,6 +456,301 @@ describe("AgentActivityCluster", () => {
}
});
it("renders GitHub-like file edit diffs when the local preference is enabled", () => {
localStorage.setItem(
"nanobot-webui.settings-preferences",
JSON.stringify({ fileEditDisplayMode: "diff" }),
);
try {
render(
<AgentActivityCluster
messages={[{
id: "t-diff",
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: 1,
deleted: 1,
approximate: false,
status: "done",
diff: unifiedFileDiff([
"--- src/app.tsx",
"+++ src/app.tsx",
"@@ -10,2 +10,2 @@",
" function App() {",
"- return <Old />;",
"+ return <New />;",
]),
}],
createdAt: 3,
}]}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
expect(screen.getByTestId("file-edit-diff")).toBeInTheDocument();
expect(screen.queryByText("@@ -10,2 +10,2 @@")).not.toBeInTheDocument();
expect(screen.getByText("return <Old />;")).toBeInTheDocument();
expect(screen.getByText("return <New />;")).toBeInTheDocument();
expect(screen.getAllByText("11").length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByTestId("activity-header-file-reference")).toHaveLength(1);
expect(screen.queryByTestId("activity-file-reference")).not.toBeInTheDocument();
expect(screen.getAllByTestId("activity-diff-pair")).toHaveLength(1);
} finally {
localStorage.removeItem("nanobot-webui.settings-preferences");
}
});
it("renders folded separators between separated file edit hunks", () => {
localStorage.setItem(
"nanobot-webui.settings-preferences",
JSON.stringify({ fileEditDisplayMode: "diff" }),
);
try {
render(
<AgentActivityCluster
messages={[{
id: "t-multi-hunk-diff",
role: "tool",
kind: "trace",
content: "edit_file()",
traces: ["edit_file()"],
fileEdits: [{
call_id: "call-multi-hunk-edit",
tool: "edit_file",
path: "src/app.tsx",
phase: "end",
added: 2,
deleted: 2,
approximate: false,
status: "done",
diff: unifiedFileDiff([
"--- src/app.tsx",
"+++ src/app.tsx",
"@@ -1,3 +1,3 @@",
" function first() {",
"- return oldFirst;",
"+ return newFirst;",
" }",
"@@ -25,3 +25,3 @@",
" function second() {",
"- return oldSecond;",
"+ return newSecond;",
" }",
]),
}],
createdAt: 3,
}]}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
expect(screen.getByTestId("file-edit-diff-hunk-gap")).toHaveTextContent(
"21 unchanged lines hidden",
);
expect(screen.queryByText("@@ -25,3 +25,3 @@")).not.toBeInTheDocument();
expect(screen.getByText("return newSecond;")).toBeInTheDocument();
} finally {
localStorage.removeItem("nanobot-webui.settings-preferences");
}
});
it("keeps long file edit diffs collapsed until opened", () => {
localStorage.setItem(
"nanobot-webui.settings-preferences",
JSON.stringify({ fileEditDisplayMode: "diff" }),
);
const lines = Array.from({ length: 165 }, (_, index) => `line-${index + 1}`);
try {
render(
<AgentActivityCluster
messages={[{
id: "t-long-diff",
role: "tool",
kind: "trace",
content: "edit_file()",
traces: ["edit_file()"],
fileEdits: [{
call_id: "call-long-edit",
tool: "edit_file",
path: "src/long.ts",
phase: "end",
added: lines.length,
deleted: 0,
approximate: false,
status: "done",
diff: unifiedFileDiff([
"--- src/long.ts",
"+++ src/long.ts",
`@@ -0,0 +1,${lines.length} @@`,
...lines.map((line) => `+${line}`),
]),
}],
createdAt: 3,
}]}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
const toggle = screen.getByTestId("file-edit-diff-toggle");
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(toggle).toHaveTextContent("View large diff");
expect(toggle).toHaveTextContent("165 lines");
expect(screen.queryByTestId("file-edit-diff")).not.toBeInTheDocument();
expect(screen.queryByText("line-1")).not.toBeInTheDocument();
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(screen.getByText("line-160")).toBeInTheDocument();
expect(screen.queryByText("line-161")).not.toBeInTheDocument();
expect(screen.getByTestId("file-edit-diff-expand-lines")).toHaveTextContent("Show 5 more lines");
fireEvent.click(screen.getByTestId("file-edit-diff-expand-lines"));
expect(screen.getByText("line-165")).toBeInTheDocument();
expect(screen.getByTestId("file-edit-diff-collapse-lines")).toHaveTextContent("Show fewer lines");
fireEvent.click(screen.getByTestId("file-edit-diff-collapse-lines"));
expect(screen.queryByText("line-165")).not.toBeInTheDocument();
expect(screen.getByTestId("file-edit-diff-expand-lines")).toHaveTextContent("Show 5 more lines");
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByTestId("file-edit-diff")).not.toBeInTheDocument();
} finally {
localStorage.removeItem("nanobot-webui.settings-preferences");
}
});
it("does not mount collapsed file edit diff rows until opened", () => {
localStorage.setItem(
"nanobot-webui.settings-preferences",
JSON.stringify({ fileEditDisplayMode: "collapsed_diff" }),
);
try {
render(
<AgentActivityCluster
messages={[{
id: "t-collapsed-diff",
role: "tool",
kind: "trace",
content: "edit_file()",
traces: ["edit_file()"],
fileEdits: [{
call_id: "call-collapsed-edit",
tool: "edit_file",
path: "src/app.tsx",
phase: "end",
added: 1,
deleted: 1,
approximate: false,
status: "done",
diff: unifiedFileDiff([
"--- src/app.tsx",
"+++ src/app.tsx",
"@@ -10,2 +10,2 @@",
" function App() {",
"- return <Old />;",
"+ return <New />;",
]),
}],
createdAt: 3,
}]}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
const toggle = screen.getByTestId("file-edit-diff-toggle");
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(toggle).toHaveTextContent("View diff");
expect(toggle).toHaveTextContent("3 lines");
expect(screen.queryByTestId("file-edit-diff")).not.toBeInTheDocument();
expect(screen.queryByText("return <New />;")).not.toBeInTheDocument();
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(screen.getByTestId("file-edit-diff")).toBeInTheDocument();
expect(screen.getByText("return <New />;")).toBeInTheDocument();
} finally {
localStorage.removeItem("nanobot-webui.settings-preferences");
}
});
it("offers the file preview entry point when a diff payload is truncated", () => {
localStorage.setItem(
"nanobot-webui.settings-preferences",
JSON.stringify({ fileEditDisplayMode: "diff" }),
);
const onOpenFilePreview = vi.fn();
try {
render(
<AgentActivityCluster
messages={[{
id: "t-truncated-diff",
role: "tool",
kind: "trace",
content: "edit_file()",
traces: ["edit_file()"],
fileEdits: [{
call_id: "call-truncated-edit",
tool: "edit_file",
path: "src/app.tsx",
absolute_path: "/repo/src/app.tsx",
phase: "end",
added: 1,
deleted: 0,
approximate: false,
status: "done",
diff: unifiedFileDiff([
"--- src/app.tsx",
"+++ src/app.tsx",
"@@ -9,0 +10,1 @@",
"+export const value = 1;",
], true),
}],
createdAt: 3,
}]}
isTurnStreaming={false}
hasBodyBelow={false}
onOpenFilePreview={onOpenFilePreview}
/>,
);
const toggle = screen.getByTestId("file-edit-diff-toggle");
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(toggle).toHaveTextContent("View large diff");
expect(screen.queryByTestId("file-edit-diff-truncated")).not.toBeInTheDocument();
fireEvent.click(toggle);
expect(screen.getByTestId("file-edit-diff-truncated")).toHaveTextContent("Diff truncated");
fireEvent.click(screen.getByTestId("file-edit-diff-open-file"));
expect(onOpenFilePreview).toHaveBeenCalledWith("/repo/src/app.tsx");
} finally {
localStorage.removeItem("nanobot-webui.settings-preferences");
}
});
it("labels whole-file deletes as deleted instead of edited", () => {
render(
<AgentActivityCluster
@@ -985,8 +1289,11 @@ describe("AgentActivityCluster", () => {
expect(screen.queryByText(/\[Errno 13\]/)).not.toBeInTheDocument();
});
it("merges repeated edits for the same path and lets successful edits win over failures", async () => {
const restoreMotion = installReducedMotion();
it("renders repeated edits for the same path as separate actions", () => {
localStorage.setItem(
"nanobot-webui.settings-preferences",
JSON.stringify({ fileEditDisplayMode: "diff" }),
);
try {
render(
<AgentActivityCluster
@@ -1006,6 +1313,13 @@ describe("AgentActivityCluster", () => {
deleted: 1,
approximate: false,
status: "done",
diff: unifiedFileDiff([
"--- minecraft-fps/index.html",
"+++ minecraft-fps/index.html",
"@@ -1,1 +1,2 @@",
" <main>",
"+ <canvas />",
]),
},
{
call_id: "call-edit-2",
@@ -1027,6 +1341,14 @@ describe("AgentActivityCluster", () => {
deleted: 6,
approximate: false,
status: "done",
diff: unifiedFileDiff([
"--- minecraft-fps/index.html",
"+++ minecraft-fps/index.html",
"@@ -8,2 +8,2 @@",
"-const fps = 30;",
"+const fps = 60;",
" start();",
]),
},
],
createdAt: 3,
@@ -1036,20 +1358,24 @@ describe("AgentActivityCluster", () => {
/>,
);
expect(screen.getByRole("button", { name: /edited index\.html/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /failed index\.html/i })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /edited index\.html/i }));
const toggle = screen.getByRole("button", { name: "Edited 3 changes" });
expect(toggle).toHaveTextContent("+8");
expect(toggle).toHaveTextContent("-7");
fireEvent.click(toggle);
const fileRefs = screen.getAllByTestId("activity-file-reference");
expect(fileRefs).toHaveLength(1);
expect(fileRefs[0]).toHaveTextContent("minecraft-fps/index.html");
expect(screen.queryByText("Failed")).not.toBeInTheDocument();
await waitFor(() => {
expect(screen.getAllByText("+8").length).toBeGreaterThan(0);
expect(screen.getAllByText("-7").length).toBeGreaterThan(0);
});
expect(fileRefs).toHaveLength(3);
expect(fileRefs.every((ref) => ref.textContent?.includes("minecraft-fps/index.html"))).toBe(true);
expect(screen.getByText("patch failed")).toBeInTheDocument();
expect(screen.getAllByTestId("file-edit-diff")).toHaveLength(2);
expect(screen.getByText("<canvas />")).toBeInTheDocument();
expect(screen.getByText("const fps = 60;")).toBeInTheDocument();
expect(screen.getAllByText("+2").length).toBeGreaterThan(0);
expect(screen.getAllByText("-1").length).toBeGreaterThan(0);
expect(screen.getAllByText("+6").length).toBeGreaterThan(0);
expect(screen.getAllByText("-6").length).toBeGreaterThan(0);
} finally {
restoreMotion();
localStorage.removeItem("nanobot-webui.settings-preferences");
}
});
@@ -0,0 +1,57 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
import { fetchFilePreview } from "@/lib/api";
vi.mock("@/components/CodeBlock", () => ({
CodeBlock: ({ code }: { code: string }) => <pre data-testid="mock-code-block">{code}</pre>,
}));
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return {
...actual,
fetchFilePreview: vi.fn(),
};
});
describe("FilePreviewPanel", () => {
beforeEach(() => {
vi.mocked(fetchFilePreview).mockReset();
});
it("shows a compact breadcrumb with one file name and a visible close action", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
vi.mocked(fetchFilePreview).mockResolvedValue({
path: "/Users/hr/workspace/quicksort.py",
display_path: "quicksort.py",
language: "python",
content: "print('ok')",
truncated: false,
});
render(
<FilePreviewPanel
sessionKey="websocket:chat-1"
path="quicksort.py"
token="tok"
onClose={onClose}
/>,
);
expect(await screen.findByTestId("mock-code-block")).toHaveTextContent("print('ok')");
expect(screen.getByTestId("file-preview-breadcrumb")).toHaveTextContent("...");
expect(screen.getByTestId("file-preview-breadcrumb")).toHaveTextContent("workspace");
expect(screen.getByTestId("file-preview-title")).toHaveTextContent("quicksort.py");
expect(screen.getAllByText("quicksort.py")).toHaveLength(1);
const closeButton = screen.getByRole("button", { name: "Close file preview" });
expect(closeButton).toBeVisible();
await user.click(closeButton);
expect(onClose).toHaveBeenCalledTimes(1);
});
});
+2
View File
@@ -81,6 +81,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.rows.language",
"settings.rows.density",
"settings.rows.activityMode",
"settings.rows.fileEditDisplay",
"settings.rows.codeWrap",
"settings.rows.brandLogos",
"settings.rows.currentModel",
@@ -91,6 +92,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.help.language",
"settings.help.density",
"settings.help.activityMode",
"settings.help.fileEditDisplay",
"settings.help.codeWrap",
"settings.help.brandLogos",
"settings.help.currentModel",
+18 -1
View File
@@ -159,7 +159,7 @@ const installedAnyGen = {
function renderSettingsView(
options: {
initialSection?: "overview" | "apps" | "automations" | "advanced" | "models" | "browser";
initialSection?: "overview" | "appearance" | "apps" | "automations" | "advanced" | "models" | "browser";
initialSettings?: SettingsPayload;
showSidebar?: boolean;
onSettingsChange?: (payload: SettingsPayload) => void;
@@ -185,10 +185,27 @@ function renderSettingsView(
describe("SettingsView Apps catalog", () => {
afterEach(() => {
localStorage.removeItem("nanobot-webui.settings-preferences");
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("persists the file edit display local preference", async () => {
renderSettingsView({
initialSection: "appearance",
initialSettings: settingsPayload(),
showSidebar: true,
});
expect(screen.getByText("File edit display")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Diff" }));
await waitFor(() => {
const saved = JSON.parse(localStorage.getItem("nanobot-webui.settings-preferences") || "{}");
expect(saved.fileEditDisplayMode).toBe("diff");
});
});
it("does not show the Settings kicker on the standalone Automations surface", async () => {
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
+65
View File
@@ -596,6 +596,71 @@ describe("useNanobotStream", () => {
expect(result.current.messages[0].toolEvents).toBeUndefined();
});
it("keeps live file edits separate from mixed non-file tool traces", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-file-edit-mixed-tools", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-file-edit-mixed-tools", {
event: "message",
chat_id: "chat-file-edit-mixed-tools",
text: "",
kind: "tool_hint",
tool_events: [
{
phase: "start",
call_id: "call-read",
name: "read_file",
arguments: { path: "quicksort.py" },
},
{
phase: "start",
call_id: "call-write",
name: "write_file",
arguments: { path: "sorting/quicksort.py", content: "def quicksort():\n" },
},
],
});
fake.emit("chat-file-edit-mixed-tools", {
event: "file_edit",
chat_id: "chat-file-edit-mixed-tools",
edits: [{
call_id: "call-write",
tool: "write_file",
path: "sorting/quicksort.py",
phase: "end",
added: 3,
deleted: 0,
approximate: false,
status: "done",
}],
});
});
expect(result.current.messages).toHaveLength(2);
expect(result.current.messages[0]).toMatchObject({
role: "tool",
kind: "trace",
traces: ['read_file({"path":"quicksort.py"})'],
});
expect(result.current.messages[0].toolEvents?.map((event) => event.name)).toEqual(["read_file"]);
expect(result.current.messages[0].fileEdits).toBeUndefined();
expect(result.current.messages[1]).toMatchObject({
role: "tool",
kind: "trace",
traces: [],
fileEdits: [{
call_id: "call-write",
tool: "write_file",
path: "sorting/quicksort.py",
status: "done",
}],
});
expect(result.current.messages[1].toolEvents).toBeUndefined();
});
it("keeps every file from one apply_patch call", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-apply-patch-many", EMPTY_MESSAGES), {