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 { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; import { shortenDisplayPath } from '@renderer/utils/pathDisplay'; import { highlightLines } from '@renderer/utils/syntaxHighlighter'; import { AlertTriangle, FileText, MessageCircleQuestion, Search, Terminal } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { ToolApprovalSettingsContent, ToolApprovalSettingsToggle, } from './dialogs/ToolApprovalSettingsPanel'; import { FileIcon } from './editor/FileIcon'; import { MemberBadge } from './MemberBadge'; import { ToolApprovalDiffPreview } from './ToolApprovalDiffPreview'; import type { ToolApprovalRequest } from '@shared/types'; // --------------------------------------------------------------------------- // Tool icon mapping // --------------------------------------------------------------------------- /** Human-readable tool name for the approval header */ function getToolDisplayName(toolName: string): string { switch (toolName) { case 'AskUserQuestion': return 'Question'; case 'Bash': return 'Terminal'; case 'Read': return 'Read File'; case 'Edit': return 'Edit File'; case 'Write': return 'Write File'; case 'NotebookEdit': return 'Edit Notebook'; case 'Grep': return 'Search Content'; case 'Glob': return 'Find Files'; default: return toolName; } } 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 ; case 'AskUserQuestion': 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, respondToToolApproval, updateToolApprovalSettings, teams, selectedTeamName, selectedTeamData, selectedTeamMembers, } = useStore( useShallow((s) => ({ pendingApprovals: s.pendingApprovals, respondToToolApproval: s.respondToToolApproval, updateToolApprovalSettings: s.updateToolApprovalSettings, teams: s.teams, selectedTeamName: s.selectedTeamName, selectedTeamData: s.selectedTeamData, selectedTeamMembers: selectResolvedMembersForTeamName(s, s.selectedTeamName), })) ); 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); const [settingsExpanded, setSettingsExpanded] = useState(false); const [selectedOptions, setSelectedOptions] = useState>(new Set()); // Clear error + selection when current approval changes useEffect(() => { setError(null); setSelectedOptions(new Set()); }, [current?.requestId]); const handleRespond = useCallback( (allow: boolean) => { if (!current || disabled) return; setDisabled(true); setError(null); // For AskUserQuestion, build per-question answers from selected options // Key format in selectedOptions: "qi:label" — parse question index to map correctly const answersMessage = allow && current.toolName === 'AskUserQuestion' && selectedOptions.size > 0 ? (() => { const questions = Array.isArray(current.toolInput.questions) ? (current.toolInput.questions as { question?: string }[]) : []; const answersByQuestion: Record = {}; for (const key of selectedOptions) { const colonIdx = key.indexOf(':'); if (colonIdx < 0) continue; const qi = parseInt(key.slice(0, colonIdx), 10); const label = key.slice(colonIdx + 1); const questionText = questions[qi]?.question ?? `Question ${qi + 1}`; const existing = answersByQuestion[questionText]; answersByQuestion[questionText] = existing ? `${existing}, ${label}` : label; } return JSON.stringify(answersByQuestion); })() : undefined; // 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, answersMessage ) .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, selectedOptions] ); const isAskQuestion = current?.toolName === 'AskUserQuestion'; const hasSelection = selectedOptions.size > 0; const handleOptionSelect = useCallback((label: string, multiSelect: boolean) => { setSelectedOptions((prev) => { // For single-select: clear all options from the SAME question (same prefix) // Key format: "qi:label" where qi is the question index const prefix = label.split(':')[0] + ':'; const next = multiSelect ? new Set(prev) : new Set(Array.from(prev).filter((k) => !k.startsWith(prefix))); if (next.has(label)) { next.delete(label); } else { next.add(label); } return next; }); }, []); useEffect(() => { const handleKeyDown = (e: KeyboardEvent): void => { const tag = document.activeElement?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; if (e.key === 'Enter') { if (isAskQuestion && !hasSelection) return; e.preventDefault(); handleRespond(true); } else if (e.key === 'Escape') { e.preventDefault(); handleRespond(false); } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [handleRespond, isAskQuestion, hasSelection]); // Resolve teammate color for MemberBadge (when source !== 'lead') const sourceColor = useMemo(() => { if (!current || current.source === 'lead') return undefined; const member = selectedTeamMembers.find((m) => m.name === current.source); return member?.color; }, [current, selectedTeamMembers]); if (!current) return null; 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 */}
{current.source !== 'lead' && ( )} {getToolIcon(current.toolName)} {getToolDisplayName(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 )} setSettingsExpanded((v) => !v)} />
{/* Settings expanded content — below actions row */} {/* Timeout progress bar */}
); }; // --------------------------------------------------------------------------- // Syntax-highlighted tool input preview // --------------------------------------------------------------------------- const FILE_TOOLS = new Set(['Edit', 'Read', 'Write', 'NotebookEdit']); const ToolInputPreview = ({ toolName, toolInput, projectPath, selectedOptions, onOptionSelect, }: { toolName: string; toolInput: Record; projectPath?: string; selectedOptions?: Set; onOptionSelect?: (label: string, multiSelect: boolean) => void; }): 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; // AskUserQuestion: render questions with options as readable UI if (toolName === 'AskUserQuestion' && Array.isArray(toolInput.questions)) { const questions = toolInput.questions as { question?: string; header?: string; options?: { label?: string; description?: string }[]; multiSelect?: boolean; }[]; return (
{questions.map((q, qi) => (
{q.header && ( {q.header} )} {q.question && (

{q.question}

)} {Array.isArray(q.options) && (
{q.options.map((opt, oi) => { const optKey = `${qi}:${opt.label ?? `opt-${oi}`}`; const isSelected = selectedOptions?.has(optKey) ?? false; return ( ); })}
)}
))}
); } 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(useShallow((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)} ); };