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:
chengyongru
2026-07-09 10:42:43 +08:00
committed by Xubin Ren
parent 207813d3b5
commit 7768672c5b
40 changed files with 2224 additions and 1753 deletions
+118 -153
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import { AlertCircle, ChevronRight, FileText, Loader2, X } from "lucide-react";
import { AlertCircle, ChevronRight, Loader2, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { CodeBlock } from "@/components/CodeBlock";
@@ -24,11 +24,6 @@ type PreviewState =
| { status: "error"; message: string }
| { status: "ready"; payload: FilePreviewPayload };
function supportsHoverCloseControl(): boolean {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
return window.matchMedia("(hover: hover) and (pointer: fine)").matches;
}
export function FilePreviewPanel({
sessionKey,
path,
@@ -41,26 +36,12 @@ export function FilePreviewPanel({
const { t } = useTranslation();
const [state, setState] = useState<PreviewState>({ status: "loading" });
const [entered, setEntered] = useState(false);
const [supportsHoverClose, setSupportsHoverClose] = useState(supportsHoverCloseControl);
useEffect(() => {
const frame = window.requestAnimationFrame(() => setEntered(true));
return () => window.cancelAnimationFrame(frame);
}, []);
useEffect(() => {
if (typeof window.matchMedia !== "function") return undefined;
const query = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setSupportsHoverClose(query.matches);
update();
if (typeof query.addEventListener === "function") {
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}
query.addListener(update);
return () => query.removeListener(update);
}, []);
useEffect(() => {
let cancelled = false;
setState({ status: "loading" });
@@ -89,15 +70,28 @@ export function FilePreviewPanel({
const normalizedPreviewPath = previewPath.replace(/\\/g, "/");
const hasRootPrefix = normalizedPreviewPath.startsWith("/");
const { name } = splitFilePath(displayPath);
const breadcrumbs = useMemo(
const fileName = name || displayPath;
const pathParts = useMemo(
() => normalizedPreviewPath.split("/").filter(Boolean),
[normalizedPreviewPath],
);
const compactBreadcrumbs = useMemo(
() => (breadcrumbs.length > 2 ? breadcrumbs.slice(-2) : breadcrumbs),
[breadcrumbs],
const directoryParts = useMemo(
() => (pathParts.length > 1 ? pathParts.slice(0, -1) : []),
[pathParts],
);
const hasCompactPrefix = breadcrumbs.length > compactBreadcrumbs.length;
const breadcrumbParts = useMemo(
() => (directoryParts.length > 0 ? [...directoryParts, fileName] : [fileName]),
[directoryParts, fileName],
);
const compactBreadcrumbParts = useMemo(
() => (breadcrumbParts.length > 3 ? breadcrumbParts.slice(-3) : breadcrumbParts),
[breadcrumbParts],
);
const hasCompactPrefix = breadcrumbParts.length > compactBreadcrumbParts.length;
const breadcrumbTitle = `${hasRootPrefix ? "/" : ""}${[
...directoryParts,
fileName,
].join("/")}`;
return (
<aside
@@ -144,144 +138,115 @@ export function FilePreviewPanel({
</button>
) : null}
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-border/60 px-3">
{supportsHoverClose ? (
<div
className={cn(
"group inline-flex max-w-full min-w-0 items-center gap-2 rounded-[12px]",
"bg-muted/70 px-2.5 py-1.5 text-sm font-medium",
)}
title={name || displayPath}
>
<button
type="button"
onClick={onClose}
className={cn(
"relative inline-flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-full",
"text-muted-foreground/75 transition-[background-color,color,opacity] duration-150 ease-out",
"group-hover:bg-foreground group-hover:text-background group-hover:opacity-100",
"group-focus-within:bg-foreground group-focus-within:text-background",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
aria-label={t("filePreview.close", { defaultValue: "Close file preview" })}
>
<FileText
className={cn(
"absolute h-4 w-4 transition-all duration-150 ease-out",
"opacity-100 group-hover:scale-75 group-hover:opacity-0",
"group-focus-within:scale-75 group-focus-within:opacity-0",
)}
<div
className="flex h-11 shrink-0 items-center gap-2 border-b border-border/60 px-3"
title={previewPath}
>
<nav
aria-label={t("filePreview.breadcrumb", { defaultValue: "File path" })}
className="flex min-w-0 flex-1 items-center overflow-hidden text-sm leading-5"
title={breadcrumbTitle}
data-testid="file-preview-breadcrumb"
>
{hasCompactPrefix ? (
<>
<span className="shrink-0 text-muted-foreground/55">...</span>
<ChevronRight
className="mx-1 h-3.5 w-3.5 shrink-0 text-muted-foreground/35"
aria-hidden
/>
<X
className={cn(
"absolute h-3.5 w-3.5 scale-75 opacity-0 transition-all duration-150 ease-out",
"group-hover:scale-100 group-hover:opacity-100",
"group-focus-within:scale-100 group-focus-within:opacity-100",
)}
</>
) : hasRootPrefix ? (
<>
<span className="shrink-0 text-muted-foreground/55">/</span>
<ChevronRight
className="mx-1 h-3.5 w-3.5 shrink-0 text-muted-foreground/35"
aria-hidden
/>
</button>
<span className="min-w-0 truncate">{name || displayPath}</span>
</>
) : null}
{compactBreadcrumbParts.map((part, index) => {
const isLast = index === compactBreadcrumbParts.length - 1;
return (
<span
key={`${part}-${index}`}
className="flex min-w-0 items-center overflow-hidden"
>
{index > 0 ? (
<ChevronRight
className="mx-1 h-3.5 w-3.5 shrink-0 text-muted-foreground/35"
aria-hidden
/>
) : null}
<span
className={cn(
"min-w-0 truncate rounded-[4px] px-1 py-0.5",
isLast
? "font-medium text-foreground"
: "max-w-[26vw] shrink text-muted-foreground/78",
)}
data-testid={isLast ? "file-preview-title" : undefined}
>
{part}
</span>
</span>
);
})}
</nav>
<button
type="button"
onClick={onClose}
className={cn(
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md",
"text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
title={t("filePreview.close", { defaultValue: "Close file preview" })}
aria-label={t("filePreview.close", { defaultValue: "Close file preview" })}
data-testid="file-preview-close"
>
<X className="h-4 w-4" aria-hidden />
</button>
</div>
<div className="min-h-0 flex-1 overflow-auto">
{state.status === "loading" ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
{t("filePreview.loading", { defaultValue: "Loading preview..." })}
</div>
) : state.status === "error" ? (
<div className="flex h-full items-center justify-center px-8 text-center text-sm text-muted-foreground">
<div className="max-w-sm">
<AlertCircle
className="mx-auto mb-3 h-5 w-5 text-muted-foreground/70"
aria-hidden
/>
<p>{state.message}</p>
</div>
</div>
) : (
<>
<button
type="button"
onClick={onClose}
className={cn(
"inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full",
"text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
aria-label={t("filePreview.close", { defaultValue: "Close file preview" })}
>
<X className="h-5 w-5" aria-hidden />
</button>
<span className="min-w-0 truncate text-sm font-medium">
{name || displayPath}
</span>
</>
<div className="min-h-full">
{state.payload.truncated ? (
<div className="mx-4 mt-3 rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-200">
{t("filePreview.truncated", {
defaultValue: "Preview is truncated because this file is large.",
})}
</div>
) : null}
<CodeBlock
language={state.payload.language}
code={state.payload.content}
chrome="none"
showLineNumbers
wrapLongLines={false}
className="min-h-full"
/>
</div>
)}
</div>
<div className="flex min-h-0 flex-1 flex-col">
<div
className={cn(
"flex min-h-10 shrink-0 items-center gap-1.5 overflow-hidden",
"border-b border-border/45 px-4 text-[13px] text-muted-foreground",
)}
title={previewPath}
>
<div className="flex min-w-0 items-center gap-1.5">
{hasCompactPrefix ? (
<span className="shrink-0 text-muted-foreground/55">...</span>
) : hasRootPrefix ? (
<span className="shrink-0 text-muted-foreground/55">/</span>
) : null}
{compactBreadcrumbs.length > 0 ? (
compactBreadcrumbs.map((part, index) => (
<span key={`${part}-${index}`} className="flex min-w-0 items-center gap-1.5">
{index > 0 || hasCompactPrefix || hasRootPrefix ? (
<ChevronRight
className="h-3 w-3 shrink-0 text-muted-foreground/40"
aria-hidden
/>
) : null}
<span
className={cn(
"min-w-0 truncate",
index === compactBreadcrumbs.length - 1
? "font-medium text-foreground"
: "max-w-[42vw] shrink text-muted-foreground/76",
)}
>
{part}
</span>
</span>
))
) : (
<span className="truncate">{previewPath}</span>
)}
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto">
{state.status === "loading" ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
{t("filePreview.loading", { defaultValue: "Loading preview..." })}
</div>
) : state.status === "error" ? (
<div className="flex h-full items-center justify-center px-8 text-center text-sm text-muted-foreground">
<div className="max-w-sm">
<AlertCircle className="mx-auto mb-3 h-5 w-5 text-muted-foreground/70" aria-hidden />
<p>{state.message}</p>
</div>
</div>
) : (
<div className="min-h-full">
{state.payload.truncated ? (
<div className="mx-4 mt-3 rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-200">
{t("filePreview.truncated", {
defaultValue: "Preview is truncated because this file is large.",
})}
</div>
) : null}
<CodeBlock
language={state.payload.language}
code={state.payload.content}
chrome="none"
showLineNumbers
wrapLongLines={false}
className="min-h-full"
/>
</div>
)}
</div>
</div>
</div>
</div>
</aside>
);
}
+27 -33
View File
@@ -111,6 +111,14 @@ import {
} from "@/lib/api";
import { notifyCliAppsChanged } from "@/lib/cli-app-events";
import { copyTextToClipboard } from "@/lib/clipboard";
import {
LOCAL_PREFS_STORAGE_KEY,
readLocalPreferences,
type FileEditDisplayMode,
type LocalActivityMode,
type LocalDensity,
type LocalPreferences,
} from "@/lib/local-preferences";
import { getHostApi } from "@/lib/runtime";
import { notifyMcpPresetsChanged } from "@/lib/mcp-preset-events";
import { fmtDateTime, relativeTime } from "@/lib/format";
@@ -155,8 +163,6 @@ export type SettingsSectionKey =
| "runtime"
| "advanced";
type LocalDensity = "comfortable" | "compact";
type LocalActivityMode = "auto" | "expanded";
type AppsKindFilter = "all" | "nanobot" | "cli" | "mcp";
type AutomationFilter = "all" | "active" | "paused" | "failed" | "system";
type AutomationSort = "next" | "last" | "updated" | "name";
@@ -166,13 +172,6 @@ type AppsCatalogItem =
| { id: string; kind: "cli"; app: CliAppInfo }
| { id: string; kind: "mcp"; preset: McpPresetInfo };
interface LocalPreferences {
density: LocalDensity;
activityMode: LocalActivityMode;
codeWrap: boolean;
brandLogos: boolean;
}
interface AgentSettingsDraft {
model: string;
provider: string;
@@ -259,14 +258,6 @@ interface CustomMcpForm {
toolTimeout: string;
}
const LOCAL_PREFS_STORAGE_KEY = "nanobot-webui.settings-preferences";
const DEFAULT_LOCAL_PREFS: LocalPreferences = {
density: "comfortable",
activityMode: "auto",
codeWrap: true,
brandLogos: false,
};
const OPENAI_API_TYPE_OPTIONS: Array<{ value: ProviderApiType; label: string }> = [
{ value: "auto", label: "Auto" },
{ value: "chat_completions", label: "Chat Completions" },
@@ -318,22 +309,6 @@ interface SettingsViewProps {
hostChromeInset?: boolean;
}
function readLocalPreferences(): LocalPreferences {
try {
const raw = window.localStorage.getItem(LOCAL_PREFS_STORAGE_KEY);
if (!raw) return DEFAULT_LOCAL_PREFS;
const parsed = JSON.parse(raw) as Partial<LocalPreferences>;
return {
density: parsed.density === "compact" ? "compact" : "comfortable",
activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto",
codeWrap: parsed.codeWrap !== false,
brandLogos: parsed.brandLogos === true,
};
} catch {
return DEFAULT_LOCAL_PREFS;
}
}
function modelPresetValue(payload: SettingsPayload): string {
return payload.agent.model_preset || "default";
}
@@ -2337,6 +2312,25 @@ function AppearanceSettings({
}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.fileEditDisplay", "File edit display")}
description={tx("settings.help.fileEditDisplay", "Choose whether file edit activity opens as line counts or a diff.")}
>
<SegmentedControl
value={localPrefs.fileEditDisplayMode}
options={[
{ value: "summary", label: tx("settings.values.summary", "Summary") },
{ value: "diff", label: tx("settings.values.diff", "Diff") },
{ value: "collapsed_diff", label: tx("settings.values.collapsedDiff", "Collapsed diff") },
]}
onChange={(fileEditDisplayMode) =>
onChangeLocalPrefs((prev) => ({
...prev,
fileEditDisplayMode: fileEditDisplayMode as FileEditDisplayMode,
}))
}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.codeWrap", "Code wrapping")}
description={tx("settings.help.codeWrap", "Keep long code lines readable on smaller screens.")}
@@ -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,
}];
});
}
@@ -1,5 +1,3 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
export function DiffPair({ added, deleted }: { added: number; deleted: number }) {
@@ -31,83 +29,7 @@ function DiffValue({ sign, value, className }: { sign: string; value: number; cl
>
<span className="inline-flex items-baseline leading-none" aria-hidden>
{sign}
<AnimatedNumber value={safeValue} />
</span>
<span className="sr-only">{sign}{safeValue}</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 <RollingNumber value={display} />;
}
function RollingNumber({ value }: { value: number }) {
const digits = String(value).split("");
return (
<span className="inline-flex items-baseline leading-none" 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 align-baseline leading-none">
<span className="invisible block h-[1em] leading-none">0</span>
<span
className="absolute inset-x-0 top-0 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>
))}
{safeValue}
</span>
</span>
);
@@ -1,13 +1,47 @@
import { AlertCircle, CheckCircle2, CircleDashed } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
AlertCircle,
CheckCircle2,
ChevronDown,
ChevronRight,
ChevronUp,
CircleDashed,
ExternalLink,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { FileReferenceChip } from "@/components/FileReferenceChip";
import type { UIFileEdit } from "@/lib/types";
import {
hasRenderableFileDiff,
parseRenderableFileDiff,
type RenderableFileDiff,
type RenderableFileDiffHunk,
type RenderableFileDiffLine,
} from "@/lib/file-diff";
import type { FileEditDisplayMode } from "@/lib/local-preferences";
import type { UIFileDiff, UIFileEdit } from "@/lib/types";
import { cn } from "@/lib/utils";
import { ActivityStep } from "./ActivityStep";
import { DiffPair } from "./DiffPair";
const INITIAL_VISIBLE_DIFF_LINES = 160;
const AUTO_COLLAPSE_DIFF_LINES = INITIAL_VISIBLE_DIFF_LINES;
type DiffFileEditDisplayMode = Exclude<FileEditDisplayMode, "summary">;
interface VisibleDiffHunk {
hunk: RenderableFileDiffHunk;
skippedBefore: number;
}
interface VisibleDiff {
hunks: VisibleDiffHunk[];
hiddenLineCount: number;
}
const EMPTY_VISIBLE_DIFF: VisibleDiff = { hunks: [], hiddenLineCount: 0 };
export interface FileEditSummary {
key: string;
path: string;
@@ -20,40 +54,97 @@ export interface FileEditSummary {
operation?: UIFileEdit["operation"];
pending: boolean;
error?: string;
diff?: UIFileDiff;
}
export function FileEditGroup({
edits,
displayMode,
onOpenFilePreview,
density = "default",
}: {
edits: FileEditSummary[];
displayMode: FileEditDisplayMode;
onOpenFilePreview?: (path: string) => void;
density?: "default" | "diff-only";
}) {
if (edits.length === 0) return null;
return (
<ul className="space-y-1">
{edits.map((edit) => (
<FileEditRow
key={edit.key}
edit={edit}
onOpenFilePreview={onOpenFilePreview}
/>
))}
{edits.map((edit) => {
if (density === "diff-only" && canRenderDiff(edit, displayMode)) {
return (
<FileEditDiffOnly
key={edit.key}
edit={edit}
displayMode={displayMode}
onOpenFilePreview={onOpenFilePreview}
/>
);
}
return (
<FileEditRow
key={edit.key}
edit={edit}
displayMode={displayMode}
onOpenFilePreview={onOpenFilePreview}
/>
);
})}
</ul>
);
}
function canRenderDiff(
edit: FileEditSummary,
displayMode: FileEditDisplayMode,
): displayMode is DiffFileEditDisplayMode {
return (
displayMode !== "summary"
&& edit.status !== "editing"
&& edit.status !== "error"
&& hasRenderableFileDiff(edit.diff)
);
}
function FileEditDiffOnly({
edit,
displayMode,
onOpenFilePreview,
}: {
edit: FileEditSummary;
displayMode: DiffFileEditDisplayMode;
onOpenFilePreview?: (path: string) => void;
}) {
return (
<li className="min-w-0 py-0.5">
<FileUnifiedDiff
diff={edit.diff!}
collapsed={displayMode === "collapsed_diff"}
added={edit.added}
deleted={edit.deleted}
showCollapsedStats={false}
previewPath={edit.absolute_path || edit.path}
onOpenFilePreview={onOpenFilePreview}
/>
</li>
);
}
function FileEditRow({
edit,
displayMode,
onOpenFilePreview,
}: {
edit: FileEditSummary;
displayMode: FileEditDisplayMode;
onOpenFilePreview?: (path: string) => void;
}) {
const { t } = useTranslation();
const editing = edit.status === "editing";
const failed = edit.status === "error";
const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit);
const showDiff = canRenderDiff(edit, displayMode);
const rawFailureDetail = failed ? cleanFileEditError(edit.error) : "";
const failureDetail = failed
? formatFileEditError(edit.error)
@@ -84,7 +175,7 @@ function FileEditRow({
active={editing}
tone={failed ? "error" : editing ? "active" : "success"}
className="text-xs"
contentClassName={failed ? "min-w-0" : "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3"}
contentClassName={failed || showDiff ? "min-w-0" : "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3"}
title={rawFailureDetail || edit.absolute_path || edit.path}
label={edit.pending && !edit.path
? t("message.fileEditPreparing", { defaultValue: "Preparing file edit…" })
@@ -109,6 +200,16 @@ function FileEditRow({
{failureDetail}
</span>
) : null}
{showDiff ? (
<FileUnifiedDiff
diff={edit.diff!}
collapsed={displayMode === "collapsed_diff"}
added={edit.added}
deleted={edit.deleted}
previewPath={edit.absolute_path || edit.path}
onOpenFilePreview={onOpenFilePreview}
/>
) : null}
</ActivityStep>
);
}
@@ -142,3 +243,279 @@ function formatFileEditError(error?: string): string {
.replace(/^path to (?:update|delete) is not a file: (.+)$/i, "Path is not a file: $1.")
.slice(0, 180);
}
function FileUnifiedDiff({
diff,
collapsed,
added,
deleted,
showCollapsedStats = true,
previewPath,
onOpenFilePreview,
}: {
diff: UIFileDiff;
collapsed: boolean;
added: number;
deleted: number;
showCollapsedStats?: boolean;
previewPath?: string;
onOpenFilePreview?: (path: string) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const [open, setOpen] = useState(false);
const [expandedLines, setExpandedLines] = useState(false);
const renderableDiff = useMemo(() => parseRenderableFileDiff(diff), [diff]);
const totalLineCount = useMemo(() => countDiffLines(renderableDiff), [renderableDiff]);
const shouldAutoCollapse = totalLineCount > AUTO_COLLAPSE_DIFF_LINES || !!diff.truncated;
const startsCollapsed = collapsed || shouldAutoCollapse;
const shouldRenderBody = !startsCollapsed || open;
const shouldLimitLines = totalLineCount > INITIAL_VISIBLE_DIFF_LINES;
const lineLimit = expandedLines || !shouldLimitLines
? totalLineCount
: INITIAL_VISIBLE_DIFF_LINES;
const visibleDiff = useMemo(
() => shouldRenderBody
? selectVisibleDiffLines(renderableDiff, lineLimit, totalLineCount)
: EMPTY_VISIBLE_DIFF,
[lineLimit, renderableDiff, shouldRenderBody, totalLineCount],
);
const lineCountLabel = t("message.fileEditDiffLineCount", {
count: diff.truncated ? `${totalLineCount}+` : totalLineCount,
defaultValue: "{{count}} lines",
});
const viewDiffLabel = shouldAutoCollapse
? tx("message.fileEditViewLargeDiff", "View large diff")
: tx("message.fileEditViewDiff", "View diff");
useEffect(() => {
setOpen(false);
setExpandedLines(false);
}, [diff]);
const handleToggleOpen = () => {
if (open) setExpandedLines(false);
setOpen(!open);
};
if (totalLineCount === 0) return null;
const renderBody = () => (
<div
className="mt-1 overflow-hidden rounded-md border border-border/55 bg-background/80 shadow-[0_1px_0_rgba(15,23,42,0.03)]"
data-testid="file-edit-diff"
>
{visibleDiff.hunks.map(({ hunk, skippedBefore }, index) => (
<div
key={`${hunk.old_start}-${hunk.new_start}-${index}`}
className={cn("min-w-0", index > 0 && "border-t border-border/45")}
>
{skippedBefore > 0 ? <DiffHunkGap lineCount={skippedBefore} /> : null}
<div className="overflow-x-auto">
<table className="w-full border-collapse font-mono text-[11px] leading-5">
<tbody>
{hunk.lines.map((line, lineIndex) => (
<DiffLineRow
key={`${line.old_lineno ?? ""}:${line.new_lineno ?? ""}:${lineIndex}`}
line={line}
/>
))}
</tbody>
</table>
</div>
</div>
))}
{visibleDiff.hiddenLineCount > 0 ? (
<div className="border-t border-border/45 bg-muted/30 px-2 py-1">
<button
type="button"
className={cn(
"inline-flex items-center gap-1 rounded px-1 py-0.5 text-[11px] font-medium",
"text-muted-foreground transition-colors hover:bg-muted/65 hover:text-foreground",
)}
data-testid="file-edit-diff-expand-lines"
onClick={() => setExpandedLines(true)}
>
<ChevronDown className="h-3 w-3" aria-hidden />
{t("message.fileEditShowMoreLines", {
count: visibleDiff.hiddenLineCount,
defaultValue: "Show {{count}} more lines",
})}
</button>
</div>
) : expandedLines && shouldLimitLines ? (
<div className="border-t border-border/45 bg-muted/30 px-2 py-1">
<button
type="button"
className={cn(
"inline-flex items-center gap-1 rounded px-1 py-0.5 text-[11px] font-medium",
"text-muted-foreground transition-colors hover:bg-muted/65 hover:text-foreground",
)}
data-testid="file-edit-diff-collapse-lines"
onClick={() => setExpandedLines(false)}
>
<ChevronUp className="h-3 w-3" aria-hidden />
{tx("message.fileEditShowFewerLines", "Show fewer lines")}
</button>
</div>
) : null}
{diff.truncated ? (
<div
className="flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-border/45 bg-muted/35 px-2 py-1 text-[11px] text-muted-foreground"
data-testid="file-edit-diff-truncated"
>
<span>
{tx("message.fileEditDiffTruncated", "Diff truncated. Open the file for the full change.")}
</span>
{previewPath && onOpenFilePreview ? (
<button
type="button"
className={cn(
"inline-flex items-center gap-1 rounded px-1 py-0.5 font-medium",
"text-muted-foreground transition-colors hover:bg-muted/65 hover:text-foreground",
)}
data-testid="file-edit-diff-open-file"
onClick={() => onOpenFilePreview(previewPath)}
>
<ExternalLink className="h-3 w-3" aria-hidden />
{tx("message.fileEditOpenFile", "Open file")}
</button>
) : null}
</div>
) : null}
</div>
);
if (!startsCollapsed) return renderBody();
return (
<div className="mt-1">
<button
type="button"
aria-expanded={open}
data-testid="file-edit-diff-toggle"
onClick={handleToggleOpen}
className={cn(
"flex w-full cursor-pointer items-center gap-2 rounded-md border border-border/45 bg-muted/35 px-2 py-1 text-left",
"text-[11px] font-medium text-muted-foreground transition-colors hover:bg-muted/50",
)}
>
<ChevronRight
className={cn("h-3 w-3 shrink-0 transition-transform", open && "rotate-90")}
aria-hidden
/>
<span className="min-w-0 flex-1">{viewDiffLabel}</span>
<span className="shrink-0 text-muted-foreground/65">{lineCountLabel}</span>
{showCollapsedStats ? <DiffPair added={added} deleted={deleted} /> : null}
</button>
{open ? renderBody() : null}
</div>
);
}
function countDiffLines(diff: RenderableFileDiff): number {
return diff.hunks.reduce((total, hunk) => total + hunk.lines.length, 0);
}
function selectVisibleDiffLines(
diff: RenderableFileDiff,
lineLimit: number,
totalLineCount: number,
): VisibleDiff {
if (lineLimit >= totalLineCount) {
return {
hunks: diff.hunks.map((hunk, index) => ({
hunk,
skippedBefore: index > 0 ? countSkippedUnchangedLines(diff.hunks[index - 1], hunk) : 0,
})),
hiddenLineCount: 0,
};
}
let remaining = Math.max(0, lineLimit);
const hunks: VisibleDiffHunk[] = [];
let previousHunk: RenderableFileDiffHunk | null = null;
for (const hunk of diff.hunks) {
if (remaining <= 0) break;
const skippedBefore = previousHunk ? countSkippedUnchangedLines(previousHunk, hunk) : 0;
if (hunk.lines.length <= remaining) {
hunks.push({ hunk, skippedBefore });
remaining -= hunk.lines.length;
previousHunk = hunk;
continue;
}
hunks.push({ hunk: { ...hunk, lines: hunk.lines.slice(0, remaining) }, skippedBefore });
remaining = 0;
previousHunk = hunk;
}
return {
hunks,
hiddenLineCount: Math.max(0, totalLineCount - lineLimit),
};
}
function countSkippedUnchangedLines(
previous: RenderableFileDiffHunk,
current: RenderableFileDiffHunk,
): number {
const oldGap = current.old_start - (previous.old_start + previous.old_lines);
const newGap = current.new_start - (previous.new_start + previous.new_lines);
return Math.max(0, oldGap, newGap);
}
function DiffHunkGap({ lineCount }: { lineCount: number }) {
const { t } = useTranslation();
return (
<div
className="flex items-center gap-2 bg-muted/35 px-2 py-1 text-[11px] text-muted-foreground"
data-testid="file-edit-diff-hunk-gap"
>
<span
className="select-none rounded border border-border/45 bg-background/70 px-1 font-mono text-muted-foreground/70"
aria-hidden
>
...
</span>
<span>
{t("message.fileEditUnchangedLinesHidden", {
count: lineCount,
defaultValue: "{{count}} unchanged lines hidden",
})}
</span>
</div>
);
}
function DiffLineRow({ line }: { line: RenderableFileDiffLine }) {
const kind = line.kind === "add" || line.kind === "delete" ? line.kind : "context";
const marker = kind === "add" ? "+" : kind === "delete" ? "-" : " ";
return (
<tr
className={cn(
"border-0",
kind === "add" && "bg-emerald-500/[0.09] dark:bg-emerald-300/[0.11]",
kind === "delete" && "bg-rose-500/[0.09] dark:bg-rose-300/[0.11]",
)}
>
<td className="w-10 select-none border-r border-border/35 px-1.5 text-right text-muted-foreground/55">
{line.old_lineno ?? ""}
</td>
<td className="w-10 select-none border-r border-border/35 px-1.5 text-right text-muted-foreground/55">
{line.new_lineno ?? ""}
</td>
<td
className={cn(
"w-5 select-none px-1 text-center",
kind === "add" && "text-emerald-600/80 dark:text-emerald-300/85",
kind === "delete" && "text-rose-600/80 dark:text-rose-300/85",
kind === "context" && "text-muted-foreground/45",
)}
>
{marker}
</td>
<td className="min-w-[16rem] px-1.5 text-foreground/86">
<span className="whitespace-pre">{line.content || " "}</span>
</td>
</tr>
);
}