feat(webui): render live file edit activity
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user