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), {