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>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { useEffect, useState } from "react";
import { readLocalPreferences, type FileEditDisplayMode } from "@/lib/local-preferences";
export function useFileEditDisplayMode(): FileEditDisplayMode {
const [mode, setMode] = useState<FileEditDisplayMode>(() =>
readLocalPreferences().fileEditDisplayMode,
);
useEffect(() => {
const refresh = () => setMode(readLocalPreferences().fileEditDisplayMode);
window.addEventListener("storage", refresh);
window.addEventListener("focus", refresh);
return () => {
window.removeEventListener("storage", refresh);
window.removeEventListener("focus", refresh);
};
}, []);
return mode;
}
+41 -8
View File
@@ -346,6 +346,44 @@ function stripCoveredFileEditToolHints(message: UIMessage, edits: UIFileEdit[]):
};
}
function traceMessageIsEmpty(message: UIMessage): boolean {
const traces = message.traces;
const hasTrace = traces?.length
? traces.some((line) => line.trim().length > 0)
: (message.content ?? "").trim().length > 0;
return (
message.kind === "trace"
&& !hasTrace
&& !message.toolEvents?.length
&& !message.fileEdits?.length
&& !message.media?.length
);
}
function stripCoveredFileEditToolHintsFromMessages(
messages: UIMessage[],
edits: UIFileEdit[],
turn: UIMessageTurnFields,
): UIMessage[] {
if (edits.length === 0) return messages;
let next = messages;
for (let i = next.length - 1; i >= 0; i -= 1) {
const candidate = next[i];
if (candidate.role === "user") break;
if (candidate.kind !== "trace") continue;
if (!matchesTurn(candidate, turn)) continue;
const cleaned = stripCoveredFileEditToolHints(candidate, edits);
if (cleaned === candidate) continue;
if (next === messages) next = [...messages];
if (traceMessageIsEmpty(cleaned)) {
next.splice(i, 1);
} else {
next[i] = cleaned;
}
}
return next;
}
function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null {
if (!edit || !edit.tool || (!edit.path && !edit.pending)) return null;
const inferredStatus =
@@ -417,10 +455,6 @@ function findFileEditTraceIndex(
)
) return i;
}
for (const event of candidate.toolEvents ?? []) {
const key = toolEventFileEditKey(event);
if (key && incomingToolEventKeys.has(key)) return i;
}
}
return null;
}
@@ -1040,16 +1074,15 @@ export function useNanobotStream(
}
setMessages((prev) => {
let segmentId = eventSegmentId;
const base = prev;
const base = stripCoveredFileEditToolHintsFromMessages(prev, normalized, turn);
const targetIndex = findFileEditTraceIndex(base, segmentId, normalized);
if (targetIndex !== null) {
const target = base[targetIndex];
segmentId = target.activitySegmentId ?? segmentId ?? detachedActivitySegmentId();
if (opensFileEditPhase) fileEditSegmentRef.current = segmentId;
const cleanedTarget = stripCoveredFileEditToolHints(target, normalized);
const merged: UIMessage = {
...cleanedTarget,
fileEdits: mergeFileEdits(cleanedTarget.fileEdits, normalized),
...target,
fileEdits: mergeFileEdits(target.fileEdits, normalized),
activitySegmentId: segmentId,
...turn,
};
+14 -1
View File
@@ -141,6 +141,7 @@
"presetModel": "Preset model",
"density": "Density",
"activityMode": "Activity detail",
"fileEditDisplay": "File edit display",
"codeWrap": "Code wrapping",
"brandLogos": "Brand logos",
"maxResults": "Max results",
@@ -187,6 +188,7 @@
"presetModel": "Switch to Default to edit model and provider from the WebUI.",
"density": "Stored only in this browser.",
"activityMode": "Choose how much agent activity chrome to show by default.",
"fileEditDisplay": "Choose whether file edit activity opens as line counts or a diff.",
"codeWrap": "Keep long code lines readable on smaller screens.",
"brandLogos": "Show third-party provider and CLI logos in Settings.",
"maxResults": "Results returned by each web_search call.",
@@ -329,6 +331,9 @@
"compact": "Compact",
"auto": "Auto",
"expanded": "Expanded",
"summary": "Summary",
"diff": "Diff",
"collapsedDiff": "Collapsed diff",
"on": "On",
"off": "Off",
"defaultPermission": "Default Permission",
@@ -1038,7 +1043,15 @@
"forkFromHere": "Fork",
"copyReply": "Copy",
"copiedReply": "Copied",
"turnLatencyTitle": "Response time (end-to-end)"
"turnLatencyTitle": "Response time (end-to-end)",
"fileEditViewDiff": "View diff",
"fileEditViewLargeDiff": "View large diff",
"fileEditDiffLineCount": "{{count}} lines",
"fileEditUnchangedLinesHidden": "{{count}} unchanged lines hidden",
"fileEditShowMoreLines": "Show {{count}} more lines",
"fileEditShowFewerLines": "Show fewer lines",
"fileEditOpenFile": "Open file",
"fileEditDiffTruncated": "Diff truncated. Open the file for the full change."
},
"lightbox": {
"title": "Image preview",
+13
View File
@@ -121,6 +121,7 @@
"presetModel": "Modelo del preajuste",
"density": "Densidad",
"activityMode": "Detalle de actividad",
"fileEditDisplay": "Vista de edición de archivos",
"codeWrap": "Ajuste de código",
"maxResults": "Resultados máximos",
"timeout": "Tiempo de espera",
@@ -165,6 +166,7 @@
"presetModel": "Cambia a Default para editar modelo y proveedor desde WebUI.",
"density": "Solo se guarda en este navegador.",
"activityMode": "Elige cuánto detalle de actividad del agente se muestra por defecto.",
"fileEditDisplay": "Elige si la actividad de edición muestra recuentos de líneas o el diff.",
"codeWrap": "Mantiene legibles las líneas largas de código en pantallas pequeñas.",
"maxResults": "Resultados devueltos por cada llamada web_search.",
"timeout": "Segundos antes de que una solicitud de búsqueda expire.",
@@ -214,6 +216,9 @@
"compact": "Compacto",
"auto": "Automático",
"expanded": "Expandido",
"summary": "Resumen",
"diff": "Diff",
"collapsedDiff": "Diff contraído",
"on": "Activado",
"off": "Desactivado",
"defaultPermission": "Permiso predeterminado",
@@ -1022,6 +1027,14 @@
"copyReply": "Copiar",
"copiedReply": "Copiado",
"turnLatencyTitle": "Tiempo de respuesta (extremo a extremo)",
"fileEditViewDiff": "Ver diff",
"fileEditViewLargeDiff": "Ver diff grande",
"fileEditDiffLineCount": "{{count}} líneas",
"fileEditUnchangedLinesHidden": "{{count}} líneas sin cambios ocultas",
"fileEditShowMoreLines": "Mostrar {{count}} líneas más",
"fileEditShowFewerLines": "Mostrar menos líneas",
"fileEditOpenFile": "Abrir archivo",
"fileEditDiffTruncated": "Diff truncado. Abre el archivo para ver el cambio completo.",
"activityThinkingFor": "Pensando durante {{duration}}",
"activityThought": "Pensamiento completado",
"activityThoughtFor": "Pensó durante {{duration}}",
+13
View File
@@ -121,6 +121,7 @@
"presetModel": "Modèle du préréglage",
"density": "Densité",
"activityMode": "Détail dactivité",
"fileEditDisplay": "Affichage des modifications de fichiers",
"codeWrap": "Retour à la ligne du code",
"maxResults": "Résultats max.",
"timeout": "Délai dattente",
@@ -165,6 +166,7 @@
"presetModel": "Passez à Default pour modifier le modèle et le fournisseur depuis WebUI.",
"density": "Enregistré seulement dans ce navigateur.",
"activityMode": "Choisissez le niveau de détail dactivité agent affiché par défaut.",
"fileEditDisplay": "Choisissez si lactivité de modification affiche le nombre de lignes ou le diff.",
"codeWrap": "Garde les longues lignes de code lisibles sur les petits écrans.",
"maxResults": "Résultats renvoyés par chaque appel web_search.",
"timeout": "Nombre de secondes avant lexpiration dune requête de recherche.",
@@ -214,6 +216,9 @@
"compact": "Compacte",
"auto": "Automatique",
"expanded": "Développé",
"summary": "Résumé",
"diff": "Diff",
"collapsedDiff": "Diff replié",
"on": "Activé",
"off": "Désactivé",
"defaultPermission": "Autorisation par défaut",
@@ -1022,6 +1027,14 @@
"copyReply": "Copier",
"copiedReply": "Copié",
"turnLatencyTitle": "Temps de réponse (de bout en bout)",
"fileEditViewDiff": "Voir le diff",
"fileEditViewLargeDiff": "Voir le grand diff",
"fileEditDiffLineCount": "{{count}} lignes",
"fileEditUnchangedLinesHidden": "{{count}} lignes inchangées masquées",
"fileEditShowMoreLines": "Afficher {{count}} lignes de plus",
"fileEditShowFewerLines": "Afficher moins de lignes",
"fileEditOpenFile": "Ouvrir le fichier",
"fileEditDiffTruncated": "Diff tronqué. Ouvrez le fichier pour voir la modification complète.",
"activityThinkingFor": "Réflexion pendant {{duration}}",
"activityThought": "Réflexion terminée",
"activityThoughtFor": "Réflexion terminée en {{duration}}",
+13
View File
@@ -121,6 +121,7 @@
"presetModel": "Model preset",
"density": "Kerapatan",
"activityMode": "Detail aktivitas",
"fileEditDisplay": "Tampilan edit file",
"codeWrap": "Bungkus kode",
"maxResults": "Hasil maksimum",
"timeout": "Batas waktu",
@@ -165,6 +166,7 @@
"presetModel": "Beralih ke Default untuk mengedit model dan penyedia dari WebUI.",
"density": "Hanya disimpan di browser ini.",
"activityMode": "Pilih seberapa banyak detail aktivitas agen yang ditampilkan secara default.",
"fileEditDisplay": "Pilih aktivitas edit file dibuka sebagai jumlah baris atau diff.",
"codeWrap": "Menjaga baris kode panjang tetap terbaca di layar kecil.",
"maxResults": "Resultados devueltos por cada llamada web_search.",
"timeout": "Segundos antes de que una solicitud de búsqueda expire.",
@@ -214,6 +216,9 @@
"compact": "Ringkas",
"auto": "Otomatis",
"expanded": "Diperluas",
"summary": "Ringkasan",
"diff": "Diff",
"collapsedDiff": "Diff diciutkan",
"on": "Aktif",
"off": "Nonaktif",
"defaultPermission": "Izin default",
@@ -1022,6 +1027,14 @@
"copyReply": "Salin",
"copiedReply": "Disalin",
"turnLatencyTitle": "Waktu respons (ujung ke ujung)",
"fileEditViewDiff": "Lihat diff",
"fileEditViewLargeDiff": "Lihat diff besar",
"fileEditDiffLineCount": "{{count}} baris",
"fileEditUnchangedLinesHidden": "{{count}} baris tidak berubah disembunyikan",
"fileEditShowMoreLines": "Tampilkan {{count}} baris lagi",
"fileEditShowFewerLines": "Tampilkan lebih sedikit baris",
"fileEditOpenFile": "Buka file",
"fileEditDiffTruncated": "Diff dipotong. Buka file untuk melihat perubahan lengkap.",
"activityThinkingFor": "Berpikir selama {{duration}}",
"activityThought": "Selesai berpikir",
"activityThoughtFor": "Selesai berpikir dalam {{duration}}",
+13
View File
@@ -121,6 +121,7 @@
"presetModel": "プリセットモデル",
"density": "表示密度",
"activityMode": "アクティビティ詳細",
"fileEditDisplay": "ファイル編集表示",
"codeWrap": "コードの折り返し",
"maxResults": "最大結果数",
"timeout": "タイムアウト",
@@ -165,6 +166,7 @@
"presetModel": "Default に切り替えると、WebUI からモデルとプロバイダーを編集できます。",
"density": "このブラウザーにのみ保存されます。",
"activityMode": "既定で表示する agent アクティビティの詳細量を選択します。",
"fileEditDisplay": "ファイル編集アクティビティを行数または差分で表示します。",
"codeWrap": "小さな画面でも長いコード行を読みやすくします。",
"maxResults": "各 web_search 呼び出しで返す結果数です。",
"timeout": "検索プロバイダーのリクエストがタイムアウトするまでの秒数です。",
@@ -214,6 +216,9 @@
"compact": "コンパクト",
"auto": "自動",
"expanded": "展開",
"summary": "概要",
"diff": "差分",
"collapsedDiff": "折りたたみ差分",
"on": "オン",
"off": "オフ",
"defaultPermission": "既定の権限",
@@ -1022,6 +1027,14 @@
"copyReply": "コピー",
"copiedReply": "コピー済み",
"turnLatencyTitle": "応答時間(全行程)",
"fileEditViewDiff": "差分を表示",
"fileEditViewLargeDiff": "大きな差分を表示",
"fileEditDiffLineCount": "{{count}} 行",
"fileEditUnchangedLinesHidden": "未変更の {{count}} 行を非表示",
"fileEditShowMoreLines": "さらに {{count}} 行を表示",
"fileEditShowFewerLines": "表示行数を減らす",
"fileEditOpenFile": "ファイルを開く",
"fileEditDiffTruncated": "差分は切り詰められました。完全な変更はファイルを開いて確認してください。",
"activityThinkingFor": "{{duration}}考えています",
"activityThought": "思考しました",
"activityThoughtFor": "{{duration}}考えました",
+13
View File
@@ -121,6 +121,7 @@
"presetModel": "프리셋 모델",
"density": "밀도",
"activityMode": "활동 상세",
"fileEditDisplay": "파일 편집 표시",
"codeWrap": "코드 줄바꿈",
"maxResults": "최대 결과 수",
"timeout": "타임아웃",
@@ -165,6 +166,7 @@
"presetModel": "Default로 전환하면 WebUI에서 모델과 제공자를 편집할 수 있습니다.",
"density": "이 브라우저에만 저장됩니다.",
"activityMode": "기본으로 표시할 agent 활동 세부 수준을 선택합니다.",
"fileEditDisplay": "파일 편집 활동을 줄 수 또는 diff로 표시할지 선택합니다.",
"codeWrap": "작은 화면에서도 긴 코드 줄을 읽기 쉽게 유지합니다.",
"maxResults": "각 web_search 호출에서 반환되는 결과 수입니다.",
"timeout": "검색 제공자 요청이 타임아웃되기 전의 초입니다.",
@@ -214,6 +216,9 @@
"compact": "컴팩트",
"auto": "자동",
"expanded": "펼침",
"summary": "요약",
"diff": "Diff",
"collapsedDiff": "접힌 diff",
"on": "켜짐",
"off": "꺼짐",
"defaultPermission": "기본 권한",
@@ -1022,6 +1027,14 @@
"copyReply": "복사",
"copiedReply": "복사됨",
"turnLatencyTitle": "응답 시간(엔드투엔드)",
"fileEditViewDiff": "Diff 보기",
"fileEditViewLargeDiff": "큰 diff 보기",
"fileEditDiffLineCount": "{{count}}줄",
"fileEditUnchangedLinesHidden": "변경되지 않은 {{count}}줄 숨김",
"fileEditShowMoreLines": "{{count}}줄 더 보기",
"fileEditShowFewerLines": "줄 줄이기",
"fileEditOpenFile": "파일 열기",
"fileEditDiffTruncated": "Diff가 잘렸습니다. 전체 변경은 파일을 열어 확인하세요.",
"activityThinkingFor": "{{duration}} 동안 생각 중",
"activityThought": "생각함",
"activityThoughtFor": "{{duration}} 동안 생각함",
+13
View File
@@ -121,6 +121,7 @@
"presetModel": "Mô hình preset",
"density": "Mật độ",
"activityMode": "Chi tiết hoạt động",
"fileEditDisplay": "Hiển thị sửa tệp",
"codeWrap": "Xuống dòng mã",
"maxResults": "Kết quả tối đa",
"timeout": "Thời gian chờ",
@@ -165,6 +166,7 @@
"presetModel": "Chuyển sang Default để chỉnh sửa mô hình và nhà cung cấp từ WebUI.",
"density": "Chỉ lưu trong trình duyệt này.",
"activityMode": "Chọn mức chi tiết hoạt động agent hiển thị mặc định.",
"fileEditDisplay": "Chọn hoạt động sửa tệp hiển thị số dòng hay diff.",
"codeWrap": "Giữ các dòng mã dài dễ đọc trên màn hình nhỏ.",
"maxResults": "Resultados devueltos por cada llamada web_search.",
"timeout": "Segundos antes de que una solicitud de búsqueda expire.",
@@ -214,6 +216,9 @@
"compact": "Gọn",
"auto": "Tự động",
"expanded": "Mở rộng",
"summary": "Tóm tắt",
"diff": "Diff",
"collapsedDiff": "Diff thu gọn",
"on": "Bật",
"off": "Tắt",
"defaultPermission": "Quyền mặc định",
@@ -1022,6 +1027,14 @@
"copyReply": "Sao chép",
"copiedReply": "Đã sao chép",
"turnLatencyTitle": "Thời gian phản hồi (end-to-end)",
"fileEditViewDiff": "Xem diff",
"fileEditViewLargeDiff": "Xem diff lớn",
"fileEditDiffLineCount": "{{count}} dòng",
"fileEditUnchangedLinesHidden": "Đã ẩn {{count}} dòng không đổi",
"fileEditShowMoreLines": "Hiển thị thêm {{count}} dòng",
"fileEditShowFewerLines": "Hiển thị ít dòng hơn",
"fileEditOpenFile": "Mở tệp",
"fileEditDiffTruncated": "Diff đã bị cắt bớt. Mở tệp để xem toàn bộ thay đổi.",
"activityThinkingFor": "Đang suy nghĩ trong {{duration}}",
"activityThought": "Đã suy nghĩ",
"activityThoughtFor": "Đã suy nghĩ trong {{duration}}",
+14 -1
View File
@@ -141,6 +141,7 @@
"presetModel": "预设模型",
"density": "密度",
"activityMode": "活动详情",
"fileEditDisplay": "文件编辑展示",
"codeWrap": "代码换行",
"brandLogos": "品牌 Logo",
"maxResults": "最大结果数",
@@ -187,6 +188,7 @@
"presetModel": "切回 Default 后可在 WebUI 中编辑模型和提供商。",
"density": "只保存在此浏览器中。",
"activityMode": "选择默认显示多少 agent 活动细节。",
"fileEditDisplay": "选择文件编辑活动默认显示行数还是差异。",
"codeWrap": "让长代码行在小屏幕上也易读。",
"brandLogos": "在设置中显示第三方提供商和 CLI 图标。",
"maxResults": "每次 web_search 调用返回的结果数。",
@@ -329,6 +331,9 @@
"compact": "紧凑",
"auto": "自动",
"expanded": "展开",
"summary": "摘要",
"diff": "差异",
"collapsedDiff": "折叠差异",
"on": "开启",
"off": "关闭",
"defaultPermission": "默认权限",
@@ -1038,7 +1043,15 @@
"forkFromHere": "分叉",
"copyReply": "复制",
"copiedReply": "已复制",
"turnLatencyTitle": "本轮耗时(端到端)"
"turnLatencyTitle": "本轮耗时(端到端)",
"fileEditViewDiff": "查看差异",
"fileEditViewLargeDiff": "查看大型差异",
"fileEditDiffLineCount": "{{count}} 行",
"fileEditUnchangedLinesHidden": "已隐藏 {{count}} 行未修改内容",
"fileEditShowMoreLines": "显示剩余 {{count}} 行",
"fileEditShowFewerLines": "收起部分行",
"fileEditOpenFile": "打开文件",
"fileEditDiffTruncated": "差异内容已截断。打开文件可查看完整更改。"
},
"lightbox": {
"title": "图片预览",
+13
View File
@@ -121,6 +121,7 @@
"presetModel": "預設模型",
"density": "密度",
"activityMode": "活動細節",
"fileEditDisplay": "檔案編輯顯示",
"codeWrap": "程式碼換行",
"maxResults": "最大結果數",
"timeout": "逾時",
@@ -165,6 +166,7 @@
"presetModel": "切回 Default 後可在 WebUI 中編輯模型與供應商。",
"density": "只儲存在此瀏覽器中。",
"activityMode": "選擇預設顯示多少 agent 活動細節。",
"fileEditDisplay": "選擇檔案編輯活動預設顯示行數或差異。",
"codeWrap": "讓長程式碼行在小螢幕上也易讀。",
"maxResults": "每次 web_search 呼叫返回的結果數。",
"timeout": "搜尋供應商請求逾時前的秒數。",
@@ -214,6 +216,9 @@
"compact": "緊湊",
"auto": "自動",
"expanded": "展開",
"summary": "摘要",
"diff": "差異",
"collapsedDiff": "摺疊差異",
"on": "開啟",
"off": "關閉",
"defaultPermission": "預設權限",
@@ -1022,6 +1027,14 @@
"copyReply": "複製",
"copiedReply": "已複製",
"turnLatencyTitle": "本輪耗時(端到端)",
"fileEditViewDiff": "查看差異",
"fileEditViewLargeDiff": "查看大型差異",
"fileEditDiffLineCount": "{{count}} 行",
"fileEditUnchangedLinesHidden": "已隱藏 {{count}} 行未修改內容",
"fileEditShowMoreLines": "顯示其餘 {{count}} 行",
"fileEditShowFewerLines": "收起部分行",
"fileEditOpenFile": "開啟檔案",
"fileEditDiffTruncated": "差異內容已截斷。開啟檔案可查看完整更改。",
"activityThinkingFor": "思考中,已 {{duration}}",
"activityThought": "已思考",
"activityThoughtFor": "已思考 {{duration}}",
+94
View File
@@ -0,0 +1,94 @@
import { parsePatch } from "diff";
import type { UIFileDiff } from "@/lib/types";
export interface RenderableFileDiffLine {
kind: "context" | "add" | "delete";
old_lineno?: number | null;
new_lineno?: number | null;
content: string;
}
export interface RenderableFileDiffHunk {
old_start: number;
old_lines: number;
new_start: number;
new_lines: number;
lines: RenderableFileDiffLine[];
}
export interface RenderableFileDiff {
hunks: RenderableFileDiffHunk[];
}
export function hasRenderableFileDiff(diff?: UIFileDiff): boolean {
if (!diff) return false;
return typeof diff.text === "string" && diff.text.trim().length > 0;
}
export function parseRenderableFileDiff(diff: UIFileDiff): RenderableFileDiff {
if (typeof diff.text === "string" && diff.text.trim().length > 0) {
return parseUnifiedDiffText(diff.text);
}
return { hunks: [] };
}
function parseUnifiedDiffText(text: string): RenderableFileDiff {
let files: ReturnType<typeof parsePatch>;
try {
files = parsePatch(text);
} catch {
return { hunks: [] };
}
return {
hunks: files.flatMap((file) =>
file.hunks.map((hunk) => {
let oldLineno = hunk.oldStart;
let newLineno = hunk.newStart;
const lines: RenderableFileDiffLine[] = [];
for (const rawLine of hunk.lines) {
if (rawLine.startsWith("\\")) continue;
const marker = rawLine[0];
const content = rawLine.slice(1);
if (marker === "+") {
lines.push({
kind: "add",
old_lineno: null,
new_lineno: newLineno,
content,
});
newLineno += 1;
continue;
}
if (marker === "-") {
lines.push({
kind: "delete",
old_lineno: oldLineno,
new_lineno: null,
content,
});
oldLineno += 1;
continue;
}
lines.push({
kind: "context",
old_lineno: oldLineno,
new_lineno: newLineno,
content: marker === " " ? content : rawLine,
});
oldLineno += 1;
newLineno += 1;
}
return {
old_start: hunk.oldStart,
old_lines: hunk.oldLines,
new_start: hunk.newStart,
new_lines: hunk.newLines,
lines,
};
}),
),
};
}
+42
View File
@@ -0,0 +1,42 @@
export type LocalDensity = "comfortable" | "compact";
export type LocalActivityMode = "auto" | "expanded";
export type FileEditDisplayMode = "summary" | "diff" | "collapsed_diff";
export interface LocalPreferences {
density: LocalDensity;
activityMode: LocalActivityMode;
codeWrap: boolean;
brandLogos: boolean;
fileEditDisplayMode: FileEditDisplayMode;
}
export const LOCAL_PREFS_STORAGE_KEY = "nanobot-webui.settings-preferences";
export const DEFAULT_LOCAL_PREFS: LocalPreferences = {
density: "comfortable",
activityMode: "auto",
codeWrap: true,
brandLogos: false,
fileEditDisplayMode: "summary",
};
export function normalizeFileEditDisplayMode(value: unknown): FileEditDisplayMode {
return value === "diff" || value === "collapsed_diff" ? value : "summary";
}
export 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,
fileEditDisplayMode: normalizeFileEditDisplayMode(parsed.fileEditDisplayMode),
};
} catch {
return DEFAULT_LOCAL_PREFS;
}
}
+8
View File
@@ -210,6 +210,13 @@ export interface ToolProgressEvent {
embeds?: unknown[];
}
export interface UIFileDiff {
format: "unified" | string;
context?: number;
truncated?: boolean;
text?: string;
}
export interface UIFileEdit {
version?: number;
call_id: string;
@@ -225,6 +232,7 @@ export interface UIFileEdit {
binary?: boolean;
error?: string;
pending?: boolean;
diff?: UIFileDiff;
}
export interface ChatSummary {
+339 -13
View File
@@ -41,6 +41,15 @@ const BROWSERBASE_MCP: McpPresetInfo = {
connection_summary: "https://mcp.browserbase.com/mcp",
};
function unifiedFileDiff(lines: string[], truncated = false) {
return {
format: "unified" as const,
context: 3,
truncated,
text: lines.join("\n"),
};
}
function activityMessages(extraReasoning = "", extraTool?: UIMessage): UIMessage[] {
const rows: UIMessage[] = [
{
@@ -447,6 +456,301 @@ describe("AgentActivityCluster", () => {
}
});
it("renders GitHub-like file edit diffs when the local preference is enabled", () => {
localStorage.setItem(
"nanobot-webui.settings-preferences",
JSON.stringify({ fileEditDisplayMode: "diff" }),
);
try {
render(
<AgentActivityCluster
messages={[{
id: "t-diff",
role: "tool",
kind: "trace",
content: "edit_file()",
traces: ["edit_file()"],
fileEdits: [{
call_id: "call-edit",
tool: "edit_file",
path: "src/app.tsx",
phase: "end",
added: 1,
deleted: 1,
approximate: false,
status: "done",
diff: unifiedFileDiff([
"--- src/app.tsx",
"+++ src/app.tsx",
"@@ -10,2 +10,2 @@",
" function App() {",
"- return <Old />;",
"+ return <New />;",
]),
}],
createdAt: 3,
}]}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
expect(screen.getByTestId("file-edit-diff")).toBeInTheDocument();
expect(screen.queryByText("@@ -10,2 +10,2 @@")).not.toBeInTheDocument();
expect(screen.getByText("return <Old />;")).toBeInTheDocument();
expect(screen.getByText("return <New />;")).toBeInTheDocument();
expect(screen.getAllByText("11").length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByTestId("activity-header-file-reference")).toHaveLength(1);
expect(screen.queryByTestId("activity-file-reference")).not.toBeInTheDocument();
expect(screen.getAllByTestId("activity-diff-pair")).toHaveLength(1);
} finally {
localStorage.removeItem("nanobot-webui.settings-preferences");
}
});
it("renders folded separators between separated file edit hunks", () => {
localStorage.setItem(
"nanobot-webui.settings-preferences",
JSON.stringify({ fileEditDisplayMode: "diff" }),
);
try {
render(
<AgentActivityCluster
messages={[{
id: "t-multi-hunk-diff",
role: "tool",
kind: "trace",
content: "edit_file()",
traces: ["edit_file()"],
fileEdits: [{
call_id: "call-multi-hunk-edit",
tool: "edit_file",
path: "src/app.tsx",
phase: "end",
added: 2,
deleted: 2,
approximate: false,
status: "done",
diff: unifiedFileDiff([
"--- src/app.tsx",
"+++ src/app.tsx",
"@@ -1,3 +1,3 @@",
" function first() {",
"- return oldFirst;",
"+ return newFirst;",
" }",
"@@ -25,3 +25,3 @@",
" function second() {",
"- return oldSecond;",
"+ return newSecond;",
" }",
]),
}],
createdAt: 3,
}]}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
expect(screen.getByTestId("file-edit-diff-hunk-gap")).toHaveTextContent(
"21 unchanged lines hidden",
);
expect(screen.queryByText("@@ -25,3 +25,3 @@")).not.toBeInTheDocument();
expect(screen.getByText("return newSecond;")).toBeInTheDocument();
} finally {
localStorage.removeItem("nanobot-webui.settings-preferences");
}
});
it("keeps long file edit diffs collapsed until opened", () => {
localStorage.setItem(
"nanobot-webui.settings-preferences",
JSON.stringify({ fileEditDisplayMode: "diff" }),
);
const lines = Array.from({ length: 165 }, (_, index) => `line-${index + 1}`);
try {
render(
<AgentActivityCluster
messages={[{
id: "t-long-diff",
role: "tool",
kind: "trace",
content: "edit_file()",
traces: ["edit_file()"],
fileEdits: [{
call_id: "call-long-edit",
tool: "edit_file",
path: "src/long.ts",
phase: "end",
added: lines.length,
deleted: 0,
approximate: false,
status: "done",
diff: unifiedFileDiff([
"--- src/long.ts",
"+++ src/long.ts",
`@@ -0,0 +1,${lines.length} @@`,
...lines.map((line) => `+${line}`),
]),
}],
createdAt: 3,
}]}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
const toggle = screen.getByTestId("file-edit-diff-toggle");
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(toggle).toHaveTextContent("View large diff");
expect(toggle).toHaveTextContent("165 lines");
expect(screen.queryByTestId("file-edit-diff")).not.toBeInTheDocument();
expect(screen.queryByText("line-1")).not.toBeInTheDocument();
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(screen.getByText("line-160")).toBeInTheDocument();
expect(screen.queryByText("line-161")).not.toBeInTheDocument();
expect(screen.getByTestId("file-edit-diff-expand-lines")).toHaveTextContent("Show 5 more lines");
fireEvent.click(screen.getByTestId("file-edit-diff-expand-lines"));
expect(screen.getByText("line-165")).toBeInTheDocument();
expect(screen.getByTestId("file-edit-diff-collapse-lines")).toHaveTextContent("Show fewer lines");
fireEvent.click(screen.getByTestId("file-edit-diff-collapse-lines"));
expect(screen.queryByText("line-165")).not.toBeInTheDocument();
expect(screen.getByTestId("file-edit-diff-expand-lines")).toHaveTextContent("Show 5 more lines");
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByTestId("file-edit-diff")).not.toBeInTheDocument();
} finally {
localStorage.removeItem("nanobot-webui.settings-preferences");
}
});
it("does not mount collapsed file edit diff rows until opened", () => {
localStorage.setItem(
"nanobot-webui.settings-preferences",
JSON.stringify({ fileEditDisplayMode: "collapsed_diff" }),
);
try {
render(
<AgentActivityCluster
messages={[{
id: "t-collapsed-diff",
role: "tool",
kind: "trace",
content: "edit_file()",
traces: ["edit_file()"],
fileEdits: [{
call_id: "call-collapsed-edit",
tool: "edit_file",
path: "src/app.tsx",
phase: "end",
added: 1,
deleted: 1,
approximate: false,
status: "done",
diff: unifiedFileDiff([
"--- src/app.tsx",
"+++ src/app.tsx",
"@@ -10,2 +10,2 @@",
" function App() {",
"- return <Old />;",
"+ return <New />;",
]),
}],
createdAt: 3,
}]}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
const toggle = screen.getByTestId("file-edit-diff-toggle");
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(toggle).toHaveTextContent("View diff");
expect(toggle).toHaveTextContent("3 lines");
expect(screen.queryByTestId("file-edit-diff")).not.toBeInTheDocument();
expect(screen.queryByText("return <New />;")).not.toBeInTheDocument();
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(screen.getByTestId("file-edit-diff")).toBeInTheDocument();
expect(screen.getByText("return <New />;")).toBeInTheDocument();
} finally {
localStorage.removeItem("nanobot-webui.settings-preferences");
}
});
it("offers the file preview entry point when a diff payload is truncated", () => {
localStorage.setItem(
"nanobot-webui.settings-preferences",
JSON.stringify({ fileEditDisplayMode: "diff" }),
);
const onOpenFilePreview = vi.fn();
try {
render(
<AgentActivityCluster
messages={[{
id: "t-truncated-diff",
role: "tool",
kind: "trace",
content: "edit_file()",
traces: ["edit_file()"],
fileEdits: [{
call_id: "call-truncated-edit",
tool: "edit_file",
path: "src/app.tsx",
absolute_path: "/repo/src/app.tsx",
phase: "end",
added: 1,
deleted: 0,
approximate: false,
status: "done",
diff: unifiedFileDiff([
"--- src/app.tsx",
"+++ src/app.tsx",
"@@ -9,0 +10,1 @@",
"+export const value = 1;",
], true),
}],
createdAt: 3,
}]}
isTurnStreaming={false}
hasBodyBelow={false}
onOpenFilePreview={onOpenFilePreview}
/>,
);
const toggle = screen.getByTestId("file-edit-diff-toggle");
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(toggle).toHaveTextContent("View large diff");
expect(screen.queryByTestId("file-edit-diff-truncated")).not.toBeInTheDocument();
fireEvent.click(toggle);
expect(screen.getByTestId("file-edit-diff-truncated")).toHaveTextContent("Diff truncated");
fireEvent.click(screen.getByTestId("file-edit-diff-open-file"));
expect(onOpenFilePreview).toHaveBeenCalledWith("/repo/src/app.tsx");
} finally {
localStorage.removeItem("nanobot-webui.settings-preferences");
}
});
it("labels whole-file deletes as deleted instead of edited", () => {
render(
<AgentActivityCluster
@@ -985,8 +1289,11 @@ describe("AgentActivityCluster", () => {
expect(screen.queryByText(/\[Errno 13\]/)).not.toBeInTheDocument();
});
it("merges repeated edits for the same path and lets successful edits win over failures", async () => {
const restoreMotion = installReducedMotion();
it("renders repeated edits for the same path as separate actions", () => {
localStorage.setItem(
"nanobot-webui.settings-preferences",
JSON.stringify({ fileEditDisplayMode: "diff" }),
);
try {
render(
<AgentActivityCluster
@@ -1006,6 +1313,13 @@ describe("AgentActivityCluster", () => {
deleted: 1,
approximate: false,
status: "done",
diff: unifiedFileDiff([
"--- minecraft-fps/index.html",
"+++ minecraft-fps/index.html",
"@@ -1,1 +1,2 @@",
" <main>",
"+ <canvas />",
]),
},
{
call_id: "call-edit-2",
@@ -1027,6 +1341,14 @@ describe("AgentActivityCluster", () => {
deleted: 6,
approximate: false,
status: "done",
diff: unifiedFileDiff([
"--- minecraft-fps/index.html",
"+++ minecraft-fps/index.html",
"@@ -8,2 +8,2 @@",
"-const fps = 30;",
"+const fps = 60;",
" start();",
]),
},
],
createdAt: 3,
@@ -1036,20 +1358,24 @@ describe("AgentActivityCluster", () => {
/>,
);
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 toggle = screen.getByRole("button", { name: "Edited 3 changes" });
expect(toggle).toHaveTextContent("+8");
expect(toggle).toHaveTextContent("-7");
fireEvent.click(toggle);
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);
});
expect(fileRefs).toHaveLength(3);
expect(fileRefs.every((ref) => ref.textContent?.includes("minecraft-fps/index.html"))).toBe(true);
expect(screen.getByText("patch failed")).toBeInTheDocument();
expect(screen.getAllByTestId("file-edit-diff")).toHaveLength(2);
expect(screen.getByText("<canvas />")).toBeInTheDocument();
expect(screen.getByText("const fps = 60;")).toBeInTheDocument();
expect(screen.getAllByText("+2").length).toBeGreaterThan(0);
expect(screen.getAllByText("-1").length).toBeGreaterThan(0);
expect(screen.getAllByText("+6").length).toBeGreaterThan(0);
expect(screen.getAllByText("-6").length).toBeGreaterThan(0);
} finally {
restoreMotion();
localStorage.removeItem("nanobot-webui.settings-preferences");
}
});
@@ -0,0 +1,57 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
import { fetchFilePreview } from "@/lib/api";
vi.mock("@/components/CodeBlock", () => ({
CodeBlock: ({ code }: { code: string }) => <pre data-testid="mock-code-block">{code}</pre>,
}));
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return {
...actual,
fetchFilePreview: vi.fn(),
};
});
describe("FilePreviewPanel", () => {
beforeEach(() => {
vi.mocked(fetchFilePreview).mockReset();
});
it("shows a compact breadcrumb with one file name and a visible close action", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
vi.mocked(fetchFilePreview).mockResolvedValue({
path: "/Users/hr/workspace/quicksort.py",
display_path: "quicksort.py",
language: "python",
content: "print('ok')",
truncated: false,
});
render(
<FilePreviewPanel
sessionKey="websocket:chat-1"
path="quicksort.py"
token="tok"
onClose={onClose}
/>,
);
expect(await screen.findByTestId("mock-code-block")).toHaveTextContent("print('ok')");
expect(screen.getByTestId("file-preview-breadcrumb")).toHaveTextContent("...");
expect(screen.getByTestId("file-preview-breadcrumb")).toHaveTextContent("workspace");
expect(screen.getByTestId("file-preview-title")).toHaveTextContent("quicksort.py");
expect(screen.getAllByText("quicksort.py")).toHaveLength(1);
const closeButton = screen.getByRole("button", { name: "Close file preview" });
expect(closeButton).toBeVisible();
await user.click(closeButton);
expect(onClose).toHaveBeenCalledTimes(1);
});
});
+2
View File
@@ -81,6 +81,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.rows.language",
"settings.rows.density",
"settings.rows.activityMode",
"settings.rows.fileEditDisplay",
"settings.rows.codeWrap",
"settings.rows.brandLogos",
"settings.rows.currentModel",
@@ -91,6 +92,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.help.language",
"settings.help.density",
"settings.help.activityMode",
"settings.help.fileEditDisplay",
"settings.help.codeWrap",
"settings.help.brandLogos",
"settings.help.currentModel",
+18 -1
View File
@@ -159,7 +159,7 @@ const installedAnyGen = {
function renderSettingsView(
options: {
initialSection?: "overview" | "apps" | "automations" | "advanced" | "models" | "browser";
initialSection?: "overview" | "appearance" | "apps" | "automations" | "advanced" | "models" | "browser";
initialSettings?: SettingsPayload;
showSidebar?: boolean;
onSettingsChange?: (payload: SettingsPayload) => void;
@@ -185,10 +185,27 @@ function renderSettingsView(
describe("SettingsView Apps catalog", () => {
afterEach(() => {
localStorage.removeItem("nanobot-webui.settings-preferences");
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("persists the file edit display local preference", async () => {
renderSettingsView({
initialSection: "appearance",
initialSettings: settingsPayload(),
showSidebar: true,
});
expect(screen.getByText("File edit display")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Diff" }));
await waitFor(() => {
const saved = JSON.parse(localStorage.getItem("nanobot-webui.settings-preferences") || "{}");
expect(saved.fileEditDisplayMode).toBe("diff");
});
});
it("does not show the Settings kicker on the standalone Automations surface", async () => {
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
+65
View File
@@ -596,6 +596,71 @@ describe("useNanobotStream", () => {
expect(result.current.messages[0].toolEvents).toBeUndefined();
});
it("keeps live file edits separate from mixed non-file tool traces", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-file-edit-mixed-tools", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-file-edit-mixed-tools", {
event: "message",
chat_id: "chat-file-edit-mixed-tools",
text: "",
kind: "tool_hint",
tool_events: [
{
phase: "start",
call_id: "call-read",
name: "read_file",
arguments: { path: "quicksort.py" },
},
{
phase: "start",
call_id: "call-write",
name: "write_file",
arguments: { path: "sorting/quicksort.py", content: "def quicksort():\n" },
},
],
});
fake.emit("chat-file-edit-mixed-tools", {
event: "file_edit",
chat_id: "chat-file-edit-mixed-tools",
edits: [{
call_id: "call-write",
tool: "write_file",
path: "sorting/quicksort.py",
phase: "end",
added: 3,
deleted: 0,
approximate: false,
status: "done",
}],
});
});
expect(result.current.messages).toHaveLength(2);
expect(result.current.messages[0]).toMatchObject({
role: "tool",
kind: "trace",
traces: ['read_file({"path":"quicksort.py"})'],
});
expect(result.current.messages[0].toolEvents?.map((event) => event.name)).toEqual(["read_file"]);
expect(result.current.messages[0].fileEdits).toBeUndefined();
expect(result.current.messages[1]).toMatchObject({
role: "tool",
kind: "trace",
traces: [],
fileEdits: [{
call_id: "call-write",
tool: "write_file",
path: "sorting/quicksort.py",
status: "done",
}],
});
expect(result.current.messages[1].toolEvents).toBeUndefined();
});
it("keeps every file from one apply_patch call", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-apply-patch-many", EMPTY_MESSAGES), {