From af8949ea852a0bfd8b18103349c6b54e43f3d085 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:25:39 +0200 Subject: [PATCH] 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. --- .../Content/Parts/CodeWindowHeader.tsx | 11 +- .../Chat/Messages/Content/Parts/DiffView.tsx | 141 ++++++++++++++++++ .../Content/Parts/FileAuthoringCall.tsx | 62 +++++--- .../__tests__/FileAuthoringCall.test.tsx | 51 +++---- 4 files changed, 217 insertions(+), 48 deletions(-) create mode 100644 client/src/components/Chat/Messages/Content/Parts/DiffView.tsx diff --git a/client/src/components/Chat/Messages/Content/Parts/CodeWindowHeader.tsx b/client/src/components/Chat/Messages/Content/Parts/CodeWindowHeader.tsx index a65fe5ca5f..6057bc0bf8 100644 --- a/client/src/components/Chat/Messages/Content/Parts/CodeWindowHeader.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/CodeWindowHeader.tsx @@ -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>(); @@ -25,9 +26,15 @@ export default function CodeWindowHeader({ language, code }: CodeWindowHeaderPro return (
- + {language} + {diffStats && ( + + )}
diff --git a/client/src/components/Chat/Messages/Content/Parts/DiffView.tsx b/client/src/components/Chat/Messages/Content/Parts/DiffView.tsx new file mode 100644 index 0000000000..4e3bb4e900 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Parts/DiffView.tsx @@ -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 = { + add: '+', + del: '-', + context: '', + hunk: '', +}; + +export default function DiffView({ parsed }: { parsed: ParsedDiff }) { + const { lines, hasLineNumbers } = parsed; + const firstContentIndex = lines.findIndex((line) => line.type !== 'hunk'); + + return ( +
+ {lines.map((line, index) => { + if (line.type === 'hunk') { + if (index < firstContentIndex || firstContentIndex === -1) { + return null; + } + if (!line.text) { + return
; + } + return ( +
+ {line.text} +
+ ); + } + return ( +
+ {hasLineNumbers && ( + <> + {line.oldLine ?? ''} + {line.newLine ?? ''} + + )} + + {LINE_MARKERS[line.type]} + + + {line.text || ' '} + +
+ ); + })} +
+ ); +} diff --git a/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx b/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx index 331677a00e..a37c467846 100644 --- a/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx @@ -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( highlighted ?? preview, phase === 'running', @@ -209,22 +216,39 @@ export default function FileAuthoringCall({
{!!preview && ( -
- -
-                
-                  {highlighted ?? preview}
-                
-              
+
+ + {parsedDiff ? ( + + ) : ( +
+                  
+                    {highlighted ?? preview}
+                  
+                
+ )} {showOutputSection && (
                   {output}
diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/FileAuthoringCall.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/FileAuthoringCall.test.tsx
index db8266c33a..17f30b60df 100644
--- a/client/src/components/Chat/Messages/Content/Parts/__tests__/FileAuthoringCall.test.tsx
+++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/FileAuthoringCall.test.tsx
@@ -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');
   });
 });