diff --git a/src/renderer/components/RunTimeline.tsx b/src/renderer/components/RunTimeline.tsx index 7edff4d..0e89acd 100644 --- a/src/renderer/components/RunTimeline.tsx +++ b/src/renderer/components/RunTimeline.tsx @@ -26,6 +26,7 @@ import { } from '@renderer/lib/runTimelineFormatting'; import type { OrchestrationMode } from '@shared/domain/pattern'; import type { RunTimelineEventRecord, SessionRunRecord } from '@shared/domain/runTimeline'; +import { FileChangePreview } from '@renderer/components/chat/FileChangePreview'; /* ── Mode accent colours (shared with ActivityPanel) ───────── */ @@ -91,67 +92,76 @@ function TimelineEventRow({ const terminal = isTerminalEvent(event.kind); return ( - - {/* Content preview for message events */} - {preview && ( -

- {preview} -

- )} - - {/* Approval detail */} - {event.kind === 'approval' && event.approvalDetail && ( -

- {truncateContent(event.approvalDetail, 120)} -

- )} - - {/* Error detail */} - {event.error && ( -

- {truncateContent(event.error, 120)} -

- )} - - + {/* File change preview for tool-call events */} + {event.kind === 'tool-call' && event.fileChanges && event.fileChanges.length > 0 && ( +
+ +
+ )} + ); } diff --git a/src/renderer/components/chat/FileChangePreview.tsx b/src/renderer/components/chat/FileChangePreview.tsx new file mode 100644 index 0000000..1fad86c --- /dev/null +++ b/src/renderer/components/chat/FileChangePreview.tsx @@ -0,0 +1,212 @@ +import { useState, useMemo } from 'react'; +import { ChevronRight, FileCode2, FilePlus2 } from 'lucide-react'; + +import type { ToolCallFileChangePreview } from '@shared/contracts/sidecar'; + +/* ── Diff stat helpers ─────────────────────────────────────── */ + +interface DiffStats { + additions: number; + deletions: number; +} + +function parseDiffStats(diff: string | undefined): DiffStats { + if (!diff) return { additions: 0, deletions: 0 }; + let additions = 0; + let deletions = 0; + for (const line of diff.split('\n')) { + if (line.startsWith('+') && !line.startsWith('+++')) additions++; + else if (line.startsWith('-') && !line.startsWith('---')) deletions++; + } + return { additions, deletions }; +} + +function fileBaseName(path: string): string { + const normalized = path.replace(/\\/g, '/'); + const lastSlash = normalized.lastIndexOf('/'); + return lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized; +} + +function fileDir(path: string): string { + const normalized = path.replace(/\\/g, '/'); + const lastSlash = normalized.lastIndexOf('/'); + return lastSlash > 0 ? normalized.slice(0, lastSlash + 1) : ''; +} + +/* ── Mini diff-stats bar (GitHub-style) ────────────────────── */ + +function DiffStatsBar({ additions, deletions }: DiffStats) { + const total = additions + deletions; + if (total === 0) return null; + const blocks = 5; + const addBlocks = Math.max(additions > 0 ? 1 : 0, Math.round((additions / total) * blocks)); + const delBlocks = blocks - addBlocks; + + return ( + + {Array.from({ length: addBlocks }, (_, i) => ( + + ))} + {Array.from({ length: delBlocks }, (_, i) => ( + + ))} + + ); +} + +/* ── Diff line renderer ────────────────────────────────────── */ + +function DiffLine({ line }: { line: string }) { + let textClass = 'text-[var(--color-text-secondary)]'; + let bgClass = ''; + + if (line.startsWith('+') && !line.startsWith('+++')) { + textClass = 'text-[var(--color-status-success)]'; + bgClass = 'bg-[var(--color-status-success)]/[0.06]'; + } else if (line.startsWith('-') && !line.startsWith('---')) { + textClass = 'text-[var(--color-status-error)]'; + bgClass = 'bg-[var(--color-status-error)]/[0.06]'; + } else if (line.startsWith('@@')) { + textClass = 'text-[var(--color-accent-sky)]'; + } else if (line.startsWith('diff ') || line.startsWith('index ') || line.startsWith('---') || line.startsWith('+++')) { + textClass = 'text-[var(--color-text-muted)]'; + } + + return
{line || '\u00A0'}
; +} + +/* ── Individual file entry ─────────────────────────────────── */ + +function FileChangeEntry({ file }: { file: ToolCallFileChangePreview }) { + const [expanded, setExpanded] = useState(false); + const isNewFile = !file.diff && !!file.newFileContents; + const stats = useMemo(() => parseDiffStats(file.diff), [file.diff]); + const hasContent = !!file.diff || !!file.newFileContents; + const dir = fileDir(file.path); + const base = fileBaseName(file.path); + + return ( +
+ + + {expanded && ( +
+
+            {file.diff
+              ? file.diff.split('\n').map((line, i) => )
+              : file.newFileContents!.split('\n').map((line, i) => (
+                  
{line || '\u00A0'}
+ ))} +
+
+ )} +
+ ); +} + +/* ── Main export ───────────────────────────────────────────── */ + +interface FileChangePreviewProps { + fileChanges: ToolCallFileChangePreview[]; +} + +export function FileChangePreview({ fileChanges }: FileChangePreviewProps) { + const [expanded, setExpanded] = useState(false); + + const totalStats = useMemo(() => { + let additions = 0; + let deletions = 0; + let newFiles = 0; + for (const fc of fileChanges) { + if (!fc.diff && fc.newFileContents) { + newFiles++; + } else { + const s = parseDiffStats(fc.diff); + additions += s.additions; + deletions += s.deletions; + } + } + return { additions, deletions, newFiles }; + }, [fileChanges]); + + const fileWord = fileChanges.length === 1 ? 'file' : 'files'; + + return ( +
+ + + {expanded && ( +
+ {fileChanges.map((fc) => ( + + ))} +
+ )} +
+ ); +}