feat(webui): render live file edit activity

This commit is contained in:
Xubin Ren
2026-05-18 22:01:33 +08:00
parent 7e2dbdef7d
commit 0537cc1682
8 changed files with 425 additions and 60 deletions
@@ -236,6 +236,7 @@ describe("AgentActivityCluster", () => {
call_id: "call-edit",
tool: "edit_file",
path: "src/app.tsx",
absolute_path: "/Users/renxubin/project/src/app.tsx",
phase: "end",
added: 12,
deleted: 3,
@@ -250,13 +251,17 @@ describe("AgentActivityCluster", () => {
);
expect(screen.getByRole("button", { name: /edited app\.tsx/i })).toBeInTheDocument();
expect(screen.getByTestId("activity-header-file-reference")).toHaveTextContent("app.tsx");
expect(screen.getByTestId("activity-header-file-reference")).toHaveAttribute(
"aria-label",
"/Users/renxubin/project/src/app.tsx",
);
fireEvent.click(screen.getByRole("button", { name: /edited app\.tsx/i }));
expect(screen.queryByText("Edited files")).not.toBeInTheDocument();
expect(screen.queryByText("Edited")).not.toBeInTheDocument();
const fileRef = screen.getByTestId("activity-file-reference");
expect(fileRef).toHaveTextContent("src/app.tsx");
expect(fileRef).toHaveAttribute("aria-label", "src/app.tsx");
expect(fileRef).toHaveAttribute("aria-label", "/Users/renxubin/project/src/app.tsx");
await waitFor(() => {
expect(screen.getAllByText("+12").length).toBeGreaterThan(0);
expect(screen.getAllByText("-3").length).toBeGreaterThan(0);
@@ -266,6 +271,38 @@ describe("AgentActivityCluster", () => {
}
});
it("renders pending file edit placeholders before the path is known", () => {
render(
<AgentActivityCluster
messages={activityMessages("", {
id: "t2",
role: "tool",
kind: "trace",
content: "",
traces: [],
fileEdits: [{
call_id: "call-edit",
tool: "edit_file",
path: "",
phase: "start",
added: 0,
deleted: 0,
approximate: true,
status: "editing",
pending: true,
}],
createdAt: 3,
})}
isTurnStreaming
hasBodyBelow={false}
/>,
);
expect(screen.getByRole("button", { name: /preparing edit/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /preparing edit/i }));
expect(screen.getByText("Preparing file edit…")).toBeInTheDocument();
});
it("merges repeated edits for the same path and lets successful edits win over failures", async () => {
const restoreMotion = installReducedMotion();
try {
+2 -1
View File
@@ -195,7 +195,8 @@ describe("MessageBubble", () => {
const references = await screen.findAllByTestId("inline-file-path");
expect(references).toHaveLength(2);
expect(references[0].parentElement).not.toHaveClass("translate-y-[0.08em]");
expect(references[0].parentElement).toHaveClass("align-[0.14em]");
expect(references[0].parentElement).toHaveClass("align-baseline");
expect(references[0].parentElement).toHaveClass("leading-[inherit]");
expect(references[0]).toHaveTextContent("MarkdownTextRenderer.tsx");
expect(references[0]).not.toHaveTextContent("webui/src/components");
expect(screen.getByText("index.html")).toBeInTheDocument();
+176 -1
View File
@@ -374,6 +374,121 @@ describe("useNanobotStream", () => {
);
});
it("upgrades pending file_edit placeholders when the path arrives", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-file-edit-pending", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-file-edit-pending", {
event: "file_edit",
chat_id: "chat-file-edit-pending",
edits: [{
call_id: "call-write",
tool: "write_file",
path: "",
phase: "start",
added: 1,
deleted: 0,
approximate: true,
status: "editing",
pending: true,
}],
});
fake.emit("chat-file-edit-pending", {
event: "file_edit",
chat_id: "chat-file-edit-pending",
edits: [{
call_id: "call-write",
tool: "write_file",
path: "foo.txt",
phase: "start",
added: 12,
deleted: 0,
approximate: true,
status: "editing",
}],
});
});
const fileEditMessages = result.current.messages.filter((message) => message.fileEdits?.length);
expect(fileEditMessages).toHaveLength(1);
expect(fileEditMessages[0].fileEdits).toEqual([{
call_id: "call-write",
tool: "write_file",
path: "foo.txt",
phase: "start",
added: 12,
deleted: 0,
approximate: true,
status: "editing",
}]);
});
it("merges file_edit updates after interleaved progress events", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-file-edit-progress", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-file-edit-progress", {
event: "message",
chat_id: "chat-file-edit-progress",
text: 'write_file({"path":"foo.txt"})',
kind: "tool_hint",
});
fake.emit("chat-file-edit-progress", {
event: "file_edit",
chat_id: "chat-file-edit-progress",
edits: [{
call_id: "call-write",
tool: "write_file",
path: "foo.txt",
phase: "start",
added: 12,
deleted: 0,
approximate: true,
status: "editing",
}],
});
fake.emit("chat-file-edit-progress", {
event: "message",
chat_id: "chat-file-edit-progress",
text: "still working",
kind: "progress",
});
fake.emit("chat-file-edit-progress", {
event: "file_edit",
chat_id: "chat-file-edit-progress",
edits: [{
call_id: "call-write",
tool: "write_file",
path: "foo.txt",
phase: "end",
added: 30,
deleted: 0,
approximate: false,
status: "done",
}],
});
});
const fileEditMessages = result.current.messages.filter((message) => message.fileEdits?.length);
expect(fileEditMessages).toHaveLength(1);
expect(fileEditMessages[0].fileEdits).toEqual([{
call_id: "call-write",
tool: "write_file",
path: "foo.txt",
phase: "end",
added: 30,
deleted: 0,
approximate: false,
status: "done",
}]);
});
it("starts a new assistant bubble for deltas after stream_end and activity", async () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-stream-segments", EMPTY_MESSAGES), {
@@ -472,7 +587,67 @@ describe("useNanobotStream", () => {
expect(result.current.messages[1].activitySegmentId).toBe(firstSegment);
expect(result.current.messages[2].activitySegmentId).toBeTruthy();
expect(result.current.messages[2].activitySegmentId).not.toBe(firstSegment);
expect(result.current.messages[3].activitySegmentId).toBe(firstSegment);
expect(result.current.messages[3].activitySegmentId).toBeTruthy();
expect(result.current.messages[3].activitySegmentId).not.toBe(result.current.messages[2].activitySegmentId);
});
it("keeps file edit blocks ordered across a new reasoning phase", async () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-file-order", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-file-order", {
event: "file_edit",
chat_id: "chat-file-order",
edits: [{
call_id: "call-one",
tool: "write_file",
path: "one.txt",
phase: "start",
added: 10,
deleted: 0,
approximate: true,
status: "editing",
}],
});
fake.emit("chat-file-order", {
event: "reasoning_delta",
chat_id: "chat-file-order",
text: "Check the next file.",
});
});
await flushStreamFrame();
act(() => {
fake.emit("chat-file-order", {
event: "file_edit",
chat_id: "chat-file-order",
edits: [{
call_id: "call-two",
tool: "write_file",
path: "two.txt",
phase: "start",
added: 20,
deleted: 0,
approximate: true,
status: "editing",
}],
});
});
expect(result.current.messages.map((message) => message.fileEdits?.[0]?.path ?? message.reasoning)).toEqual([
"one.txt",
"Check the next file.",
"two.txt",
]);
const fileEditSegments = result.current.messages
.filter((message) => message.fileEdits?.length)
.map((message) => message.activitySegmentId);
expect(fileEditSegments).toHaveLength(2);
expect(fileEditSegments[0]).not.toBe(fileEditSegments[1]);
});
it("accumulates reasoning_delta chunks on a placeholder until reasoning_end", async () => {