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 { DiffViewer } from '@renderer/components/chat/viewers/DiffViewer';
|
||||||
import { useToolApprovalDiff } from '@renderer/hooks/useToolApprovalDiff';
|
import { useToolApprovalDiff } from '@renderer/hooks/useToolApprovalDiff';
|
||||||
|
|
@ -16,6 +16,71 @@ interface ToolApprovalDiffPreviewProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const DIFF_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit']);
|
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
|
// Component
|
||||||
|
|
@ -27,21 +92,21 @@ export const ToolApprovalDiffPreview: React.FC<ToolApprovalDiffPreviewProps> = (
|
||||||
requestId,
|
requestId,
|
||||||
onExpandedChange,
|
onExpandedChange,
|
||||||
}) => {
|
}) => {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(loadExpandedPref);
|
||||||
const diff = useToolApprovalDiff(toolName, toolInput, requestId, expanded);
|
const diff = useToolApprovalDiff(toolName, toolInput, requestId, expanded);
|
||||||
|
|
||||||
// Collapse when approval changes
|
const stats = useMemo(() => {
|
||||||
useEffect(() => {
|
if (!diff.hasDiff || diff.loading || diff.isBinary || diff.error) return null;
|
||||||
setExpanded(false);
|
if (!diff.oldString && !diff.newString) return null;
|
||||||
onExpandedChange?.(false);
|
return computeDiffStats(diff.oldString, diff.newString);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- onExpandedChange is stable setter, only reset on requestId change
|
}, [diff.hasDiff, diff.loading, diff.isBinary, diff.error, diff.oldString, diff.newString]);
|
||||||
}, [requestId]);
|
|
||||||
|
|
||||||
if (!DIFF_TOOLS.has(toolName)) return null;
|
if (!DIFF_TOOLS.has(toolName)) return null;
|
||||||
|
|
||||||
const toggleExpanded = (): void => {
|
const toggleExpanded = (): void => {
|
||||||
const next = !expanded;
|
const next = !expanded;
|
||||||
setExpanded(next);
|
setExpanded(next);
|
||||||
|
saveExpandedPref(next);
|
||||||
onExpandedChange?.(next);
|
onExpandedChange?.(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -64,6 +129,14 @@ export const ToolApprovalDiffPreview: React.FC<ToolApprovalDiffPreviewProps> = (
|
||||||
>
|
>
|
||||||
<FileDiff className="size-3" />
|
<FileDiff className="size-3" />
|
||||||
<span>Preview changes</span>
|
<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" />}
|
{expanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { shortenDisplayPath } from '@renderer/utils/pathDisplay';
|
||||||
import { highlightLines } from '@renderer/utils/syntaxHighlighter';
|
import { highlightLines } from '@renderer/utils/syntaxHighlighter';
|
||||||
import { AlertTriangle, FileText, Search, Terminal } from 'lucide-react';
|
import { AlertTriangle, FileText, Search, Terminal } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -37,15 +38,22 @@ function getToolIcon(toolName: string): React.JSX.Element {
|
||||||
// Smart input preview
|
// 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) {
|
switch (toolName) {
|
||||||
case 'Bash':
|
case 'Bash':
|
||||||
return typeof input.command === 'string' ? input.command : JSON.stringify(input, null, 2);
|
return typeof input.command === 'string' ? input.command : JSON.stringify(input, null, 2);
|
||||||
case 'Edit':
|
case 'Edit':
|
||||||
case 'Read':
|
case 'Read':
|
||||||
case 'Write':
|
case 'Write':
|
||||||
case 'NotebookEdit':
|
case 'NotebookEdit': {
|
||||||
return typeof input.file_path === 'string' ? input.file_path : JSON.stringify(input, null, 2);
|
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 'Grep':
|
||||||
case 'Glob':
|
case 'Glob':
|
||||||
return typeof input.pattern === 'string' ? input.pattern : JSON.stringify(input, null, 2);
|
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 updateToolApprovalSettings = useStore((s) => s.updateToolApprovalSettings);
|
||||||
const teams = useStore((s) => s.teams);
|
const teams = useStore((s) => s.teams);
|
||||||
const selectedTeamName = useStore((s) => s.selectedTeamName);
|
const selectedTeamName = useStore((s) => s.selectedTeamName);
|
||||||
|
const selectedTeamData = useStore((s) => s.selectedTeamData);
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
|
|
||||||
const current: ToolApprovalRequest | undefined = pendingApprovals[0];
|
const current: ToolApprovalRequest | undefined = pendingApprovals[0];
|
||||||
|
|
@ -115,10 +124,9 @@ export const ToolApprovalSheet: React.FC = () => {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [diffExpanded, setDiffExpanded] = useState(false);
|
const [diffExpanded, setDiffExpanded] = useState(false);
|
||||||
|
|
||||||
// Clear error and collapse diff when current approval changes
|
// Clear error when current approval changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setDiffExpanded(false);
|
|
||||||
}, [current?.requestId]);
|
}, [current?.requestId]);
|
||||||
|
|
||||||
const handleRespond = useCallback(
|
const handleRespond = useCallback(
|
||||||
|
|
@ -214,7 +222,11 @@ export const ToolApprovalSheet: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tool input preview (syntax-highlighted) */}
|
{/* 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) */}
|
{/* Diff preview (Write/Edit/NotebookEdit only) */}
|
||||||
<ToolApprovalDiffPreview
|
<ToolApprovalDiffPreview
|
||||||
|
|
@ -332,11 +344,13 @@ export const ToolApprovalSheet: React.FC = () => {
|
||||||
const ToolInputPreview = ({
|
const ToolInputPreview = ({
|
||||||
toolName,
|
toolName,
|
||||||
toolInput,
|
toolInput,
|
||||||
|
projectPath,
|
||||||
}: {
|
}: {
|
||||||
toolName: string;
|
toolName: string;
|
||||||
toolInput: Record<string, unknown>;
|
toolInput: Record<string, unknown>;
|
||||||
|
projectPath?: string;
|
||||||
}): React.JSX.Element => {
|
}): React.JSX.Element => {
|
||||||
const text = renderToolInput(toolName, toolInput);
|
const text = renderToolInput(toolName, toolInput, projectPath);
|
||||||
const fileName = getToolInputFileName(toolName, toolInput);
|
const fileName = getToolInputFileName(toolName, toolInput);
|
||||||
const lines = useMemo(() => highlightLines(text, fileName), [text, fileName]);
|
const lines = useMemo(() => highlightLines(text, fileName), [text, fileName]);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue