feat: add file edit diff progress view
Capture file edit snapshots through runner tool lifecycle hooks and render unified diffs in the WebUI with folding and truncation controls.
This commit is contained in:
@@ -29,6 +29,9 @@ import {
|
||||
isReasoningOnlyAssistant,
|
||||
type ActivityEvidence,
|
||||
} from "@/lib/activity-timeline";
|
||||
import { useFileEditDisplayMode } from "@/hooks/useFileEditDisplayMode";
|
||||
import { hasRenderableFileDiff } from "@/lib/file-diff";
|
||||
import type { FileEditDisplayMode } from "@/lib/local-preferences";
|
||||
import { faviconUrls, logoFallbackUrls } from "@/lib/provider-brand";
|
||||
import { formatToolCallTrace } from "@/lib/tool-traces";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -190,6 +193,7 @@ export function AgentActivityCluster({
|
||||
onOpenFilePreview,
|
||||
}: AgentActivityClusterProps) {
|
||||
const { t } = useTranslation();
|
||||
const fileEditDisplayMode = useFileEditDisplayMode();
|
||||
const fileEdits = useMemo(
|
||||
() => summarizeFileEdits(collectFileEdits(messages), isTurnStreaming),
|
||||
[messages, isTurnStreaming],
|
||||
@@ -282,7 +286,7 @@ export function AgentActivityCluster({
|
||||
})
|
||||
: t(fileActivityManySummaryKey(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles), {
|
||||
count: fileCount,
|
||||
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles)} {{count}} files`,
|
||||
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles)} {{count}} changes`,
|
||||
})
|
||||
: "";
|
||||
|
||||
@@ -438,6 +442,7 @@ export function AgentActivityCluster({
|
||||
added={added}
|
||||
deleted={deleted}
|
||||
hasDiffStats={hasDiffStats}
|
||||
fileEditDisplayMode={fileEditDisplayMode}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
);
|
||||
@@ -532,6 +537,7 @@ export function AgentActivityCluster({
|
||||
{fileEdits.length ? (
|
||||
<FileEditGroup
|
||||
edits={fileEdits}
|
||||
displayMode={fileEditDisplayMode}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
) : null}
|
||||
@@ -561,6 +567,7 @@ function FileEditFlatActivity({
|
||||
added,
|
||||
deleted,
|
||||
hasDiffStats,
|
||||
fileEditDisplayMode,
|
||||
onOpenFilePreview,
|
||||
}: {
|
||||
edits: FileEditSummary[];
|
||||
@@ -575,9 +582,23 @@ function FileEditFlatActivity({
|
||||
added: number;
|
||||
deleted: number;
|
||||
hasDiffStats: boolean;
|
||||
fileEditDisplayMode: FileEditDisplayMode;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}) {
|
||||
const showRows = edits.length > 1 || edits.some((edit) => edit.status === "error" || edit.pending);
|
||||
const diffOnlyRows = edits.length === 1
|
||||
&& !!singleFilePath
|
||||
&& fileEditDisplayMode !== "summary"
|
||||
&& edits.some((edit) => (
|
||||
edit.status !== "editing"
|
||||
&& edit.status !== "error"
|
||||
&& hasRenderableFileDiff(edit.diff)
|
||||
));
|
||||
const showRows = edits.length > 1
|
||||
|| edits.some((edit) => edit.status === "error" || edit.pending)
|
||||
|| (
|
||||
fileEditDisplayMode !== "summary"
|
||||
&& edits.some((edit) => hasRenderableFileDiff(edit.diff))
|
||||
);
|
||||
return (
|
||||
<div className={cn("w-full", hasBodyBelow && "mb-2")} aria-label={summary}>
|
||||
<div
|
||||
@@ -611,7 +632,12 @@ function FileEditFlatActivity({
|
||||
</div>
|
||||
{showRows ? (
|
||||
<div className="mt-0.5 pl-4">
|
||||
<FileEditGroup edits={edits} onOpenFilePreview={onOpenFilePreview} />
|
||||
<FileEditGroup
|
||||
edits={edits}
|
||||
displayMode={fileEditDisplayMode}
|
||||
density={diffOnlyRows ? "diff-only" : "default"}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1579,122 +1605,32 @@ function latestFileEditEvents(edits: UIFileEdit[]): UIFileEdit[] {
|
||||
}
|
||||
|
||||
function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSummary[] {
|
||||
interface MutableSummary {
|
||||
key: string;
|
||||
path: string;
|
||||
absolute_path?: string;
|
||||
added: number;
|
||||
deleted: number;
|
||||
approximate: boolean;
|
||||
binary: boolean;
|
||||
pending: boolean;
|
||||
hasSuccessfulChange: boolean;
|
||||
hasActiveEditing: boolean;
|
||||
hasFailed: boolean;
|
||||
operation?: UIFileEdit["operation"];
|
||||
error?: string;
|
||||
}
|
||||
return latestFileEditEvents(edits).flatMap((edit) => {
|
||||
const editing = active && edit.status === "editing";
|
||||
const failed = edit.status === "error";
|
||||
if (!edit.path && edit.pending && !editing) return [];
|
||||
if (!edit.path && !editing && !failed) return [];
|
||||
|
||||
const order: string[] = [];
|
||||
const byPath = new Map<string, MutableSummary>();
|
||||
for (const edit of latestFileEditEvents(edits)) {
|
||||
const key = edit.path || edit.call_id || edit.tool;
|
||||
let summary = byPath.get(key);
|
||||
if (!summary) {
|
||||
summary = {
|
||||
key,
|
||||
path: edit.path || "",
|
||||
absolute_path: edit.absolute_path,
|
||||
added: 0,
|
||||
deleted: 0,
|
||||
approximate: false,
|
||||
binary: false,
|
||||
pending: false,
|
||||
hasSuccessfulChange: false,
|
||||
hasActiveEditing: false,
|
||||
hasFailed: false,
|
||||
operation: undefined,
|
||||
};
|
||||
byPath.set(key, summary);
|
||||
order.push(key);
|
||||
}
|
||||
|
||||
if (edit.path && !summary.path) {
|
||||
summary.path = edit.path;
|
||||
}
|
||||
if (edit.absolute_path) {
|
||||
summary.absolute_path = edit.absolute_path;
|
||||
}
|
||||
if (edit.operation === "delete") {
|
||||
summary.operation = "delete";
|
||||
}
|
||||
summary.pending = summary.pending || !!edit.pending || !edit.path;
|
||||
if (!edit.path && edit.pending) {
|
||||
if (active && edit.status === "editing") {
|
||||
summary.hasActiveEditing = true;
|
||||
summary.approximate = summary.approximate || !!edit.approximate;
|
||||
if (!edit.binary) {
|
||||
summary.added += edit.added;
|
||||
summary.deleted += edit.deleted;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
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.flatMap((key) => {
|
||||
const summary = byPath.get(key)!;
|
||||
if (
|
||||
!summary.path
|
||||
&& !summary.hasActiveEditing
|
||||
&& !summary.hasSuccessfulChange
|
||||
&& !summary.hasFailed
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const status: UIFileEdit["status"] = summary.hasActiveEditing
|
||||
const status: UIFileEdit["status"] = editing
|
||||
? "editing"
|
||||
: summary.hasSuccessfulChange
|
||||
? "done"
|
||||
: summary.hasFailed
|
||||
? "error"
|
||||
: "done";
|
||||
: failed
|
||||
? "error"
|
||||
: "done";
|
||||
const binary = !!edit.binary;
|
||||
const diff = hasRenderableFileDiff(edit.diff) ? edit.diff : undefined;
|
||||
return [{
|
||||
key: summary.key,
|
||||
path: summary.path,
|
||||
absolute_path: summary.absolute_path,
|
||||
added: summary.added,
|
||||
deleted: summary.deleted,
|
||||
approximate: summary.approximate,
|
||||
binary: summary.binary,
|
||||
key: fileEditCallKey(edit),
|
||||
path: edit.path || "",
|
||||
absolute_path: edit.absolute_path,
|
||||
added: binary ? 0 : edit.added,
|
||||
deleted: binary ? 0 : edit.deleted,
|
||||
approximate: active && !!edit.approximate,
|
||||
binary,
|
||||
status,
|
||||
operation: summary.operation,
|
||||
pending: summary.pending && !summary.path,
|
||||
error: summary.error,
|
||||
operation: edit.operation,
|
||||
pending: !!edit.pending && !edit.path,
|
||||
error: edit.error,
|
||||
diff,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user