fix: consistent diff stats between preview badge and DiffViewer

- Export computeDiffLineStats from DiffViewer — uses the same LCS/semantic
  diff algorithm so stats always match the rendered diff
- Remove custom computeDiffStats (set-based) that produced different counts
- Add quickStats from toolInput.content for Write tool — shows +N
  immediately before IPC completes (no need to expand first)
- diffStats (accurate, from IPC) replaces quickStats once available
This commit is contained in:
iliya 2026-03-21 13:30:53 +02:00
parent 987ad96f4a
commit 4d65bad24f
2 changed files with 36 additions and 45 deletions

View file

@ -149,6 +149,19 @@ function generateDiff(oldLines: string[], newLines: string[]): DiffLine[] {
return result;
}
/**
* Computes diff statistics from old/new strings using the same algorithm as DiffViewer.
*/
export function computeDiffLineStats(
oldString: string,
newString: string
): { added: number; removed: number } {
const oldLines = oldString.split(/\r?\n/);
const newLines = newString.split(/\r?\n/);
const diffLines = generateDiff(oldLines, newLines);
return computeStats(diffLines);
}
/**
* Computes diff statistics.
*/

View file

@ -1,6 +1,6 @@
import React, { useMemo, useState } from 'react';
import { DiffViewer } from '@renderer/components/chat/viewers/DiffViewer';
import { computeDiffLineStats, DiffViewer } from '@renderer/components/chat/viewers/DiffViewer';
import { useToolApprovalDiff } from '@renderer/hooks/useToolApprovalDiff';
import { AlertTriangle, ChevronDown, ChevronRight, FileDiff, Loader2 } from 'lucide-react';
@ -35,51 +35,18 @@ function saveExpandedPref(value: boolean): void {
}
// =============================================================================
// Diff stats helper
// Quick stats from toolInput (before IPC, for Write tool)
// =============================================================================
function computeDiffStats(
oldString: string,
newString: string
): { added: number; removed: number } {
const oldLines = oldString.split(/\r?\n/);
const newLines = newString.split(/\r?\n/);
// Simple line-count based stats (matches DiffViewer's own count)
const maxLen = Math.max(oldLines.length, newLines.length);
let added = 0;
let removed = 0;
// Count lines that differ
if (oldString === '' && newString !== '') {
added = newLines.length;
} else if (newString === '' && oldString !== '') {
removed = oldLines.length;
} else {
// Diff-based: count added/removed from line diff
const oldSet = new Map<string, number>();
for (const line of oldLines) {
oldSet.set(line, (oldSet.get(line) ?? 0) + 1);
}
const newSet = new Map<string, number>();
for (const line of newLines) {
newSet.set(line, (newSet.get(line) ?? 0) + 1);
}
// Lines in new but not in old
for (const [line, count] of newSet) {
const oldCount = oldSet.get(line) ?? 0;
if (count > oldCount) added += count - oldCount;
}
// Lines in old but not in new
for (const [line, count] of oldSet) {
const newCount = newSet.get(line) ?? 0;
if (count > newCount) removed += count - newCount;
}
// Ensure at least something shows if strings differ but stats are 0
if (added === 0 && removed === 0 && oldString !== newString) {
added = Math.max(0, newLines.length - maxLen);
removed = Math.max(0, oldLines.length - maxLen);
}
function computeQuickStats(
toolName: string,
toolInput: Record<string, unknown>
): { added: number; removed: number } | null {
if (toolName === 'Write' && typeof toolInput.content === 'string') {
const lines = toolInput.content.split(/\r?\n/).length;
return { added: lines, removed: 0 };
}
return { added, removed };
return null;
}
// =============================================================================
@ -95,12 +62,23 @@ export const ToolApprovalDiffPreview: React.FC<ToolApprovalDiffPreviewProps> = (
const [expanded, setExpanded] = useState(loadExpandedPref);
const diff = useToolApprovalDiff(toolName, toolInput, requestId, expanded);
const stats = useMemo(() => {
// Stats from actual diff data (after IPC), using the same algorithm as DiffViewer
const diffStats = useMemo(() => {
if (!diff.hasDiff || diff.loading || diff.isBinary || diff.error) return null;
if (!diff.oldString && !diff.newString) return null;
return computeDiffStats(diff.oldString, diff.newString);
return computeDiffLineStats(diff.oldString, diff.newString);
}, [diff.hasDiff, diff.loading, diff.isBinary, diff.error, diff.oldString, diff.newString]);
// Quick stats from toolInput (available immediately, before diff loads)
const quickStats = useMemo(
() => computeQuickStats(toolName, toolInput),
// eslint-disable-next-line react-hooks/exhaustive-deps -- toolInput identity tied to requestId
[toolName, requestId]
);
// Prefer accurate diffStats when available, fall back to quickStats
const stats = diffStats ?? quickStats;
if (!DIFF_TOOLS.has(toolName)) return null;
const toggleExpanded = (): void => {