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.
This commit is contained in:
Brooklyn Nicholson 2026-06-22 05:04:13 -05:00
parent fb3d31ba8b
commit a61baa9615
7 changed files with 451 additions and 50 deletions

View file

@ -15,11 +15,17 @@ interface DiffLineKind {
const DIFF_LINE_KINDS: DiffLineKind[] = [
{
className: 'text-emerald-700 dark:text-emerald-300',
className: 'border-emerald-500 bg-emerald-500/12 text-emerald-800 dark:text-emerald-200',
match: line => line.startsWith('+') && !line.startsWith('+++')
},
{ className: 'text-rose-700 dark:text-rose-300', match: line => line.startsWith('-') && !line.startsWith('---') },
{ className: 'text-sky-700 dark:text-sky-300', match: line => line.startsWith('@@') },
{
className: 'border-rose-500 bg-rose-500/12 text-rose-800 dark:text-rose-200',
match: line => line.startsWith('-') && !line.startsWith('---')
},
{
className: 'text-sky-700 dark:text-sky-300',
match: line => line.startsWith('@@')
},
{
className: 'text-muted-foreground/70',
match: line => line.startsWith('---') || line.startsWith('+++') || / → /.test(line.slice(0, 60))
@ -30,25 +36,127 @@ function classifyLine(line: string): string | undefined {
return DIFF_LINE_KINDS.find(kind => kind.match(line))?.className
}
// Drop the leading +/-/space gutter character so changes read by color alone
// (like Cursor), keeping the rest of the indentation intact. Hunk headers
// (`@@`) and any stray file headers are left untouched.
function stripDiffMarker(line: string): string {
if (line.startsWith('@@')) {
return line
}
if ((line.startsWith('+') && !line.startsWith('+++')) || (line.startsWith('-') && !line.startsWith('---'))) {
return line.slice(1)
}
if (line.startsWith(' ')) {
return line.slice(1)
}
return line
}
interface DisplayLine {
className?: string
text: string
}
// Build the rendered line list: drop `@@ … @@` hunk headers (git noise in a
// GUI) and the +/- gutter, but keep a blank separator between hunks so
// multi-hunk diffs don't visually merge.
function toDisplayLines(text: string): DisplayLine[] {
const out: DisplayLine[] = []
let emitted = false
for (const line of text.split('\n')) {
if (line.startsWith('@@')) {
if (emitted) {
out.push({ text: '' })
}
continue
}
out.push({ className: classifyLine(line), text: stripDiffMarker(line) })
emitted = true
}
return out
}
interface DiffLinesProps extends Omit<React.ComponentProps<'pre'>, 'children'> {
text: string
}
export function DiffLines({ className, text, ...props }: DiffLinesProps) {
const lines = React.useMemo(() => toDisplayLines(text), [text])
return (
<pre
className={cn(
'mt-1 mb-1.5 max-h-96 max-w-full min-w-0 overflow-auto rounded-md border border-border/60 bg-muted/35 px-2.5 py-1.5 font-mono text-[0.7rem] leading-relaxed text-muted-foreground',
'max-h-[12rem] max-w-full min-w-0 overflow-auto overscroll-contain px-0 py-1 font-mono text-[0.7rem] leading-relaxed text-(--ui-text-secondary)',
className
)}
data-slot="diff-lines"
{...props}
>
{text.split('\n').map((line, index) => (
<span className={cn('block min-w-max whitespace-pre', classifyLine(line))} key={`${index}-${line}`}>
{line || ' '}
{lines.map((line, index) => (
<span
className={cn('block min-w-max border-l-2 border-transparent whitespace-pre px-2.5 py-px', line.className)}
key={`${index}-${line.text}`}
>
{line.text || ' '}
</span>
))}
</pre>
)
}
// Git-style unified diffs arrive with a file-header preamble — `diff --git`,
// `index …`, `--- a/path`, `+++ b/path`, and Hermes' own `a/path → b/path`
// arrow line. That preamble just repeats the path (which the tool row already
// shows) and reads especially badly for absolute paths (`a//Users/…`). Strip
// the leading header zone up to the first hunk so the panel shows only hunks +
// changes, the way Cursor does.
const DIFF_HEADER_PREFIXES = ['diff --git', 'index ', '--- ', '+++ ', 'similarity ', 'rename ', 'new file', 'deleted file']
function isArrowHeaderLine(line: string): boolean {
const trimmed = line.trim()
return trimmed.includes('→') && /^\S.*→\s*\S+$/.test(trimmed) && !/^[+\-@]/.test(trimmed)
}
/** Exported for tests. */
export function stripDiffFileHeaders(diff: string): string {
const lines = diff.split('\n')
let start = 0
for (; start < lines.length; start += 1) {
const line = lines[start]
if (line.startsWith('@@')) {
break
}
if (line.trim() === '' || isArrowHeaderLine(line) || DIFF_HEADER_PREFIXES.some(prefix => line.startsWith(prefix))) {
continue
}
break
}
return lines.slice(start).join('\n')
}
interface FileDiffPanelProps {
diff: string
}
export function FileDiffPanel({ diff }: FileDiffPanelProps) {
const display = React.useMemo(() => stripDiffFileHeaders(diff), [diff])
// Bleed out of the tool-card body's `p-1.5` so changed-line tints/borders run
// flush to the card edges (rounded corners clip via the card's overflow).
// `max-w-none` lifts the base `max-w-full` cap that would otherwise stop the
// negative margins from widening the block.
return <DiffLines className="-mx-1.5 -mb-1.5 max-w-none" data-slot="file-diff-panel" text={display} />
}