feat: relative paths, persistent diff preview, change stats

- Show relative path in tool input preview when file is inside
  team's project directory (via shortenDisplayPath)
- Persist diff preview expanded state in localStorage — once opened,
  stays open across approvals
- Show +N / -N line stats in "Preview changes" toggle button
  (green for added, red for removed)
This commit is contained in:
iliya 2026-03-21 12:43:49 +02:00
parent 1f9f0b6390
commit afea718b0f
2 changed files with 102 additions and 15 deletions

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useMemo, useState } from 'react';
import { DiffViewer } from '@renderer/components/chat/viewers/DiffViewer';
import { useToolApprovalDiff } from '@renderer/hooks/useToolApprovalDiff';
@ -16,6 +16,71 @@ interface ToolApprovalDiffPreviewProps {
}
const DIFF_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit']);
const STORAGE_KEY = 'tool-approval:preview-expanded';
function loadExpandedPref(): boolean {
try {
return localStorage.getItem(STORAGE_KEY) === 'true';
} catch {
return false;
}
}
function saveExpandedPref(value: boolean): void {
try {
localStorage.setItem(STORAGE_KEY, String(value));
} catch {
// quota or disabled — ignore
}
}
// =============================================================================
// Diff stats helper
// =============================================================================
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);
}
}
return { added, removed };
}
// =============================================================================
// Component
@ -27,21 +92,21 @@ export const ToolApprovalDiffPreview: React.FC<ToolApprovalDiffPreviewProps> = (
requestId,
onExpandedChange,
}) => {
const [expanded, setExpanded] = useState(false);
const [expanded, setExpanded] = useState(loadExpandedPref);
const diff = useToolApprovalDiff(toolName, toolInput, requestId, expanded);
// Collapse when approval changes
useEffect(() => {
setExpanded(false);
onExpandedChange?.(false);
// eslint-disable-next-line react-hooks/exhaustive-deps -- onExpandedChange is stable setter, only reset on requestId change
}, [requestId]);
const stats = 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);
}, [diff.hasDiff, diff.loading, diff.isBinary, diff.error, diff.oldString, diff.newString]);
if (!DIFF_TOOLS.has(toolName)) return null;
const toggleExpanded = (): void => {
const next = !expanded;
setExpanded(next);
saveExpandedPref(next);
onExpandedChange?.(next);
};
@ -64,6 +129,14 @@ export const ToolApprovalDiffPreview: React.FC<ToolApprovalDiffPreviewProps> = (
>
<FileDiff className="size-3" />
<span>Preview changes</span>
{stats && (
<>
{stats.added > 0 && <span style={{ color: 'rgb(46, 160, 67)' }}>+{stats.added}</span>}
{stats.removed > 0 && (
<span style={{ color: 'rgb(248, 81, 73)' }}>-{stats.removed}</span>
)}
</>
)}
{expanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
</button>

View file

@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
import { shortenDisplayPath } from '@renderer/utils/pathDisplay';
import { highlightLines } from '@renderer/utils/syntaxHighlighter';
import { AlertTriangle, FileText, Search, Terminal } from 'lucide-react';
@ -37,15 +38,22 @@ function getToolIcon(toolName: string): React.JSX.Element {
// Smart input preview
// ---------------------------------------------------------------------------
function renderToolInput(toolName: string, input: Record<string, unknown>): string {
function renderToolInput(
toolName: string,
input: Record<string, unknown>,
projectPath?: string
): string {
switch (toolName) {
case 'Bash':
return typeof input.command === 'string' ? input.command : JSON.stringify(input, null, 2);
case 'Edit':
case 'Read':
case 'Write':
case 'NotebookEdit':
return typeof input.file_path === 'string' ? input.file_path : JSON.stringify(input, null, 2);
case 'NotebookEdit': {
const fp = typeof input.file_path === 'string' ? input.file_path : null;
if (!fp) return JSON.stringify(input, null, 2);
return projectPath ? shortenDisplayPath(fp, projectPath, 200) : fp;
}
case 'Grep':
case 'Glob':
return typeof input.pattern === 'string' ? input.pattern : JSON.stringify(input, null, 2);
@ -107,6 +115,7 @@ export const ToolApprovalSheet: React.FC = () => {
const updateToolApprovalSettings = useStore((s) => s.updateToolApprovalSettings);
const teams = useStore((s) => s.teams);
const selectedTeamName = useStore((s) => s.selectedTeamName);
const selectedTeamData = useStore((s) => s.selectedTeamData);
const { isLight } = useTheme();
const current: ToolApprovalRequest | undefined = pendingApprovals[0];
@ -115,10 +124,9 @@ export const ToolApprovalSheet: React.FC = () => {
const [error, setError] = useState<string | null>(null);
const [diffExpanded, setDiffExpanded] = useState(false);
// Clear error and collapse diff when current approval changes
// Clear error when current approval changes
useEffect(() => {
setError(null);
setDiffExpanded(false);
}, [current?.requestId]);
const handleRespond = useCallback(
@ -214,7 +222,11 @@ export const ToolApprovalSheet: React.FC = () => {
</div>
{/* Tool input preview (syntax-highlighted) */}
<ToolInputPreview toolName={current.toolName} toolInput={current.toolInput} />
<ToolInputPreview
toolName={current.toolName}
toolInput={current.toolInput}
projectPath={selectedTeamData?.config?.projectPath}
/>
{/* Diff preview (Write/Edit/NotebookEdit only) */}
<ToolApprovalDiffPreview
@ -332,11 +344,13 @@ export const ToolApprovalSheet: React.FC = () => {
const ToolInputPreview = ({
toolName,
toolInput,
projectPath,
}: {
toolName: string;
toolInput: Record<string, unknown>;
projectPath?: string;
}): React.JSX.Element => {
const text = renderToolInput(toolName, toolInput);
const text = renderToolInput(toolName, toolInput, projectPath);
const fileName = getToolInputFileName(toolName, toolInput);
const lines = useMemo(() => highlightLines(text, fileName), [text, fileName]);