-
+ {edit.pending && !edit.path ? (
+
+ {t("message.fileEditPreparing", { defaultValue: "Preparing file edit…" })}
+
+ ) : (
+
+ )}
{failed ? (
@@ -487,13 +543,30 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
function DiffPair({ added, deleted }: { added: number; deleted: number }) {
return (
-
-
- +
-
-
- -
+
+
+
+
+ );
+}
+
+function DiffValue({ sign, value, className }: { sign: string; value: number; className: string }) {
+ const safeValue = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
+ return (
+
+
+ {sign}
+
+ {sign}{safeValue}
);
}
@@ -537,5 +610,37 @@ function AnimatedNumber({ value }: { value: number }) {
return () => window.cancelAnimationFrame(frame);
}, [safeValue, setAnimatedDisplay]);
- return <>{display}>;
+ return ;
+}
+
+function RollingNumber({ value }: { value: number }) {
+ const digits = String(value).split("");
+ return (
+
+ {digits.map((digit, index) => (
+
+ ))}
+
+ );
+}
+
+function RollingDigit({ digit }: { digit: number }) {
+ const safeDigit = Number.isFinite(digit) ? Math.min(9, Math.max(0, digit)) : 0;
+ return (
+
+
+ {Array.from({ length: 10 }, (_, n) => (
+
+ {n}
+
+ ))}
+
+
+ );
}
diff --git a/webui/src/globals.css b/webui/src/globals.css
index 7c9cc8958..4d9496405 100644
--- a/webui/src/globals.css
+++ b/webui/src/globals.css
@@ -131,6 +131,9 @@
position: relative;
color: hsl(var(--muted-foreground));
}
+ .file-reference-sheen {
+ color: inherit;
+ }
.streaming-text-sheen::after {
content: attr(data-sheen-text);
position: absolute;
diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts
index 2ee113227..0461a642f 100644
--- a/webui/src/hooks/useNanobotStream.ts
+++ b/webui/src/hooks/useNanobotStream.ts
@@ -215,18 +215,19 @@ function absorbCompleteAssistantMessage(
}
function fileEditKey(edit: Pick): string {
- return `${edit.call_id}|${edit.tool}|${edit.path}`;
+ if (edit.call_id) return `${edit.call_id}|${edit.tool}`;
+ return `${edit.tool}|${edit.path}`;
}
function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null {
- if (!edit || !edit.path || !edit.tool) return null;
+ if (!edit || !edit.tool || (!edit.path && !edit.pending)) return null;
const inferredStatus =
edit.phase === "error"
? "error"
: edit.phase === "end"
? "done"
: "editing";
- return {
+ const normalized: UIFileEdit = {
...edit,
call_id: edit.call_id || `${edit.tool}:${edit.path}`,
added: Number.isFinite(edit.added) ? Math.max(0, Math.round(edit.added)) : 0,
@@ -235,6 +236,8 @@ function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null {
? edit.status
: inferredStatus,
};
+ if (edit.pending && !edit.path) normalized.pending = true;
+ return normalized;
}
function mergeFileEdits(existing: UIFileEdit[] | undefined, incoming: UIFileEdit[]): UIFileEdit[] {
@@ -250,11 +253,31 @@ function mergeFileEdits(existing: UIFileEdit[] | undefined, incoming: UIFileEdit
next.push(edit);
continue;
}
- next[existingIndex] = { ...next[existingIndex], ...edit };
+ const merged = { ...next[existingIndex], ...edit };
+ if (edit.path && !edit.pending) delete merged.pending;
+ next[existingIndex] = merged;
}
return next;
}
+function findFileEditTraceIndex(
+ prev: UIMessage[],
+ segmentId: string | null,
+ incoming: UIFileEdit[],
+): number | null {
+ const incomingKeys = new Set(incoming.map(fileEditKey));
+ for (let i = prev.length - 1; i >= 0; i -= 1) {
+ const candidate = prev[i];
+ if (candidate.role === "user") break;
+ if (candidate.kind !== "trace" || !candidate.fileEdits?.length) continue;
+ if (segmentId && candidate.activitySegmentId === segmentId) return i;
+ for (const existing of candidate.fileEdits) {
+ if (incomingKeys.has(fileEditKey(existing))) return i;
+ }
+ }
+ return null;
+}
+
/**
* Subscribe to a chat by ID. Returns the in-memory message list for the chat,
* a streaming flag, and a ``send`` function. Initial history must be seeded
@@ -534,6 +557,7 @@ export function useNanobotStream(
if (suppressStreamUntilTurnEndRef.current) return;
const chunk = typeof ev.text === "string" ? ev.text : "";
if (!chunk) return;
+ clearActivitySegment();
setIsStreaming(true);
pendingStreamEventsRef.current.push({ kind: "delta", text: chunk });
schedulePendingStreamFlush();
@@ -544,6 +568,7 @@ export function useNanobotStream(
if (suppressStreamUntilTurnEndRef.current) return;
const chunk = ev.text;
if (!chunk) return;
+ if (fileEditSegmentRef.current) clearActivitySegment();
setIsStreaming(true);
pendingStreamEventsRef.current.push({ kind: "reasoning", text: chunk });
schedulePendingStreamFlush();
@@ -622,6 +647,7 @@ export function useNanobotStream(
if (ev.kind === "reasoning") {
const line = ev.text;
if (!line) return;
+ if (fileEditSegmentRef.current) clearActivitySegment();
setMessages((prev) => closeReasoningStream(attachReasoningChunk(prev, line, {
ensure: ensureActivitySegmentId,
})));
@@ -685,6 +711,7 @@ export function useNanobotStream(
// flight, drop the placeholder so we don't render the text twice.
// Do NOT reset isStreaming here — only ``turn_end`` signals that
// the full turn (all tool calls + final text) is complete.
+ clearActivitySegment();
setMessages((prev) => {
const activeId = buffer.current?.messageId;
buffer.current = null;
@@ -709,27 +736,32 @@ export function useNanobotStream(
if (ev.event === "file_edit") {
const edits = Array.isArray(ev.edits) ? ev.edits : [];
if (edits.length === 0) return;
+ const normalized = mergeFileEdits(undefined, edits);
+ if (normalized.length === 0) return;
+ const opensFileEditPhase = normalized.some(
+ (edit) => edit.status === "editing" || edit.phase === "start",
+ );
+ let eventSegmentId = fileEditSegmentRef.current;
+ if (!eventSegmentId && opensFileEditPhase) {
+ eventSegmentId = detachedActivitySegmentId();
+ fileEditSegmentRef.current = eventSegmentId;
+ }
setMessages((prev) => {
- const last = prev[prev.length - 1];
- let segmentId = fileEditSegmentRef.current;
- if (!segmentId || !(last?.kind === "trace" && last.fileEdits?.length)) {
- segmentId = detachedActivitySegmentId();
- fileEditSegmentRef.current = segmentId;
- }
- if (
- last
- && last.kind === "trace"
- && !last.isStreaming
- && !!last.fileEdits?.length
- && last.activitySegmentId === segmentId
- ) {
+ let segmentId = eventSegmentId;
+ const targetIndex = findFileEditTraceIndex(prev, segmentId, normalized);
+ if (targetIndex !== null) {
+ const target = prev[targetIndex];
+ segmentId = target.activitySegmentId ?? segmentId ?? detachedActivitySegmentId();
+ if (opensFileEditPhase) fileEditSegmentRef.current = segmentId;
const merged: UIMessage = {
- ...last,
- fileEdits: mergeFileEdits(last.fileEdits, edits),
- activitySegmentId: last.activitySegmentId ?? segmentId,
+ ...target,
+ fileEdits: mergeFileEdits(target.fileEdits, normalized),
+ activitySegmentId: segmentId,
};
- return [...prev.slice(0, -1), merged];
+ return replaceMessageAt(prev, targetIndex, merged);
}
+ segmentId = segmentId ?? detachedActivitySegmentId();
+ if (opensFileEditPhase) fileEditSegmentRef.current = segmentId;
return [
...prev,
{
@@ -738,7 +770,7 @@ export function useNanobotStream(
kind: "trace",
content: "",
traces: [],
- fileEdits: mergeFileEdits(undefined, edits),
+ fileEdits: normalized,
activitySegmentId: segmentId,
createdAt: Date.now(),
},
diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts
index 8ffb4a70a..c8c7e96e1 100644
--- a/webui/src/lib/types.ts
+++ b/webui/src/lib/types.ts
@@ -89,6 +89,7 @@ export interface UIFileEdit {
call_id: string;
tool: string;
path: string;
+ absolute_path?: string;
phase?: "start" | "end" | "error" | string;
added: number;
deleted: number;
@@ -96,6 +97,7 @@ export interface UIFileEdit {
status: "editing" | "done" | "error";
binary?: boolean;
error?: string;
+ pending?: boolean;
}
export interface ChatSummary {
diff --git a/webui/src/tests/agent-activity-cluster.test.tsx b/webui/src/tests/agent-activity-cluster.test.tsx
index 120268500..041195e2b 100644
--- a/webui/src/tests/agent-activity-cluster.test.tsx
+++ b/webui/src/tests/agent-activity-cluster.test.tsx
@@ -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(
+ ,
+ );
+
+ 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 {
diff --git a/webui/src/tests/message-bubble.test.tsx b/webui/src/tests/message-bubble.test.tsx
index baae344dc..e0476898c 100644
--- a/webui/src/tests/message-bubble.test.tsx
+++ b/webui/src/tests/message-bubble.test.tsx
@@ -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();
diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx
index 925102dad..3339c85c0 100644
--- a/webui/src/tests/useNanobotStream.test.tsx
+++ b/webui/src/tests/useNanobotStream.test.tsx
@@ -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 () => {