From 5ddfc242211180e45ab4d67d87a365ef5e026d84 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 4 Mar 2026 02:58:48 +0200 Subject: [PATCH] feat: open chat file references directly in VS Code editor with line targeting Markdown file links now open in VS Code editor tabs in VS Code runtime Tool entries for edit/apply_patch now open the file at the first changed line --- .../src/components/chat/MarkdownRenderer.tsx | 40 ++++++++-- .../chat/message/parts/ToolPart.tsx | 77 ++++++++++++++++++- 2 files changed, 111 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/chat/MarkdownRenderer.tsx b/packages/ui/src/components/chat/MarkdownRenderer.tsx index eb8446da..cbc624a9 100644 --- a/packages/ui/src/components/chat/MarkdownRenderer.tsx +++ b/packages/ui/src/components/chat/MarkdownRenderer.tsx @@ -19,6 +19,7 @@ import { useUIStore } from '@/stores/useUIStore'; import { useDeviceInfo } from '@/lib/device'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import type { EditorAPI } from '@/lib/api/types'; const withStableStringId = (value: T, id: string): T => { const existingPrimitive = (value as Record)[Symbol.toPrimitive]; @@ -966,10 +967,14 @@ const useFileReferenceInteractions = ({ containerRef, effectiveDirectory, readFile, + editor, + preferRuntimeEditor, }: { containerRef: React.RefObject; effectiveDirectory: string; readFile?: (path: string) => Promise<{ content: string; path: string }>; + editor?: EditorAPI; + preferRuntimeEditor?: boolean; }) => { const validationCacheRef = React.useRef>(new Map()); const inFlightValidationsRef = React.useRef>>(new Map()); @@ -1126,6 +1131,19 @@ const useFileReferenceInteractions = ({ } const contextDirectory = getContextDirectory(effectiveDirectory, resolved.resolvedPath); + if (preferRuntimeEditor && editor) { + await editor.openFile( + resolved.resolvedPath, + Number.isFinite(resolved.line ?? Number.NaN) + ? Math.max(1, Math.trunc(resolved.line as number)) + : undefined, + Number.isFinite(resolved.column ?? Number.NaN) + ? Math.max(1, Math.trunc(resolved.column as number)) + : undefined, + ); + return true; + } + const uiStore = useUIStore.getState(); if (Number.isFinite(resolved.line ?? Number.NaN)) { uiStore.openContextFileAtLine( @@ -1206,7 +1224,7 @@ const useFileReferenceInteractions = ({ container.removeEventListener('click', handleClick); container.removeEventListener('keydown', handleKeyDown); }; - }, [containerRef, effectiveDirectory, readFile]); + }, [containerRef, editor, effectiveDirectory, preferRuntimeEditor, readFile]); }; const useMermaidInlineInteractions = ({ @@ -1312,12 +1330,18 @@ export const MarkdownRenderer: React.FC = ({ variant = 'assistant', onShowPopup, }) => { - const { files } = useRuntimeAPIs(); + const { files, editor, runtime } = useRuntimeAPIs(); const streamdownContainerRef = React.useRef(null); const effectiveDirectory = useEffectiveDirectory() ?? ''; const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(content), [content]); useMermaidInlineInteractions({ containerRef: streamdownContainerRef, mermaidBlocks, onShowPopup }); - useFileReferenceInteractions({ containerRef: streamdownContainerRef, effectiveDirectory, readFile: files.readFile }); + useFileReferenceInteractions({ + containerRef: streamdownContainerRef, + effectiveDirectory, + readFile: files.readFile, + editor, + preferRuntimeEditor: runtime.isVSCode, + }); const shikiThemes = useMarkdownShikiThemes(); const streamdownPlugins = useStreamdownPlugins(shikiThemes); @@ -1373,7 +1397,7 @@ export const SimpleMarkdownRenderer: React.FC<{ onShowPopup, allowMermaidWheelZoom = false, }) => { - const { files } = useRuntimeAPIs(); + const { files, editor, runtime } = useRuntimeAPIs(); const renderedContent = React.useMemo( () => (stripFrontmatter ? stripLeadingFrontmatter(content) : content), [content, stripFrontmatter], @@ -1387,7 +1411,13 @@ export const SimpleMarkdownRenderer: React.FC<{ onShowPopup, allowWheelZoom: allowMermaidWheelZoom, }); - useFileReferenceInteractions({ containerRef: streamdownContainerRef, effectiveDirectory, readFile: files.readFile }); + useFileReferenceInteractions({ + containerRef: streamdownContainerRef, + effectiveDirectory, + readFile: files.readFile, + editor, + preferRuntimeEditor: runtime.isVSCode, + }); const shikiThemes = useMarkdownShikiThemes(); const streamdownPlugins = useStreamdownPlugins(shikiThemes); diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 813c5921..5d9b8356 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -157,6 +157,78 @@ const parseDiffStats = (metadata?: Record): { added: number; re return { added, removed }; }; +const extractFirstChangedLineFromDiff = (diffText: string): number | undefined => { + if (!diffText || typeof diffText !== 'string') { + return undefined; + } + + const lines = diffText.split('\n'); + let currentNewLine: number | undefined; + let firstHunkStart: number | undefined; + + for (const rawLine of lines) { + const line = rawLine.replace(/\r$/, ''); + const hunkMatch = line.match(/^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/); + if (hunkMatch) { + const parsed = Number.parseInt(hunkMatch[1] ?? '', 10); + if (Number.isFinite(parsed)) { + currentNewLine = Math.max(1, parsed); + if (!Number.isFinite(firstHunkStart)) { + firstHunkStart = currentNewLine; + } + } + continue; + } + + if (currentNewLine === undefined || !Number.isFinite(currentNewLine)) { + continue; + } + + if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('diff ')) { + continue; + } + + if (line.startsWith('+')) { + return currentNewLine; + } + + if (line.startsWith(' ')) { + currentNewLine += 1; + continue; + } + + if (line.startsWith('-') || line.startsWith('\\')) { + continue; + } + } + + return firstHunkStart; +}; + +const getFirstChangedLineFromMetadata = (tool: string, metadata?: Record): number | undefined => { + if (!metadata || (tool !== 'edit' && tool !== 'multiedit' && tool !== 'apply_patch')) { + return undefined; + } + + if (typeof metadata.diff === 'string') { + const line = extractFirstChangedLineFromDiff(metadata.diff); + if (Number.isFinite(line)) { + return line; + } + } + + const files = Array.isArray(metadata.files) ? metadata.files : []; + const firstFile = files[0] as { diff?: unknown } | undefined; + if (typeof firstFile?.diff === 'string') { + const line = extractFirstChangedLineFromDiff(firstFile.diff); + if (Number.isFinite(line)) { + return line; + } + } + + return undefined; +}; + const getRelativePath = (absolutePath: string, currentDirectory: string): string => { if (absolutePath.startsWith(currentDirectory)) { const relativePath = absolutePath.substring(currentDirectory.length); @@ -1637,12 +1709,15 @@ const ToolPart: React.FC = ({ } let filePath: unknown; + let targetLine: number | undefined; if (part.tool === 'edit' || part.tool === 'multiedit') { filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path; + targetLine = getFirstChangedLineFromMetadata(part.tool, metadata); } 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 (['write', 'create', 'file_write', 'read', 'view', 'file_read', 'cat'].includes(part.tool)) { filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path; } @@ -1653,7 +1728,7 @@ const ToolPart: React.FC = ({ if (!filePath.startsWith('/')) { absolutePath = currentDirectory.endsWith('/') ? currentDirectory + filePath : currentDirectory + '/' + filePath; } - runtime.editor.openFile(absolutePath); + runtime.editor.openFile(absolutePath, targetLine); } else { onToggle(part.id); }