Files
atc-agents/ui/src/components/features/ApprovalInbox.tsx
T
mo a11621b21f Add Command Center v2: DQ/RAG integration, S3 browser, Jupyter, GPU matrix.
Mirror mo/atc-GPU layout with config/, docs/, scripts/ for Gitea deploy.
2026-06-25 00:28:23 +00:00

123 lines
6.3 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react'
import { Check, ShieldCheck, X } from 'lucide-react'
import { fetchApprovalHistory } from '../../lib/api'
import type { Agent, Approval } from '../../types'
import { getAgentMeta } from '../../lib/agentMeta'
import { Badge } from '../ui/Badge'
import { Button } from '../ui/Button'
import { cn } from '../../lib/utils'
type Filter = 'pending' | 'approved' | 'denied' | 'all'
type Props = {
agents: Agent[]
livePending: Approval[]
onDecide: (id: string, approved: boolean, decidedBy: string, note: string) => Promise<void>
}
export function ApprovalInbox({ agents, livePending, onDecide }: Props) {
const [filter, setFilter] = useState<Filter>('pending')
const [items, setItems] = useState<Approval[]>([])
const [stats, setStats] = useState({ pending: 0, approved: 0, denied: 0, total: 0 })
const [selectedId, setSelectedId] = useState<string | null>(null)
const [decider, setDecider] = useState<'mo-commander' | 'bart-commander'>('mo-commander')
const [note, setNote] = useState('')
const [busy, setBusy] = useState(false)
const load = useCallback(async () => {
const res = await fetchApprovalHistory(filter === 'all' ? 'all' : filter)
setItems(res.approvals)
if (res.stats) setStats(res.stats)
}, [filter])
useEffect(() => { load() }, [load, livePending])
const selected = useMemo(() => items.find((a) => a.id === selectedId) || items[0] || null, [items, selectedId])
const agentOf = (id: string) => agents.find((a) => a.id === id)
const handleDecide = async (approved: boolean) => {
if (!selected || selected.status !== 'pending') return
setBusy(true)
try {
await onDecide(selected.id, approved, decider, note)
setNote('')
await load()
} finally {
setBusy(false)
}
}
return (
<section className="panel flex min-h-0 flex-1 flex-col overflow-hidden">
<header className="flex shrink-0 items-start justify-between gap-3 border-b border-border px-3 py-2">
<div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-docker" />
<div>
<h2 className="text-sm font-semibold text-foreground">Approval Inbox</h2>
<p className="text-[10px] text-foreground-muted">Mo & Bart review mutating agent actions</p>
</div>
</div>
<div className="flex gap-1">
<Badge variant="warning">{stats.pending} pending</Badge>
<Badge variant="success">{stats.approved} ok</Badge>
<Badge variant="danger">{stats.denied} denied</Badge>
</div>
</header>
<div className="flex shrink-0 gap-1 border-b border-border px-2 py-1">
{(['pending', 'approved', 'denied', 'all'] as Filter[]).map((f) => (
<button key={f} type="button" onClick={() => setFilter(f)} className={cn('rounded px-2 py-0.5 text-[10px] capitalize', filter === f ? 'bg-docker-light text-docker' : 'text-foreground-muted')}>
{f}
</button>
))}
</div>
<div className="grid min-h-0 flex-1 grid-cols-[minmax(200px,0.9fr)_1.1fr]">
<div className="scrollbar-thin overflow-y-auto border-r border-border p-1">
{!items.length && <p className="p-4 text-center text-[10px] text-foreground-faint">No {filter} requests.</p>}
{items.map((a) => {
const ag = agentOf(a.agent_id)
const meta = ag ? getAgentMeta(ag.id) : null
const Icon = meta?.icon
return (
<button key={a.id} type="button" onClick={() => setSelectedId(a.id)} className={cn('mb-1 flex w-full gap-2 rounded-lg border p-2 text-left', selected?.id === a.id ? 'border-docker/40 bg-docker-light' : 'border-transparent hover:bg-surface-overlay')}>
{Icon && <Icon className="h-4 w-4 shrink-0 text-docker" />}
<span className="min-w-0 flex-1">
<span className="block truncate text-[11px] font-medium text-foreground">{a.action.slice(0, 80)}</span>
<span className="block text-[9px] text-foreground-faint">{ag?.name || a.agent_id}</span>
</span>
<Badge variant={a.status === 'pending' ? 'warning' : a.status === 'approved' ? 'success' : 'danger'}>{a.status}</Badge>
</button>
)
})}
</div>
{selected && (
<div className="scrollbar-thin overflow-y-auto p-3">
<Badge variant={selected.status === 'pending' ? 'warning' : 'success'}>{selected.status}</Badge>
<dl className="mt-3 grid grid-cols-2 gap-2 text-[10px]">
<div><dt className="text-foreground-faint">Agent</dt><dd className="text-foreground">{agentOf(selected.agent_id)?.name}</dd></div>
<div><dt className="text-foreground-faint">Type</dt><dd className="text-foreground">{selected.action_type}</dd></div>
<div className="col-span-2"><dt className="text-foreground-faint">Action</dt><dd className="text-foreground-muted">{selected.action}</dd></div>
<div className="col-span-2"><dt className="text-foreground-faint">Reason</dt><dd className="text-foreground-muted">{selected.reason}</dd></div>
</dl>
{selected.status === 'pending' && (
<div className="mt-4 space-y-2">
<select value={decider} onChange={(e) => setDecider(e.target.value as typeof decider)} className="w-full rounded border border-border bg-surface px-2 py-1 text-[10px] text-foreground-muted">
<option value="mo-commander">Decide as Mo</option>
<option value="bart-commander">Decide as Bart</option>
</select>
<input value={note} onChange={(e) => setNote(e.target.value)} placeholder="Note (optional)" className="w-full rounded border border-border bg-surface px-2 py-1 text-[10px] text-foreground-muted" />
<div className="flex gap-2">
<Button variant="success" className="flex-1" disabled={busy} onClick={() => handleDecide(true)}><Check className="h-3 w-3" /> Approve</Button>
<Button variant="danger" className="flex-1" disabled={busy} onClick={() => handleDecide(false)}><X className="h-3 w-3" /> Deny</Button>
</div>
</div>
)}
</div>
)}
</div>
</section>
)
}