From 9f60375cac98bba3dd38c887b37f9b8ea466b669 Mon Sep 17 00:00:00 2001 From: Nabeel Siddiqui Date: Sat, 1 Aug 2026 13:39:11 -0400 Subject: [PATCH 1/2] fix(vscode): resolve apply patch diff paths --- .../chat/message/parts/ToolPart.tsx | 88 ++++--------------- .../chat/message/parts/toolDiffUtils.test.ts | 32 ++++++- .../chat/message/parts/toolDiffUtils.ts | 47 ++++++++++ 3 files changed, 95 insertions(+), 72 deletions(-) diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 0a3cfc61..59943789 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -53,10 +53,11 @@ import { } from './taskToolModel'; import { areRenderRelevantPartsEqual } from '../renderCompare'; import { useI18n } from '@/lib/i18n'; -import { getDiffPatchEntries, getPatchText, type DiffPatchEntry } from './toolDiffUtils'; +import { getDiffPatchEntries, getPatchText, getPrimaryToolPath, type DiffPatchEntry } from './toolDiffUtils'; import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat'; import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle'; import { getStreamingOutputAppend, getToolOutput } from './toolOutput'; +import { toAbsoluteFilePath } from '@/lib/path-utils'; const TOOL_ROW_TEXT_CLASS = '!text-[length:var(--text-meta)] !leading-5 sm:!leading-6 tracking-normal'; const TOOL_ROW_TITLE_CLASS = cn('typography-meta font-medium', TOOL_ROW_TEXT_CLASS); @@ -423,8 +424,10 @@ const getPrimaryDiffFromMetadata = ( if (!file || typeof file !== 'object') { return false; } - const candidate = file as { relativePath?: unknown; filePath?: unknown }; - return candidate.relativePath === preferred || candidate.filePath === preferred; + const candidate = file as { relativePath?: unknown; filePath?: unknown; movePath?: unknown }; + return candidate.relativePath === preferred + || candidate.filePath === preferred + || candidate.movePath === preferred; }) : files[0]; @@ -524,58 +527,6 @@ const normalizeToolDiagnostic = (value: unknown): ToolDiagnostic | null => { }; }; -const getPrimaryToolPath = ( - toolName: string, - input: Record | undefined, - metadata: Record | undefined, -): string | null => { - if (toolName === 'apply_patch') { - const files = Array.isArray(metadata?.files) ? metadata.files : []; - const first = files.find((entry) => { - if (!isRecord(entry)) { - return false; - } - return entry.type !== 'delete'; - }); - if (!isRecord(first)) { - return null; - } - return typeof first.movePath === 'string' - ? first.movePath - : typeof first.filePath === 'string' - ? first.filePath - : typeof first.relativePath === 'string' - ? first.relativePath - : null; - } - - if (toolName === 'edit' || toolName === 'multiedit') { - const fileDiff = isRecord(metadata?.filediff) ? metadata.filediff : undefined; - if (isRecord(fileDiff) && typeof fileDiff.file === 'string') { - return fileDiff.file; - } - return typeof input?.filePath === 'string' - ? input.filePath - : typeof input?.file_path === 'string' - ? input.file_path - : typeof input?.path === 'string' - ? input.path - : null; - } - - if (toolName === 'write') { - return typeof input?.filePath === 'string' - ? input.filePath - : typeof input?.file_path === 'string' - ? input.file_path - : typeof input?.path === 'string' - ? input.path - : null; - } - - return null; -}; - const getToolDiagnosticSection = ( toolName: string, input: Record | undefined, @@ -2349,23 +2300,21 @@ const ToolPartContent: React.FC = ({ let filePath: unknown; let targetLine: number | undefined; let toolDiff: string | undefined; - if (part.tool === 'edit' || part.tool === 'multiedit') { + if (normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit') { filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path; - targetLine = getFirstChangedLineFromMetadata(part.tool, metadata); + targetLine = getFirstChangedLineFromMetadata(normalizedPartTool, metadata); if (typeof filePath === 'string') { - toolDiff = getPrimaryDiffFromMetadata(part.tool, metadata, filePath); + toolDiff = getPrimaryDiffFromMetadata(normalizedPartTool, metadata, filePath); } - } else if (part.tool === 'apply_patch') { - const files = Array.isArray(metadata?.files) ? metadata?.files : []; - const firstFile = files[0] as { relativePath?: string; filePath?: string } | undefined; - filePath = firstFile?.relativePath || firstFile?.filePath; - targetLine = getFirstChangedLineFromMetadata(part.tool, metadata); + } else if (normalizedPartTool === 'apply_patch') { + filePath = getPrimaryToolPath(normalizedPartTool, input, metadata); + targetLine = getFirstChangedLineFromMetadata(normalizedPartTool, metadata); if (typeof filePath === 'string') { - toolDiff = getPrimaryDiffFromMetadata(part.tool, metadata, filePath); + toolDiff = getPrimaryDiffFromMetadata(normalizedPartTool, metadata, filePath); } - } else if (['write', 'create', 'file_write'].includes(part.tool)) { + } else if (['write', 'create', 'file_write'].includes(normalizedPartTool)) { filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path; - } else if (part.tool === 'lsp') { + } else if (normalizedPartTool === 'lsp') { filePath = input?.filePath || input?.file_path || input?.path; const line = input?.line; targetLine = typeof line === 'number' && Number.isFinite(line) ? Math.trunc(line) : undefined; @@ -2373,11 +2322,8 @@ const ToolPartContent: React.FC = ({ if (typeof filePath === 'string') { e.stopPropagation(); - let absolutePath = filePath; - if (!filePath.startsWith('/')) { - absolutePath = currentDirectory.endsWith('/') ? currentDirectory + filePath : currentDirectory + '/' + filePath; - } - if (runtime.runtime.isVSCode && toolDiff && (part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch')) { + const absolutePath = toAbsoluteFilePath(currentDirectory, filePath); + if (runtime.runtime.isVSCode && toolDiff && (normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit' || normalizedPartTool === 'apply_patch')) { const label = `${getRelativePath(absolutePath, currentDirectory)} (changes)`; void runtime.editor.openDiff('', absolutePath, label, { line: targetLine, patch: toolDiff }); return; 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 58929c5d..d7b36700 100644 --- a/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts +++ b/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts @@ -1,10 +1,40 @@ import { describe, expect, test } from 'bun:test'; -import { getDiffPatchEntries, getRenderablePatchInfo } from './toolDiffUtils'; +import { getDiffPatchEntries, getPrimaryToolPath, getRenderablePatchInfo } from './toolDiffUtils'; const identity = (path: string) => path; describe('toolDiffUtils', () => { + test('prefers the absolute apply_patch path over its worktree-relative label', () => { + expect(getPrimaryToolPath('apply_patch', undefined, { + files: [{ + filePath: '/workspace/project/src/file.ts', + relativePath: 'workspace/project/src/file.ts', + type: 'update', + }], + })).toBe('/workspace/project/src/file.ts'); + }); + + test('opens the move destination and skips deleted apply_patch files', () => { + expect(getPrimaryToolPath('apply_patch', undefined, { + files: [ + { filePath: '/workspace/deleted.ts', relativePath: 'deleted.ts', type: 'delete' }, + { + filePath: '/workspace/old.ts', + relativePath: 'new.ts', + movePath: '/workspace/new.ts', + type: 'move', + }, + ], + })).toBe('/workspace/new.ts'); + }); + + test('falls back to the relative apply_patch path for legacy metadata', () => { + expect(getPrimaryToolPath('apply_patch', undefined, { + files: [{ relativePath: 'src/file.ts', type: 'update' }], + })).toBe('src/file.ts'); + }); + test('treats raw apply_patch envelopes as text, not visual diffs', () => { const entries = getDiffPatchEntries(undefined, [ '*** Begin Patch', diff --git a/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts b/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts index 23d9aac0..737929bf 100644 --- a/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts +++ b/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts @@ -140,6 +140,53 @@ export const getPatchText = (value: unknown): string | undefined => { return undefined; }; +export const getPrimaryToolPath = ( + toolName: string, + input: Record | undefined, + metadata: Record | undefined, +): 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; + } + return typeof first.movePath === 'string' + ? first.movePath + : typeof first.filePath === 'string' + ? first.filePath + : typeof first.relativePath === 'string' + ? first.relativePath + : null; + } + + if (toolName === 'edit' || toolName === 'multiedit') { + const fileDiff = isRecord(metadata?.filediff) ? metadata.filediff : undefined; + if (fileDiff && typeof fileDiff.file === 'string') { + return fileDiff.file; + } + return typeof input?.filePath === 'string' + ? input.filePath + : typeof input?.file_path === 'string' + ? input.file_path + : typeof input?.path === 'string' + ? input.path + : null; + } + + if (toolName === 'write') { + return typeof input?.filePath === 'string' + ? input.filePath + : typeof input?.file_path === 'string' + ? input.file_path + : typeof input?.path === 'string' + ? input.path + : null; + } + + return null; +}; + const normalizeParsedPath = (path: string | undefined): string => { const trimmed = (path ?? '').trim().replace(/\t.*$/, ''); if (!trimmed || trimmed === '/dev/null') { From 8eee5b27cd67aceaca7240b41d942f02e92336e0 Mon Sep 17 00:00:00 2001 From: Nabeel Siddiqui Date: Sat, 1 Aug 2026 13:58:48 -0400 Subject: [PATCH 2/2] 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) {