feat(webui): render file edit activity
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
|
||||
@@ -72,6 +72,25 @@ function setScrollGeometry(
|
||||
});
|
||||
}
|
||||
|
||||
function installReducedMotion() {
|
||||
const original = window.matchMedia;
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
matches: true,
|
||||
media: "(prefers-reduced-motion: reduce)",
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
}),
|
||||
});
|
||||
return () => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: original,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
describe("AgentActivityCluster", () => {
|
||||
it("jumps to the latest activity when opened", () => {
|
||||
const raf = installAnimationFrameQueue();
|
||||
@@ -201,4 +220,117 @@ describe("AgentActivityCluster", () => {
|
||||
raf.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders file edit totals and a compact expanded file list", async () => {
|
||||
const restoreMotion = installReducedMotion();
|
||||
try {
|
||||
render(
|
||||
<AgentActivityCluster
|
||||
messages={activityMessages("", {
|
||||
id: "t2",
|
||||
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: 12,
|
||||
deleted: 3,
|
||||
approximate: false,
|
||||
status: "done",
|
||||
}],
|
||||
createdAt: 3,
|
||||
})}
|
||||
isTurnStreaming={false}
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /edited app\.tsx/i })).toBeInTheDocument();
|
||||
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");
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("+12").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("-3").length).toBeGreaterThan(0);
|
||||
});
|
||||
} finally {
|
||||
restoreMotion();
|
||||
}
|
||||
});
|
||||
|
||||
it("merges repeated edits for the same path and lets successful edits win over failures", async () => {
|
||||
const restoreMotion = installReducedMotion();
|
||||
try {
|
||||
render(
|
||||
<AgentActivityCluster
|
||||
messages={activityMessages("", {
|
||||
id: "t2",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "edit_file()",
|
||||
traces: ["edit_file()"],
|
||||
fileEdits: [
|
||||
{
|
||||
call_id: "call-edit-1",
|
||||
tool: "edit_file",
|
||||
path: "minecraft-fps/index.html",
|
||||
phase: "end",
|
||||
added: 2,
|
||||
deleted: 1,
|
||||
approximate: false,
|
||||
status: "done",
|
||||
},
|
||||
{
|
||||
call_id: "call-edit-2",
|
||||
tool: "edit_file",
|
||||
path: "minecraft-fps/index.html",
|
||||
phase: "error",
|
||||
added: 0,
|
||||
deleted: 0,
|
||||
approximate: false,
|
||||
status: "error",
|
||||
error: "patch failed",
|
||||
},
|
||||
{
|
||||
call_id: "call-edit-3",
|
||||
tool: "edit_file",
|
||||
path: "minecraft-fps/index.html",
|
||||
phase: "end",
|
||||
added: 6,
|
||||
deleted: 6,
|
||||
approximate: false,
|
||||
status: "done",
|
||||
},
|
||||
],
|
||||
createdAt: 3,
|
||||
})}
|
||||
isTurnStreaming={false}
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
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 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);
|
||||
});
|
||||
} finally {
|
||||
restoreMotion();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +55,153 @@ describe("ThreadMessages", () => {
|
||||
expect(rows[1]).toHaveClass("mt-4");
|
||||
});
|
||||
|
||||
it("starts a new activity cluster when the activity segment changes", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "r1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "first pass",
|
||||
activitySegmentId: "seg-1",
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "t1",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "edit_file()",
|
||||
traces: ["edit_file()"],
|
||||
fileEdits: [{
|
||||
call_id: "call-edit",
|
||||
tool: "edit_file",
|
||||
path: "foo.txt",
|
||||
phase: "end",
|
||||
added: 2,
|
||||
deleted: 1,
|
||||
status: "done",
|
||||
}],
|
||||
activitySegmentId: "seg-1",
|
||||
createdAt: 2,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "second pass",
|
||||
activitySegmentId: "seg-2",
|
||||
createdAt: 3,
|
||||
},
|
||||
];
|
||||
|
||||
const units = buildDisplayUnits(messages);
|
||||
|
||||
expect(units).toHaveLength(2);
|
||||
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
||||
"r1",
|
||||
"t1",
|
||||
]);
|
||||
expect(units[1].type === "cluster" ? units[1].messages.map((m) => m.id) : []).toEqual([
|
||||
"r2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not split ordinary tool activity just because segment ids changed", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "r1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "first pass",
|
||||
activitySegmentId: "seg-1",
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "t1",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "read_file()",
|
||||
traces: ["read_file()"],
|
||||
activitySegmentId: "seg-1",
|
||||
createdAt: 2,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "second pass",
|
||||
activitySegmentId: "seg-2",
|
||||
createdAt: 3,
|
||||
},
|
||||
{
|
||||
id: "t2",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "grep()",
|
||||
traces: ["grep()"],
|
||||
activitySegmentId: "seg-2",
|
||||
createdAt: 4,
|
||||
},
|
||||
];
|
||||
|
||||
const units = buildDisplayUnits(messages);
|
||||
|
||||
expect(units).toHaveLength(1);
|
||||
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
||||
"r1",
|
||||
"t1",
|
||||
"r2",
|
||||
"t2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("only marks the current activity cluster as live while streaming", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "r1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "first pass",
|
||||
reasoningStreaming: true,
|
||||
activitySegmentId: "seg-1",
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "t1",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "edit_file()",
|
||||
traces: ["edit_file()"],
|
||||
fileEdits: [{
|
||||
call_id: "call-edit",
|
||||
tool: "edit_file",
|
||||
path: "foo.txt",
|
||||
phase: "start",
|
||||
added: 4,
|
||||
deleted: 1,
|
||||
approximate: true,
|
||||
status: "editing",
|
||||
}],
|
||||
activitySegmentId: "seg-1",
|
||||
createdAt: 2,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "second pass",
|
||||
reasoningStreaming: true,
|
||||
activitySegmentId: "seg-2",
|
||||
createdAt: 3,
|
||||
},
|
||||
];
|
||||
|
||||
render(<ThreadMessages messages={messages} isStreaming />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /edited foo\.txt/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /editing foo\.txt/i })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /working/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("folds final answer reasoning into the preceding activity cluster", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
|
||||
@@ -308,6 +308,173 @@ describe("useNanobotStream", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("renders live file_edit events as their own activity trace", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-file-edit", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-file-edit", {
|
||||
event: "message",
|
||||
chat_id: "chat-file-edit",
|
||||
text: 'write_file({"path":"foo.txt"})',
|
||||
kind: "tool_hint",
|
||||
});
|
||||
fake.emit("chat-file-edit", {
|
||||
event: "file_edit",
|
||||
chat_id: "chat-file-edit",
|
||||
edits: [{
|
||||
call_id: "call-write",
|
||||
tool: "write_file",
|
||||
path: "foo.txt",
|
||||
phase: "start",
|
||||
added: 1,
|
||||
deleted: 0,
|
||||
approximate: true,
|
||||
status: "editing",
|
||||
}],
|
||||
});
|
||||
fake.emit("chat-file-edit", {
|
||||
event: "file_edit",
|
||||
chat_id: "chat-file-edit",
|
||||
edits: [{
|
||||
call_id: "call-write",
|
||||
tool: "write_file",
|
||||
path: "foo.txt",
|
||||
phase: "end",
|
||||
added: 3,
|
||||
deleted: 1,
|
||||
approximate: false,
|
||||
status: "done",
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(2);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
traces: ['write_file({"path":"foo.txt"})'],
|
||||
});
|
||||
expect(result.current.messages[1]).toMatchObject({
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
fileEdits: [{
|
||||
call_id: "call-write",
|
||||
status: "done",
|
||||
added: 3,
|
||||
deleted: 1,
|
||||
approximate: false,
|
||||
}],
|
||||
});
|
||||
expect(result.current.messages[1].activitySegmentId).toBeTruthy();
|
||||
expect(result.current.messages[1].activitySegmentId).not.toBe(
|
||||
result.current.messages[0].activitySegmentId,
|
||||
);
|
||||
});
|
||||
|
||||
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), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-stream-segments", {
|
||||
event: "delta",
|
||||
chat_id: "chat-stream-segments",
|
||||
text: "I created the files.",
|
||||
});
|
||||
fake.emit("chat-stream-segments", {
|
||||
event: "stream_end",
|
||||
chat_id: "chat-stream-segments",
|
||||
});
|
||||
fake.emit("chat-stream-segments", {
|
||||
event: "message",
|
||||
chat_id: "chat-stream-segments",
|
||||
text: 'write_file({"path":"minecraft-fps/options.txt"})',
|
||||
kind: "tool_hint",
|
||||
});
|
||||
fake.emit("chat-stream-segments", {
|
||||
event: "delta",
|
||||
chat_id: "chat-stream-segments",
|
||||
text: "Now I will summarize the edits.",
|
||||
});
|
||||
});
|
||||
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toHaveLength(3);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "I created the files.",
|
||||
});
|
||||
expect(result.current.messages[1]).toMatchObject({
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
traces: ['write_file({"path":"minecraft-fps/options.txt"})'],
|
||||
});
|
||||
expect(result.current.messages[2]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "Now I will summarize the edits.",
|
||||
});
|
||||
});
|
||||
|
||||
it("opens a new activity segment for reasoning after file edit activity", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-file-segments", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-file-segments", {
|
||||
event: "reasoning_delta",
|
||||
chat_id: "chat-file-segments",
|
||||
text: "Plan.",
|
||||
});
|
||||
fake.emit("chat-file-segments", {
|
||||
event: "reasoning_end",
|
||||
chat_id: "chat-file-segments",
|
||||
});
|
||||
fake.emit("chat-file-segments", {
|
||||
event: "message",
|
||||
chat_id: "chat-file-segments",
|
||||
text: 'edit_file({"path":"foo.txt"})',
|
||||
kind: "tool_hint",
|
||||
});
|
||||
fake.emit("chat-file-segments", {
|
||||
event: "file_edit",
|
||||
chat_id: "chat-file-segments",
|
||||
edits: [{
|
||||
call_id: "call-edit",
|
||||
tool: "edit_file",
|
||||
path: "foo.txt",
|
||||
phase: "start",
|
||||
added: 1,
|
||||
deleted: 1,
|
||||
approximate: true,
|
||||
status: "editing",
|
||||
}],
|
||||
});
|
||||
fake.emit("chat-file-segments", {
|
||||
event: "reasoning_delta",
|
||||
chat_id: "chat-file-segments",
|
||||
text: "Review result.",
|
||||
});
|
||||
});
|
||||
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toHaveLength(4);
|
||||
const firstSegment = result.current.messages[0].activitySegmentId;
|
||||
expect(firstSegment).toBeTruthy();
|
||||
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);
|
||||
});
|
||||
|
||||
it("accumulates reasoning_delta chunks on a placeholder until reasoning_end", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-r", EMPTY_MESSAGES), {
|
||||
|
||||
Reference in New Issue
Block a user