From 8eee5b27cd67aceaca7240b41d942f02e92336e0 Mon Sep 17 00:00:00 2001 From: Nabeel Siddiqui Date: Sat, 1 Aug 2026 13:58:48 -0400 Subject: [PATCH] fix(vscode): open each apply patch file --- .../chat/message/parts/ToolPart.tsx | 55 +++++++++++++++---- .../chat/message/parts/toolDiffUtils.test.ts | 35 +++++++++++- .../chat/message/parts/toolDiffUtils.ts | 38 +++++++++---- 3 files changed, 105 insertions(+), 23 deletions(-) diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 59943789..9a132831 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -53,7 +53,7 @@ import { } from './taskToolModel'; import { areRenderRelevantPartsEqual } from '../renderCompare'; import { useI18n } from '@/lib/i18n'; -import { getDiffPatchEntries, getPatchText, getPrimaryToolPath, type DiffPatchEntry } from './toolDiffUtils'; +import { getApplyPatchFilePath, getDiffPatchEntries, getPatchText, getPrimaryToolPath, type DiffPatchEntry } from './toolDiffUtils'; import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat'; import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle'; import { getStreamingOutputAppend, getToolOutput } from './toolOutput'; @@ -80,6 +80,7 @@ const getMultiFileDescription = ( metadata: Record | undefined, animate = true, showFileIcons = true, + onFileClick?: (file: Record, event: React.MouseEvent) => void, ): React.ReactNode => { const files = Array.isArray(metadata?.files) ? metadata?.files : []; if (files.length <= 1) return null; @@ -103,10 +104,11 @@ const getMultiFileDescription = ( return base + incoming; }; - const entriesByPath = new Map(); + const entriesByPath = new Map; path: string; name: string; added: number | null; removed: number | null }>(); for (const file of files) { - const fileObj = file as { relativePath?: string; filePath?: string; additions?: unknown; deletions?: unknown }; + if (!file || typeof file !== 'object') continue; + const fileObj = file as Record & { relativePath?: string; filePath?: string; additions?: unknown; deletions?: unknown }; const filePath = fileObj.relativePath || fileObj.filePath || ''; if (!filePath) continue; const fileName = filePath.split('/').pop() || filePath; @@ -120,7 +122,7 @@ const getMultiFileDescription = ( continue; } - entriesByPath.set(filePath, { path: filePath, name: fileName, added, removed }); + entriesByPath.set(filePath, { file: fileObj, path: filePath, name: fileName, added, removed }); } const entries = Array.from(entriesByPath.values()); @@ -129,8 +131,8 @@ const getMultiFileDescription = ( <> {entries.map((entry) => { const hasPerFileDiff = entry.added !== null || entry.removed !== null; - return ( - + const content = ( + <> {showFileIcons ? : null} -{entry.removed ?? 0} ) : null} + + ); + const canOpen = onFileClick && entry.file.type !== 'delete' && getApplyPatchFilePath(entry.file); + return canOpen ? ( + + ) : ( + + {content} ); })} @@ -1633,9 +1652,7 @@ const ToolExpandedContent: React.FC = React.memo(({ ); const renderResultContent = () => { - const getEntryAbsolutePath = (entry: DiffPatchEntry) => ( - entry.title.startsWith('/') ? entry.title : `${currentDirectory}/${entry.title}`.replace(/\/+/g, '/') - ); + const getEntryAbsolutePath = (entry: DiffPatchEntry) => toAbsoluteFilePath(currentDirectory, entry.filePath ?? entry.title); const openEntryFile = (entry: DiffPatchEntry, event: React.MouseEvent) => { event.stopPropagation(); const line = extractFirstChangedLineFromDiff(entry.patch); @@ -2291,6 +2308,24 @@ const ToolPartContent: React.FC = ({ }, [descriptionPath, normalizedPartTool, stateWithData, input]); const runtime = React.useContext(RuntimeAPIContext); + const openApplyPatchFile = (file: Record, event: React.MouseEvent) => { + const filePath = getApplyPatchFilePath(file); + if (!runtime?.editor || !filePath || file.type === 'delete') { + return; + } + + event.stopPropagation(); + const patch = getPatchText(file.patch) ?? getPatchText(file.diff); + const targetLine = patch ? extractFirstChangedLineFromDiff(patch) : undefined; + const absolutePath = toAbsoluteFilePath(currentDirectory, filePath); + if (runtime.runtime.isVSCode && patch) { + const label = `${getRelativePath(absolutePath, currentDirectory)} (changes)`; + void runtime.editor.openDiff('', absolutePath, label, { line: targetLine, patch }); + return; + } + void runtime.editor.openFile(absolutePath, targetLine); + }; + const handleMainClick = (e: { stopPropagation: () => void }) => { if (isTaskTool || !runtime?.editor) { onToggle(part.id); @@ -2403,7 +2438,7 @@ const ToolPartContent: React.FC = ({ > {displayName} - {getMultiFileDescription(metadata, animateTailText, showToolFileIcons)} + {getMultiFileDescription(metadata, animateTailText, showToolFileIcons, runtime?.editor ? openApplyPatchFile : undefined)} ) : ( <> diff --git a/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts b/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts index d7b36700..9b3e90df 100644 --- a/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts +++ b/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test'; -import { getDiffPatchEntries, getPrimaryToolPath, getRenderablePatchInfo } from './toolDiffUtils'; +import { getApplyPatchFilePath, getDiffPatchEntries, getPrimaryToolPath, getRenderablePatchInfo } from './toolDiffUtils'; const identity = (path: string) => path; @@ -35,6 +35,18 @@ describe('toolDiffUtils', () => { })).toBe('src/file.ts'); }); + test('resolves each apply_patch file independently', () => { + expect(getApplyPatchFilePath({ + filePath: '/workspace/project/src/first.ts', + relativePath: 'workspace/project/src/first.ts', + })).toBe('/workspace/project/src/first.ts'); + expect(getApplyPatchFilePath({ + filePath: '/workspace/project/src/old.ts', + movePath: '/workspace/project/src/second.ts', + relativePath: 'src/second.ts', + })).toBe('/workspace/project/src/second.ts'); + }); + test('treats raw apply_patch envelopes as text, not visual diffs', () => { const entries = getDiffPatchEntries(undefined, [ '*** Begin Patch', @@ -87,6 +99,27 @@ describe('toolDiffUtils', () => { expect(entries[0]?.title).toBe('src/file.ts'); }); + test('keeps the authoritative path for every metadata file entry', () => { + const patch = [ + '--- a/src/file.ts', + '+++ b/src/file.ts', + '@@ -1 +1 @@', + '-old', + '+new', + ].join('\n'); + const entries = getDiffPatchEntries({ + files: [ + { filePath: '/workspace/project/src/first.ts', relativePath: 'src/first.ts', patch }, + { filePath: '/workspace/project/src/second.ts', relativePath: 'src/second.ts', patch }, + ], + }, undefined, identity); + + expect(entries.map((entry) => entry.filePath)).toEqual([ + '/workspace/project/src/first.ts', + '/workspace/project/src/second.ts', + ]); + }); + test('synthesizes headers for valid headerless hunks', () => { const entries = getDiffPatchEntries(undefined, [ '@@ -1 +1 @@', diff --git a/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts b/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts index 737929bf..75a7ce94 100644 --- a/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts +++ b/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts @@ -3,6 +3,7 @@ import { parsePatchFiles } from '@pierre/diffs'; export type DiffPatchEntry = { id: string; title: string; + filePath?: string; patch: string; renderMode: 'diff' | 'text'; }; @@ -140,6 +141,20 @@ export const getPatchText = (value: unknown): string | undefined => { return undefined; }; +export const getApplyPatchFilePath = (file: unknown): string | null => { + if (!isRecord(file)) { + return null; + } + + return typeof file.movePath === 'string' + ? file.movePath + : typeof file.filePath === 'string' + ? file.filePath + : typeof file.relativePath === 'string' + ? file.relativePath + : null; +}; + export const getPrimaryToolPath = ( toolName: string, input: Record | undefined, @@ -147,17 +162,15 @@ export const getPrimaryToolPath = ( ): string | null => { if (toolName === 'apply_patch') { const files = Array.isArray(metadata?.files) ? metadata.files : []; - const first = files.find((entry) => isRecord(entry) && entry.type !== 'delete'); - if (!isRecord(first)) { - return null; + for (const file of files) { + if (isRecord(file) && file.type !== 'delete') { + const filePath = getApplyPatchFilePath(file); + if (filePath) { + return filePath; + } + } } - return typeof first.movePath === 'string' - ? first.movePath - : typeof first.filePath === 'string' - ? first.filePath - : typeof first.relativePath === 'string' - ? first.relativePath - : null; + return null; } if (toolName === 'edit' || toolName === 'multiedit') { @@ -334,7 +347,7 @@ const getPatchEntriesFromText = ( }]; }; -const getFilePatch = (file: unknown): { patch: string; title: string } | null => { +const getFilePatch = (file: unknown): { filePath?: string; patch: string; title: string } | null => { if (!isRecord(file)) { return null; } @@ -351,6 +364,7 @@ const getFilePatch = (file: unknown): { patch: string; title: string } | null => : ''; return { + filePath: getApplyPatchFilePath(file) ?? undefined, patch, title: rawPath, }; @@ -372,7 +386,7 @@ export const getDiffPatchEntries = ( filePatch.title || `File ${index + 1}`, `file-${index}`, resolveTitle, - ); + ).map((entry) => ({ ...entry, filePath: filePatch.filePath })); }); if (fileEntries.length > 0) {