feat(webui): render live file edit activity

This commit is contained in:
Xubin Ren 2026-05-18 19:10:50 +08:00
parent 7e2dbdef7d
commit 0537cc1682
8 changed files with 425 additions and 60 deletions

View File

@ -19,6 +19,7 @@ type FileReferenceKind =
interface FileReferenceChipProps { interface FileReferenceChipProps {
path: string; path: string;
tooltipPath?: string;
display?: "name" | "path"; display?: "name" | "path";
active?: boolean; active?: boolean;
className?: string; className?: string;
@ -28,27 +29,29 @@ interface FileReferenceChipProps {
export function FileReferenceChip({ export function FileReferenceChip({
path, path,
tooltipPath,
display = "name", display = "name",
active = false, active = false,
className, className,
textClassName, textClassName,
testId = "inline-file-path", testId = "inline-file-path",
}: FileReferenceChipProps) { }: FileReferenceChipProps) {
const { name } = splitFilePath(path); const { directory, name } = splitFilePath(path);
const kind = fileKindForPath(path); const kind = fileKindForPath(path);
const displayText = display === "path" ? path.replace(/\\/g, "/") : name; const displayText = display === "path" ? path.replace(/\\/g, "/") : name;
const fullPath = tooltipPath || path;
return ( return (
<TooltipProvider delayDuration={500} skipDelayDuration={100}> <TooltipProvider delayDuration={500} skipDelayDuration={100}>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span <span
className={cn("not-prose inline-flex max-w-full align-[0.14em]", className)} className={cn("not-prose inline-flex max-w-full align-baseline leading-[inherit]", className)}
> >
<span <span
data-testid={testId} data-testid={testId}
aria-label={path} aria-label={fullPath}
className={cn( className={cn(
"inline-flex max-w-full items-center gap-1 font-medium leading-[1.1]", "inline-flex max-w-full items-center gap-1 font-medium leading-[inherit]",
"text-sky-600 transition-colors hover:text-sky-700", "text-sky-600 transition-colors hover:text-sky-700",
"dark:text-sky-300 dark:hover:text-sky-200", "dark:text-sky-300 dark:hover:text-sky-200",
)} )}
@ -57,12 +60,19 @@ export function FileReferenceChip({
<span <span
data-sheen-text={active ? displayText : undefined} data-sheen-text={active ? displayText : undefined}
className={cn( className={cn(
"min-w-0 truncate", "min-w-0 max-w-full truncate",
active && "streaming-text-sheen", active && "streaming-text-sheen file-reference-sheen",
textClassName, textClassName,
)} )}
> >
{displayText} {display === "path" && directory ? (
<>
<span className="text-muted-foreground/65">{directory}</span>
<span className="font-semibold text-sky-700 dark:text-sky-200">{name}</span>
</>
) : (
displayText
)}
</span> </span>
</span> </span>
</span> </span>
@ -79,7 +89,7 @@ export function FileReferenceChip({
"shadow-lg backdrop-blur", "shadow-lg backdrop-blur",
)} )}
> >
{path} {fullPath}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>

View File

@ -30,16 +30,19 @@ interface ActivityCounts {
hasEditingFiles: boolean; hasEditingFiles: boolean;
hasFailedFiles: boolean; hasFailedFiles: boolean;
primaryFilePath?: string; primaryFilePath?: string;
primaryFileTooltipPath?: string;
} }
interface FileEditSummary { interface FileEditSummary {
key: string; key: string;
path: string; path: string;
absolute_path?: string;
added: number; added: number;
deleted: number; deleted: number;
approximate: boolean; approximate: boolean;
binary: boolean; binary: boolean;
status: UIFileEdit["status"]; status: UIFileEdit["status"];
pending: boolean;
error?: string; error?: string;
} }
@ -61,8 +64,10 @@ function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): Act
let hasEditingFiles = false; let hasEditingFiles = false;
let failedFileCount = 0; let failedFileCount = 0;
let primaryFilePath: string | undefined; let primaryFilePath: string | undefined;
let primaryFileTooltipPath: string | undefined;
for (const edit of fileEdits) { for (const edit of fileEdits) {
primaryFilePath = edit.path; primaryFilePath = edit.path;
primaryFileTooltipPath = edit.absolute_path || edit.path;
if (edit.status === "editing") { if (edit.status === "editing") {
hasEditingFiles = true; hasEditingFiles = true;
} }
@ -84,6 +89,7 @@ function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): Act
hasEditingFiles, hasEditingFiles,
hasFailedFiles: fileEdits.length > 0 && failedFileCount === fileEdits.length, hasFailedFiles: fileEdits.length > 0 && failedFileCount === fileEdits.length,
primaryFilePath, primaryFilePath,
primaryFileTooltipPath,
}; };
} }
@ -117,7 +123,9 @@ export function AgentActivityCluster({
hasEditingFiles, hasEditingFiles,
hasFailedFiles, hasFailedFiles,
primaryFilePath, primaryFilePath,
primaryFileTooltipPath,
} = countActivity(messages, fileEdits); } = countActivity(messages, fileEdits);
const hasPendingFileEdit = fileEdits.some((edit) => edit.pending);
const [userToggledOuter, setUserToggledOuter] = useState(false); const [userToggledOuter, setUserToggledOuter] = useState(false);
const [outerOpenLocal, setOuterOpenLocal] = useState(false); const [outerOpenLocal, setOuterOpenLocal] = useState(false);
@ -130,11 +138,15 @@ export function AgentActivityCluster({
const hasLiveEditingFiles = isTurnStreaming && hasEditingFiles; const hasLiveEditingFiles = isTurnStreaming && hasEditingFiles;
const headerBusy = fileCount > 0 ? hasEditingFiles : isTurnStreaming; const headerBusy = fileCount > 0 ? hasEditingFiles : isTurnStreaming;
const singleFilePath = fileCount === 1 ? primaryFilePath : undefined;
const singleFileTooltipPath = fileCount === 1 ? primaryFileTooltipPath : undefined;
const fileActivitySummary = fileCount > 0 const fileActivitySummary = fileCount > 0
? fileCount === 1 && primaryFilePath ? hasPendingFileEdit && !singleFilePath
? t("message.fileActivityPreparing", { defaultValue: "Preparing edit…" })
: singleFilePath
? t(fileActivitySummaryKey(hasLiveEditingFiles, hasFailedFiles), { ? t(fileActivitySummaryKey(hasLiveEditingFiles, hasFailedFiles), {
file: shortFileName(primaryFilePath), file: shortFileName(singleFilePath),
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} {{file}}`, defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} {{file}}`,
}) })
: t(fileActivityManySummaryKey(hasLiveEditingFiles, hasFailedFiles), { : t(fileActivityManySummaryKey(hasLiveEditingFiles, hasFailedFiles), {
@ -241,15 +253,35 @@ export function AgentActivityCluster({
"text-xs text-muted-foreground transition-colors hover:bg-muted/45", "text-xs text-muted-foreground transition-colors hover:bg-muted/45",
)} )}
aria-expanded={outerExpanded} aria-expanded={outerExpanded}
aria-label={summary}
> >
<Layers className="h-3.5 w-3.5 shrink-0" aria-hidden /> <Layers className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-left"> <span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-left">
<StreamingLabelSheen {singleFilePath ? (
active={headerBusy} <span className="inline-flex min-w-0 items-center gap-1.5">
className="min-w-0" <StreamingLabelSheen
> active={headerBusy}
{summary} className="shrink-0"
</StreamingLabelSheen> >
{fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)}
</StreamingLabelSheen>
<FileReferenceChip
path={singleFilePath}
tooltipPath={singleFileTooltipPath}
active={hasLiveEditingFiles}
className="-my-0.5 min-w-0"
textClassName="text-xs"
testId="activity-header-file-reference"
/>
</span>
) : (
<StreamingLabelSheen
active={headerBusy}
className="min-w-0"
>
{summary}
</StreamingLabelSheen>
)}
{fileCount > 0 && ( {fileCount > 0 && (
<span className="inline-flex min-w-0 items-center gap-1 text-muted-foreground/85"> <span className="inline-flex min-w-0 items-center gap-1 text-muted-foreground/85">
<DiffPair added={added} deleted={deleted} /> <DiffPair added={added} deleted={deleted} />
@ -332,7 +364,8 @@ function fileActivityManySummaryKey(editing: boolean, failed: boolean): string {
} }
function fileEditCallKey(edit: UIFileEdit): 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[] { function collectFileEdits(messages: UIMessage[]): UIFileEdit[] {
@ -360,10 +393,12 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
interface MutableSummary { interface MutableSummary {
key: string; key: string;
path: string; path: string;
absolute_path?: string;
added: number; added: number;
deleted: number; deleted: number;
approximate: boolean; approximate: boolean;
binary: boolean; binary: boolean;
pending: boolean;
hasSuccessfulChange: boolean; hasSuccessfulChange: boolean;
hasActiveEditing: boolean; hasActiveEditing: boolean;
hasFailed: boolean; hasFailed: boolean;
@ -373,16 +408,18 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
const order: string[] = []; const order: string[] = [];
const byPath = new Map<string, MutableSummary>(); const byPath = new Map<string, MutableSummary>();
for (const edit of latestFileEditEvents(edits)) { for (const edit of latestFileEditEvents(edits)) {
const key = edit.path; const key = edit.path || edit.call_id || edit.tool;
let summary = byPath.get(key); let summary = byPath.get(key);
if (!summary) { if (!summary) {
summary = { summary = {
key, key,
path: edit.path, path: edit.path || "",
absolute_path: edit.absolute_path,
added: 0, added: 0,
deleted: 0, deleted: 0,
approximate: false, approximate: false,
binary: false, binary: false,
pending: false,
hasSuccessfulChange: false, hasSuccessfulChange: false,
hasActiveEditing: false, hasActiveEditing: false,
hasFailed: false, hasFailed: false,
@ -391,6 +428,13 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
order.push(key); 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") { if (active && edit.status === "editing") {
summary.hasActiveEditing = true; summary.hasActiveEditing = true;
summary.binary = summary.binary || !!edit.binary; summary.binary = summary.binary || !!edit.binary;
@ -429,11 +473,13 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
return { return {
key: summary.key, key: summary.key,
path: summary.path, path: summary.path,
absolute_path: summary.absolute_path,
added: summary.added, added: summary.added,
deleted: summary.deleted, deleted: summary.deleted,
approximate: summary.approximate, approximate: summary.approximate,
binary: summary.binary, binary: summary.binary,
status, status,
pending: summary.pending && !summary.path,
error: summary.error, error: summary.error,
}; };
}); });
@ -458,14 +504,24 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
return ( return (
<li className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-md px-2 py-1.5 text-xs"> <li className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-md px-2 py-1.5 text-xs">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<FileReferenceChip {edit.pending && !edit.path ? (
path={edit.path} <StreamingLabelSheen
display="path" active={editing}
active={editing} className="min-w-0 text-[12px] font-medium text-muted-foreground"
className="min-w-0" >
textClassName="text-[12px]" {t("message.fileEditPreparing", { defaultValue: "Preparing file edit…" })}
testId="activity-file-reference" </StreamingLabelSheen>
/> ) : (
<FileReferenceChip
path={edit.path}
tooltipPath={edit.absolute_path}
display="path"
active={editing}
className="min-w-0"
textClassName="text-[12px]"
testId="activity-file-reference"
/>
)}
{failed ? ( {failed ? (
<span className="inline-flex shrink-0 items-center gap-1 text-[10.5px] font-medium text-destructive/75"> <span className="inline-flex shrink-0 items-center gap-1 text-[10.5px] font-medium text-destructive/75">
<AlertCircle className="h-3 w-3" aria-hidden /> <AlertCircle className="h-3 w-3" aria-hidden />
@ -487,13 +543,30 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
function DiffPair({ added, deleted }: { added: number; deleted: number }) { function DiffPair({ added, deleted }: { added: number; deleted: number }) {
return ( return (
<span className="inline-flex shrink-0 items-center gap-1.5 tabular-nums"> <span className="inline-flex shrink-0 translate-y-[0.055em] items-center gap-1.5 tabular-nums">
<span className="text-emerald-600/75 dark:text-emerald-300/75"> <DiffValue
+<AnimatedNumber value={added} /> sign="+"
</span> value={added}
<span className="text-rose-600/70 dark:text-rose-300/75"> className="text-emerald-600/75 dark:text-emerald-300/75"
-<AnimatedNumber value={deleted} /> />
<DiffValue
sign="-"
value={deleted}
className="text-rose-600/70 dark:text-rose-300/75"
/>
</span>
);
}
function DiffValue({ sign, value, className }: { sign: string; value: number; className: string }) {
const safeValue = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
return (
<span className={cn("inline-flex", className)} aria-label={`${sign}${safeValue}`}>
<span className="inline-flex" aria-hidden>
{sign}
<AnimatedNumber value={safeValue} />
</span> </span>
<span className="sr-only">{sign}{safeValue}</span>
</span> </span>
); );
} }
@ -537,5 +610,37 @@ function AnimatedNumber({ value }: { value: number }) {
return () => window.cancelAnimationFrame(frame); return () => window.cancelAnimationFrame(frame);
}, [safeValue, setAnimatedDisplay]); }, [safeValue, setAnimatedDisplay]);
return <>{display}</>; return <RollingNumber value={display} />;
}
function RollingNumber({ value }: { value: number }) {
const digits = String(value).split("");
return (
<span className="inline-flex h-[1em] overflow-hidden align-[-0.13em]" aria-hidden>
{digits.map((digit, index) => (
<RollingDigit
key={`${digits.length}-${index}`}
digit={Number(digit)}
/>
))}
</span>
);
}
function RollingDigit({ digit }: { digit: number }) {
const safeDigit = Number.isFinite(digit) ? Math.min(9, Math.max(0, digit)) : 0;
return (
<span className="relative inline-block h-[1em] w-[0.62em] overflow-hidden">
<span
className="flex flex-col transition-transform duration-200 ease-out will-change-transform"
style={{ transform: `translateY(-${safeDigit}em)` }}
>
{Array.from({ length: 10 }, (_, n) => (
<span key={n} className="block h-[1em] leading-none">
{n}
</span>
))}
</span>
</span>
);
} }

View File

@ -131,6 +131,9 @@
position: relative; position: relative;
color: hsl(var(--muted-foreground)); color: hsl(var(--muted-foreground));
} }
.file-reference-sheen {
color: inherit;
}
.streaming-text-sheen::after { .streaming-text-sheen::after {
content: attr(data-sheen-text); content: attr(data-sheen-text);
position: absolute; position: absolute;

View File

@ -215,18 +215,19 @@ function absorbCompleteAssistantMessage(
} }
function fileEditKey(edit: Pick<UIFileEdit, "call_id" | "tool" | "path">): string { function fileEditKey(edit: Pick<UIFileEdit, "call_id" | "tool" | "path">): 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 { 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 = const inferredStatus =
edit.phase === "error" edit.phase === "error"
? "error" ? "error"
: edit.phase === "end" : edit.phase === "end"
? "done" ? "done"
: "editing"; : "editing";
return { const normalized: UIFileEdit = {
...edit, ...edit,
call_id: edit.call_id || `${edit.tool}:${edit.path}`, call_id: edit.call_id || `${edit.tool}:${edit.path}`,
added: Number.isFinite(edit.added) ? Math.max(0, Math.round(edit.added)) : 0, 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 ? edit.status
: inferredStatus, : inferredStatus,
}; };
if (edit.pending && !edit.path) normalized.pending = true;
return normalized;
} }
function mergeFileEdits(existing: UIFileEdit[] | undefined, incoming: UIFileEdit[]): UIFileEdit[] { function mergeFileEdits(existing: UIFileEdit[] | undefined, incoming: UIFileEdit[]): UIFileEdit[] {
@ -250,11 +253,31 @@ function mergeFileEdits(existing: UIFileEdit[] | undefined, incoming: UIFileEdit
next.push(edit); next.push(edit);
continue; continue;
} }
next[existingIndex] = { ...next[existingIndex], ...edit }; const merged = { ...next[existingIndex], ...edit };
if (edit.path && !edit.pending) delete merged.pending;
next[existingIndex] = merged;
} }
return next; 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, * 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 * a streaming flag, and a ``send`` function. Initial history must be seeded
@ -534,6 +557,7 @@ export function useNanobotStream(
if (suppressStreamUntilTurnEndRef.current) return; if (suppressStreamUntilTurnEndRef.current) return;
const chunk = typeof ev.text === "string" ? ev.text : ""; const chunk = typeof ev.text === "string" ? ev.text : "";
if (!chunk) return; if (!chunk) return;
clearActivitySegment();
setIsStreaming(true); setIsStreaming(true);
pendingStreamEventsRef.current.push({ kind: "delta", text: chunk }); pendingStreamEventsRef.current.push({ kind: "delta", text: chunk });
schedulePendingStreamFlush(); schedulePendingStreamFlush();
@ -544,6 +568,7 @@ export function useNanobotStream(
if (suppressStreamUntilTurnEndRef.current) return; if (suppressStreamUntilTurnEndRef.current) return;
const chunk = ev.text; const chunk = ev.text;
if (!chunk) return; if (!chunk) return;
if (fileEditSegmentRef.current) clearActivitySegment();
setIsStreaming(true); setIsStreaming(true);
pendingStreamEventsRef.current.push({ kind: "reasoning", text: chunk }); pendingStreamEventsRef.current.push({ kind: "reasoning", text: chunk });
schedulePendingStreamFlush(); schedulePendingStreamFlush();
@ -622,6 +647,7 @@ export function useNanobotStream(
if (ev.kind === "reasoning") { if (ev.kind === "reasoning") {
const line = ev.text; const line = ev.text;
if (!line) return; if (!line) return;
if (fileEditSegmentRef.current) clearActivitySegment();
setMessages((prev) => closeReasoningStream(attachReasoningChunk(prev, line, { setMessages((prev) => closeReasoningStream(attachReasoningChunk(prev, line, {
ensure: ensureActivitySegmentId, ensure: ensureActivitySegmentId,
}))); })));
@ -685,6 +711,7 @@ export function useNanobotStream(
// flight, drop the placeholder so we don't render the text twice. // flight, drop the placeholder so we don't render the text twice.
// Do NOT reset isStreaming here — only ``turn_end`` signals that // Do NOT reset isStreaming here — only ``turn_end`` signals that
// the full turn (all tool calls + final text) is complete. // the full turn (all tool calls + final text) is complete.
clearActivitySegment();
setMessages((prev) => { setMessages((prev) => {
const activeId = buffer.current?.messageId; const activeId = buffer.current?.messageId;
buffer.current = null; buffer.current = null;
@ -709,27 +736,32 @@ export function useNanobotStream(
if (ev.event === "file_edit") { if (ev.event === "file_edit") {
const edits = Array.isArray(ev.edits) ? ev.edits : []; const edits = Array.isArray(ev.edits) ? ev.edits : [];
if (edits.length === 0) return; 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) => { setMessages((prev) => {
const last = prev[prev.length - 1]; let segmentId = eventSegmentId;
let segmentId = fileEditSegmentRef.current; const targetIndex = findFileEditTraceIndex(prev, segmentId, normalized);
if (!segmentId || !(last?.kind === "trace" && last.fileEdits?.length)) { if (targetIndex !== null) {
segmentId = detachedActivitySegmentId(); const target = prev[targetIndex];
fileEditSegmentRef.current = segmentId; segmentId = target.activitySegmentId ?? segmentId ?? detachedActivitySegmentId();
} if (opensFileEditPhase) fileEditSegmentRef.current = segmentId;
if (
last
&& last.kind === "trace"
&& !last.isStreaming
&& !!last.fileEdits?.length
&& last.activitySegmentId === segmentId
) {
const merged: UIMessage = { const merged: UIMessage = {
...last, ...target,
fileEdits: mergeFileEdits(last.fileEdits, edits), fileEdits: mergeFileEdits(target.fileEdits, normalized),
activitySegmentId: last.activitySegmentId ?? segmentId, activitySegmentId: segmentId,
}; };
return [...prev.slice(0, -1), merged]; return replaceMessageAt(prev, targetIndex, merged);
} }
segmentId = segmentId ?? detachedActivitySegmentId();
if (opensFileEditPhase) fileEditSegmentRef.current = segmentId;
return [ return [
...prev, ...prev,
{ {
@ -738,7 +770,7 @@ export function useNanobotStream(
kind: "trace", kind: "trace",
content: "", content: "",
traces: [], traces: [],
fileEdits: mergeFileEdits(undefined, edits), fileEdits: normalized,
activitySegmentId: segmentId, activitySegmentId: segmentId,
createdAt: Date.now(), createdAt: Date.now(),
}, },

View File

@ -89,6 +89,7 @@ export interface UIFileEdit {
call_id: string; call_id: string;
tool: string; tool: string;
path: string; path: string;
absolute_path?: string;
phase?: "start" | "end" | "error" | string; phase?: "start" | "end" | "error" | string;
added: number; added: number;
deleted: number; deleted: number;
@ -96,6 +97,7 @@ export interface UIFileEdit {
status: "editing" | "done" | "error"; status: "editing" | "done" | "error";
binary?: boolean; binary?: boolean;
error?: string; error?: string;
pending?: boolean;
} }
export interface ChatSummary { export interface ChatSummary {

View File

@ -236,6 +236,7 @@ describe("AgentActivityCluster", () => {
call_id: "call-edit", call_id: "call-edit",
tool: "edit_file", tool: "edit_file",
path: "src/app.tsx", path: "src/app.tsx",
absolute_path: "/Users/renxubin/project/src/app.tsx",
phase: "end", phase: "end",
added: 12, added: 12,
deleted: 3, deleted: 3,
@ -250,13 +251,17 @@ describe("AgentActivityCluster", () => {
); );
expect(screen.getByRole("button", { name: /edited app\.tsx/i })).toBeInTheDocument(); 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 })); fireEvent.click(screen.getByRole("button", { name: /edited app\.tsx/i }));
expect(screen.queryByText("Edited files")).not.toBeInTheDocument(); expect(screen.queryByText("Edited files")).not.toBeInTheDocument();
expect(screen.queryByText("Edited")).not.toBeInTheDocument();
const fileRef = screen.getByTestId("activity-file-reference"); const fileRef = screen.getByTestId("activity-file-reference");
expect(fileRef).toHaveTextContent("src/app.tsx"); 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(() => { await waitFor(() => {
expect(screen.getAllByText("+12").length).toBeGreaterThan(0); expect(screen.getAllByText("+12").length).toBeGreaterThan(0);
expect(screen.getAllByText("-3").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(
<AgentActivityCluster
messages={activityMessages("", {
id: "t2",
role: "tool",
kind: "trace",
content: "",
traces: [],
fileEdits: [{
call_id: "call-edit",
tool: "edit_file",
path: "",
phase: "start",
added: 0,
deleted: 0,
approximate: true,
status: "editing",
pending: true,
}],
createdAt: 3,
})}
isTurnStreaming
hasBodyBelow={false}
/>,
);
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 () => { it("merges repeated edits for the same path and lets successful edits win over failures", async () => {
const restoreMotion = installReducedMotion(); const restoreMotion = installReducedMotion();
try { try {

View File

@ -195,7 +195,8 @@ describe("MessageBubble", () => {
const references = await screen.findAllByTestId("inline-file-path"); const references = await screen.findAllByTestId("inline-file-path");
expect(references).toHaveLength(2); expect(references).toHaveLength(2);
expect(references[0].parentElement).not.toHaveClass("translate-y-[0.08em]"); 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]).toHaveTextContent("MarkdownTextRenderer.tsx");
expect(references[0]).not.toHaveTextContent("webui/src/components"); expect(references[0]).not.toHaveTextContent("webui/src/components");
expect(screen.getByText("index.html")).toBeInTheDocument(); expect(screen.getByText("index.html")).toBeInTheDocument();

View File

@ -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 () => { it("starts a new assistant bubble for deltas after stream_end and activity", async () => {
const fake = fakeClient(); const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-stream-segments", EMPTY_MESSAGES), { 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[1].activitySegmentId).toBe(firstSegment);
expect(result.current.messages[2].activitySegmentId).toBeTruthy(); expect(result.current.messages[2].activitySegmentId).toBeTruthy();
expect(result.current.messages[2].activitySegmentId).not.toBe(firstSegment); 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 () => { it("accumulates reasoning_delta chunks on a placeholder until reasoning_end", async () => {