From a61baa96157241c2e422fd85b3527bee14b41c62 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 22 Jun 2026 05:04:13 -0500 Subject: [PATCH] feat(desktop): PR-style file diffs in chat Render write_file/edit_file/patch as a reviewable diff instead of raw result JSON, closer to a Cursor/T3 per-edit review. - Unified diff via FileDiffPanel: strip git file-header + @@ hunk noise, drop the +/- gutter, color by line with a 2px gutter accent, full-bleed to the card, transparent context lines, compact scroll height. - Header shows filename + language icon + +N/-N stats; full path moves to a hover tooltip (no Edited verb, no ms). - Treat the three file-edit tools uniformly (isFileEditTool); read diff from inline_diff or patch's diff field; suppress raw-arg detail. - Reusable FileTypeIcon primitive sharing the code-block icon mapping (codiconForFilename), codicon fallback. - Per-row scaffolding fade (not the group wrapper, which trapped child opacity); expanded edits stay full, collapsed fade; keyboard-only focus lift. Hide diff-less rehydrated creates that read as dupes. --- .../assistant-ui/tool-fallback-model.test.ts | 55 +++++++- .../assistant-ui/tool-fallback-model.ts | 122 +++++++++++++++--- .../components/assistant-ui/tool-fallback.tsx | 104 ++++++++++++--- .../src/components/chat/diff-lines.tsx | 122 +++++++++++++++++- .../src/components/ui/file-type-icon.tsx | 22 ++++ apps/desktop/src/lib/markdown-code.ts | 50 +++++++ apps/desktop/src/styles.css | 26 +++- 7 files changed, 451 insertions(+), 50 deletions(-) create mode 100644 apps/desktop/src/components/ui/file-type-icon.tsx diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts b/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts index 55b7755973e..bf4409384c0 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts +++ b/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' -import { buildToolView, type ToolPart } from './tool-fallback-model' +import { + buildToolView, + countDiffLineStats, + inlineDiffFromResult, + type ToolPart +} from './tool-fallback-model' const part = (overrides: Partial): ToolPart => ({ args: {}, @@ -64,3 +69,51 @@ describe('buildToolView terminal exit-code status', () => { ) }) }) + +describe('buildToolView file edit diffs', () => { + const patchDiff = '--- a/src/demo.ts\n+++ b/src/demo.ts\n@@ -1 +1 @@\n-old\n+new' + + it('reads inline_diff and diff fields from patch results', () => { + expect(inlineDiffFromResult({ inline_diff: patchDiff })).toBe(patchDiff) + expect(inlineDiffFromResult({ diff: patchDiff })).toBe(patchDiff) + }) + + it('suppresses raw patch args when a diff is available', () => { + const view = buildToolView( + part({ + args: { context: 'src/demo.ts', mode: 'replace', new_string: 'new', path: 'src/demo.ts' }, + result: { diff: patchDiff, success: true }, + toolName: 'patch' + }), + patchDiff + ) + + expect(view.title).toBe('demo.ts') + expect(view.subtitle).toBe('src/demo.ts') + expect(view.detail).toBe('') + expect(view.inlineDiff).toBe(patchDiff) + }) + + it('shows path subtitle instead of patch args JSON while pending', () => { + const view = buildToolView( + part({ + args: { context: 'src/demo.ts', mode: 'replace', new_string: 'new', path: 'src/demo.ts' }, + result: undefined, + toolName: 'patch' + }), + '' + ) + + expect(view.title).toBe('demo.ts') + expect(view.subtitle).toBe('src/demo.ts') + expect(view.detail).toBe('') + }) +}) + +describe('countDiffLineStats', () => { + it('counts added and removed lines', () => { + expect( + countDiffLineStats(`--- a/x\n+++ b/x\n@@\n-old\n+new\n context\n+another`) + ).toEqual({ added: 2, removed: 1 }) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts index 3618d8011fb..6e67b0b9a4b 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts +++ b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts @@ -72,6 +72,46 @@ export interface MessageRunningStateSlice { } } +const FILE_EDIT_TOOL_NAMES = new Set(['edit_file', 'patch', 'write_file']) + +export function isFileEditTool(toolName: string): boolean { + return FILE_EDIT_TOOL_NAMES.has(toolName) +} + +export interface DiffLineStats { + added: number + removed: number +} + +export function countDiffLineStats(diff: string): DiffLineStats { + let added = 0 + let removed = 0 + + for (const line of diff.split('\n')) { + if (line.startsWith('+') && !line.startsWith('+++')) { + added += 1 + } else if (line.startsWith('-') && !line.startsWith('---')) { + removed += 1 + } + } + + return { added, removed } +} + +function fileEditPath(args: Record, result: Record): string { + return ( + firstStringField(args, ['path', 'file', 'filepath']) || + firstStringField(result, ['path', 'file', 'filepath', 'resolved_path']) || + htmlPathFromInlineDiff(firstStringField(result, ['inline_diff', 'diff'])) + ) +} + +function fileEditBasename(path: string): string { + const normalized = path.replace(/\\/g, '/').trim() + + return normalized.split('/').filter(Boolean).pop() || normalized +} + const TOOL_META: Record = { browser_click: { done: 'Clicked page element', pending: 'Clicking page element', icon: 'globe', tone: 'browser' }, browser_fill: { done: 'Filled form field', pending: 'Filling form field', icon: 'globe', tone: 'browser' }, @@ -95,7 +135,7 @@ const TOOL_META: Record = { execute_code: { done: 'Ran code', pending: 'Running code', icon: 'terminal', tone: 'terminal' }, image_generate: { done: 'Generated image', pending: 'Generating image', icon: 'file-media', tone: 'image' }, list_files: { done: 'Listed files', pending: 'Listing files', icon: 'files', tone: 'file' }, - patch: { done: 'Patched file', pending: 'Patching file', icon: 'diff', tone: 'file' }, + patch: { done: 'Patched file', pending: 'Patching file', icon: 'edit', tone: 'file' }, read_file: { done: 'Read file', pending: 'Reading file', icon: 'file', tone: 'file' }, search_files: { done: 'Searched files', pending: 'Searching files', icon: 'search', tone: 'file' }, session_search_recall: { @@ -797,8 +837,8 @@ function toolPreviewTarget(toolName: string, args: Record, resu return looksLikeUrl(explicit) ? explicit : findFirstUrl(args, result) } - if (toolName === 'write_file' || toolName === 'edit_file') { - return htmlPathFromInlineDiff(firstStringField(result, ['inline_diff'])) + if (isFileEditTool(toolName)) { + return htmlPathFromInlineDiff(firstStringField(result, ['inline_diff', 'diff'])) } return '' @@ -858,9 +898,17 @@ function stripDividerLines(value: string): string { } export function inlineDiffFromResult(result: unknown): string { - const value = parseMaybeObject(result).inline_diff + const record = parseMaybeObject(result) - return typeof value === 'string' ? stripInlineDiffChrome(value) : '' + for (const key of ['inline_diff', 'diff']) { + const value = record[key] + + if (typeof value === 'string' && value.trim()) { + return stripInlineDiffChrome(value) + } + } + + return '' } // Falls back to a string only when there's something concrete to render — @@ -1047,15 +1095,22 @@ function toolSubtitle( return command ? compactPreview(command, 120) : 'Executed command' } - if (toolName === 'read_file' || toolName === 'write_file' || toolName === 'edit_file') { - const path = - firstStringField(argsRecord, ['path', 'file', 'filepath']) || - htmlPathFromInlineDiff(firstStringField(resultRecord, ['inline_diff'])) + if (toolName === 'read_file' || isFileEditTool(toolName)) { + const isEdit = isFileEditTool(toolName) - return ( - path || - (firstStringField(resultRecord, ['inline_diff']) ? 'Changed file' : fallbackDetailText(argsRecord, resultRecord)) - ) + const path = isEdit + ? fileEditPath(argsRecord, resultRecord) + : firstStringField(argsRecord, ['path', 'file', 'filepath']) + + if (path) { + return path + } + + if (!isEdit) { + return fallbackDetailText(argsRecord, resultRecord) + } + + return inlineDiffFromResult(resultRecord) ? 'Changed file' : '' } if (toolName === 'web_extract') { @@ -1153,8 +1208,22 @@ function toolDetailText( } } - if (part.toolName === 'write_file' || part.toolName === 'edit_file') { - return inlineDiffFromResult(part.result) ? '' : fallbackDetailText(argsRecord, resultRecord) + if (isFileEditTool(part.toolName)) { + if (inlineDiffFromResult(part.result)) { + return '' + } + + const summary = firstStringField(resultRecord, ['message', 'summary']) + + if (summary) { + return summary + } + + if (fileEditPath(argsRecord, resultRecord)) { + return '' + } + + return fallbackDetailText(argsRecord, resultRecord) } if (part.toolName === 'web_search') { @@ -1253,8 +1322,12 @@ export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string } } - if (part.toolName === 'write_file' || part.toolName === 'edit_file') { - const path = firstStringField(args, ['path', 'file', 'filepath']) + if (isFileEditTool(part.toolName)) { + if (view.inlineDiff.trim()) { + return { label: copy.file, text: view.inlineDiff } + } + + const path = fileEditPath(args, result) if (path) { return { label: copy.path, text: path } @@ -1304,6 +1377,14 @@ function dynamicTitle( } } + if (isFileEditTool(part.toolName)) { + const path = fileEditPath(args, result) + + if (path) { + return fileEditBasename(path) + } + } + return fallback } @@ -1317,7 +1398,12 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView { const title = dynamicTitle(part, argsRecord, resultRecord, baseTitle) const titleEnriched = title !== baseTitle const baseSubtitle = error || toolSubtitle(part, argsRecord, resultRecord) - const keepSubtitleWithTitle = part.toolName === 'terminal' || part.toolName === 'execute_code' + + const keepSubtitleWithTitle = + part.toolName === 'terminal' || + part.toolName === 'execute_code' || + (isFileEditTool(part.toolName) && Boolean(baseSubtitle.trim())) + const subtitle = titleEnriched && !error && !keepSubtitleWithTitle ? '' : baseSubtitle const detailBody = stripDividerLines(toolDetailText(part, argsRecord, resultRecord)) diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx index e93eabe1557..900d4767f7b 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx @@ -8,7 +8,7 @@ import { AnsiText } from '@/components/assistant-ui/ansi-text' import { useElapsedSeconds } from '@/components/chat/activity-timer' import { ActivityTimerText } from '@/components/chat/activity-timer-text' import { CompactMarkdown } from '@/components/chat/compact-markdown' -import { DiffLines } from '@/components/chat/diff-lines' +import { FileDiffPanel } from '@/components/chat/diff-lines' import { DisclosureRow } from '@/components/chat/disclosure-row' import { PreviewAttachment } from '@/components/chat/preview-attachment' import { ZoomableImage } from '@/components/chat/zoomable-image' @@ -16,6 +16,7 @@ import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { CopyButton } from '@/components/ui/copy-button' import { FadeText } from '@/components/ui/fade-text' +import { FileTypeIcon } from '@/components/ui/file-type-icon' import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { ToolIcon } from '@/components/ui/tool-icon' import { Tip } from '@/components/ui/tooltip' @@ -32,7 +33,9 @@ import { PendingToolApproval } from './tool-approval' import { buildToolView, cleanVisibleText, + countDiffLineStats, inlineDiffFromResult, + isFileEditTool, isPreviewableTarget, looksRedundant, type SearchResultRow, @@ -133,9 +136,21 @@ function statusGlyph(status: ToolStatus, copy: ToolStatusCopy): ReactNode { // Leading glyph for any tool-row header. Status (running/error/warning) // takes precedence; otherwise falls back to the tool's codicon. Returns // null when neither applies so callers can render unconditionally. -function ToolGlyph({ copy, icon, status }: { copy: ToolStatusCopy; icon?: string; status?: ToolStatus }) { +function ToolGlyph({ + copy, + filePath, + icon, + status +}: { + copy: ToolStatusCopy + filePath?: string + icon?: string + status?: ToolStatus +}) { const node = status ? ( statusGlyph(status, copy) + ) : filePath ? ( + ) : icon ? ( ) : null @@ -204,8 +219,13 @@ function ToolEntry({ part }: ToolEntryProps) { const toolViewMode = useStore($toolViewMode) const disclosureId = `tool-entry:${messageId}:${toolPartDisclosureId(part)}` const dismissed = useStore($toolRowDismissed(disclosureId)) - const open = useDisclosureOpen(disclosureId) const isPending = messageRunning && part.result === undefined + const liveDiffs = useStore($toolInlineDiffs) + const sideDiff = part.toolCallId ? liveDiffs[part.toolCallId] || '' : '' + const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(part.result) + const isFileEdit = isFileEditTool(part.toolName) + const defaultOpen = Boolean(inlineDiff) + const open = useDisclosureOpen(disclosureId, defaultOpen) const canDismiss = !isPending && !embedded // Only animate entries that mount while their message is actively // streaming — historical sessions mount with `messageRunning === false`, @@ -213,9 +233,6 @@ function ToolEntry({ part }: ToolEntryProps) { // handles its own enter animation, so embedded children skip it. const enterRef = useEnterAnimation(messageRunning && !embedded, `tool-entry:${disclosureId}`) const elapsed = useElapsedSeconds(isPending, `tool:${disclosureId}`) - const liveDiffs = useStore($toolInlineDiffs) - const sideDiff = part.toolCallId ? liveDiffs[part.toolCallId] || '' : '' - const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(part.result) // Stale parts (no result, but message stopped running) get a synthetic // empty result so buildToolView treats them as completed-no-output. @@ -253,11 +270,12 @@ function ToolEntry({ part }: ToolEntryProps) { const detailMatchesSubtitle = looksRedundant(view.subtitle, view.detail) const showDetail = - (view.status === 'error' && Boolean(detailSections.summary || detailSections.body)) || - (view.status !== 'error' && - Boolean(view.detail) && - !looksRedundant(view.title, view.detail) && - !detailMatchesSubtitle) + !view.inlineDiff && + ((view.status === 'error' && Boolean(detailSections.summary || detailSections.body)) || + (view.status !== 'error' && + Boolean(view.detail) && + !looksRedundant(view.title, view.detail) && + !detailMatchesSubtitle)) const renderDetailAsCode = view.status !== 'error' && @@ -283,6 +301,13 @@ function ToolEntry({ part }: ToolEntryProps) { const copyAction = useMemo(() => toolCopyPayload(part, view), [part, view]) + const diffStats = useMemo( + () => (isFileEdit && view.inlineDiff ? countDiffLineStats(view.inlineDiff) : null), + [isFileEdit, view.inlineDiff] + ) + + const showDiffStats = !isPending && Boolean(diffStats && (diffStats.added > 0 || diffStats.removed > 0)) + // The header trailing slot only carries the live duration timer while the // tool is running. The copy control used to live here too, but an // `opacity-0` (yet still clickable) button straddling the caret/duration made @@ -299,7 +324,12 @@ function ToolEntry({ part }: ToolEntryProps) {