feat(client): render file edits as a structured diff view

Replace the highlighted-text diff in file authoring output with a
line-based renderer: red and green tinted rows with +/- gutters,
old/new line numbers when hunk headers carry them, and diff noise
stripped (file headers, streamed old_text/new_text markers, bare @@
separators become dividers). The code window header shows the file
name with +N/-N change stats next to Copy Code, and file authoring
rows adopt the path-first arg parsing and shared panel spacing.
This commit is contained in:
Marco Beretta 2026-07-31 02:25:39 +02:00
parent 2fb33ad0b4
commit af8949ea85
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
4 changed files with 217 additions and 48 deletions

View file

@ -7,9 +7,10 @@ import { useLocalize } from '~/hooks';
interface CodeWindowHeaderProps {
language: string;
code: string;
diffStats?: { additions: number; deletions: number };
}
export default function CodeWindowHeader({ language, code }: CodeWindowHeaderProps) {
export default function CodeWindowHeader({ language, code, diffStats }: CodeWindowHeaderProps) {
const localize = useLocalize();
const [isCopied, setIsCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
@ -25,9 +26,15 @@ export default function CodeWindowHeader({ language, code }: CodeWindowHeaderPro
return (
<div className="flex items-center justify-between bg-surface-primary-alt px-1.5 py-1.5 font-sans text-xs text-text-secondary dark:bg-transparent">
<span className="flex items-center gap-1.5 text-xs font-medium">
<span className="flex items-center gap-1.5 pl-1.5 text-xs font-medium">
<LangIcon lang={language} className="size-3.5 shrink-0" />
{language}
{diffStats && (
<span className="flex items-center gap-1 font-normal tabular-nums" aria-hidden="true">
<span className="text-green-600 dark:text-green-400">+{diffStats.additions}</span>
<span className="text-red-600 dark:text-red-400">-{diffStats.deletions}</span>
</span>
)}
</span>
<CopyButton isCopied={isCopied} onClick={handleCopy} label={localize('com_ui_copy_code')} />
</div>

View file

@ -0,0 +1,141 @@
import { cn } from '~/utils';
const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
export interface DiffLine {
type: 'add' | 'del' | 'context' | 'hunk';
text: string;
oldLine?: number;
newLine?: number;
}
export interface ParsedDiff {
lines: DiffLine[];
additions: number;
deletions: number;
hasLineNumbers: boolean;
}
/**
* Parses unified-diff text into typed lines with old/new line numbers when the
* hunk headers carry them. Also understands the argless `@@` separators and
* `--- old_text` / `+++ new_text` markers the streaming args preview emits —
* file headers and `\ No newline` markers are dropped (the window header
* already names the file).
*/
export function parseUnifiedDiff(diff: string): ParsedDiff {
const lines: DiffLine[] = [];
let additions = 0;
let deletions = 0;
let hasLineNumbers = false;
let oldLine: number | undefined;
let newLine: number | undefined;
for (const raw of diff.replace(/\n$/, '').split('\n')) {
if (raw.startsWith('--- ') || raw.startsWith('+++ ') || raw.startsWith('\\')) {
continue;
}
const hunk = HUNK_HEADER.exec(raw);
if (hunk) {
oldLine = Number(hunk[1]);
newLine = Number(hunk[2]);
hasLineNumbers = true;
lines.push({ type: 'hunk', text: raw });
continue;
}
if (raw === '@@') {
oldLine = undefined;
newLine = undefined;
lines.push({ type: 'hunk', text: '' });
continue;
}
if (raw.startsWith('+')) {
additions += 1;
lines.push({ type: 'add', text: raw.slice(1), newLine });
newLine = newLine == null ? undefined : newLine + 1;
continue;
}
if (raw.startsWith('-')) {
deletions += 1;
lines.push({ type: 'del', text: raw.slice(1), oldLine });
oldLine = oldLine == null ? undefined : oldLine + 1;
continue;
}
lines.push({
type: 'context',
text: raw.startsWith(' ') ? raw.slice(1) : raw,
oldLine,
newLine,
});
oldLine = oldLine == null ? undefined : oldLine + 1;
newLine = newLine == null ? undefined : newLine + 1;
}
return { lines, additions, deletions, hasLineNumbers };
}
const LINE_NUMBER_CLASS = 'w-9 shrink-0 select-none pr-1.5 text-right text-text-tertiary';
const LINE_MARKERS: Record<DiffLine['type'], string> = {
add: '+',
del: '-',
context: '',
hunk: '',
};
export default function DiffView({ parsed }: { parsed: ParsedDiff }) {
const { lines, hasLineNumbers } = parsed;
const firstContentIndex = lines.findIndex((line) => line.type !== 'hunk');
return (
<div
data-testid="diff-view"
className="max-h-[300px] overflow-auto bg-surface-chat py-2 font-mono text-xs leading-5 dark:bg-surface-primary-alt"
>
{lines.map((line, index) => {
if (line.type === 'hunk') {
if (index < firstContentIndex || firstContentIndex === -1) {
return null;
}
if (!line.text) {
return <div key={index} className="mx-3 my-1.5 border-t border-border-light" />;
}
return (
<div key={index} className="select-none px-3 py-0.5 text-[11px] text-text-tertiary">
{line.text}
</div>
);
}
return (
<div
key={index}
className={cn(
'flex',
line.type === 'add' && 'bg-green-500/10',
line.type === 'del' && 'bg-red-500/10',
)}
>
{hasLineNumbers && (
<>
<span className={LINE_NUMBER_CLASS}>{line.oldLine ?? ''}</span>
<span className={LINE_NUMBER_CLASS}>{line.newLine ?? ''}</span>
</>
)}
<span
className={cn(
'w-6 shrink-0 select-none text-center font-semibold',
line.type === 'add' && 'text-green-600 dark:text-green-400',
line.type === 'del' && 'text-red-600 dark:text-red-400',
)}
>
{LINE_MARKERS[line.type]}
</span>
<span className="min-w-0 flex-1 whitespace-pre-wrap break-words pr-3 text-text-primary">
{line.text || ' '}
</span>
</div>
);
})}
</div>
);
}

View file

@ -3,6 +3,8 @@ import { FilePenLine, FilePlus2 } from 'lucide-react';
import type { TAttachment, PartMetadata } from 'librechat-data-provider';
import parseJsonField, { parseJsonFieldOccurrences } from './parseJsonField';
import ProgressText from '~/components/Chat/Messages/Content/ProgressText';
import { toolPanelSpacingClassName } from '../disclosure';
import DiffView, { parseUnifiedDiff } from './DiffView';
import useToolCallState from './useToolCallState';
import useLazyHighlight from './useLazyHighlight';
import CodeWindowHeader from './CodeWindowHeader';
@ -134,11 +136,14 @@ export default function FileAuthoringCall({
* or skill SKILL.md updates). The host-authored summary always opens with
* `Created`/`Updated`, so key the finished label off it for truthfulness. */
const overwrote = isCreate && output.startsWith('Updated ');
const filePath = useMemo(() => parseJsonField(args, 'file_path'), [args]);
const filePath = useMemo(
() => parseJsonField(args, 'path') || parseJsonField(args, 'file_path'),
[args],
);
const intent = useToolCallIntent(args);
const authoredContent = useMemo(() => parseJsonField(args, 'content'), [args]);
const editArgsPreview = useMemo(() => buildEditArgsPreview(args), [args]);
const fileName = filePath.split('/').pop() || filePath;
const fileName = filePath.split('/').pop() || filePath || localize('com_ui_file').toLowerCase();
const fileLang = useMemo(() => langFromPath(filePath), [filePath]);
const argsPreview = isCreate ? authoredContent : editArgsPreview;
const outputIsDiff = hasDiff(output);
@ -147,11 +152,13 @@ export default function FileAuthoringCall({
const showOutputSection = !!output && preview !== output;
const previewIsDiff = outputIsDiff || (!isCreate && !!editArgsPreview && preview !== output);
let previewLang = 'plaintext';
if (previewIsDiff) {
previewLang = 'diff';
} else if (isCreate && authoredContent && preview === authoredContent) {
if (isCreate && authoredContent && preview === authoredContent) {
previewLang = fileLang;
}
const parsedDiff = useMemo(
() => (previewIsDiff && preview ? parseUnifiedDiff(preview) : null),
[previewIsDiff, preview],
);
const { showCode, toggleCode, expandStyle, expandRef, phase } = useToolCallState({
initialProgress,
@ -162,7 +169,7 @@ export default function FileAuthoringCall({
runStepStatus,
});
const highlighted = useLazyHighlight(preview || undefined, previewLang);
const highlighted = useLazyHighlight(!parsedDiff && preview ? preview : undefined, previewLang);
const { ref: previewPaneRef, onScroll: onPreviewPaneScroll } = useFollowScroll<HTMLPreElement>(
highlighted ?? preview,
phase === 'running',
@ -209,22 +216,39 @@ export default function FileAuthoringCall({
<div style={expandStyle}>
<div className="overflow-hidden" ref={expandRef}>
{!!preview && (
<div className="my-2 overflow-hidden rounded-lg border border-border-light bg-surface-secondary">
<CodeWindowHeader language={previewIsDiff ? 'diff' : fileName} code={preview} />
<pre
ref={previewPaneRef}
onScroll={onPreviewPaneScroll}
className="max-h-[300px] overflow-auto bg-surface-chat p-4 font-mono text-xs dark:bg-surface-primary-alt"
>
<code className={`hljs language-${previewLang} !whitespace-pre`}>
{highlighted ?? preview}
</code>
</pre>
<div
className={cn(
toolPanelSpacingClassName,
'overflow-hidden rounded-lg border border-border-light bg-surface-secondary',
)}
>
<CodeWindowHeader
language={fileName}
code={preview}
diffStats={
parsedDiff
? { additions: parsedDiff.additions, deletions: parsedDiff.deletions }
: undefined
}
/>
{parsedDiff ? (
<DiffView parsed={parsedDiff} />
) : (
<pre
ref={previewPaneRef}
onScroll={onPreviewPaneScroll}
className="max-h-[300px] overflow-auto bg-surface-chat p-4 font-mono text-xs dark:bg-surface-primary-alt"
>
<code className={`hljs language-${previewLang} !whitespace-pre`}>
{highlighted ?? preview}
</code>
</pre>
)}
{showOutputSection && (
<pre
className={cn(
'max-h-[300px] overflow-auto whitespace-pre-wrap break-words border-t border-border-light px-3 py-2.5 font-mono text-xs',
phase === 'failed' ? 'text-status-error' : 'text-text-primary',
'max-h-[300px] overflow-auto whitespace-pre-wrap break-words border-t border-border-light px-3 py-2 font-mono text-xs',
phase === 'failed' ? 'text-status-error' : 'text-text-secondary',
)}
>
{output}

View file

@ -158,9 +158,8 @@ describe('FileAuthoringCall', () => {
/>,
);
const preview = screen.getByText((_, element) => element?.tagName.toLowerCase() === 'code');
expect(preview).toHaveTextContent('-old line');
expect(preview).toHaveTextContent('+new line');
expect(screen.getByText('old line').closest('div')).toHaveClass('bg-red-500/10');
expect(screen.getByText('new line').closest('div')).toHaveClass('bg-green-500/10');
expect(screen.queryByText(/--- old_text/)).not.toBeInTheDocument();
});
@ -179,9 +178,8 @@ describe('FileAuthoringCall', () => {
/>,
);
const preview = screen.getByText((_, element) => element?.tagName.toLowerCase() === 'code');
expect(preview).toHaveTextContent('-missing text');
expect(preview).toHaveTextContent('+replacement');
expect(screen.getByText('missing text').closest('div')).toHaveClass('bg-red-500/10');
expect(screen.getByText('replacement').closest('div')).toHaveClass('bg-green-500/10');
expect(screen.getByText(/matched 0 locations/)).toBeInTheDocument();
});
@ -201,12 +199,14 @@ describe('FileAuthoringCall', () => {
);
expect(screen.getByTestId('progress-text')).toHaveTextContent('Editing SKILL.md');
expect(screen.getByTestId('code-window-header')).toHaveAttribute('data-language', 'diff');
const preview = screen.getByText((_, element) => element?.tagName.toLowerCase() === 'code');
expect(preview).toHaveTextContent('--- old_text');
expect(preview).toHaveTextContent('+++ new_text');
expect(preview).toHaveTextContent('-description: Old behavior');
expect(preview).toHaveTextContent('+description: New behavior');
expect(screen.getByTestId('code-window-header')).toHaveAttribute('data-language', 'SKILL.md');
expect(screen.queryByText(/--- old_text/)).not.toBeInTheDocument();
expect(screen.getByText('description: Old behavior').closest('div')).toHaveClass(
'bg-red-500/10',
);
expect(screen.getByText('description: New behavior').closest('div')).toHaveClass(
'bg-green-500/10',
);
});
it('streams create_file content from partial JSON string args during run_step_delta', () => {
@ -238,9 +238,10 @@ describe('FileAuthoringCall', () => {
/>,
);
const preview = screen.getByText((_, element) => element?.tagName.toLowerCase() === 'code');
expect(preview).toHaveTextContent('-description: Old behavior');
expect(preview).toHaveTextContent('+description: New beh');
expect(screen.getByText('description: Old behavior').closest('div')).toHaveClass(
'bg-red-500/10',
);
expect(screen.getByText('description: New beh').closest('div')).toHaveClass('bg-green-500/10');
});
it('streams batched edit_file previews from a partial edits array', () => {
@ -258,13 +259,12 @@ describe('FileAuthoringCall', () => {
/>,
);
const preview = screen.getByText((_, element) => element?.tagName.toLowerCase() === 'code');
expect(preview).toHaveTextContent('--- old_text 1');
expect(preview).toHaveTextContent('-first old');
expect(preview).toHaveTextContent('+first new');
expect(preview).toHaveTextContent('--- old_text 2');
expect(preview).toHaveTextContent('-second old');
expect(preview).toHaveTextContent('+second n');
const diff = screen.getByTestId('diff-view');
expect(screen.getByText('first old').closest('div')).toHaveClass('bg-red-500/10');
expect(screen.getByText('first new').closest('div')).toHaveClass('bg-green-500/10');
expect(screen.getByText('second old').closest('div')).toHaveClass('bg-red-500/10');
expect(screen.getByText('second n').closest('div')).toHaveClass('bg-green-500/10');
expect(diff).toBeInTheDocument();
});
it('shows batched edit_file replacements from edits args while the call is in progress', () => {
@ -284,11 +284,8 @@ describe('FileAuthoringCall', () => {
/>,
);
const preview = screen.getByText((_, element) => element?.tagName.toLowerCase() === 'code');
expect(preview).toHaveTextContent('--- old_text 1');
expect(preview).toHaveTextContent('+++ new_text 2');
expect(preview).toHaveTextContent('-first old');
expect(preview).toHaveTextContent('+second new');
expect(screen.getByText('first old').closest('div')).toHaveClass('bg-red-500/10');
expect(screen.getByText('second new').closest('div')).toHaveClass('bg-green-500/10');
});
});