diff --git a/src/renderer/components/team/ToolApprovalDiffPreview.tsx b/src/renderer/components/team/ToolApprovalDiffPreview.tsx index bd9f01b0..6d0a79e7 100644 --- a/src/renderer/components/team/ToolApprovalDiffPreview.tsx +++ b/src/renderer/components/team/ToolApprovalDiffPreview.tsx @@ -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(); + for (const line of oldLines) { + oldSet.set(line, (oldSet.get(line) ?? 0) + 1); + } + const newSet = new Map(); + 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 = ( 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 = ( > Preview changes + {stats && ( + <> + {stats.added > 0 && +{stats.added}} + {stats.removed > 0 && ( + -{stats.removed} + )} + + )} {expanded ? : } diff --git a/src/renderer/components/team/ToolApprovalSheet.tsx b/src/renderer/components/team/ToolApprovalSheet.tsx index 83d1c977..c35a47ce 100644 --- a/src/renderer/components/team/ToolApprovalSheet.tsx +++ b/src/renderer/components/team/ToolApprovalSheet.tsx @@ -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 { +function renderToolInput( + toolName: string, + input: Record, + 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(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 = () => { {/* Tool input preview (syntax-highlighted) */} - + {/* Diff preview (Write/Edit/NotebookEdit only) */} { const ToolInputPreview = ({ toolName, toolInput, + projectPath, }: { toolName: string; toolInput: Record; + 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]);