Add human-in-the-loop approval UI for AI agent actions
- Add useAgentRuns hook with real-time Supabase subscription - Add ApprovalPanel component showing pending agent actions - Update project view with approval button in toolbar - Badge shows count of pending approvals - Approve/reject triggers n8n webhook for execution 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
247
src/components/project/approval-panel.tsx
Normal file
247
src/components/project/approval-panel.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useAgentRuns } from '@/hooks/use-agent-runs'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
FileCode,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Database } from '@/types/database'
|
||||
|
||||
type AgentRun = Database['public']['Tables']['agent_runs']['Row']
|
||||
|
||||
interface ApprovalPanelProps {
|
||||
projectId: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ApprovalPanel({ projectId, className }: ApprovalPanelProps) {
|
||||
const { pendingRuns, loading, approve, reject } = useAgentRuns(projectId)
|
||||
const [expandedRun, setExpandedRun] = useState<string | null>(null)
|
||||
const [processing, setProcessing] = useState<string | null>(null)
|
||||
|
||||
const handleApprove = async (runId: string) => {
|
||||
setProcessing(runId)
|
||||
try {
|
||||
await approve(runId)
|
||||
} finally {
|
||||
setProcessing(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReject = async (runId: string) => {
|
||||
setProcessing(runId)
|
||||
try {
|
||||
await reject(runId)
|
||||
} finally {
|
||||
setProcessing(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (pendingRuns.length === 0) {
|
||||
return (
|
||||
<div className={cn('text-center py-8', className)}>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
No pending approvals
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
AI-proposed changes will appear here for review
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-3', className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{pendingRuns.length} pending
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto max-h-[calc(100vh-20rem)] space-y-3 pr-1">
|
||||
{pendingRuns.map((run) => (
|
||||
<ApprovalCard
|
||||
key={run.id}
|
||||
run={run}
|
||||
expanded={expandedRun === run.id}
|
||||
onToggle={() => setExpandedRun(expandedRun === run.id ? null : run.id)}
|
||||
onApprove={() => handleApprove(run.id)}
|
||||
onReject={() => handleReject(run.id)}
|
||||
processing={processing === run.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ApprovalCardProps {
|
||||
run: AgentRun
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
onApprove: () => void
|
||||
onReject: () => void
|
||||
processing: boolean
|
||||
}
|
||||
|
||||
function ApprovalCard({
|
||||
run,
|
||||
expanded,
|
||||
onToggle,
|
||||
onApprove,
|
||||
onReject,
|
||||
processing,
|
||||
}: ApprovalCardProps) {
|
||||
const changes = run.proposed_changes
|
||||
|
||||
if (!changes) return null
|
||||
|
||||
const fileCount = changes.files?.length || 0
|
||||
const operations = changes.files?.reduce(
|
||||
(acc, file) => {
|
||||
acc[file.operation] = (acc[file.operation] || 0) + 1
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, number>
|
||||
) || {}
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="p-3 pb-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-sm font-medium truncate">
|
||||
{run.command}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs mt-0.5 line-clamp-2">
|
||||
{changes.description}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Badge variant="outline" className="text-xs gap-1">
|
||||
<FileCode className="w-3 h-3" />
|
||||
{fileCount} file{fileCount !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{operations.create && (
|
||||
<Badge variant="outline" className="text-xs gap-1 text-emerald-600 border-emerald-200">
|
||||
<Plus className="w-3 h-3" />
|
||||
{operations.create}
|
||||
</Badge>
|
||||
)}
|
||||
{operations.update && (
|
||||
<Badge variant="outline" className="text-xs gap-1 text-blue-600 border-blue-200">
|
||||
<Pencil className="w-3 h-3" />
|
||||
{operations.update}
|
||||
</Badge>
|
||||
)}
|
||||
{operations.delete && (
|
||||
<Badge variant="outline" className="text-xs gap-1 text-red-600 border-red-200">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
{operations.delete}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{expanded && changes.files && (
|
||||
<CardContent className="p-3 pt-0">
|
||||
<div className="border rounded-md overflow-hidden mt-2">
|
||||
{changes.files.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'px-3 py-2 text-xs font-mono border-b last:border-b-0',
|
||||
file.operation === 'create' && 'bg-emerald-50 dark:bg-emerald-950/20',
|
||||
file.operation === 'update' && 'bg-blue-50 dark:bg-blue-950/20',
|
||||
file.operation === 'delete' && 'bg-red-50 dark:bg-red-950/20'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{file.operation === 'create' && <Plus className="w-3 h-3 text-emerald-600" />}
|
||||
{file.operation === 'update' && <Pencil className="w-3 h-3 text-blue-600" />}
|
||||
{file.operation === 'delete' && <Trash2 className="w-3 h-3 text-red-600" />}
|
||||
<span className="truncate">{file.path}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{changes.estimated_impact && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Impact: {changes.estimated_impact}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 p-3 pt-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1 h-8 text-xs"
|
||||
onClick={onReject}
|
||||
disabled={processing}
|
||||
>
|
||||
{processing ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<X className="w-3 h-3 mr-1" />
|
||||
Reject
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="brand"
|
||||
className="flex-1 h-8 text-xs"
|
||||
onClick={onApprove}
|
||||
disabled={processing}
|
||||
>
|
||||
{processing ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Check className="w-3 h-3 mr-1" />
|
||||
Approve
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user