import { useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react'; import type { ReactNode } from 'react'; import type { ApprovalStatus, ToolStatus } from 'shared/types'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; import { approvalsApi } from '@/lib/api'; import { Check, X } from 'lucide-react'; import { FileSearchTextarea } from '@/components/ui/file-search-textarea'; import { useHotkeysContext } from 'react-hotkeys-hook'; import { TabNavContext } from '@/contexts/TabNavigationContext'; import { useKeyApproveRequest, useKeyDenyApproval, Scope } from '@/keyboard'; import { useProject } from '@/contexts/project-context'; const DEFAULT_DENIAL_REASON = 'User denied this tool use request.'; // ---------- Types ---------- interface PendingApprovalEntryProps { pendingStatus: Extract; executionProcessId?: string; children: ReactNode; } function useApprovalCountdown( requestedAt: string | number | Date, timeoutAt: string | number | Date, paused: boolean ) { const totalSeconds = useMemo(() => { const total = Math.floor( (new Date(timeoutAt).getTime() - new Date(requestedAt).getTime()) / 1000 ); return Math.max(1, total); }, [requestedAt, timeoutAt]); const [timeLeft, setTimeLeft] = useState(() => { const remaining = new Date(timeoutAt).getTime() - Date.now(); return Math.max(0, Math.floor(remaining / 1000)); }); useEffect(() => { if (paused) return; const id = window.setInterval(() => { const remaining = new Date(timeoutAt).getTime() - Date.now(); const next = Math.max(0, Math.floor(remaining / 1000)); setTimeLeft(next); if (next <= 0) window.clearInterval(id); }, 1000); return () => window.clearInterval(id); }, [timeoutAt, paused]); const percent = useMemo( () => Math.max(0, Math.min(100, Math.round((timeLeft / totalSeconds) * 100))), [timeLeft, totalSeconds] ); return { timeLeft, percent }; } function ActionButtons({ disabled, isResponding, onApprove, onStartDeny, }: { disabled: boolean; isResponding: boolean; onApprove: () => void; onStartDeny: () => void; }) { return (

{isResponding ? 'Submitting…' : 'Approve request'}

{isResponding ? 'Submitting…' : 'Provide denial reason'}

); } function DenyReasonForm({ isResponding, value, onChange, onCancel, onSubmit, inputRef, projectId, }: { isResponding: boolean; value: string; onChange: (v: string) => void; onCancel: () => void; onSubmit: () => void; inputRef: React.RefObject; projectId?: string; }) { return (
); } // ---------- Main Component ---------- const PendingApprovalEntry = ({ pendingStatus, executionProcessId, children, }: PendingApprovalEntryProps) => { const [isResponding, setIsResponding] = useState(false); const [hasResponded, setHasResponded] = useState(false); const [error, setError] = useState(null); const [isEnteringReason, setIsEnteringReason] = useState(false); const [denyReason, setDenyReason] = useState(''); const denyReasonRef = useRef(null); const { projectId } = useProject(); const { enableScope, disableScope, activeScopes } = useHotkeysContext(); const tabNav = useContext(TabNavContext); const isLogsTabActive = tabNav ? tabNav.activeTab === 'logs' : true; const dialogScopeActive = activeScopes.includes(Scope.DIALOG); const shouldControlScopes = isLogsTabActive && !dialogScopeActive; const approvalsScopeEnabledRef = useRef(false); const dialogScopeActiveRef = useRef(dialogScopeActive); useEffect(() => { dialogScopeActiveRef.current = dialogScopeActive; }, [dialogScopeActive]); const { timeLeft } = useApprovalCountdown( pendingStatus.requested_at, pendingStatus.timeout_at, hasResponded ); const disabled = isResponding || hasResponded || timeLeft <= 0; const shouldEnableApprovalsScope = shouldControlScopes && !disabled; useEffect(() => { const shouldEnable = shouldEnableApprovalsScope; if (shouldEnable && !approvalsScopeEnabledRef.current) { enableScope(Scope.APPROVALS); disableScope(Scope.KANBAN); approvalsScopeEnabledRef.current = true; } else if (!shouldEnable && approvalsScopeEnabledRef.current) { disableScope(Scope.APPROVALS); if (!dialogScopeActive) { enableScope(Scope.KANBAN); } approvalsScopeEnabledRef.current = false; } return () => { if (approvalsScopeEnabledRef.current) { disableScope(Scope.APPROVALS); if (!dialogScopeActiveRef.current) { enableScope(Scope.KANBAN); } approvalsScopeEnabledRef.current = false; } }; }, [ disableScope, enableScope, dialogScopeActive, shouldEnableApprovalsScope, ]); const respond = useCallback( async (approved: boolean, reason?: string) => { if (disabled) return; if (!executionProcessId) { setError('Missing executionProcessId'); return; } setIsResponding(true); setError(null); const status: ApprovalStatus = approved ? { status: 'approved' } : { status: 'denied', reason }; try { await approvalsApi.respond(pendingStatus.approval_id, { execution_process_id: executionProcessId, status, }); setHasResponded(true); setIsEnteringReason(false); setDenyReason(''); } catch (e: any) { console.error('Approval respond failed:', e); setError(e?.message || 'Failed to send response'); } finally { setIsResponding(false); } }, [disabled, executionProcessId, pendingStatus.approval_id] ); const handleApprove = useCallback(() => respond(true), [respond]); const handleStartDeny = useCallback(() => { if (disabled) return; setError(null); setIsEnteringReason(true); }, [disabled]); const handleCancelDeny = useCallback(() => { if (isResponding) return; setIsEnteringReason(false); setDenyReason(''); }, [isResponding]); const handleSubmitDeny = useCallback(() => { const trimmed = denyReason.trim(); respond(false, trimmed || DEFAULT_DENIAL_REASON); }, [denyReason, respond]); const triggerDeny = useCallback( (event?: KeyboardEvent) => { if (!isEnteringReason || disabled || hasResponded) return; event?.preventDefault(); handleSubmitDeny(); }, [isEnteringReason, disabled, hasResponded, handleSubmitDeny] ); useKeyApproveRequest(handleApprove, { scope: Scope.APPROVALS, when: () => shouldEnableApprovalsScope && !isEnteringReason, preventDefault: true, }); useKeyDenyApproval(triggerDeny, { scope: Scope.APPROVALS, when: () => shouldEnableApprovalsScope && !hasResponded, enableOnFormTags: ['textarea', 'TEXTAREA'], preventDefault: true, }); useEffect(() => { if (!isEnteringReason) return; const id = window.setTimeout(() => denyReasonRef.current?.focus(), 0); return () => window.clearTimeout(id); }, [isEnteringReason]); return (
{children}
{!isEnteringReason && ( Would you like to approve this? )}
{!isEnteringReason && ( )}
{error && (
{error}
)} {isEnteringReason && !hasResponded && ( )}
); }; export default PendingApprovalEntry;