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:
@@ -4,8 +4,10 @@ import { useState } from 'react'
|
|||||||
import { ProjectChat } from '@/components/chat/project-chat'
|
import { ProjectChat } from '@/components/chat/project-chat'
|
||||||
import { DesignThinkingDashboard } from '@/components/project/design-thinking-dashboard'
|
import { DesignThinkingDashboard } from '@/components/project/design-thinking-dashboard'
|
||||||
import { PhaseIndicatorCompact } from '@/components/project/phase-indicator-compact'
|
import { PhaseIndicatorCompact } from '@/components/project/phase-indicator-compact'
|
||||||
|
import { ApprovalPanel } from '@/components/project/approval-panel'
|
||||||
import { useProject } from '@/hooks/use-project'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import type { Database } from '@/types/database'
|
import type { Database } from '@/types/database'
|
||||||
@@ -20,12 +22,19 @@ interface ProjectViewProps {
|
|||||||
initialMessages: Message[]
|
initialMessages: Message[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SidebarView = 'phases' | 'approvals' | null
|
||||||
|
|
||||||
export function ProjectView({ projectId, initialMessages }: ProjectViewProps) {
|
export function ProjectView({ projectId, initialMessages }: ProjectViewProps) {
|
||||||
const [showPhases, setShowPhases] = useState(false)
|
const [sidebarView, setSidebarView] = useState<SidebarView>(null)
|
||||||
const { project: projectData, phaseStatuses, loading } = useProject(projectId)
|
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 = () => {
|
const handlePhaseClick = () => {
|
||||||
setShowPhases(true)
|
setSidebarView('phases')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default to 'empathize' if project not loaded yet
|
// Default to 'empathize' if project not loaded yet
|
||||||
@@ -36,7 +45,7 @@ export function ProjectView({ projectId, initialMessages }: ProjectViewProps) {
|
|||||||
{/* Main Chat Area */}
|
{/* Main Chat Area */}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'flex-1 flex flex-col transition-all duration-300',
|
'flex-1 flex flex-col transition-all duration-300',
|
||||||
showPhases ? 'lg:mr-80' : ''
|
hasSidebar ? 'lg:mr-80' : ''
|
||||||
)}>
|
)}>
|
||||||
{/* Inline toolbar with phase indicator */}
|
{/* Inline toolbar with phase indicator */}
|
||||||
<div className="border-b bg-muted/30 px-4 py-1.5">
|
<div className="border-b bg-muted/30 px-4 py-1.5">
|
||||||
@@ -55,24 +64,37 @@ export function ProjectView({ projectId, initialMessages }: ProjectViewProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="flex items-center gap-1">
|
||||||
variant="ghost"
|
{/* Approvals button with badge */}
|
||||||
size="sm"
|
<Button
|
||||||
className="h-7 gap-1.5 text-xs"
|
variant={showApprovals ? 'secondary' : 'ghost'}
|
||||||
onClick={() => setShowPhases(!showPhases)}
|
size="sm"
|
||||||
>
|
className="h-7 gap-1.5 text-xs relative"
|
||||||
{showPhases ? (
|
onClick={() => setSidebarView(showApprovals ? null : 'approvals')}
|
||||||
<>
|
>
|
||||||
|
<Bell className="w-3.5 h-3.5" />
|
||||||
|
<span className="hidden sm:inline">Approvals</span>
|
||||||
|
{pendingRuns.length > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-brand text-[10px] text-white flex items-center justify-center">
|
||||||
|
{pendingRuns.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{/* Phases button */}
|
||||||
|
<Button
|
||||||
|
variant={showPhases ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1.5 text-xs"
|
||||||
|
onClick={() => setSidebarView(showPhases ? null : 'phases')}
|
||||||
|
>
|
||||||
|
{hasSidebar ? (
|
||||||
<PanelRightClose className="w-3.5 h-3.5" />
|
<PanelRightClose className="w-3.5 h-3.5" />
|
||||||
<span className="hidden sm:inline">Hide Phases</span>
|
) : (
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<PanelRightOpen className="w-3.5 h-3.5" />
|
<PanelRightOpen className="w-3.5 h-3.5" />
|
||||||
<span className="hidden sm:inline">Show Phases</span>
|
)}
|
||||||
</>
|
<span className="hidden sm:inline">Phases</span>
|
||||||
)}
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -82,27 +104,32 @@ export function ProjectView({ projectId, initialMessages }: ProjectViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Phases Sidebar - slides in from right */}
|
{/* Sidebar - slides in from right */}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'fixed right-0 top-[calc(4rem+2.5rem+2rem)] bottom-0 w-80 bg-card border-l transform transition-transform duration-300 overflow-y-auto z-40',
|
'fixed right-0 top-[calc(4rem+2.5rem+2rem)] bottom-0 w-80 bg-card border-l transform transition-transform duration-300 overflow-y-auto z-40',
|
||||||
showPhases ? 'translate-x-0' : 'translate-x-full'
|
hasSidebar ? 'translate-x-0' : 'translate-x-full'
|
||||||
)}>
|
)}>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Compass className="w-4 h-4 text-muted-foreground" />
|
{showPhases && <Compass className="w-4 h-4 text-muted-foreground" />}
|
||||||
<h2 className="text-sm font-medium">Design Phases</h2>
|
{showApprovals && <Bell className="w-4 h-4 text-muted-foreground" />}
|
||||||
|
<h2 className="text-sm font-medium">
|
||||||
|
{showPhases && 'Design Phases'}
|
||||||
|
{showApprovals && 'Pending Approvals'}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={() => setShowPhases(false)}
|
onClick={() => setSidebarView(null)}
|
||||||
>
|
>
|
||||||
<PanelRightClose className="w-4 h-4" />
|
<PanelRightClose className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<DesignThinkingDashboard projectId={projectId} />
|
{showPhases && <DesignThinkingDashboard projectId={projectId} />}
|
||||||
|
{showApprovals && <ApprovalPanel projectId={projectId} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
143
src/hooks/use-agent-runs.ts
Normal file
143
src/hooks/use-agent-runs.ts
Normal file
@@ -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<void>
|
||||||
|
approve: (runId: string) => Promise<void>
|
||||||
|
reject: (runId: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgentRuns(projectId: string): UseAgentRunsReturn {
|
||||||
|
const [runs, setRuns] = useState<AgentRun[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<Error | null>(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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user