From 034513e95b4fe17804e67e811b2d21d3f29f61ef Mon Sep 17 00:00:00 2001 From: christiankrag Date: Mon, 15 Dec 2025 13:32:09 +0100 Subject: [PATCH] Add human-in-the-loop approval UI for AI agent actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../projects/[id]/project-view.tsx | 79 ++++-- src/components/project/approval-panel.tsx | 247 ++++++++++++++++++ src/hooks/use-agent-runs.ts | 143 ++++++++++ 3 files changed, 443 insertions(+), 26 deletions(-) create mode 100644 src/components/project/approval-panel.tsx create mode 100644 src/hooks/use-agent-runs.ts diff --git a/src/app/(dashboard)/projects/[id]/project-view.tsx b/src/app/(dashboard)/projects/[id]/project-view.tsx index 0415a79..c7defc0 100644 --- a/src/app/(dashboard)/projects/[id]/project-view.tsx +++ b/src/app/(dashboard)/projects/[id]/project-view.tsx @@ -4,8 +4,10 @@ import { useState } from 'react' import { ProjectChat } from '@/components/chat/project-chat' import { DesignThinkingDashboard } from '@/components/project/design-thinking-dashboard' import { PhaseIndicatorCompact } from '@/components/project/phase-indicator-compact' +import { ApprovalPanel } from '@/components/project/approval-panel' import { useProject } from '@/hooks/use-project' -import { MessageSquare, Compass, PanelRightOpen, PanelRightClose } from 'lucide-react' +import { useAgentRuns } from '@/hooks/use-agent-runs' +import { MessageSquare, Compass, PanelRightOpen, PanelRightClose, Bell } from 'lucide-react' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import type { Database } from '@/types/database' @@ -20,12 +22,19 @@ interface ProjectViewProps { initialMessages: Message[] } +type SidebarView = 'phases' | 'approvals' | null + export function ProjectView({ projectId, initialMessages }: ProjectViewProps) { - const [showPhases, setShowPhases] = useState(false) + const [sidebarView, setSidebarView] = useState(null) const { project: projectData, phaseStatuses, loading } = useProject(projectId) + const { pendingRuns } = useAgentRuns(projectId) + + const showPhases = sidebarView === 'phases' + const showApprovals = sidebarView === 'approvals' + const hasSidebar = sidebarView !== null const handlePhaseClick = () => { - setShowPhases(true) + setSidebarView('phases') } // Default to 'empathize' if project not loaded yet @@ -36,7 +45,7 @@ export function ProjectView({ projectId, initialMessages }: ProjectViewProps) { {/* Main Chat Area */}
{/* Inline toolbar with phase indicator */}
@@ -55,24 +64,37 @@ export function ProjectView({ projectId, initialMessages }: ProjectViewProps) { /> )}
- + {/* Phases button */} + + )} + Phases + +
@@ -82,27 +104,32 @@ export function ProjectView({ projectId, initialMessages }: ProjectViewProps) { - {/* Phases Sidebar - slides in from right */} + {/* Sidebar - slides in from right */}
- -

Design Phases

+ {showPhases && } + {showApprovals && } +

+ {showPhases && 'Design Phases'} + {showApprovals && 'Pending Approvals'} +

- + {showPhases && } + {showApprovals && }
diff --git a/src/components/project/approval-panel.tsx b/src/components/project/approval-panel.tsx new file mode 100644 index 0000000..e6785fa --- /dev/null +++ b/src/components/project/approval-panel.tsx @@ -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(null) + const [processing, setProcessing] = useState(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 ( +
+ +
+ ) + } + + if (pendingRuns.length === 0) { + return ( +
+
+ No pending approvals +
+

+ AI-proposed changes will appear here for review +

+
+ ) + } + + return ( +
+
+ + + {pendingRuns.length} pending + +
+ +
+ {pendingRuns.map((run) => ( + setExpandedRun(expandedRun === run.id ? null : run.id)} + onApprove={() => handleApprove(run.id)} + onReject={() => handleReject(run.id)} + processing={processing === run.id} + /> + ))} +
+
+ ) +} + +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 + ) || {} + + return ( + + +
+
+ + {run.command} + + + {changes.description} + +
+ +
+ +
+ + + {fileCount} file{fileCount !== 1 ? 's' : ''} + + {operations.create && ( + + + {operations.create} + + )} + {operations.update && ( + + + {operations.update} + + )} + {operations.delete && ( + + + {operations.delete} + + )} +
+
+ + {expanded && changes.files && ( + +
+ {changes.files.map((file, index) => ( +
+
+ {file.operation === 'create' && } + {file.operation === 'update' && } + {file.operation === 'delete' && } + {file.path} +
+
+ ))} +
+ + {changes.estimated_impact && ( +

+ Impact: {changes.estimated_impact} +

+ )} +
+ )} + +
+ + +
+
+ ) +} diff --git a/src/hooks/use-agent-runs.ts b/src/hooks/use-agent-runs.ts new file mode 100644 index 0000000..00a275c --- /dev/null +++ b/src/hooks/use-agent-runs.ts @@ -0,0 +1,143 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { createClient } from '@/lib/supabase/client' +import type { Database } from '@/types/database' + +type AgentRun = Database['public']['Tables']['agent_runs']['Row'] +type AgentRunInsert = Database['public']['Tables']['agent_runs']['Insert'] + +interface UseAgentRunsReturn { + runs: AgentRun[] + pendingRuns: AgentRun[] + loading: boolean + error: Error | null + refresh: () => Promise + approve: (runId: string) => Promise + reject: (runId: string) => Promise +} + +export function useAgentRuns(projectId: string): UseAgentRunsReturn { + const [runs, setRuns] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const fetchRuns = useCallback(async () => { + try { + setLoading(true) + const supabase = createClient() + + const { data, error: fetchError } = await supabase + .from('agent_runs') + .select('*') + .eq('project_id', projectId) + .order('created_at', { ascending: false }) + .limit(50) + + if (fetchError) throw fetchError + setRuns((data || []) as unknown as AgentRun[]) + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to load agent runs')) + } finally { + setLoading(false) + } + }, [projectId]) + + useEffect(() => { + fetchRuns() + + // Set up real-time subscription + const supabase = createClient() + const channel = supabase + .channel(`agent_runs:${projectId}`) + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'agent_runs', + filter: `project_id=eq.${projectId}`, + }, + (payload) => { + if (payload.eventType === 'INSERT') { + setRuns((prev) => [payload.new as AgentRun, ...prev]) + } else if (payload.eventType === 'UPDATE') { + setRuns((prev) => + prev.map((run) => + run.id === (payload.new as AgentRun).id + ? (payload.new as AgentRun) + : run + ) + ) + } else if (payload.eventType === 'DELETE') { + setRuns((prev) => + prev.filter((run) => run.id !== (payload.old as AgentRun).id) + ) + } + } + ) + .subscribe() + + return () => { + supabase.removeChannel(channel) + } + }, [projectId, fetchRuns]) + + const approve = useCallback(async (runId: string) => { + const supabase = createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) throw new Error('Not authenticated') + + const { error: updateError } = await supabase + .from('agent_runs') + .update({ + approval_status: 'approved', + approved_by: user.id, + approved_at: new Date().toISOString(), + } as never) + .eq('id', runId) + + if (updateError) throw updateError + + // Trigger n8n webhook to execute the approved action + await fetch(`${process.env.NEXT_PUBLIC_N8N_WEBHOOK_URL}/agent-approved`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ run_id: runId, project_id: projectId }), + }) + }, [projectId]) + + const reject = useCallback(async (runId: string) => { + const supabase = createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) throw new Error('Not authenticated') + + const { error: updateError } = await supabase + .from('agent_runs') + .update({ + approval_status: 'rejected', + approved_by: user.id, + approved_at: new Date().toISOString(), + status: 'cancelled', + } as never) + .eq('id', runId) + + if (updateError) throw updateError + }, []) + + const pendingRuns = runs.filter( + (run) => run.approval_status === 'pending' && run.proposed_changes + ) + + return { + runs, + pendingRuns, + loading, + error, + refresh: fetchRuns, + approve, + reject, + } +}