From 9b182ee0cc28678f4929dc6fabccd6200f90e9f4 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 28 Feb 2026 01:20:24 +0200 Subject: [PATCH] fix: render multi-file patch diffs correctly in chat --- .../chat/message/ToolOutputDialog.tsx | 20 +++ .../chat/message/parts/ToolPart.tsx | 128 +++++++++++++++++- .../src/components/views/PierreDiffViewer.tsx | 4 +- packages/ui/src/index.css | 2 +- 4 files changed, 144 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx index e65a9287..8ad99303 100644 --- a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx +++ b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx @@ -97,6 +97,23 @@ type PierreThemeConfig = { themeType: 'light' | 'dark'; }; +const TOOL_DIFF_UNSAFE_CSS = ` + [data-diff-header], + [data-diff] { + [data-separator] { + height: 24px !important; + } + } +`; + +const TOOL_DIFF_METRICS = { + hunkLineCount: 50, + lineHeight: 24, + diffHeaderHeight: 44, + hunkSeparatorHeight: 24, + fileGap: 0, +}; + const usePierreThemeConfig = (): PierreThemeConfig => { const themeSystem = useOptionalThemeSystem(); const fallbackLightTheme = React.useMemo(() => getDefaultTheme(false), []); @@ -500,16 +517,19 @@ const DialogUnifiedDiff: React.FC<{
diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index f01dac67..a308ee16 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -670,21 +670,120 @@ interface DiffPreviewProps { diffViewMode: DiffViewMode; } +const TOOL_DIFF_UNSAFE_CSS = ` + [data-diff-header], + [data-diff] { + [data-separator] { + height: 24px !important; + } + } +`; + +const TOOL_DIFF_METRICS = { + hunkLineCount: 50, + lineHeight: 24, + diffHeaderHeight: 44, + hunkSeparatorHeight: 24, + fileGap: 0, +}; + +type DiffPatchEntry = { + id: string; + title: string; + patch: string; +}; + +const renderPathLikeGitChanges = (path: string) => { + const lastSlash = path.lastIndexOf('/'); + if (lastSlash === -1) { + return {path}; + } + + const dir = path.slice(0, lastSlash); + const name = path.slice(lastSlash + 1); + + return ( + + + {dir} + + + / + {name} + + + ); +}; + +const getDiffPatchEntries = ( + metadata: Record | undefined, + fallbackDiff: string, + currentDirectory: string, + isMobile: boolean, +): DiffPatchEntry[] => { + const files = Array.isArray(metadata?.files) ? metadata.files : []; + + const entries = files + .map((file, index) => { + if (!file || typeof file !== 'object') { + return null; + } + + const record = file as { relativePath?: unknown; filePath?: unknown; diff?: unknown }; + const patch = typeof record.diff === 'string' ? record.diff.trim() : ''; + if (!patch) { + return null; + } + + const rawPath = typeof record.relativePath === 'string' + ? record.relativePath + : typeof record.filePath === 'string' + ? record.filePath + : `File ${index + 1}`; + + const title = typeof rawPath === 'string' + ? getRelativePath(rawPath, currentDirectory, isMobile) + : `File ${index + 1}`; + + return { + id: `${title}-${index}`, + title, + patch, + } satisfies DiffPatchEntry; + }) + .filter((entry): entry is DiffPatchEntry => entry !== null); + + if (entries.length > 0) { + return entries; + } + + return [ + { + id: 'diff-0', + title: 'Diff', + patch: fallbackDiff, + }, + ]; +}; + const DiffPreview: React.FC = React.memo(({ diff, pierreTheme, pierreThemeType, diffViewMode }) => { return (
@@ -894,6 +993,10 @@ const ToolExpandedContent: React.FC = React.memo(({ const outputString = typeof rawOutput === 'string' ? rawOutput : ''; const diffContent = typeof metadata?.diff === 'string' ? (metadata.diff as string) : null; + const diffEntries = React.useMemo( + () => (diffContent ? getDiffPatchEntries(metadata, diffContent, currentDirectory, isMobile) : []), + [currentDirectory, diffContent, isMobile, metadata] + ); const writeFilePath = part.tool === 'write' ? typeof input?.filePath === 'string' ? input.filePath @@ -1085,14 +1188,25 @@ const ToolExpandedContent: React.FC = React.memo(({ ); } - if ((part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch') && diffContent) { + if ((part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch') && diffEntries.length > 0) { return renderScrollableBlock( - , +
+ {diffEntries.map((entry) => ( +
+ {diffEntries.length > 1 ? ( +
+ {renderPathLikeGitChanges(entry.title)} +
+ ) : null} + +
+ ))} +
, { className: 'p-1' } ); } diff --git a/packages/ui/src/components/views/PierreDiffViewer.tsx b/packages/ui/src/components/views/PierreDiffViewer.tsx index 3891f01b..ee9859e2 100644 --- a/packages/ui/src/components/views/PierreDiffViewer.tsx +++ b/packages/ui/src/components/views/PierreDiffViewer.tsx @@ -63,7 +63,7 @@ const WEBKIT_SCROLL_FIX_CSS = ` pre[data-interactive-line-numbers] [data-line-number] { touch-action: manipulation; } - /* Match OpenCode hunk separator sizing */ + [data-diff-header], [data-diff] { [data-separator] { @@ -201,7 +201,7 @@ export const PierreDiffViewer: React.FC = ({ layout = 'fill', }) => { const themeContext = useOptionalThemeSystem(); - + const isDark = themeContext?.currentTheme.metadata.variant === 'dark'; const lightTheme = themeContext?.availableThemes.find(t => t.metadata.id === themeContext.lightThemeId) ?? getDefaultTheme(false); const darkTheme = themeContext?.availableThemes.find(t => t.metadata.id === themeContext.darkThemeId) ?? getDefaultTheme(true); diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 4ad9df22..ebdc4561 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -584,7 +584,7 @@ html:not(.dark) .chat-scroll { } /* Desktop: slightly smaller diff code text */ - + .pierre-diff-wrapper diffs-container { display: block;