diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 5957b171..80e4bc2c 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -44,6 +44,7 @@ import { useDurationTickerNow } from './useDurationTicker'; import { resolveFallbackTaskSessionId } from './resolveFallbackTaskSessionId'; import { areRenderRelevantPartsEqual } from '../renderCompare'; import { useI18n } from '@/lib/i18n'; +import { getDiffPatchEntries, getPatchText } from './toolDiffUtils'; const TOOL_ROW_TEXT_CLASS = '!text-[length:var(--text-meta)] !leading-4 sm:!leading-6 tracking-normal'; const TOOL_ROW_TITLE_CLASS = cn('typography-meta font-medium', TOOL_ROW_TEXT_CLASS); @@ -308,21 +309,6 @@ const extractFirstChangedLineFromDiff = (diffText: string): number | undefined = return firstHunkStart; }; -const getPatchText = (value: unknown): string | undefined => { - if (typeof value === 'string') { - return /\S/.test(value) ? value : undefined; - } - - if (value && typeof value === 'object') { - const patch = (value as { patch?: unknown }).patch; - if (typeof patch === 'string') { - return /\S/.test(patch) ? patch : undefined; - } - } - - return undefined; -}; - const buildWritePreviewPatch = (filePath: string | undefined, content: string): string | undefined => { const normalizedContent = content.replace(/\r\n/g, '\n'); if (!normalizedContent.trim()) { @@ -1298,70 +1284,6 @@ const TOOL_NORMAL_ICON_STYLE: React.CSSProperties = { color: 'var(--tools-icon)' const TOOL_ERROR_TITLE_STYLE: React.CSSProperties = { color: 'var(--status-error)' }; const TOOL_NORMAL_TITLE_STYLE: React.CSSProperties = { color: 'var(--tools-title)' }; -type DiffPatchEntry = { - id: string; - title: string; - patch: string; -}; - -const hasUnifiedDiffHunk = (patch: string): boolean => /^@@\s+-\d+(?:,\d+)?\s+\+\d+(?:,\d+)?\s+@@/m.test(patch); - -const isUnifiedFileHeaderPair = (minusLine: string, plusLine: string): boolean => { - if (!minusLine.startsWith('--- ') || !plusLine.startsWith('+++ ')) { - return false; - } - - const oldPath = minusLine.slice(4).trim(); - const newPath = plusLine.slice(4).trim(); - return oldPath.length > 0 && newPath.length > 0; -}; - -const getUnifiedDiffPath = (patch: string, fallbackTitle: string): string => { - const plusHeader = patch.match(/^\+\+\+\s+(?:[ab]\/(.+)|(.+))$/m); - const rawPath = plusHeader?.[1] ?? plusHeader?.[2]; - if (!rawPath || rawPath === '/dev/null') { - return fallbackTitle; - } - return rawPath; -}; - -const splitUnifiedDiffPatch = (patch: string): DiffPatchEntry[] => { - const normalized = patch.replace(/\r\n/g, '\n').trim(); - if (!normalized) { - return []; - } - - const lines = normalized.split('\n'); - const starts: number[] = []; - - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index] ?? ''; - const nextLine = lines[index + 1] ?? ''; - if (line.startsWith('diff --git ') || line.startsWith('Index: ') || isUnifiedFileHeaderPair(line, nextLine)) { - starts.push(index); - } - } - - const chunks = starts.length > 0 - ? starts.map((start, index) => lines.slice(start, starts[index + 1] ?? lines.length).join('\n').trim()) - : [normalized]; - - return chunks - .map((chunk, index) => { - if (!hasUnifiedDiffHunk(chunk)) { - return null; - } - - const title = getUnifiedDiffPath(chunk, `Diff ${index + 1}`); - return { - id: `${title}-${index}`, - title, - patch: chunk, - } satisfies DiffPatchEntry; - }) - .filter((entry): entry is DiffPatchEntry => entry !== null); -}; - const renderPathLikeGitChanges = (path: string, grow = true) => { const lastSlash = path.lastIndexOf('/'); if (lastSlash === -1) { @@ -1447,60 +1369,48 @@ const renderAnimatedPathWithIcon = (path: string, animate = true, grow = true, s ); }; -const getDiffPatchEntries = ( - metadata: Record | undefined, - fallbackDiff: string, - currentDirectory: string, -): DiffPatchEntry[] => { - const files = Array.isArray(metadata?.files) ? metadata.files : []; +const PlainDiffFallback: React.FC<{ diff: string }> = ({ diff }) => ( +
+        {diff}
+    
+); - const entries = files - .map((file, index) => { - if (!file || typeof file !== 'object') { - return null; - } +class DiffPreviewErrorBoundary extends React.Component<{ + resetKey: string; + fallback: React.ReactNode; + children: React.ReactNode; +}, { hasError: boolean }> { + state = { hasError: false }; - const record = file as { relativePath?: unknown; filePath?: unknown; patch?: unknown; diff?: unknown }; - const patch = getPatchText(record.patch) ?? getPatchText(record.diff) ?? ''; - const splitPatchEntries = splitUnifiedDiffPatch(patch); - if (!patch || splitPatchEntries.length === 0) { - 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) - : `File ${index + 1}`; - - return splitPatchEntries.map((entry, splitIndex) => ({ - id: `${title}-${index}-${splitIndex}`, - title: splitPatchEntries.length === 1 ? title : getRelativePath(entry.title, currentDirectory), - patch: entry.patch, - } satisfies DiffPatchEntry)); - }) - .flat() - .filter((entry): entry is DiffPatchEntry => entry !== null); - - if (entries.length > 0) { - return entries; + static getDerivedStateFromError(): { hasError: boolean } { + return { hasError: true }; } - const splitEntries = splitUnifiedDiffPatch(fallbackDiff).map((entry) => ({ - ...entry, - title: getRelativePath(entry.title, currentDirectory), - })); - - if (splitEntries.length > 0) { - return splitEntries; + componentDidUpdate(prevProps: { resetKey: string }) { + if (prevProps.resetKey !== this.props.resetKey && this.state.hasError) { + this.setState({ hasError: false }); + } } - return []; -}; + componentDidCatch(error: Error) { + if (process.env.NODE_ENV === 'development') { + console.warn('Tool diff preview failed; rendering raw patch instead.', error); + } + } + + render() { + if (this.state.hasError) { + return this.props.fallback; + } + return this.props.children; + } +} const DiffPreview: React.FC = React.memo(({ diff, pierreTheme, pierreThemeType, diffViewMode }) => { const options = React.useMemo( @@ -1520,14 +1430,18 @@ const DiffPreview: React.FC = React.memo(({ diff, pierreTheme, [diffViewMode, pierreTheme, pierreThemeType] ); + const fallback = ; + return (
- + + +
); }); @@ -1559,13 +1473,17 @@ const ToolExpandedContent: React.FC = React.memo(({ const hasStringOutput = typeof rawOutput === 'string' && rawOutput.length > 0; const outputString = typeof rawOutput === 'string' ? rawOutput : ''; + const fileDiff = isRecord(metadata?.filediff) ? metadata.filediff : undefined; const diffContent = getPatchText((metadata as { patch?: unknown } | undefined)?.patch) ?? getPatchText(metadata?.diff) + ?? getPatchText(fileDiff?.patch) + ?? getPatchText(fileDiff?.diff) ?? null; const diffEntries = React.useMemo( - () => (diffContent ? getDiffPatchEntries(metadata, diffContent, currentDirectory) : []), + () => getDiffPatchEntries(metadata, diffContent ?? undefined, (path) => getRelativePath(path, currentDirectory)), [currentDirectory, diffContent, metadata] ); + const hasVisualDiffEntry = diffEntries.some((entry) => entry.renderMode === 'diff'); const hideToolInputPreview = part.tool === 'apply_patch' || part.tool === 'edit' || part.tool === 'multiedit'; @@ -1752,12 +1670,16 @@ const ToolExpandedContent: React.FC = React.memo(({ {renderPathLikeGitChanges(entry.title)} ) : null} - + {entry.renderMode === 'diff' ? ( + + ) : ( + + )} ))} {renderDiagnosticsSection()} @@ -1840,7 +1762,7 @@ const ToolExpandedContent: React.FC = React.memo(({ {state.status === 'completed' && 'output' in state && (
- {(part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch' || part.tool === 'write') && diffContent ? ( + {(part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch' || part.tool === 'write') && hasVisualDiffEntry ? (
= React.memo(({ ToolExpandedContent.displayName = 'ToolExpandedContent'; -const ToolPart: React.FC = ({ +const ToolPartContent: React.FC = ({ part, isExpanded, onToggle, @@ -2831,6 +2753,72 @@ const ToolPart: React.FC = ({ ); }; +class ToolPartErrorBoundary extends React.Component<{ + children: React.ReactNode; + displayName: string; + errorLabel: string; + resetKey: unknown; + toolName: string; +}, { hasError: boolean; error?: Error }> { + state: { hasError: boolean; error?: Error } = { hasError: false }; + + static getDerivedStateFromError(error: Error): { hasError: boolean; error: Error } { + return { hasError: true, error }; + } + + componentDidUpdate(prevProps: { resetKey: unknown }) { + if (prevProps.resetKey !== this.props.resetKey && this.state.hasError) { + this.setState({ hasError: false, error: undefined }); + } + } + + componentDidCatch(error: Error) { + if (process.env.NODE_ENV === 'development') { + console.warn('Tool part failed to render; showing safe fallback.', error); + } + } + + render() { + if (!this.state.hasError) { + return this.props.children; + } + + const message = this.state.error?.message; + return ( +
+
+ {getToolIcon(this.props.toolName)} +
+ + {this.props.displayName} + + {message ? ( + + {this.props.errorLabel}: {message} + + ) : null} +
+ ); + } +} + +const ToolPart: React.FC = (props) => { + const { t } = useI18n(); + const toolName = normalizeToolName(props.part.tool) || 'tool'; + const displayName = getToolMetadata(toolName).displayName; + + return ( + + + + ); +}; + export default React.memo(ToolPart, (prev, next) => { return areRenderRelevantPartsEqual([prev.part], [next.part]) && prev.isExpanded === next.isExpanded diff --git a/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts b/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts new file mode 100644 index 00000000..58929c5d --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from 'bun:test'; + +import { getDiffPatchEntries, getRenderablePatchInfo } from './toolDiffUtils'; + +const identity = (path: string) => path; + +describe('toolDiffUtils', () => { + test('treats raw apply_patch envelopes as text, not visual diffs', () => { + const entries = getDiffPatchEntries(undefined, [ + '*** Begin Patch', + '*** Update File: src/app.ts', + '@@ -1 +1 @@', + '-old', + '+new', + '*** End Patch', + ].join('\n'), identity); + + expect(entries).toHaveLength(1); + expect(entries[0]?.renderMode).toBe('text'); + expect(entries[0]?.patch).toContain('*** Begin Patch'); + }); + + test('splits multi-file unified patches into one renderable entry per file', () => { + const entries = getDiffPatchEntries(undefined, [ + '--- a/src/a.ts', + '+++ b/src/a.ts', + '@@ -1 +1 @@', + '-old', + '+new', + '--- a/src/b.ts', + '+++ b/src/b.ts', + '@@ -1 +1 @@', + '-left', + '+right', + ].join('\n'), identity); + + expect(entries.map((entry) => entry.renderMode)).toEqual(['diff', 'diff']); + expect(entries.map((entry) => entry.title)).toEqual(['src/a.ts', 'src/b.ts']); + }); + + test('uses metadata.files patches before top-level fallback diffs', () => { + const entries = getDiffPatchEntries({ + files: [{ + relativePath: 'src/file.ts', + patch: [ + '--- a/src/file.ts', + '+++ b/src/file.ts', + '@@ -1 +1 @@', + '-old', + '+new', + ].join('\n'), + }], + }, 'not a diff', identity); + + expect(entries).toHaveLength(1); + expect(entries[0]?.renderMode).toBe('diff'); + expect(entries[0]?.title).toBe('src/file.ts'); + }); + + test('synthesizes headers for valid headerless hunks', () => { + const entries = getDiffPatchEntries(undefined, [ + '@@ -1 +1 @@', + '-old', + '+new', + ].join('\n'), identity); + + expect(entries).toHaveLength(1); + expect(entries[0]?.renderMode).toBe('diff'); + expect(getRenderablePatchInfo(entries[0]?.patch ?? '')).not.toBeNull(); + }); + + test('keeps malformed unified patches as text fallbacks', () => { + const entries = getDiffPatchEntries(undefined, [ + '--- a/src/file.ts', + '+++ b/src/file.ts', + '@@', + '-old', + '+new', + ].join('\n'), identity); + + expect(entries).toHaveLength(1); + expect(entries[0]?.renderMode).toBe('text'); + expect(entries[0]?.patch).toContain('@@'); + }); +}); diff --git a/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts b/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts new file mode 100644 index 00000000..ea8dd07b --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts @@ -0,0 +1,234 @@ +import { parsePatchFiles } from '@pierre/diffs'; + +export type DiffPatchEntry = { + id: string; + title: string; + patch: string; + renderMode: 'diff' | 'text'; +}; + +const APPLY_PATCH_ENVELOPE_PATTERN = /^\*\*\*\s+(?:Begin Patch|End Patch|Add File:|Update File:|Delete File:|Move to:)/m; +const HUNK_HEADER_PATTERN = /^@@\s+-\d+(?:,\d+)?\s+\+\d+(?:,\d+)?\s+@@/m; +const GIT_DIFF_FILE_BREAK_PATTERN = /(?=^diff --git\s+)/gm; +const GIT_DIFF_FILE_BREAK_TEST = /^diff --git\s+/m; +const UNIFIED_DIFF_FILE_BREAK_PATTERN = /(?=^---\s+\S)/gm; +const UNIFIED_DIFF_FILE_BREAK_TEST = /^---\s+\S/m; + +const isRecord = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null; +}; + +export const normalizePatchText = (patch: string): string => { + return patch.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim(); +}; + +export const getPatchText = (value: unknown): string | undefined => { + if (typeof value === 'string') { + return /\S/.test(value) ? value : undefined; + } + + if (isRecord(value)) { + const patch = value.patch; + if (typeof patch === 'string') { + return /\S/.test(patch) ? patch : undefined; + } + } + + return undefined; +}; + +const normalizeParsedPath = (path: string | undefined): string => { + const trimmed = (path ?? '').trim().replace(/\t.*$/, ''); + if (!trimmed || trimmed === '/dev/null') { + return ''; + } + return trimmed.replace(/^[ab]\//, ''); +}; + +const makeSyntheticPath = (title: string): string => { + const normalized = title.trim().replace(/\s+/g, '-'); + return normalized.length > 0 ? normalized : 'file'; +}; + +const hasOnlyUnifiedDiffBodyLines = (patch: string): boolean => { + let inHunk = false; + for (const line of patch.split('\n')) { + if (line.startsWith('@@')) { + if (!HUNK_HEADER_PATTERN.test(line)) { + return false; + } + inHunk = true; + continue; + } + + if (!inHunk || line.length === 0) { + continue; + } + + const first = line[0]; + if (first !== ' ' && first !== '+' && first !== '-' && first !== '\\') { + return false; + } + } + + return true; +}; + +export const getRenderablePatchInfo = (patch: string): { patch: string; title?: string } | null => { + const normalized = normalizePatchText(patch); + if ( + !normalized + || APPLY_PATCH_ENVELOPE_PATTERN.test(normalized) + || !HUNK_HEADER_PATTERN.test(normalized) + || !hasOnlyUnifiedDiffBodyLines(normalized) + ) { + return null; + } + + try { + const parsedPatches = parsePatchFiles(normalized, undefined, true); + if (parsedPatches.length !== 1) { + return null; + } + + const files = parsedPatches[0]?.files ?? []; + const file = files[0]; + if (files.length !== 1 || !file || file.hunks.length === 0) { + return null; + } + + return { + patch: normalized, + title: normalizeParsedPath(file.name), + }; + } catch { + return null; + } +}; + +const getPatchChunks = (patch: string): string[] => { + const isGitDiff = GIT_DIFF_FILE_BREAK_TEST.test(patch); + const hasUnifiedDiff = UNIFIED_DIFF_FILE_BREAK_TEST.test(patch); + if (!isGitDiff && !hasUnifiedDiff) { + return []; + } + + return patch + .split(isGitDiff ? GIT_DIFF_FILE_BREAK_PATTERN : UNIFIED_DIFF_FILE_BREAK_PATTERN) + .map((chunk) => chunk.trim()) + .filter((chunk) => chunk.length > 0); +}; + +const getPatchEntriesFromText = ( + patch: string, + fallbackTitle: string, + idPrefix: string, + resolveTitle: (path: string) => string, +): DiffPatchEntry[] => { + const normalized = normalizePatchText(patch); + if (!normalized) { + return []; + } + + const direct = getRenderablePatchInfo(normalized); + if (direct) { + const title = direct.title ? resolveTitle(direct.title) : resolveTitle(fallbackTitle); + return [{ id: `${idPrefix}-0`, title, patch: direct.patch, renderMode: 'diff' }]; + } + + const chunkEntries: DiffPatchEntry[] = []; + for (const chunk of getPatchChunks(normalized)) { + const info = getRenderablePatchInfo(chunk); + const title = info?.title ? resolveTitle(info.title) : resolveTitle(fallbackTitle); + if (!info) { + if (HUNK_HEADER_PATTERN.test(chunk) || GIT_DIFF_FILE_BREAK_TEST.test(chunk) || UNIFIED_DIFF_FILE_BREAK_TEST.test(chunk)) { + chunkEntries.push({ + id: `${idPrefix}-${chunkEntries.length}`, + title, + patch: chunk, + renderMode: 'text', + }); + } + continue; + } + chunkEntries.push({ + id: `${idPrefix}-${chunkEntries.length}`, + title, + patch: info.patch, + renderMode: 'diff', + }); + } + + if (chunkEntries.length > 0) { + return chunkEntries; + } + + if (!APPLY_PATCH_ENVELOPE_PATTERN.test(normalized) && HUNK_HEADER_PATTERN.test(normalized)) { + const syntheticPath = makeSyntheticPath(fallbackTitle); + const synthetic = getRenderablePatchInfo(`--- ${syntheticPath}\n+++ ${syntheticPath}\n${normalized}`); + if (synthetic) { + return [{ + id: `${idPrefix}-0`, + title: resolveTitle(fallbackTitle), + patch: synthetic.patch, + renderMode: 'diff', + }]; + } + } + + return [{ + id: `${idPrefix}-0`, + title: resolveTitle(fallbackTitle), + patch: normalized, + renderMode: 'text', + }]; +}; + +const getFilePatch = (file: unknown): { patch: string; title: string } | null => { + if (!isRecord(file)) { + return null; + } + + const patch = getPatchText(file.patch) ?? getPatchText(file.diff); + if (!patch) { + return null; + } + + const rawPath = typeof file.relativePath === 'string' + ? file.relativePath + : typeof file.filePath === 'string' + ? file.filePath + : ''; + + return { + patch, + title: rawPath, + }; +}; + +export const getDiffPatchEntries = ( + metadata: Record | undefined, + fallbackDiff: string | undefined, + resolveTitle: (path: string) => string, +): DiffPatchEntry[] => { + const files = Array.isArray(metadata?.files) ? metadata.files : []; + const fileEntries = files.flatMap((file, index) => { + const filePatch = getFilePatch(file); + if (!filePatch) { + return []; + } + return getPatchEntriesFromText( + filePatch.patch, + filePatch.title || `File ${index + 1}`, + `file-${index}`, + resolveTitle, + ); + }); + + if (fileEntries.length > 0) { + return fileEntries; + } + + const diff = typeof fallbackDiff === 'string' ? fallbackDiff : ''; + return getPatchEntriesFromText(diff, 'Diff', 'fallback', resolveTitle); +};