mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 08:28:36 +00:00
fix(webui): restore file edit diff display (#5096)
This commit is contained in:
parent
7c94ba9643
commit
ee93725e83
@ -40,6 +40,7 @@ import {
|
|||||||
isAgentActivityMember,
|
isAgentActivityMember,
|
||||||
isReasoningOnlyAssistant,
|
isReasoningOnlyAssistant,
|
||||||
} from "@/lib/activity-timeline";
|
} from "@/lib/activity-timeline";
|
||||||
|
import { useFileEditDisplayMode } from "@/hooks/useFileEditDisplayMode";
|
||||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||||
import { logoFallbackUrls } from "@/lib/provider-brand";
|
import { logoFallbackUrls } from "@/lib/provider-brand";
|
||||||
import { canonicalToolTrace, formatToolCallTrace } from "@/lib/tool-traces";
|
import { canonicalToolTrace, formatToolCallTrace } from "@/lib/tool-traces";
|
||||||
@ -144,6 +145,7 @@ export function AgentActivityCluster({
|
|||||||
onOpenFilePreview,
|
onOpenFilePreview,
|
||||||
}: AgentActivityClusterProps) {
|
}: AgentActivityClusterProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const fileEditDisplayMode = useFileEditDisplayMode();
|
||||||
const pageVisible = usePageVisibility();
|
const pageVisible = usePageVisibility();
|
||||||
const activityMessages = useMemo(() => coalesceActivityMessages(messages), [messages]);
|
const activityMessages = useMemo(() => coalesceActivityMessages(messages), [messages]);
|
||||||
const fileEdits = useMemo(
|
const fileEdits = useMemo(
|
||||||
@ -305,6 +307,7 @@ export function AgentActivityCluster({
|
|||||||
<div className={cn("w-full", hasBodyBelow && "mb-2")}>
|
<div className={cn("w-full", hasBodyBelow && "mb-2")}>
|
||||||
<FileEditGroup
|
<FileEditGroup
|
||||||
edits={fileEdits}
|
edits={fileEdits}
|
||||||
|
displayMode={fileEditDisplayMode}
|
||||||
onOpenFilePreview={onOpenFilePreview}
|
onOpenFilePreview={onOpenFilePreview}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -331,6 +334,7 @@ export function AgentActivityCluster({
|
|||||||
{fileEdits.length ? (
|
{fileEdits.length ? (
|
||||||
<FileEditGroup
|
<FileEditGroup
|
||||||
edits={fileEdits}
|
edits={fileEdits}
|
||||||
|
displayMode={fileEditDisplayMode}
|
||||||
onOpenFilePreview={onOpenFilePreview}
|
onOpenFilePreview={onOpenFilePreview}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@ -1031,6 +1035,7 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
|
|||||||
operation: edit.operation,
|
operation: edit.operation,
|
||||||
pending: !!edit.pending && !edit.path,
|
pending: !!edit.pending && !edit.path,
|
||||||
error: edit.error,
|
error: edit.error,
|
||||||
|
diff: edit.diff,
|
||||||
}];
|
}];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,45 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronUp,
|
||||||
CircleDashed,
|
CircleDashed,
|
||||||
|
ExternalLink,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { FileReferenceChip } from "@/components/FileReferenceChip";
|
import { FileReferenceChip } from "@/components/FileReferenceChip";
|
||||||
import type { UIFileEdit } from "@/lib/types";
|
import { codeLanguageFromPath } from "@/lib/code-language";
|
||||||
|
import {
|
||||||
|
hasRenderableFileDiff,
|
||||||
|
parseRenderableFileDiff,
|
||||||
|
type RenderableFileDiff,
|
||||||
|
type RenderableFileDiffHunk,
|
||||||
|
} from "@/lib/file-diff";
|
||||||
|
import type { FileEditDisplayMode } from "@/lib/local-preferences";
|
||||||
|
import type { UIFileDiff, UIFileEdit } from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { ActivityStep } from "./ActivityStep";
|
import { ActivityStep } from "./ActivityStep";
|
||||||
import { DiffPair } from "./DiffPair";
|
import { DiffPair } from "./DiffPair";
|
||||||
|
import { DiffSyntaxHighlight } from "./DiffSyntaxHighlight";
|
||||||
|
|
||||||
|
const INITIAL_VISIBLE_DIFF_LINES = 160;
|
||||||
|
const AUTO_COLLAPSE_DIFF_LINES = INITIAL_VISIBLE_DIFF_LINES;
|
||||||
|
|
||||||
|
interface VisibleDiffHunk {
|
||||||
|
hunk: RenderableFileDiffHunk;
|
||||||
|
skippedBefore: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VisibleDiff {
|
||||||
|
hunks: VisibleDiffHunk[];
|
||||||
|
hiddenLineCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_VISIBLE_DIFF: VisibleDiff = { hunks: [], hiddenLineCount: 0 };
|
||||||
|
|
||||||
export interface FileEditSummary {
|
export interface FileEditSummary {
|
||||||
key: string;
|
key: string;
|
||||||
@ -24,13 +53,16 @@ export interface FileEditSummary {
|
|||||||
operation?: UIFileEdit["operation"];
|
operation?: UIFileEdit["operation"];
|
||||||
pending: boolean;
|
pending: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
diff?: UIFileDiff;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileEditGroup({
|
export function FileEditGroup({
|
||||||
edits,
|
edits,
|
||||||
|
displayMode,
|
||||||
onOpenFilePreview,
|
onOpenFilePreview,
|
||||||
}: {
|
}: {
|
||||||
edits: FileEditSummary[];
|
edits: FileEditSummary[];
|
||||||
|
displayMode: FileEditDisplayMode;
|
||||||
onOpenFilePreview?: (path: string) => void;
|
onOpenFilePreview?: (path: string) => void;
|
||||||
}) {
|
}) {
|
||||||
if (edits.length === 0) return null;
|
if (edits.length === 0) return null;
|
||||||
@ -40,6 +72,7 @@ export function FileEditGroup({
|
|||||||
<FileEditRow
|
<FileEditRow
|
||||||
key={edit.key}
|
key={edit.key}
|
||||||
edit={edit}
|
edit={edit}
|
||||||
|
displayMode={displayMode}
|
||||||
onOpenFilePreview={onOpenFilePreview}
|
onOpenFilePreview={onOpenFilePreview}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@ -49,9 +82,11 @@ export function FileEditGroup({
|
|||||||
|
|
||||||
function FileEditRow({
|
function FileEditRow({
|
||||||
edit,
|
edit,
|
||||||
|
displayMode,
|
||||||
onOpenFilePreview,
|
onOpenFilePreview,
|
||||||
}: {
|
}: {
|
||||||
edit: FileEditSummary;
|
edit: FileEditSummary;
|
||||||
|
displayMode: FileEditDisplayMode;
|
||||||
onOpenFilePreview?: (path: string) => void;
|
onOpenFilePreview?: (path: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -59,6 +94,7 @@ function FileEditRow({
|
|||||||
const failed = edit.status === "error";
|
const failed = edit.status === "error";
|
||||||
const action = fileEditAction(edit, editing, failed);
|
const action = fileEditAction(edit, editing, failed);
|
||||||
const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit);
|
const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit);
|
||||||
|
const showDiff = canRenderDiff(edit, displayMode);
|
||||||
const statusIcon = failed ? (
|
const statusIcon = failed ? (
|
||||||
<AlertCircle className="h-3 w-3" aria-hidden />
|
<AlertCircle className="h-3 w-3" aria-hidden />
|
||||||
) : editing ? (
|
) : editing ? (
|
||||||
@ -68,42 +104,54 @@ function FileEditRow({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActivityStep
|
<div className="min-w-0">
|
||||||
marker={(
|
<ActivityStep
|
||||||
<span
|
marker={(
|
||||||
className={cn(
|
<span
|
||||||
"grid h-3.5 w-3.5 place-items-center rounded-full border bg-background transition-colors",
|
className={cn(
|
||||||
failed && "border-destructive/30 text-destructive/78",
|
"grid h-3.5 w-3.5 place-items-center rounded-full border bg-background transition-colors",
|
||||||
editing && "border-muted-foreground/24 text-muted-foreground/65",
|
failed && "border-destructive/30 text-destructive/78",
|
||||||
!failed && !editing && "border-emerald-500/28 text-emerald-500/78",
|
editing && "border-muted-foreground/24 text-muted-foreground/65",
|
||||||
)}
|
!failed && !editing && "border-emerald-500/28 text-emerald-500/78",
|
||||||
>
|
)}
|
||||||
{statusIcon}
|
>
|
||||||
</span>
|
{statusIcon}
|
||||||
)}
|
|
||||||
active={editing}
|
|
||||||
tone={failed ? "error" : editing ? "active" : "success"}
|
|
||||||
className="text-xs"
|
|
||||||
ariaLabel={edit.path ? `${action} ${edit.path}` : action}
|
|
||||||
label={edit.pending && !edit.path
|
|
||||||
? t("message.fileEditPreparing", { defaultValue: "Preparing file edit…" })
|
|
||||||
: (
|
|
||||||
<span className="flex min-w-0 items-center gap-1.5 overflow-hidden whitespace-nowrap">
|
|
||||||
<span className="shrink-0">{action}</span>
|
|
||||||
<FileReferenceChip
|
|
||||||
path={edit.path}
|
|
||||||
previewPath={edit.absolute_path || edit.path}
|
|
||||||
onOpen={onOpenFilePreview}
|
|
||||||
display="path"
|
|
||||||
active={editing}
|
|
||||||
className="min-w-0"
|
|
||||||
textClassName="truncate text-[12px]"
|
|
||||||
testId="activity-file-reference"
|
|
||||||
/>
|
|
||||||
{hasCountedDiff ? <DiffPair added={edit.added} deleted={edit.deleted} /> : null}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
/>
|
active={editing}
|
||||||
|
tone={failed ? "error" : editing ? "active" : "success"}
|
||||||
|
className="text-xs"
|
||||||
|
ariaLabel={edit.path ? `${action} ${edit.path}` : action}
|
||||||
|
label={edit.pending && !edit.path
|
||||||
|
? t("message.fileEditPreparing", { defaultValue: "Preparing file edit…" })
|
||||||
|
: (
|
||||||
|
<span className="flex min-w-0 items-center gap-1.5 overflow-hidden whitespace-nowrap">
|
||||||
|
<span className="shrink-0">{action}</span>
|
||||||
|
<FileReferenceChip
|
||||||
|
path={edit.path}
|
||||||
|
previewPath={edit.absolute_path || edit.path}
|
||||||
|
onOpen={onOpenFilePreview}
|
||||||
|
display="path"
|
||||||
|
active={editing}
|
||||||
|
className="min-w-0"
|
||||||
|
textClassName="truncate text-[12px]"
|
||||||
|
testId="activity-file-reference"
|
||||||
|
/>
|
||||||
|
{hasCountedDiff ? <DiffPair added={edit.added} deleted={edit.deleted} /> : null}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{showDiff ? (
|
||||||
|
<div className="ml-[2.125rem] min-w-0">
|
||||||
|
<FileUnifiedDiff
|
||||||
|
diff={edit.diff!}
|
||||||
|
collapsed={displayMode === "collapsed_diff"}
|
||||||
|
previewPath={edit.absolute_path || edit.path}
|
||||||
|
onOpenFilePreview={onOpenFilePreview}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -117,3 +165,239 @@ function fileEditAction(edit: FileEditSummary, editing: boolean, failed: boolean
|
|||||||
if (editing) return deleting ? "Deleting" : "Editing";
|
if (editing) return deleting ? "Deleting" : "Editing";
|
||||||
return deleting ? "Deleted" : "Edited";
|
return deleting ? "Deleted" : "Edited";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canRenderDiff(edit: FileEditSummary, displayMode: FileEditDisplayMode): boolean {
|
||||||
|
return (
|
||||||
|
displayMode !== "summary"
|
||||||
|
&& edit.status !== "editing"
|
||||||
|
&& edit.status !== "error"
|
||||||
|
&& hasRenderableFileDiff(edit.diff)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileUnifiedDiff({
|
||||||
|
diff,
|
||||||
|
collapsed,
|
||||||
|
previewPath,
|
||||||
|
onOpenFilePreview,
|
||||||
|
}: {
|
||||||
|
diff: UIFileDiff;
|
||||||
|
collapsed: 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 language = useMemo(() => codeLanguageFromPath(previewPath), [previewPath]);
|
||||||
|
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">
|
||||||
|
<DiffSyntaxHighlight language={language} lines={hunk.lines} />
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
|
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
|
||||||
|
import { DEFAULT_LOCAL_PREFS, writeLocalPreferences } from "@/lib/local-preferences";
|
||||||
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
|
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
const BLENDER_CLI_APP: CliAppInfo = {
|
const BLENDER_CLI_APP: CliAppInfo = {
|
||||||
@ -448,7 +449,7 @@ describe("AgentActivityCluster", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps file edits flat even when the legacy diff preference is enabled", () => {
|
it("renders file edit diffs and responds to preference changes", () => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"nanobot-webui.settings-preferences",
|
"nanobot-webui.settings-preferences",
|
||||||
JSON.stringify({ fileEditDisplayMode: "diff" }),
|
JSON.stringify({ fileEditDisplayMode: "diff" }),
|
||||||
@ -488,17 +489,27 @@ describe("AgentActivityCluster", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.queryByTestId("file-edit-diff")).not.toBeInTheDocument();
|
expect(screen.getByTestId("file-edit-diff")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("return <Old />;")).not.toBeInTheDocument();
|
expect(screen.getByText("return <Old />;")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("return <New />;")).not.toBeInTheDocument();
|
expect(screen.getByText("return <New />;")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("activity-file-reference")).toHaveTextContent("src/app.tsx");
|
expect(screen.getByTestId("activity-file-reference")).toHaveTextContent("src/app.tsx");
|
||||||
expect(screen.getAllByTestId("activity-diff-pair")).toHaveLength(1);
|
expect(screen.getAllByTestId("activity-diff-pair")).toHaveLength(1);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
writeLocalPreferences({ ...DEFAULT_LOCAL_PREFS, fileEditDisplayMode: "summary" });
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId("file-edit-diff")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
writeLocalPreferences({ ...DEFAULT_LOCAL_PREFS, fileEditDisplayMode: "diff" });
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId("file-edit-diff")).toBeInTheDocument();
|
||||||
} finally {
|
} finally {
|
||||||
localStorage.removeItem("nanobot-webui.settings-preferences");
|
localStorage.removeItem("nanobot-webui.settings-preferences");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not render diff hunks inside the activity list", () => {
|
it("renders folded separators between separated file edit hunks", () => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"nanobot-webui.settings-preferences",
|
"nanobot-webui.settings-preferences",
|
||||||
JSON.stringify({ fileEditDisplayMode: "diff" }),
|
JSON.stringify({ fileEditDisplayMode: "diff" }),
|
||||||
@ -544,16 +555,18 @@ describe("AgentActivityCluster", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.queryByTestId("file-edit-diff-hunk-gap")).not.toBeInTheDocument();
|
expect(screen.getByTestId("file-edit-diff-hunk-gap")).toHaveTextContent(
|
||||||
|
"21 unchanged lines hidden",
|
||||||
|
);
|
||||||
expect(screen.queryByText("@@ -25,3 +25,3 @@")).not.toBeInTheDocument();
|
expect(screen.queryByText("@@ -25,3 +25,3 @@")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("return newSecond;")).not.toBeInTheDocument();
|
expect(screen.getByText("return newSecond;")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("activity-file-reference")).toHaveTextContent("src/app.tsx");
|
expect(screen.getByTestId("activity-file-reference")).toHaveTextContent("src/app.tsx");
|
||||||
} finally {
|
} finally {
|
||||||
localStorage.removeItem("nanobot-webui.settings-preferences");
|
localStorage.removeItem("nanobot-webui.settings-preferences");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("summarizes long file edit diffs without an expansion control", () => {
|
it("keeps long file edit diffs collapsed until opened", () => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"nanobot-webui.settings-preferences",
|
"nanobot-webui.settings-preferences",
|
||||||
JSON.stringify({ fileEditDisplayMode: "diff" }),
|
JSON.stringify({ fileEditDisplayMode: "diff" }),
|
||||||
@ -592,16 +605,46 @@ describe("AgentActivityCluster", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.queryByTestId("file-edit-diff-toggle")).not.toBeInTheDocument();
|
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.queryByTestId("file-edit-diff")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("line-1")).not.toBeInTheDocument();
|
expect(screen.queryByText("line-1")).not.toBeInTheDocument();
|
||||||
expect(screen.getByText("+165")).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 {
|
} finally {
|
||||||
localStorage.removeItem("nanobot-webui.settings-preferences");
|
localStorage.removeItem("nanobot-webui.settings-preferences");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores the legacy collapsed diff mode in the activity list", () => {
|
it("does not mount collapsed file edit diffs until opened", () => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"nanobot-webui.settings-preferences",
|
"nanobot-webui.settings-preferences",
|
||||||
JSON.stringify({ fileEditDisplayMode: "collapsed_diff" }),
|
JSON.stringify({ fileEditDisplayMode: "collapsed_diff" }),
|
||||||
@ -641,16 +684,24 @@ describe("AgentActivityCluster", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.queryByTestId("file-edit-diff-toggle")).not.toBeInTheDocument();
|
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.queryByTestId("file-edit-diff")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("return <New />;")).not.toBeInTheDocument();
|
expect(screen.queryByText("return <New />;")).not.toBeInTheDocument();
|
||||||
expect(screen.getByTestId("activity-file-reference")).toHaveTextContent("src/app.tsx");
|
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
|
||||||
|
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||||
|
expect(screen.getByTestId("file-edit-diff")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("return <New />;")).toBeInTheDocument();
|
||||||
} finally {
|
} finally {
|
||||||
localStorage.removeItem("nanobot-webui.settings-preferences");
|
localStorage.removeItem("nanobot-webui.settings-preferences");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens the edited file directly instead of expanding a truncated diff", () => {
|
it("offers the file preview entry point when a diff payload is truncated", () => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"nanobot-webui.settings-preferences",
|
"nanobot-webui.settings-preferences",
|
||||||
JSON.stringify({ fileEditDisplayMode: "diff" }),
|
JSON.stringify({ fileEditDisplayMode: "diff" }),
|
||||||
@ -691,9 +742,15 @@ describe("AgentActivityCluster", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.queryByTestId("file-edit-diff-toggle")).not.toBeInTheDocument();
|
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();
|
expect(screen.queryByTestId("file-edit-diff-truncated")).not.toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByTestId("activity-file-reference"));
|
|
||||||
|
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");
|
expect(onOpenFilePreview).toHaveBeenCalledWith("/repo/src/app.tsx");
|
||||||
} finally {
|
} finally {
|
||||||
@ -1601,7 +1658,7 @@ describe("AgentActivityCluster", () => {
|
|||||||
expect(screen.queryByText(/\[Errno 13\]/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/\[Errno 13\]/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders repeated edits for the same path as separate actions", () => {
|
it("keeps repeated edits for the same path as separate actions", () => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"nanobot-webui.settings-preferences",
|
"nanobot-webui.settings-preferences",
|
||||||
JSON.stringify({ fileEditDisplayMode: "diff" }),
|
JSON.stringify({ fileEditDisplayMode: "diff" }),
|
||||||
@ -1679,9 +1736,9 @@ describe("AgentActivityCluster", () => {
|
|||||||
expect(failedRow).toBeInTheDocument();
|
expect(failedRow).toBeInTheDocument();
|
||||||
expect(failedRow).not.toHaveAttribute("title");
|
expect(failedRow).not.toHaveAttribute("title");
|
||||||
expect(screen.queryByText("patch failed")).not.toBeInTheDocument();
|
expect(screen.queryByText("patch failed")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByTestId("file-edit-diff")).not.toBeInTheDocument();
|
expect(screen.getAllByTestId("file-edit-diff")).toHaveLength(2);
|
||||||
expect(screen.queryByText("<canvas />")).not.toBeInTheDocument();
|
expect(screen.getByText("<canvas />")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("const fps = 60;")).not.toBeInTheDocument();
|
expect(screen.getByText("const fps = 60;")).toBeInTheDocument();
|
||||||
expect(screen.getAllByText("+2").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("+2").length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByText("-1").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("-1").length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByText("+6").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("+6").length).toBeGreaterThan(0);
|
||||||
@ -1691,6 +1748,50 @@ describe("AgentActivityCluster", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the latest failed attempt visible after an earlier edit succeeded", () => {
|
||||||
|
render(
|
||||||
|
<AgentActivityCluster
|
||||||
|
messages={activityMessages("", {
|
||||||
|
id: "t2",
|
||||||
|
role: "tool",
|
||||||
|
kind: "trace",
|
||||||
|
content: "edit_file()",
|
||||||
|
traces: ["edit_file()"],
|
||||||
|
fileEdits: [
|
||||||
|
{
|
||||||
|
call_id: "call-edit-1",
|
||||||
|
tool: "edit_file",
|
||||||
|
path: "src/app.tsx",
|
||||||
|
phase: "end",
|
||||||
|
added: 1,
|
||||||
|
deleted: 0,
|
||||||
|
approximate: false,
|
||||||
|
status: "done",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
call_id: "call-edit-2",
|
||||||
|
tool: "edit_file",
|
||||||
|
path: "src/app.tsx",
|
||||||
|
phase: "error",
|
||||||
|
added: 0,
|
||||||
|
deleted: 0,
|
||||||
|
approximate: false,
|
||||||
|
status: "error",
|
||||||
|
error: "patch failed",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: 3,
|
||||||
|
})}
|
||||||
|
isTurnStreaming={false}
|
||||||
|
hasBodyBelow={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Edited")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Could not edit")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByTestId("activity-file-reference")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps tool event embeds out of the flat activity list", () => {
|
it("keeps tool event embeds out of the flat activity list", () => {
|
||||||
render(
|
render(
|
||||||
<AgentActivityCluster
|
<AgentActivityCluster
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user