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:
parent
1f9f0b6390
commit
afea718b0f
2 changed files with 102 additions and 15 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue