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

@ -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>): 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 })
})
})

View file

@ -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<string, unknown>, result: Record<string, unknown>): 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<string, ToolMeta> = {
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<string, ToolMeta> = {
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<string, unknown>, 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))

View file

@ -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 ? (
<FileTypeIcon className="text-(--ui-text-tertiary)" path={filePath} size="0.875rem" />
) : icon ? (
<ToolIcon className="text-(--ui-text-tertiary)" name={icon} size="0.875rem" />
) : 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) {
<Tip label={statusCopy.dismiss}>
<Button
aria-label={statusCopy.dismiss}
className="size-5 rounded-md text-(--ui-text-tertiary) opacity-0 transition-opacity hover:text-(--ui-text-primary) hover:opacity-100 group-hover/disclosure-row:opacity-80 group-focus-within/disclosure-row:opacity-80"
className={cn(
'size-5 rounded-md text-(--ui-text-tertiary) transition-opacity hover:text-(--ui-text-primary) hover:opacity-100',
open
? 'opacity-80'
: 'opacity-0 group-hover/disclosure-row:opacity-80 group-focus-within/disclosure-row:opacity-80'
)}
onClick={event => {
event.stopPropagation()
dismissToolRow(disclosureId)
@ -317,13 +347,24 @@ function ToolEntry({ part }: ToolEntryProps) {
return null
}
// A completed file edit with no diff to review is a bare, unexpandable row.
// This is almost always a `write_file` create after a reload: only `patch`
// persists its diff in the tool result, so creates rehydrate diff-less and
// read like dead duplicates of the real diff row. Hide them — but keep
// in-flight writes (activity) and failures (errors) visible.
if (isFileEdit && !isPending && view.status !== 'error' && !view.inlineDiff) {
return null
}
return (
<div
className={cn(
'min-w-0 max-w-full overflow-hidden text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)',
open && 'rounded-[0.625rem] border border-(--ui-stroke-tertiary)'
)}
data-file-edit={isFileEdit && open ? '' : undefined}
data-slot="tool-block"
data-tool-row=""
ref={enterRef}
>
<div className={cn(open && 'border-b border-(--ui-stroke-tertiary) px-2 py-1.5')}>
@ -333,8 +374,16 @@ function ToolEntry({ part }: ToolEntryProps) {
open={open}
trailing={trailing}
>
<span className="flex min-w-0 items-center gap-1.5">
<ToolGlyph copy={copy} icon={view.icon} status={leadingStatus(isPending, view.status)} />
<span
className="flex min-w-0 items-center gap-1.5"
title={isFileEdit && view.subtitle ? view.subtitle : undefined}
>
<ToolGlyph
copy={copy}
filePath={isFileEdit ? view.subtitle : undefined}
icon={view.icon}
status={leadingStatus(isPending, view.status)}
/>
<FadeText
className={cn(
TOOL_HEADER_TITLE_CLASS,
@ -346,7 +395,17 @@ function ToolEntry({ part }: ToolEntryProps) {
{view.title}
</FadeText>
{!isPending && view.countLabel && <span className={TOOL_HEADER_DURATION_CLASS}>{view.countLabel}</span>}
{!isPending && view.durationLabel && (
{showDiffStats && diffStats && (
<span className="flex shrink-0 items-center gap-1 font-mono text-[0.625rem] tabular-nums">
{diffStats.added > 0 && (
<span className="text-emerald-600 dark:text-emerald-400">+{diffStats.added}</span>
)}
{diffStats.removed > 0 && (
<span className="text-rose-600 dark:text-rose-400">{diffStats.removed}</span>
)}
</span>
)}
{!isFileEdit && !isPending && view.durationLabel && (
<span className={TOOL_HEADER_DURATION_CLASS}>{view.durationLabel}</span>
)}
</span>
@ -358,7 +417,7 @@ function ToolEntry({ part }: ToolEntryProps) {
{copyAction.text && (
<CopyButton
appearance="inline"
className="absolute right-1.5 top-1.5 z-10 h-5 gap-0 rounded-md border border-(--ui-stroke-tertiary) bg-background/80 px-1 opacity-60 backdrop-blur-sm transition-opacity hover:opacity-100 focus-visible:opacity-100"
className="absolute right-1.5 top-1.5 z-10 h-5 gap-0 rounded-md border border-(--ui-stroke-tertiary) bg-background/80 px-1 opacity-100 backdrop-blur-sm transition-opacity hover:opacity-100 focus-visible:opacity-100"
iconClassName="size-3"
label={copyAction.label}
showLabel={false}
@ -380,6 +439,7 @@ function ToolEntry({ part }: ToolEntryProps) {
<SearchResultsList hits={view.searchHits} />
</div>
)}
{view.inlineDiff && <FileDiffPanel diff={view.inlineDiff} />}
{showDetail &&
toolViewMode !== 'technical' &&
(view.status === 'error' ? (
@ -448,14 +508,21 @@ function ToolEntry({ part }: ToolEntryProps) {
</pre>
</details>
)}
{toolViewMode === 'technical' && (
{toolViewMode === 'technical' && !(isFileEdit && view.inlineDiff) && (
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
{rawTechnicalTrace(part.args, part.result)}
</pre>
)}
{toolViewMode === 'technical' && isFileEdit && view.inlineDiff && (
<details className="max-w-full">
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'mb-0 cursor-pointer')}>Tool payload</summary>
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'mt-1 whitespace-pre-wrap wrap-anywhere')}>
{rawTechnicalTrace(part.args, part.result)}
</pre>
</details>
)}
</div>
)}
{open && view.inlineDiff && <DiffLines text={view.inlineDiff} />}
</div>
)
}
@ -488,6 +555,7 @@ export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex:
<div
className="grid min-w-0 max-w-full gap-(--tool-row-gap) overflow-hidden"
data-slot="tool-block"
data-tool-group=""
ref={enterRef}
>
{children}

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} />
}

View file

@ -0,0 +1,22 @@
import { ToolIcon, type ToolIconProps } from '@/components/ui/tool-icon'
import { codiconForFilename, codiconForLanguage } from '@/lib/markdown-code'
export interface FileTypeIconProps extends Omit<ToolIconProps, 'name'> {
/** A code-fence language tag (e.g. `ts`, `json`). Used when no `path`. */
language?: string
/** A file path or bare name; its extension selects the icon. Wins over `language`. */
path?: string
}
/**
* Icon for a file or code language, resolved through the one mapping shared
* with code blocks (`codiconForFilename` / `codiconForLanguage`). Renders via
* `ToolIcon`, so it uses a filled glyph when one exists and falls back to the
* outline codicon font otherwise. Pass a `path` for file rows or a `language`
* for fenced code.
*/
export function FileTypeIcon({ language, path, ...props }: FileTypeIconProps) {
const name = path ? codiconForFilename(path) : codiconForLanguage(language)
return <ToolIcon name={name} {...props} />
}

View file

@ -108,6 +108,56 @@ export function codiconForLanguage(language: string | undefined): string {
return CODICON_BY_LANGUAGE[sanitizeLanguageTag(language || '')] || 'code'
}
// File extension → language tag, so a filename can resolve to the same icon a
// fenced code block of that language would get. Only extensions that map to a
// non-generic codicon need an entry; everything else falls through to `code`.
const LANGUAGE_BY_EXTENSION: Record<string, string> = {
bash: 'bash',
cfg: 'ini',
conf: 'ini',
css: 'css',
dockerfile: 'dockerfile',
env: 'env',
gql: 'graphql',
graphql: 'graphql',
ini: 'ini',
json: 'json',
json5: 'json',
less: 'less',
markdown: 'markdown',
md: 'markdown',
mdx: 'markdown',
mmd: 'mermaid',
ps1: 'powershell',
psql: 'sql',
sass: 'sass',
scss: 'scss',
sh: 'bash',
sql: 'sql',
svg: 'svg',
toml: 'toml',
yaml: 'yaml',
yml: 'yml',
zsh: 'zsh'
}
// Pick an icon for a file path by its extension (or bare name like
// `Dockerfile`), reusing the language→codicon map so file-edit rows and code
// blocks share one visual vocabulary. Unknown / generic code files get `code`.
export function codiconForFilename(path: string | undefined): string {
const base = (path || '').replace(/\\/g, '/').split('/').pop()?.trim().toLowerCase() || ''
if (!base) {
return 'code'
}
const dot = base.lastIndexOf('.')
const token = dot > 0 ? base.slice(dot + 1) : base
const language = LANGUAGE_BY_EXTENSION[token] || token
return codiconForLanguage(language)
}
function proseLineCount(body: string): number {
return body.split('\n').filter(line => {
const trimmed = line.trim()

View file

@ -1214,19 +1214,33 @@ canvas {
background: transparent !important;
}
[data-slot='aui_assistant-message-content'] > :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) {
/* Fade scaffolding so the prose reading column stays primary. Two targets:
a thinking disclosure fades as one block, and each *individual* tool row
(`[data-tool-row]`) fades on its own. We deliberately do NOT fade the tool
group wrapper (`[data-tool-group]`): opacity on a parent opens a stacking
context, so a child row can never be more opaque than the group that made
it impossible to keep one row lit (an open diff) while its siblings faded.
With the fade per-row, each row hovers/focuses independently. */
[data-slot='aui_assistant-message-content'] > [data-slot='aui_thinking-disclosure'],
[data-slot='aui_assistant-message-content'] [data-slot='tool-block'][data-tool-row] {
opacity: 0.67;
transition: opacity 120ms ease-out;
}
[data-slot='aui_assistant-message-content']
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']):is(:hover, :focus-within) {
/* Lift on hover or *keyboard* focus only. `:focus-within` also matches the
focus a mouse click leaves on the disclosure toggle, which kept a row lit
after you clicked to collapse it; `:has(:focus-visible)` excludes that. */
[data-slot='aui_assistant-message-content'] > [data-slot='aui_thinking-disclosure']:is(:hover, :has(:focus-visible)),
[data-slot='aui_assistant-message-content'] [data-slot='tool-block'][data-tool-row]:is(:hover, :has(:focus-visible)) {
opacity: 1;
}
/* A generated image is the deliverable, not scaffolding keep it at full
strength instead of dimming it until hover. */
[data-slot='aui_assistant-message-content'] > [data-slot='tool-block']:has([data-slot='aui_generated-image']) {
/* File edits (write_file / edit_file / patch) are the deliverable, not
scaffolding the diff is what the user reviews, like a PR. An *expanded*
edit stays at full strength; collapsed it fades like any other row. The
`data-file-edit` marker sits on the same row element and is only present
while the row is open. */
[data-slot='aui_assistant-message-content'] [data-slot='tool-block'][data-tool-row][data-file-edit] {
opacity: 1;
}