diff --git a/webui/src/components/FileReferenceChip.tsx b/webui/src/components/FileReferenceChip.tsx index 18e63d1ca..aa170538b 100644 --- a/webui/src/components/FileReferenceChip.tsx +++ b/webui/src/components/FileReferenceChip.tsx @@ -19,6 +19,7 @@ type FileReferenceKind = interface FileReferenceChipProps { path: string; + tooltipPath?: string; display?: "name" | "path"; active?: boolean; className?: string; @@ -28,27 +29,29 @@ interface FileReferenceChipProps { export function FileReferenceChip({ path, + tooltipPath, display = "name", active = false, className, textClassName, testId = "inline-file-path", }: FileReferenceChipProps) { - const { name } = splitFilePath(path); + const { directory, name } = splitFilePath(path); const kind = fileKindForPath(path); const displayText = display === "path" ? path.replace(/\\/g, "/") : name; + const fullPath = tooltipPath || path; return ( - {displayText} + {display === "path" && directory ? ( + <> + {directory} + {name} + + ) : ( + displayText + )} @@ -79,7 +89,7 @@ export function FileReferenceChip({ "shadow-lg backdrop-blur", )} > - {path} + {fullPath} diff --git a/webui/src/components/thread/AgentActivityCluster.tsx b/webui/src/components/thread/AgentActivityCluster.tsx index 792a41562..46be8b43d 100644 --- a/webui/src/components/thread/AgentActivityCluster.tsx +++ b/webui/src/components/thread/AgentActivityCluster.tsx @@ -30,16 +30,19 @@ interface ActivityCounts { hasEditingFiles: boolean; hasFailedFiles: boolean; primaryFilePath?: string; + primaryFileTooltipPath?: string; } interface FileEditSummary { key: string; path: string; + absolute_path?: string; added: number; deleted: number; approximate: boolean; binary: boolean; status: UIFileEdit["status"]; + pending: boolean; error?: string; } @@ -61,8 +64,10 @@ function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): Act let hasEditingFiles = false; let failedFileCount = 0; let primaryFilePath: string | undefined; + let primaryFileTooltipPath: string | undefined; for (const edit of fileEdits) { primaryFilePath = edit.path; + primaryFileTooltipPath = edit.absolute_path || edit.path; if (edit.status === "editing") { hasEditingFiles = true; } @@ -84,6 +89,7 @@ function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): Act hasEditingFiles, hasFailedFiles: fileEdits.length > 0 && failedFileCount === fileEdits.length, primaryFilePath, + primaryFileTooltipPath, }; } @@ -117,7 +123,9 @@ export function AgentActivityCluster({ hasEditingFiles, hasFailedFiles, primaryFilePath, + primaryFileTooltipPath, } = countActivity(messages, fileEdits); + const hasPendingFileEdit = fileEdits.some((edit) => edit.pending); const [userToggledOuter, setUserToggledOuter] = useState(false); const [outerOpenLocal, setOuterOpenLocal] = useState(false); @@ -130,11 +138,15 @@ export function AgentActivityCluster({ const hasLiveEditingFiles = isTurnStreaming && hasEditingFiles; const headerBusy = fileCount > 0 ? hasEditingFiles : isTurnStreaming; + const singleFilePath = fileCount === 1 ? primaryFilePath : undefined; + const singleFileTooltipPath = fileCount === 1 ? primaryFileTooltipPath : undefined; const fileActivitySummary = fileCount > 0 - ? fileCount === 1 && primaryFilePath + ? hasPendingFileEdit && !singleFilePath + ? t("message.fileActivityPreparing", { defaultValue: "Preparing edit…" }) + : singleFilePath ? t(fileActivitySummaryKey(hasLiveEditingFiles, hasFailedFiles), { - file: shortFileName(primaryFilePath), + file: shortFileName(singleFilePath), defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} {{file}}`, }) : t(fileActivityManySummaryKey(hasLiveEditingFiles, hasFailedFiles), { @@ -241,15 +253,35 @@ export function AgentActivityCluster({ "text-xs text-muted-foreground transition-colors hover:bg-muted/45", )} aria-expanded={outerExpanded} + aria-label={summary} > - - {summary} - + {singleFilePath ? ( + + + {fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} + + + + ) : ( + + {summary} + + )} {fileCount > 0 && ( @@ -332,7 +364,8 @@ function fileActivityManySummaryKey(editing: boolean, failed: boolean): string { } function fileEditCallKey(edit: UIFileEdit): 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 collectFileEdits(messages: UIMessage[]): UIFileEdit[] { @@ -360,10 +393,12 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma interface MutableSummary { key: string; path: string; + absolute_path?: string; added: number; deleted: number; approximate: boolean; binary: boolean; + pending: boolean; hasSuccessfulChange: boolean; hasActiveEditing: boolean; hasFailed: boolean; @@ -373,16 +408,18 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma const order: string[] = []; const byPath = new Map(); for (const edit of latestFileEditEvents(edits)) { - const key = edit.path; + const key = edit.path || edit.call_id || edit.tool; let summary = byPath.get(key); if (!summary) { summary = { key, - path: edit.path, + path: edit.path || "", + absolute_path: edit.absolute_path, added: 0, deleted: 0, approximate: false, binary: false, + pending: false, hasSuccessfulChange: false, hasActiveEditing: false, hasFailed: false, @@ -391,6 +428,13 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma order.push(key); } + if (edit.path && !summary.path) { + summary.path = edit.path; + } + if (edit.absolute_path) { + summary.absolute_path = edit.absolute_path; + } + summary.pending = summary.pending || !!edit.pending || !edit.path; if (active && edit.status === "editing") { summary.hasActiveEditing = true; summary.binary = summary.binary || !!edit.binary; @@ -429,11 +473,13 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma return { key: summary.key, path: summary.path, + absolute_path: summary.absolute_path, added: summary.added, deleted: summary.deleted, approximate: summary.approximate, binary: summary.binary, status, + pending: summary.pending && !summary.path, error: summary.error, }; }); @@ -458,14 +504,24 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) { return (
  • - + {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 () => {