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'; import { ToolApprovalSettingsPanel } from './dialogs/ToolApprovalSettingsPanel'; import { FileIcon } from './editor/FileIcon'; import { ToolApprovalDiffPreview } from './ToolApprovalDiffPreview'; import type { ToolApprovalRequest } from '@shared/types'; // --------------------------------------------------------------------------- // Tool icon mapping // --------------------------------------------------------------------------- function getToolIcon(toolName: string): React.JSX.Element { const cls = 'size-4 shrink-0'; switch (toolName) { case 'Bash': return ; case 'Read': case 'Edit': case 'Write': case 'NotebookEdit': return ; case 'Grep': case 'Glob': return ; default: return ; } } // --------------------------------------------------------------------------- // Smart input preview // --------------------------------------------------------------------------- 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': { 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); default: return JSON.stringify(input, null, 2); } } /** Map tool name to a virtual filename for syntax highlighting. */ function getToolInputFileName(toolName: string, input: Record): string { switch (toolName) { case 'Bash': return 'command.sh'; case 'Edit': case 'Read': case 'Write': case 'NotebookEdit': return typeof input.file_path === 'string' ? input.file_path : 'input.json'; case 'Grep': case 'Glob': return 'pattern.txt'; default: return 'input.json'; } } // --------------------------------------------------------------------------- // Elapsed timer hook // --------------------------------------------------------------------------- function useElapsed(receivedAt: string): number { const [elapsed, setElapsed] = useState(() => Math.max(0, Math.floor((Date.now() - new Date(receivedAt).getTime()) / 1000)) ); useEffect(() => { const computeElapsed = (): number => Math.max(0, Math.floor((Date.now() - new Date(receivedAt).getTime()) / 1000)); queueMicrotask(() => setElapsed(computeElapsed())); const id = setInterval(() => { setElapsed(computeElapsed()); }, 1000); return () => clearInterval(id); }, [receivedAt]); return elapsed; } // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- /** Max time (ms) to wait for the IPC before considering it stuck */ const RESPOND_TIMEOUT_MS = 10_000; export const ToolApprovalSheet: React.FC = () => { const pendingApprovals = useStore((s) => s.pendingApprovals); const respondToToolApproval = useStore((s) => s.respondToToolApproval); 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]; const containerRef = useRef(null); const [disabled, setDisabled] = useState(false); const [error, setError] = useState(null); const [diffExpanded, setDiffExpanded] = useState(false); // Clear error when current approval changes useEffect(() => { setError(null); }, [current?.requestId]); const handleRespond = useCallback( (allow: boolean) => { if (!current || disabled) return; setDisabled(true); setError(null); // Safety timeout — if IPC hangs (e.g. stdin.write callback never fires), // re-enable the button so the user isn't stuck forever. const safetyTimer = setTimeout(() => { setDisabled(false); setError('Response timed out — process may be unresponsive. Try again or stop the team.'); }, RESPOND_TIMEOUT_MS); respondToToolApproval(current.teamName, current.runId, current.requestId, allow) .then(() => { clearTimeout(safetyTimer); // Small delay before re-enabling to prevent accidental double-clicks setTimeout(() => setDisabled(false), 200); }) .catch((err: unknown) => { clearTimeout(safetyTimer); const msg = err instanceof Error ? err.message : String(err); setError(msg); setDisabled(false); }); }, [current, disabled, respondToToolApproval] ); useEffect(() => { const handleKeyDown = (e: KeyboardEvent): void => { const tag = document.activeElement?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; if (e.key === 'Enter') { e.preventDefault(); handleRespond(true); } else if (e.key === 'Escape') { e.preventDefault(); handleRespond(false); } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [handleRespond]); if (!current) return null; // Prefer color from the approval itself (always available, even during provisioning), // fall back to teams list, then getTeamColorSet hashes unknown names into TEAMMATE_COLORS. const teamSummary = teams.find((t) => t.teamName === current.teamName); const colorName = current.teamColor ?? teamSummary?.color ?? current.teamName; const teamColor = getTeamColorSet(colorName); const displayName = current.teamDisplayName ?? teamSummary?.displayName ?? current.teamName; return ( <> {/* Backdrop overlay */}
{/* Header */}
{getToolIcon(current.toolName)} {current.source !== 'lead' ? `${current.source} — ` : ''} {current.toolName}
{selectedTeamName !== current.teamName && ( {displayName} )}
{/* Tool input preview (syntax-highlighted) */} {/* Diff preview (Write/Edit/NotebookEdit only) */} {/* Error feedback */} {error && (
{error}
)} {/* Actions */}
{pendingApprovals.length > 1 && ( {pendingApprovals.length - 1} pending )}
{/* Timeout progress bar */}
); }; // --------------------------------------------------------------------------- // Syntax-highlighted tool input preview // --------------------------------------------------------------------------- const FILE_TOOLS = new Set(['Edit', 'Read', 'Write', 'NotebookEdit']); const ToolInputPreview = ({ toolName, toolInput, projectPath, }: { toolName: string; toolInput: Record; projectPath?: string; }): React.JSX.Element => { const text = renderToolInput(toolName, toolInput, projectPath); const fileName = getToolInputFileName(toolName, toolInput); const lines = useMemo(() => highlightLines(text, fileName), [text, fileName]); const rawFilePath = typeof toolInput.file_path === 'string' ? toolInput.file_path : null; const isFileTool = FILE_TOOLS.has(toolName) && rawFilePath; return (
{isFileTool ? (
{text}
) : ( /* highlightLines uses hljs which HTML-escapes all input text, producing only tags. This is safe: the source is our own renderToolInput() output, not arbitrary user HTML. Same pattern used in ReviewDiffContent.tsx and DiffViewer for syntax highlighting. */ lines.map((html, i) => (
)) )}
); }; // --------------------------------------------------------------------------- // Timeout progress bar sub-component // --------------------------------------------------------------------------- const TimeoutProgress = ({ receivedAt }: { receivedAt: string }): React.JSX.Element | null => { const settings = useStore((s) => s.toolApprovalSettings); const elapsed = useElapsed(receivedAt); if (settings.timeoutAction === 'wait') return null; const progress = Math.min(1, elapsed / settings.timeoutSeconds); const remaining = Math.max(0, settings.timeoutSeconds - elapsed); const color = settings.timeoutAction === 'allow' ? 'rgb(5, 150, 105)' : 'rgb(239, 68, 68)'; return (
Auto-{settings.timeoutAction} in {formatElapsed(remaining)}
); }; // --------------------------------------------------------------------------- // Elapsed display sub-component (uses hook) // --------------------------------------------------------------------------- function formatElapsed(seconds: number): string { if (seconds < 60) return `${seconds}s`; const m = Math.floor(seconds / 60); const s = seconds % 60; return s > 0 ? `${m}m ${s}s` : `${m}m`; } const ElapsedDisplay = ({ receivedAt }: { receivedAt: string }): React.JSX.Element => { const elapsed = useElapsed(receivedAt); return ( {formatElapsed(elapsed)} ); };