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:
@@ -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,
|
||||
};
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user