agent-ecosystem/src/renderer/components/team/ToolApprovalDiffPreview.tsx
iliya 7b13a4c398 refactor: update README and enhance team UI components
- Changed image format for agent comments screenshot in README.
- Improved table of contents in README for better navigation.
- Refactored imports in IPC config and discovery services for consistency.
- Added shimmer effect for waiting members in CSS.
- Enhanced dashboard view to display active teams with online indicators.
- Updated team provisioning components to support message severity.
- Improved task detail dialog layout for related tasks and dependencies.
- Adjusted team model selector default value and refined member status handling.
- Fixed minor styling issues in messages panel and tab bar.

These changes aim to improve user experience and maintainability across the application.
2026-03-23 12:58:38 +02:00

206 lines
7 KiB
TypeScript

import React, { useMemo, useState } from 'react';
import { computeDiffLineStats, DiffViewer } from '@renderer/components/chat/viewers/DiffViewer';
import { useToolApprovalDiff } from '@renderer/hooks/useToolApprovalDiff';
import { AlertTriangle, ChevronDown, ChevronRight, FileDiff, Loader2 } from 'lucide-react';
// =============================================================================
// Types
// =============================================================================
interface ToolApprovalDiffPreviewProps {
toolName: string;
toolInput: Record<string, unknown>;
requestId: string;
onExpandedChange?: (expanded: boolean) => void;
}
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
}
}
// =============================================================================
// Quick stats from toolInput (before IPC, for Write tool)
// =============================================================================
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 null;
}
// =============================================================================
// Component
// =============================================================================
export const ToolApprovalDiffPreview: React.FC<ToolApprovalDiffPreviewProps> = ({
toolName,
toolInput,
requestId,
onExpandedChange,
}) => {
const [expanded, setExpanded] = useState(loadExpandedPref);
const diff = useToolApprovalDiff(toolName, toolInput, requestId, expanded);
// 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 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 => {
const next = !expanded;
setExpanded(next);
saveExpandedPref(next);
onExpandedChange?.(next);
};
return (
<div className="border-t px-4 py-2" style={{ borderColor: 'var(--color-border)' }}>
{/* Toggle button */}
<button
type="button"
onClick={toggleExpanded}
className="flex items-center gap-1.5 rounded px-2 py-1 text-[11px] transition-colors"
style={{ color: 'var(--color-text-muted)' }}
onMouseEnter={(e) => {
Object.assign(e.currentTarget.style, {
backgroundColor: 'var(--color-surface-raised)',
});
}}
onMouseLeave={(e) => {
Object.assign(e.currentTarget.style, { backgroundColor: 'transparent' });
}}
>
<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>
{/* Collapsible content */}
{expanded && (
<div className="mt-2">
{diff.loading && (
<div
className="flex items-center gap-2 rounded-md border p-3 text-xs"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-border)',
color: 'var(--color-text-muted)',
}}
>
<Loader2 className="size-3.5 animate-spin" />
<span>Reading file...</span>
</div>
)}
{diff.isBinary && (
<div
className="flex items-center gap-2 rounded-md border px-3 py-2.5 text-xs"
style={{
backgroundColor: 'rgba(234, 179, 8, 0.08)',
borderColor: 'rgba(234, 179, 8, 0.25)',
color: 'rgb(234, 179, 8)',
}}
>
<AlertTriangle className="size-3.5 shrink-0" />
<span>Binary file cannot preview</span>
</div>
)}
{diff.error && !diff.loading && (
<div
className="flex items-start gap-2 rounded-md border px-3 py-2.5 text-xs"
style={{
backgroundColor: 'rgba(234, 179, 8, 0.08)',
borderColor: 'rgba(234, 179, 8, 0.25)',
color: 'rgb(234, 179, 8)',
}}
>
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span className="break-words">{diff.error}</span>
</div>
)}
{diff.truncated && !diff.loading && (
<div
className="mb-2 flex items-center gap-2 rounded-md border px-3 py-1.5 text-[10px]"
style={{
backgroundColor: 'rgba(234, 179, 8, 0.06)',
borderColor: 'rgba(234, 179, 8, 0.2)',
color: 'rgb(234, 179, 8)',
}}
>
<AlertTriangle className="size-3 shrink-0" />
<span>File truncated at 2MB diff may be incomplete</span>
</div>
)}
{!diff.loading && !diff.isBinary && !diff.error && (diff.oldString || diff.newString) && (
<div>
{diff.isNewFile && (
<span
className="mb-1.5 inline-block rounded px-1.5 py-0.5 text-[10px] font-medium"
style={{
backgroundColor: 'rgba(46, 160, 67, 0.15)',
color: 'rgb(46, 160, 67)',
}}
>
New file
</span>
)}
<DiffViewer
fileName={diff.fileName}
oldString={diff.oldString}
newString={diff.newString}
maxHeight="max-h-[300px]"
syntaxHighlight
/>
</div>
)}
</div>
)}
</div>
);
};