From 945f208d382c929f6988e358563771562061e294 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 17 May 2026 23:52:14 +0800 Subject: [PATCH] feat(webui): render file edit activity --- webui/src/components/FileReferenceChip.tsx | 220 +++++++++++ .../thread/AgentActivityCluster.tsx | 359 +++++++++++++++++- .../src/components/thread/ThreadMessages.tsx | 69 +++- webui/src/hooks/useNanobotStream.ts | 220 +++++++++-- webui/src/lib/types.ts | 25 +- .../src/tests/agent-activity-cluster.test.tsx | 134 ++++++- webui/src/tests/thread-messages.test.tsx | 147 +++++++ webui/src/tests/useNanobotStream.test.tsx | 167 ++++++++ 8 files changed, 1292 insertions(+), 49 deletions(-) create mode 100644 webui/src/components/FileReferenceChip.tsx diff --git a/webui/src/components/FileReferenceChip.tsx b/webui/src/components/FileReferenceChip.tsx new file mode 100644 index 000000000..18e63d1ca --- /dev/null +++ b/webui/src/components/FileReferenceChip.tsx @@ -0,0 +1,220 @@ +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +type FileReferenceKind = + | "default" + | "css" + | "html" + | "json" + | "markdown" + | "notebook" + | "python" + | "react" + | "typescript"; + +interface FileReferenceChipProps { + path: string; + display?: "name" | "path"; + active?: boolean; + className?: string; + textClassName?: string; + testId?: string; +} + +export function FileReferenceChip({ + path, + display = "name", + active = false, + className, + textClassName, + testId = "inline-file-path", +}: FileReferenceChipProps) { + const { name } = splitFilePath(path); + const kind = fileKindForPath(path); + const displayText = display === "path" ? path.replace(/\\/g, "/") : name; + return ( + + + + + + + + {displayText} + + + + + + {path} + + + + ); +} + +export function isLikelyFilePath(value: string): boolean { + const raw = value.trim(); + if (!raw || raw.includes("\n")) return false; + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) return false; + if (!/[\\/]/.test(raw) && !/^(dockerfile|makefile|readme|package-lock\.json)$/i.test(raw)) { + return false; + } + const normalized = raw.replace(/\\/g, "/"); + const name = normalized.split("/").filter(Boolean).pop() ?? normalized; + if (!name || name === "." || name === "..") return false; + if (/^(dockerfile|makefile|readme|package-lock\.json)$/i.test(name)) return true; + return /\.[a-z0-9][a-z0-9_-]{0,12}$/i.test(name); +} + +function splitFilePath(path: string): { directory: string; name: string } { + const normalized = path.replace(/\\/g, "/"); + const slash = normalized.lastIndexOf("/"); + if (slash < 0) return { directory: "", name: path }; + return { + directory: normalized.slice(0, slash + 1), + name: normalized.slice(slash + 1) || normalized, + }; +} + +function fileKindForPath(path: string): FileReferenceKind { + const normalized = path.toLowerCase(); + const name = normalized.split(/[\\/]/).pop() ?? normalized; + const ext = name.includes(".") ? name.split(".").pop() ?? "" : ""; + if (name === "dockerfile") { + return "default"; + } + switch (ext) { + case "py": + case "pyi": + return "python"; + case "jsx": + case "tsx": + return "react"; + case "ts": + return "typescript"; + case "html": + case "htm": + return "html"; + case "css": + case "scss": + case "sass": + return "css"; + case "json": + case "jsonl": + return "json"; + case "md": + case "mdx": + return "markdown"; + case "ipynb": + return "notebook"; + default: + return "default"; + } +} + +function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) { + if (kind === "react") { + return ( + + + + + + + ); + } + if (kind === "default") { + return ( + + + + + ); + } + const label = fileKindLabel(kind); + return ( + + {label} + + ); +} + +function fileKindLabel(kind: FileReferenceKind): string { + switch (kind) { + case "css": + return "#"; + case "html": + return "H"; + case "json": + return "{}"; + case "markdown": + return "M"; + case "notebook": + return "N"; + case "python": + return "PY"; + case "typescript": + return "TS"; + default: + return ""; + } +} diff --git a/webui/src/components/thread/AgentActivityCluster.tsx b/webui/src/components/thread/AgentActivityCluster.tsx index a29f590a8..792a41562 100644 --- a/webui/src/components/thread/AgentActivityCluster.tsx +++ b/webui/src/components/thread/AgentActivityCluster.tsx @@ -1,10 +1,11 @@ -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; -import { ChevronRight, Layers } from "lucide-react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { AlertCircle, ChevronRight, Layers } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { FileReferenceChip } from "@/components/FileReferenceChip"; import { ReasoningBubble, StreamingLabelSheen, TraceGroup } from "@/components/MessageBubble"; import { cn } from "@/lib/utils"; -import type { UIMessage } from "@/lib/types"; +import type { UIFileEdit, UIMessage } from "@/lib/types"; /** Scrollport height for the Cursor-style “live trace” strip (tailwind spacing). */ const CLUSTER_SCROLL_MAX_CLASS = "max-h-52"; @@ -20,7 +21,29 @@ export function isAgentActivityMember(m: UIMessage): boolean { return isReasoningOnlyAssistant(m) || m.kind === "trace"; } -function countActivity(messages: UIMessage[]): { reasoningSteps: number; toolCalls: number } { +interface ActivityCounts { + reasoningSteps: number; + toolCalls: number; + fileCount: number; + added: number; + deleted: number; + hasEditingFiles: boolean; + hasFailedFiles: boolean; + primaryFilePath?: string; +} + +interface FileEditSummary { + key: string; + path: string; + added: number; + deleted: number; + approximate: boolean; + binary: boolean; + status: UIFileEdit["status"]; + error?: string; +} + +function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): ActivityCounts { let reasoningSteps = 0; let toolCalls = 0; for (const m of messages) { @@ -30,10 +53,38 @@ function countActivity(messages: UIMessage[]): { reasoningSteps: number; toolCal } if (m.kind === "trace") { const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0); - toolCalls += Math.max(lines, 1); + toolCalls += lines; } } - return { reasoningSteps, toolCalls }; + let added = 0; + let deleted = 0; + let hasEditingFiles = false; + let failedFileCount = 0; + let primaryFilePath: string | undefined; + for (const edit of fileEdits) { + primaryFilePath = edit.path; + if (edit.status === "editing") { + hasEditingFiles = true; + } + if (edit.status === "error") { + failedFileCount += 1; + } + if (edit.status === "error" || edit.binary) { + continue; + } + added += edit.added; + deleted += edit.deleted; + } + return { + reasoningSteps, + toolCalls, + fileCount: fileEdits.length, + added, + deleted, + hasEditingFiles, + hasFailedFiles: fileEdits.length > 0 && failedFileCount === fileEdits.length, + primaryFilePath, + }; } interface AgentActivityClusterProps { @@ -53,7 +104,20 @@ export function AgentActivityCluster({ hasBodyBelow, }: AgentActivityClusterProps) { const { t } = useTranslation(); - const { reasoningSteps, toolCalls } = countActivity(messages); + const fileEdits = useMemo( + () => summarizeFileEdits(collectFileEdits(messages), isTurnStreaming), + [messages, isTurnStreaming], + ); + const { + reasoningSteps, + toolCalls, + fileCount, + added, + deleted, + hasEditingFiles, + hasFailedFiles, + primaryFilePath, + } = countActivity(messages, fileEdits); const [userToggledOuter, setUserToggledOuter] = useState(false); const [outerOpenLocal, setOuterOpenLocal] = useState(false); @@ -64,16 +128,32 @@ export function AgentActivityCluster({ /** Collapsed by default during “Working…” and after the turn; user expands to inspect traces. */ const outerExpanded = userToggledOuter ? outerOpenLocal : false; - const headerBusy = isTurnStreaming; + const hasLiveEditingFiles = isTurnStreaming && hasEditingFiles; + const headerBusy = fileCount > 0 ? hasEditingFiles : isTurnStreaming; - const summary = - isTurnStreaming + const fileActivitySummary = fileCount > 0 + ? fileCount === 1 && primaryFilePath + ? t(fileActivitySummaryKey(hasLiveEditingFiles, hasFailedFiles), { + file: shortFileName(primaryFilePath), + defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} {{file}}`, + }) + : t(fileActivityManySummaryKey(hasLiveEditingFiles, hasFailedFiles), { + count: fileCount, + defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} {{count}} files`, + }) + : ""; + + const summary = fileCount > 0 + ? fileActivitySummary + : isTurnStreaming ? reasoningSteps > 0 ? t("message.agentActivityLiveSummary", { reasoning: reasoningSteps, tools: toolCalls, defaultValue: "Working… · {{reasoning}} steps · {{tools}} tool calls", }) + : toolCalls === 0 && fileCount > 0 + ? t("message.agentActivityLiveFilesOnly", { defaultValue: "Working…" }) : t("message.agentActivityLiveToolsOnly", { tools: toolCalls, defaultValue: "Working… · {{tools}} tool calls", @@ -84,6 +164,8 @@ export function AgentActivityCluster({ tools: toolCalls, defaultValue: "{{reasoning}} steps · {{tools}} tool calls", }) + : toolCalls === 0 && fileCount > 0 + ? t("message.agentActivityFilesOnly", { defaultValue: "File changes" }) : t("message.agentActivityToolsOnly", { tools: toolCalls, defaultValue: "{{tools}} tool calls", @@ -161,12 +243,19 @@ export function AgentActivityCluster({ aria-expanded={outerExpanded} > - - {summary} - + + + {summary} + + {fileCount > 0 && ( + + + + )} + ); } if (m.kind === "trace") { - return ; + const hasTraceLines = (m.traces?.length ?? 0) > 0 || m.content.trim().length > 0; + return hasTraceLines ? ( +
+ +
+ ) : null; } return null; })} + {fileEdits.length ? : null} @@ -216,3 +311,231 @@ export function AgentActivityCluster({ ); } + +function shortFileName(path: string): string { + return path.split(/[\\/]/).pop() || path; +} + +function fileActivityVerb(editing: boolean, failed: boolean): string { + if (failed) return "Failed"; + return editing ? "Editing" : "Edited"; +} + +function fileActivitySummaryKey(editing: boolean, failed: boolean): string { + if (failed) return "message.fileActivityFailedOne"; + return editing ? "message.fileActivityEditingOne" : "message.fileActivityEditedOne"; +} + +function fileActivityManySummaryKey(editing: boolean, failed: boolean): string { + if (failed) return "message.fileActivityFailedMany"; + return editing ? "message.fileActivityEditingMany" : "message.fileActivityEditedMany"; +} + +function fileEditCallKey(edit: UIFileEdit): string { + return `${edit.call_id}|${edit.tool}|${edit.path}`; +} + +function collectFileEdits(messages: UIMessage[]): UIFileEdit[] { + const edits: UIFileEdit[] = []; + for (const message of messages) { + if (message.kind === "trace" && message.fileEdits?.length) { + edits.push(...message.fileEdits); + } + } + return edits; +} + +function latestFileEditEvents(edits: UIFileEdit[]): UIFileEdit[] { + const order: string[] = []; + const byKey = new Map(); + for (const edit of edits) { + const key = fileEditCallKey(edit); + if (!byKey.has(key)) order.push(key); + byKey.set(key, edit); + } + return order.map((key) => byKey.get(key)).filter(Boolean) as UIFileEdit[]; +} + +function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSummary[] { + interface MutableSummary { + key: string; + path: string; + added: number; + deleted: number; + approximate: boolean; + binary: boolean; + hasSuccessfulChange: boolean; + hasActiveEditing: boolean; + hasFailed: boolean; + error?: string; + } + + const order: string[] = []; + const byPath = new Map(); + for (const edit of latestFileEditEvents(edits)) { + const key = edit.path; + let summary = byPath.get(key); + if (!summary) { + summary = { + key, + path: edit.path, + added: 0, + deleted: 0, + approximate: false, + binary: false, + hasSuccessfulChange: false, + hasActiveEditing: false, + hasFailed: false, + }; + byPath.set(key, summary); + order.push(key); + } + + if (active && edit.status === "editing") { + summary.hasActiveEditing = true; + summary.binary = summary.binary || !!edit.binary; + summary.approximate = summary.approximate || !!edit.approximate; + if (!edit.binary) { + summary.added += edit.added; + summary.deleted += edit.deleted; + } + continue; + } + + if (edit.status === "error") { + summary.hasFailed = true; + summary.error = edit.error ?? summary.error; + continue; + } + + summary.hasSuccessfulChange = true; + summary.binary = summary.binary || !!edit.binary; + summary.approximate = active && (summary.approximate || !!edit.approximate); + if (!edit.binary) { + summary.added += edit.added; + summary.deleted += edit.deleted; + } + } + + return order.map((key) => { + const summary = byPath.get(key)!; + const status: UIFileEdit["status"] = summary.hasActiveEditing + ? "editing" + : summary.hasSuccessfulChange + ? "done" + : summary.hasFailed + ? "error" + : "done"; + return { + key: summary.key, + path: summary.path, + added: summary.added, + deleted: summary.deleted, + approximate: summary.approximate, + binary: summary.binary, + status, + error: summary.error, + }; + }); +} + +function FileEditGroup({ edits }: { edits: FileEditSummary[] }) { + if (edits.length === 0) return null; + return ( +
    + {edits.map((edit) => ( + + ))} +
+ ); +} + +function FileEditRow({ edit }: { edit: FileEditSummary }) { + const { t } = useTranslation(); + const editing = edit.status === "editing"; + const failed = edit.status === "error"; + const hasCountedDiff = !failed && !edit.binary; + return ( +
  • +
    + + {failed ? ( + + + {t("message.fileEditFailed", { defaultValue: "Failed" })} + + ) : null} + {edit.approximate && !failed ? ( + + {t("message.fileEditApproximate", { defaultValue: "estimated" })} + + ) : null} +
    + {hasCountedDiff ? ( + + ) : null} +
  • + ); +} + +function DiffPair({ added, deleted }: { added: number; deleted: number }) { + return ( + + + + + + + - + + + ); +} + +function AnimatedNumber({ value }: { value: number }) { + const safeValue = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0; + const [display, setDisplay] = useState(0); + const displayRef = useRef(0); + + const setAnimatedDisplay = useCallback((next: number) => { + displayRef.current = next; + setDisplay(next); + }, []); + + useEffect(() => { + const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; + if (reduceMotion) { + setAnimatedDisplay(safeValue); + return; + } + const start = displayRef.current; + const delta = safeValue - start; + if (delta === 0) { + setAnimatedDisplay(safeValue); + return; + } + const duration = 260; + const startedAt = performance.now(); + let frame = 0; + const tick = (now: number) => { + const progress = Math.min(1, (now - startedAt) / duration); + const eased = 1 - Math.pow(1 - progress, 3); + setAnimatedDisplay(Math.round(start + delta * eased)); + if (progress < 1) { + frame = window.requestAnimationFrame(tick); + return; + } + displayRef.current = safeValue; + }; + frame = window.requestAnimationFrame(tick); + return () => window.cancelAnimationFrame(frame); + }, [safeValue, setAnimatedDisplay]); + + return <>{display}; +} diff --git a/webui/src/components/thread/ThreadMessages.tsx b/webui/src/components/thread/ThreadMessages.tsx index 308171210..869d282fe 100644 --- a/webui/src/components/thread/ThreadMessages.tsx +++ b/webui/src/components/thread/ThreadMessages.tsx @@ -42,26 +42,77 @@ export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] { const m = messages[i]; if (isAgentActivityMember(m)) { const cluster: UIMessage[] = []; - while (i < messages.length && isAgentActivityMember(messages[i])) { - cluster.push(messages[i]); + let segmentId: string | undefined = m.activitySegmentId; + let clusterHasFileEdits = hasFileEdits(m); + while ( + i < messages.length + && isAgentActivityMember(messages[i]) + && canJoinActivityCluster(segmentId, clusterHasFileEdits, messages[i]) + ) { + const current = messages[i]; + if (!segmentId && current.activitySegmentId) { + segmentId = current.activitySegmentId; + } + clusterHasFileEdits = clusterHasFileEdits || hasFileEdits(current); + cluster.push(current); i += 1; } out.push({ type: "cluster", messages: cluster }); continue; } const previous = out[out.length - 1]; - if (previous?.type === "cluster" && assistantHasInlineReasoning(m)) { + if ( + previous?.type === "cluster" + && assistantHasInlineReasoning(m) + && canFoldInlineReasoning(previous.messages, m) + ) { previous.messages.push(reasoningOnlyMessageFromAnswer(m)); out.push({ type: "single", message: stripInlineReasoning(m) }); i += 1; continue; } + if (assistantHasInlineReasoning(m)) { + out.push({ type: "cluster", messages: [reasoningOnlyMessageFromAnswer(m)] }); + out.push({ type: "single", message: stripInlineReasoning(m) }); + i += 1; + continue; + } out.push({ type: "single", message: m }); i += 1; } return out; } +function clusterSegmentId(messages: UIMessage[]): string | undefined { + return messages.find((message) => message.activitySegmentId)?.activitySegmentId; +} + +function hasFileEdits(message: UIMessage): boolean { + return !!message.fileEdits?.length; +} + +function clusterHasFileEdits(messages: UIMessage[]): boolean { + return messages.some(hasFileEdits); +} + +function canJoinActivityCluster( + clusterSegmentId: string | undefined, + clusterIncludesFileEdits: boolean, + message: UIMessage, +): boolean { + const messageHasFileEdits = hasFileEdits(message); + if (!clusterIncludesFileEdits && !messageHasFileEdits) return true; + if (!clusterSegmentId || !message.activitySegmentId) return true; + return clusterSegmentId === message.activitySegmentId; +} + +function canFoldInlineReasoning(cluster: UIMessage[], message: UIMessage): boolean { + if (!clusterHasFileEdits(cluster) && !hasFileEdits(message)) return true; + const segmentId = clusterSegmentId(cluster); + if (!segmentId || !message.activitySegmentId) return true; + return segmentId === message.activitySegmentId; +} + function assistantHasInlineReasoning(message: UIMessage): boolean { return ( message.role === "assistant" @@ -80,6 +131,7 @@ function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage { reasoning: message.reasoning, reasoningStreaming: message.reasoningStreaming, isStreaming: message.reasoningStreaming, + activitySegmentId: message.activitySegmentId, }; } @@ -116,6 +168,10 @@ export function ThreadMessages({ const { t } = useTranslation(); const units = useMemo(() => buildDisplayUnits(messages), [messages]); const copyFlags = useMemo(() => assistantCopyFlags(units), [units]); + const liveActivityClusterIndex = useMemo( + () => isStreaming ? currentActivityClusterIndex(units) : -1, + [isStreaming, units], + ); return (
    @@ -150,7 +206,7 @@ export function ThreadMessages({ {unit.type === "cluster" ? ( ) : ( @@ -170,6 +226,11 @@ export function ThreadMessages({ ); } +function currentActivityClusterIndex(units: DisplayUnit[]): number { + const last = units.length - 1; + return units[last]?.type === "cluster" ? last : -1; +} + function unitKey(unit: DisplayUnit, index: number): string { if (unit.type === "cluster") { const anchor = unit.messages[0]?.id; diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts index 9ea03602c..2ee113227 100644 --- a/webui/src/hooks/useNanobotStream.ts +++ b/webui/src/hooks/useNanobotStream.ts @@ -10,6 +10,7 @@ import type { OutboundMedia, GoalStateWsPayload, UIImage, + UIFileEdit, UIMessage, } from "@/lib/types"; @@ -27,12 +28,17 @@ type PendingStreamEvent = | { kind: "delta"; text: string } | { kind: "reasoning"; text: string }; -/** Scan upward from the bottom skipping trace rows so tool breadcrumbs don't steal the stream target. */ -function findStreamingAssistantIndex(prev: UIMessage[]): number | null { +/** Find a still-open streamed assistant turn. Closed stream segments stay visible + * as streaming until ``turn_end`` for visual continuity, but they must not + * receive later delta segments. */ +function findStreamingAssistantIndex( + prev: UIMessage[], + closedStreamIds: ReadonlySet, +): number | null { for (let i = prev.length - 1; i >= 0; i -= 1) { const m = prev[i]; if (m.kind === "trace") continue; - if (m.role === "assistant" && m.isStreaming) return i; + if (m.role === "assistant" && m.isStreaming && !closedStreamIds.has(m.id)) return i; if (m.role === "user") break; } return null; @@ -47,7 +53,13 @@ function findStreamingAssistantIndex(prev: UIMessage[]): number | null { * case the reasoning still belongs to the same assistant turn and must render * above the answer, not as a new row below it. */ -function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] { +function attachReasoningChunk( + prev: UIMessage[], + chunk: string, + segments?: { + ensure: () => string; + }, +): UIMessage[] { for (let i = prev.length - 1; i >= 0; i -= 1) { const candidate = prev[i]; // A user turn is a hard boundary: reasoning after it belongs to the new @@ -58,6 +70,7 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] { // that produced those tool calls. if (candidate.kind === "trace") break; if (candidate.role !== "assistant") continue; + const activitySegmentId = candidate.activitySegmentId ?? segments?.ensure(); const hasAnswer = candidate.content.length > 0; if ( candidate.reasoningStreaming @@ -69,6 +82,7 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] { ...candidate, reasoning: (candidate.reasoning ?? "") + chunk, reasoningStreaming: true, + ...(activitySegmentId ? { activitySegmentId } : {}), }; return [...prev.slice(0, i), merged, ...prev.slice(i + 1)]; } @@ -77,11 +91,13 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] { ...candidate, reasoning: chunk, reasoningStreaming: true, + ...(activitySegmentId ? { activitySegmentId } : {}), }; return [...prev.slice(0, i), merged, ...prev.slice(i + 1)]; } break; } + const activitySegmentId = segments?.ensure(); return [ ...prev, { @@ -91,6 +107,7 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] { isStreaming: true, reasoning: chunk, reasoningStreaming: true, + ...(activitySegmentId ? { activitySegmentId } : {}), createdAt: Date.now(), }, ]; @@ -197,6 +214,47 @@ function absorbCompleteAssistantMessage( ]; } +function fileEditKey(edit: Pick): string { + return `${edit.call_id}|${edit.tool}|${edit.path}`; +} + +function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null { + if (!edit || !edit.path || !edit.tool) return null; + const inferredStatus = + edit.phase === "error" + ? "error" + : edit.phase === "end" + ? "done" + : "editing"; + return { + ...edit, + call_id: edit.call_id || `${edit.tool}:${edit.path}`, + added: Number.isFinite(edit.added) ? Math.max(0, Math.round(edit.added)) : 0, + deleted: Number.isFinite(edit.deleted) ? Math.max(0, Math.round(edit.deleted)) : 0, + status: edit.status === "error" || edit.status === "done" || edit.status === "editing" + ? edit.status + : inferredStatus, + }; +} + +function mergeFileEdits(existing: UIFileEdit[] | undefined, incoming: UIFileEdit[]): UIFileEdit[] { + const next = [...(existing ?? [])]; + const indexByKey = new Map(next.map((edit, index) => [fileEditKey(edit), index])); + for (const raw of incoming) { + const edit = normalizeFileEdit(raw); + if (!edit) continue; + const key = fileEditKey(edit); + const existingIndex = indexByKey.get(key); + if (existingIndex === undefined) { + indexByKey.set(key, next.length); + next.push(edit); + continue; + } + next[existingIndex] = { ...next[existingIndex], ...edit }; + } + return next; +} + /** * 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 @@ -255,6 +313,10 @@ export function useNanobotStream( const [streamError, setStreamError] = useState(null); const buffer = useRef(null); const activeAssistantRef = useRef(null); + const closedAssistantStreamIdsRef = useRef>(new Set()); + const activitySegmentRef = useRef(null); + const fileEditSegmentRef = useRef(null); + const activitySegmentCounterRef = useRef(0); const pendingStreamEventsRef = useRef([]); const streamFrameRef = useRef(null); const suppressStreamUntilTurnEndRef = useRef(false); @@ -281,6 +343,40 @@ export function useNanobotStream( pendingStreamEventsRef.current = []; }, []); + const createActivitySegmentId = useCallback((activate = true) => { + activitySegmentCounterRef.current += 1; + const id = `activity-${activitySegmentCounterRef.current}`; + if (activate) activitySegmentRef.current = id; + return id; + }, []); + + const freshActivitySegmentId = useCallback( + () => createActivitySegmentId(true), + [createActivitySegmentId], + ); + + const detachedActivitySegmentId = useCallback( + () => createActivitySegmentId(false), + [createActivitySegmentId], + ); + + const ensureActivitySegmentId = useCallback(() => { + if (activitySegmentRef.current) return activitySegmentRef.current; + return freshActivitySegmentId(); + }, [freshActivitySegmentId]); + + const clearActivitySegment = useCallback(() => { + activitySegmentRef.current = null; + fileEditSegmentRef.current = null; + }, []); + + const closeActiveAssistantStream = useCallback(() => { + const closedStreamId = buffer.current?.messageId ?? activeAssistantRef.current?.id; + if (closedStreamId) closedAssistantStreamIdsRef.current.add(closedStreamId); + buffer.current = null; + activeAssistantRef.current = null; + }, []); + const resolveActiveAssistantIndex = useCallback((prev: UIMessage[]): number | null => { const cursor = activeAssistantRef.current; if (!cursor) return null; @@ -311,7 +407,7 @@ export function useNanobotStream( targetIndex = findActiveAssistantPlaceholderIndex(next); } if (targetIndex === null) { - targetIndex = findStreamingAssistantIndex(next); + targetIndex = findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current); } if (targetIndex === null) { const id = crypto.randomUUID(); @@ -334,6 +430,7 @@ export function useNanobotStream( content: target.content + chunk, isStreaming: true, }; + closedAssistantStreamIdsRef.current.delete(merged.id); activeAssistantRef.current = { id: merged.id, index: targetIndex }; buffer.current = { messageId: merged.id }; return replaceMessageAt(next, targetIndex, merged); @@ -353,23 +450,32 @@ export function useNanobotStream( } next = kind === "delta" ? appendAnswerChunk(next, text) - : attachReasoningChunk(next, text); + : attachReasoningChunk(next, text, { + ensure: ensureActivitySegmentId, + }); } return next; }, - [appendAnswerChunk], + [appendAnswerChunk, ensureActivitySegmentId], ); - const flushPendingStreamEvents = useCallback(() => { + const flushPendingStreamEvents = useCallback((options?: { closeAnswerSegment?: boolean }) => { if (streamFrameRef.current !== null) { window.cancelAnimationFrame(streamFrameRef.current); streamFrameRef.current = null; } const events = pendingStreamEventsRef.current; - if (events.length === 0) return; + if (events.length === 0) { + if (options?.closeAnswerSegment) closeActiveAssistantStream(); + return; + } pendingStreamEventsRef.current = []; - setMessages((prev) => applyPendingStreamEvents(prev, events)); - }, [applyPendingStreamEvents]); + setMessages((prev) => { + const next = applyPendingStreamEvents(prev, events); + if (options?.closeAnswerSegment) closeActiveAssistantStream(); + return next; + }); + }, [applyPendingStreamEvents, closeActiveAssistantStream]); const schedulePendingStreamFlush = useCallback(() => { if (streamFrameRef.current !== null) return; @@ -397,6 +503,8 @@ export function useNanobotStream( setGoalState(chatId ? client.getGoalState(chatId) : undefined); buffer.current = null; activeAssistantRef.current = null; + closedAssistantStreamIdsRef.current.clear(); + clearActivitySegment(); clearPendingStreamWork(); suppressStreamUntilTurnEndRef.current = false; if (streamEndTimerRef.current !== null) { @@ -404,7 +512,7 @@ export function useNanobotStream( streamEndTimerRef.current = null; } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [chatId, client, clearPendingStreamWork]); + }, [chatId, client, clearActivitySegment, clearPendingStreamWork]); useEffect(() => { if (hasPendingToolCalls) setIsStreaming(true); @@ -442,21 +550,17 @@ export function useNanobotStream( return; } - flushPendingStreamEvents(); - if (ev.event === "stream_end") { - if (suppressStreamUntilTurnEndRef.current) { - buffer.current = null; - return; - } + flushPendingStreamEvents({ closeAnswerSegment: true }); + if (suppressStreamUntilTurnEndRef.current) return; // stream_end only means the text segment finished — the model may // still be executing tools. Do NOT reset isStreaming here; the // definitive "turn is complete" signal is ``turn_end``. - if (!buffer.current) return; - buffer.current = null; return; } + flushPendingStreamEvents(); + if (ev.event === "reasoning_end") { if (suppressStreamUntilTurnEndRef.current) return; setMessages((prev) => closeReasoningStream(prev)); @@ -496,6 +600,8 @@ export function useNanobotStream( } buffer.current = null; activeAssistantRef.current = null; + clearActivitySegment(); + closedAssistantStreamIdsRef.current.clear(); return finalized; }); suppressStreamUntilTurnEndRef.current = false; @@ -516,7 +622,9 @@ export function useNanobotStream( if (ev.kind === "reasoning") { const line = ev.text; if (!line) return; - setMessages((prev) => closeReasoningStream(attachReasoningChunk(prev, line))); + setMessages((prev) => closeReasoningStream(attachReasoningChunk(prev, line, { + ensure: ensureActivitySegmentId, + }))); return; } // Intermediate agent breadcrumbs (tool-call hints, raw progress). @@ -531,12 +639,24 @@ export function useNanobotStream( : []; if (lines.length === 0) return; setMessages((prev) => { + const segmentId = ensureActivitySegmentId(); const last = prev[prev.length - 1]; - if (last && last.kind === "trace" && !last.isStreaming) { + if ( + last + && last.kind === "trace" + && !last.isStreaming + && (!last.activitySegmentId || last.activitySegmentId === segmentId) + ) { + const previousTraces = last.traces?.length + ? last.traces + : last.content + ? [last.content] + : []; const merged: UIMessage = { ...last, - traces: [...(last.traces ?? [last.content]), ...lines], + traces: [...previousTraces, ...lines], content: lines[lines.length - 1], + activitySegmentId: last.activitySegmentId ?? segmentId, }; return [...prev.slice(0, -1), merged]; } @@ -548,6 +668,7 @@ export function useNanobotStream( kind: "trace", content: lines[lines.length - 1], traces: lines, + activitySegmentId: segmentId, createdAt: Date.now(), }, ]; @@ -585,6 +706,46 @@ export function useNanobotStream( } return; } + if (ev.event === "file_edit") { + const edits = Array.isArray(ev.edits) ? ev.edits : []; + if (edits.length === 0) return; + 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 + ) { + const merged: UIMessage = { + ...last, + fileEdits: mergeFileEdits(last.fileEdits, edits), + activitySegmentId: last.activitySegmentId ?? segmentId, + }; + return [...prev.slice(0, -1), merged]; + } + return [ + ...prev, + { + id: crypto.randomUUID(), + role: "tool", + kind: "trace", + content: "", + traces: [], + fileEdits: mergeFileEdits(undefined, edits), + activitySegmentId: segmentId, + createdAt: Date.now(), + }, + ]; + }); + return; + } // ``attached`` / ``error`` frames aren't actionable here; the client // shell handles them separately. }; @@ -594,6 +755,8 @@ export function useNanobotStream( unsub(); buffer.current = null; activeAssistantRef.current = null; + closedAssistantStreamIdsRef.current.clear(); + clearActivitySegment(); clearPendingStreamWork(); if (streamEndTimerRef.current !== null) { clearTimeout(streamEndTimerRef.current); @@ -603,7 +766,10 @@ export function useNanobotStream( }, [ chatId, client, + clearActivitySegment, clearPendingStreamWork, + detachedActivitySegmentId, + ensureActivitySegmentId, flushPendingStreamEvents, onTurnEnd, schedulePendingStreamFlush, @@ -622,6 +788,8 @@ export function useNanobotStream( setMessages((prev) => { buffer.current = null; activeAssistantRef.current = null; + closedAssistantStreamIdsRef.current.clear(); + clearActivitySegment(); return [ ...pruneReasoningOnlyPlaceholders(prev), { @@ -643,7 +811,7 @@ export function useNanobotStream( client.sendMessage(chatId, content, wireMedia); } }, - [chatId, client, flushPendingStreamEvents], + [chatId, clearActivitySegment, client, flushPendingStreamEvents], ); const stop = useCallback(() => { @@ -653,11 +821,13 @@ export function useNanobotStream( setMessages((prev) => { buffer.current = null; activeAssistantRef.current = null; + closedAssistantStreamIdsRef.current.clear(); + clearActivitySegment(); return prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)); }); suppressStreamUntilTurnEndRef.current = false; client.sendMessage(chatId, "/stop"); - }, [chatId, client, flushPendingStreamEvents]); + }, [chatId, clearActivitySegment, client, flushPendingStreamEvents]); return { messages, diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 59ad8566c..8ffb4a70a 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -40,6 +40,10 @@ export interface UIMessage { /** For trace rows: each individual hint line, so consecutive hints can * render as a single collapsible group. */ traces?: string[]; + /** Activity rows: explicit file edits emitted by edit tools. */ + fileEdits?: UIFileEdit[]; + /** Activity rows created during the same agent phase share one collapsible block. */ + activitySegmentId?: string; /** User turn: optimistic blob URLs for preview. Replay: placeholder chips. */ images?: UIImage[]; /** Signed or local UI-renderable media attachments. */ @@ -80,6 +84,20 @@ export interface ToolProgressEvent { embeds?: unknown[]; } +export interface UIFileEdit { + version?: number; + call_id: string; + tool: string; + path: string; + phase?: "start" | "end" | "error" | string; + added: number; + deleted: number; + approximate?: boolean; + status: "editing" | "done" | "error"; + binary?: boolean; + error?: string; +} + export interface ChatSummary { /** Server-side session key, e.g. ``websocket:abcd-...``. */ key: string; @@ -183,6 +201,11 @@ export type InboundEvent = /** Optional structured payload on progress frames (channel-specific). */ agent_ui?: AgentUIBlob; } + | { + event: "file_edit"; + chat_id: string; + edits: UIFileEdit[]; + } | { event: "delta"; chat_id: string; @@ -230,7 +253,7 @@ export type InboundEvent = chat_id: string; goal_state: GoalStateWsPayload; } - | { event: "session_updated"; chat_id: string } + | { event: "session_updated"; chat_id: string; scope?: "metadata" | "thread" | string } | { event: "error"; chat_id?: string; detail?: string }; /** Base64-encoded image attached to an outbound ``message`` envelope. diff --git a/webui/src/tests/agent-activity-cluster.test.tsx b/webui/src/tests/agent-activity-cluster.test.tsx index e6bffd382..120268500 100644 --- a/webui/src/tests/agent-activity-cluster.test.tsx +++ b/webui/src/tests/agent-activity-cluster.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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(); + } + }); }); diff --git a/webui/src/tests/thread-messages.test.tsx b/webui/src/tests/thread-messages.test.tsx index 4e7711fa5..7b3f2150c 100644 --- a/webui/src/tests/thread-messages.test.tsx +++ b/webui/src/tests/thread-messages.test.tsx @@ -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(); + + 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[] = [ { diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index 0f736a016..925102dad 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -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), {