HuggingClaw-MissionControl / src /components /modals /exec-approval-overlay.tsx
nyk
feat(refactor): ready for manual QA after main sync (#274)
b6ecafa unverified
'use client'
import { useEffect, useState, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { useMissionControl, type ExecApprovalRequest } from '@/store'
import { useWebSocket } from '@/lib/websocket'
const RISK_BORDER: Record<ExecApprovalRequest['risk'], string> = {
low: 'border-l-green-500',
medium: 'border-l-yellow-500',
high: 'border-l-orange-500',
critical: 'border-l-red-500',
}
const RISK_BADGE: Record<ExecApprovalRequest['risk'], string> = {
low: 'bg-green-500/20 text-green-400',
medium: 'bg-yellow-500/20 text-yellow-400',
high: 'bg-orange-500/20 text-orange-400',
critical: 'bg-red-500/20 text-red-400',
}
function formatRemaining(ms: number): string {
const remaining = Math.max(0, ms)
const totalSeconds = Math.floor(remaining / 1000)
if (totalSeconds < 60) return `${totalSeconds}s`
const minutes = Math.floor(totalSeconds / 60)
if (minutes < 60) return `${minutes}m`
const hours = Math.floor(minutes / 60)
return `${hours}h`
}
function MetaRow({ label, value }: { label: string; value?: string | null }) {
if (!value) return null
return (
<div className="flex justify-between text-xs py-0.5">
<span className="text-muted-foreground">{label}</span>
<span className="font-mono text-foreground truncate ml-4 max-w-[300px]">{value}</span>
</div>
)
}
export function ExecApprovalOverlay() {
const { execApprovals, updateExecApproval } = useMissionControl()
const { sendMessage } = useWebSocket()
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [, setTick] = useState(0)
const pending = execApprovals.filter(a => a.status === 'pending')
const active = pending[0]
// Tick every second to update expiry countdown
useEffect(() => {
if (!active) return
const interval = setInterval(() => setTick(t => t + 1), 1000)
return () => clearInterval(interval)
}, [active?.id])
// Auto-expire client-side
useEffect(() => {
if (!active?.expiresAt) return
if (active.expiresAt < Date.now()) {
updateExecApproval(active.id, { status: 'expired' })
}
}, [active, updateExecApproval])
const handleDecision = useCallback(async (decision: 'allow-once' | 'allow-always' | 'deny') => {
if (!active || busy) return
setBusy(true)
setError(null)
// Try WebSocket RPC first
const sent = sendMessage({
type: 'req',
method: 'exec.approval.resolve',
id: `ea-${Date.now()}`,
params: { id: active.id, decision },
})
if (!sent) {
// Fallback to HTTP
try {
const action = decision === 'deny' ? 'deny' : decision === 'allow-always' ? 'always_allow' : 'approve'
const res = await fetch('/api/exec-approvals', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: active.id, action }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
setError(data.error || 'Failed to send decision')
setBusy(false)
return
}
} catch {
setError('Failed to reach gateway')
setBusy(false)
return
}
}
// Optimistic update
const newStatus = decision === 'deny' ? 'denied' : 'approved'
updateExecApproval(active.id, { status: newStatus as ExecApprovalRequest['status'] })
setBusy(false)
}, [active, busy, sendMessage, updateExecApproval])
if (!active) return null
const remainingMs = active.expiresAt ? active.expiresAt - Date.now() : null
const remainingText = remainingMs !== null
? (remainingMs > 0 ? `expires in ${formatRemaining(remainingMs)}` : 'expired')
: null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
role="dialog"
aria-live="polite"
aria-label="Execution approval required"
>
<div className={`w-[min(540px,95vw)] bg-card border border-border rounded-lg p-5 shadow-2xl border-l-4 ${RISK_BORDER[active.risk]}`}>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div>
<div className="text-sm font-semibold text-foreground">Exec approval needed</div>
{remainingText && (
<div className="text-xs text-muted-foreground mt-0.5">{remainingText}</div>
)}
</div>
<div className="flex items-center gap-2">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${RISK_BADGE[active.risk]}`}>
{active.risk}
</span>
{pending.length > 1 && (
<span className="text-xs font-medium text-muted-foreground bg-secondary rounded-full px-2 py-0.5">
{pending.length} pending
</span>
)}
</div>
</div>
{/* Command */}
{active.command && (
<pre className="bg-secondary rounded p-3 text-xs font-mono overflow-auto max-h-24 text-foreground mb-3 border border-border">
<code>$ {active.command}</code>
</pre>
)}
{/* Tool args (if no command) */}
{!active.command && active.toolArgs && Object.keys(active.toolArgs).length > 0 && (
<pre className="bg-secondary rounded p-3 text-xs font-mono overflow-auto max-h-32 text-foreground mb-3">
{JSON.stringify(active.toolArgs, null, 2)}
</pre>
)}
{/* Metadata */}
<div className="mb-3">
<MetaRow label="Agent" value={active.agentName} />
<MetaRow label="Session" value={active.sessionId} />
<MetaRow label="Tool" value={active.toolName} />
<MetaRow label="CWD" value={active.cwd} />
<MetaRow label="Host" value={active.host} />
<MetaRow label="Resolved" value={active.resolvedPath} />
</div>
{/* Error */}
{error && (
<div className="text-xs text-red-400 mb-3">{error}</div>
)}
{/* Actions */}
<div className="flex items-center gap-2">
<Button
size="sm"
className="bg-green-600 hover:bg-green-700 text-white"
disabled={busy}
onClick={() => handleDecision('allow-once')}
>
{busy ? 'Sending...' : 'Allow once'}
</Button>
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() => handleDecision('allow-always')}
>
Always allow
</Button>
<Button
size="sm"
className="bg-red-600 hover:bg-red-700 text-white"
disabled={busy}
onClick={() => handleDecision('deny')}
>
Deny
</Button>
</div>
</div>
</div>
)
}