mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 18:13:27 +00:00
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:
parent
2fb33ad0b4
commit
af8949ea85
4 changed files with 217 additions and 48 deletions
|
|
@ -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>
|
||||
|
|
|
|||
141
client/src/components/Chat/Messages/Content/Parts/DiffView.tsx
Normal file
141
client/src/components/Chat/Messages/Content/Parts/DiffView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue