feat(webui): render file edit activity

This commit is contained in:
Xubin Ren
2026-05-17 23:52:14 +08:00
parent c8bb04a8fe
commit 945f208d38
8 changed files with 1292 additions and 49 deletions
@@ -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}
>
<Layers className="h-3.5 w-3.5 shrink-0" aria-hidden />
<StreamingLabelSheen
active={headerBusy}
className="min-w-0 flex-1 text-left"
>
{summary}
</StreamingLabelSheen>
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-left">
<StreamingLabelSheen
active={headerBusy}
className="min-w-0"
>
{summary}
</StreamingLabelSheen>
{fileCount > 0 && (
<span className="inline-flex min-w-0 items-center gap-1 text-muted-foreground/85">
<DiffPair added={added} deleted={deleted} />
</span>
)}
</span>
<ChevronRight
aria-hidden
className={cn(
@@ -198,17 +287,23 @@ export function AgentActivityCluster({
<ReasoningBubble
key={m.id}
text={m.reasoning ?? ""}
streaming={!!m.reasoningStreaming}
streaming={isTurnStreaming && !!m.reasoningStreaming}
hasBodyBelow={false}
embeddedInCluster
/>
);
}
if (m.kind === "trace") {
return <TraceGroup key={m.id} message={m} animClass="" />;
const hasTraceLines = (m.traces?.length ?? 0) > 0 || m.content.trim().length > 0;
return hasTraceLines ? (
<div key={m.id} className="flex flex-col gap-1">
<TraceGroup message={m} animClass="" />
</div>
) : null;
}
return null;
})}
{fileEdits.length ? <FileEditGroup edits={fileEdits} /> : null}
</div>
</div>
</div>
@@ -216,3 +311,231 @@ export function AgentActivityCluster({
</div>
);
}
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<string, UIFileEdit>();
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<string, MutableSummary>();
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 (
<ul className="space-y-1 border-l border-muted-foreground/15 pl-3">
{edits.map((edit) => (
<FileEditRow key={edit.key} edit={edit} />
))}
</ul>
);
}
function FileEditRow({ edit }: { edit: FileEditSummary }) {
const { t } = useTranslation();
const editing = edit.status === "editing";
const failed = edit.status === "error";
const hasCountedDiff = !failed && !edit.binary;
return (
<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">
<FileReferenceChip
path={edit.path}
display="path"
active={editing}
className="min-w-0"
textClassName="text-[12px]"
testId="activity-file-reference"
/>
{failed ? (
<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 />
{t("message.fileEditFailed", { defaultValue: "Failed" })}
</span>
) : null}
{edit.approximate && !failed ? (
<span className="shrink-0 text-[10.5px] font-medium text-muted-foreground/55">
{t("message.fileEditApproximate", { defaultValue: "estimated" })}
</span>
) : null}
</div>
{hasCountedDiff ? (
<DiffPair added={edit.added} deleted={edit.deleted} />
) : null}
</li>
);
}
function DiffPair({ added, deleted }: { added: number; deleted: number }) {
return (
<span className="inline-flex shrink-0 items-center gap-1.5 tabular-nums">
<span className="text-emerald-600/75 dark:text-emerald-300/75">
+<AnimatedNumber value={added} />
</span>
<span className="text-rose-600/70 dark:text-rose-300/75">
-<AnimatedNumber value={deleted} />
</span>
</span>
);
}
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}</>;
}