mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-03-16 18:33:08 +00:00
Agents can now request structured human input when they encounter genuine blockers (API keys, design choices, external configs). The request is displayed in the UI with a dynamic form, and the human's response is stored and made available when the agent resumes. Changes span 21 files + 1 new component: - Database: 3 new columns (needs_human_input, human_input_request, human_input_response) with migration - MCP: new feature_request_human_input tool + guards on existing tools - API: new resolve-human-input endpoint, 4th feature bucket - Orchestrator: skip needs_human_input features in scheduling - Progress: 4-tuple return from count_passing_tests - WebSocket: needs_human_input count in progress messages - UI: conditional 4th Kanban column, HumanInputForm component, amber status indicators, dependency graph support Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
346 lines
12 KiB
TypeScript
346 lines
12 KiB
TypeScript
import { useState } from 'react'
|
|
import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle, Pencil, Link2, AlertTriangle, UserCircle } from 'lucide-react'
|
|
import { useSkipFeature, useDeleteFeature, useFeatures, useResolveHumanInput } from '../hooks/useProjects'
|
|
import { EditFeatureForm } from './EditFeatureForm'
|
|
import { HumanInputForm } from './HumanInputForm'
|
|
import type { Feature } from '../lib/types'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from '@/components/ui/dialog'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
|
import { Separator } from '@/components/ui/separator'
|
|
|
|
// Generate consistent color for category
|
|
function getCategoryColor(category: string): string {
|
|
const colors = [
|
|
'bg-pink-500',
|
|
'bg-cyan-500',
|
|
'bg-green-500',
|
|
'bg-yellow-500',
|
|
'bg-orange-500',
|
|
'bg-purple-500',
|
|
'bg-blue-500',
|
|
]
|
|
|
|
let hash = 0
|
|
for (let i = 0; i < category.length; i++) {
|
|
hash = category.charCodeAt(i) + ((hash << 5) - hash)
|
|
}
|
|
|
|
return colors[Math.abs(hash) % colors.length]
|
|
}
|
|
|
|
interface FeatureModalProps {
|
|
feature: Feature
|
|
projectName: string
|
|
onClose: () => void
|
|
}
|
|
|
|
export function FeatureModal({ feature, projectName, onClose }: FeatureModalProps) {
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
|
const [showEdit, setShowEdit] = useState(false)
|
|
|
|
const skipFeature = useSkipFeature(projectName)
|
|
const deleteFeature = useDeleteFeature(projectName)
|
|
const { data: allFeatures } = useFeatures(projectName)
|
|
|
|
const resolveHumanInput = useResolveHumanInput(projectName)
|
|
|
|
// Build a map of feature ID to feature for looking up dependency names
|
|
const featureMap = new Map<number, Feature>()
|
|
if (allFeatures) {
|
|
;[...allFeatures.pending, ...allFeatures.in_progress, ...allFeatures.done, ...(allFeatures.needs_human_input || [])].forEach(f => {
|
|
featureMap.set(f.id, f)
|
|
})
|
|
}
|
|
|
|
// Get dependency features
|
|
const dependencies = (feature.dependencies || [])
|
|
.map(id => featureMap.get(id))
|
|
.filter((f): f is Feature => f !== undefined)
|
|
|
|
// Get blocking dependencies (unmet dependencies)
|
|
const blockingDeps = (feature.blocking_dependencies || [])
|
|
.map(id => featureMap.get(id))
|
|
.filter((f): f is Feature => f !== undefined)
|
|
|
|
const handleSkip = async () => {
|
|
setError(null)
|
|
try {
|
|
await skipFeature.mutateAsync(feature.id)
|
|
onClose()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to skip feature')
|
|
}
|
|
}
|
|
|
|
const handleDelete = async () => {
|
|
setError(null)
|
|
try {
|
|
await deleteFeature.mutateAsync(feature.id)
|
|
onClose()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to delete feature')
|
|
}
|
|
}
|
|
|
|
// Show edit form when in edit mode
|
|
if (showEdit) {
|
|
return (
|
|
<EditFeatureForm
|
|
feature={feature}
|
|
projectName={projectName}
|
|
onClose={() => setShowEdit(false)}
|
|
onSaved={onClose}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Dialog open={true} onOpenChange={(open) => !open && onClose()}>
|
|
<DialogContent className="sm:max-w-2xl p-0 gap-0">
|
|
{/* Header */}
|
|
<DialogHeader className="p-6 pb-4">
|
|
<div className="flex items-start gap-3">
|
|
<Badge className={`${getCategoryColor(feature.category)} text-white`}>
|
|
{feature.category}
|
|
</Badge>
|
|
</div>
|
|
<DialogTitle className="text-xl mt-2">{feature.name}</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<Separator />
|
|
|
|
{/* Content */}
|
|
<div className="p-6 space-y-6 max-h-[60vh] overflow-y-auto">
|
|
{/* Error Message */}
|
|
{error && (
|
|
<Alert variant="destructive">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription className="flex items-center justify-between">
|
|
<span>{error}</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
onClick={() => setError(null)}
|
|
>
|
|
<X size={14} />
|
|
</Button>
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Status */}
|
|
<div className="flex items-center gap-3 p-4 bg-muted rounded-lg">
|
|
{feature.passes ? (
|
|
<>
|
|
<CheckCircle2 size={24} className="text-primary" />
|
|
<span className="font-semibold text-primary">COMPLETE</span>
|
|
</>
|
|
) : feature.needs_human_input ? (
|
|
<>
|
|
<UserCircle size={24} className="text-amber-500" />
|
|
<span className="font-semibold text-amber-500">NEEDS YOUR INPUT</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Circle size={24} className="text-muted-foreground" />
|
|
<span className="font-semibold text-muted-foreground">PENDING</span>
|
|
</>
|
|
)}
|
|
<span className="ml-auto font-mono text-sm text-muted-foreground">
|
|
Priority: #{feature.priority}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Human Input Request */}
|
|
{feature.needs_human_input && feature.human_input_request && (
|
|
<HumanInputForm
|
|
request={feature.human_input_request}
|
|
onSubmit={async (fields) => {
|
|
setError(null)
|
|
try {
|
|
await resolveHumanInput.mutateAsync({ featureId: feature.id, fields })
|
|
onClose()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to submit response')
|
|
}
|
|
}}
|
|
isLoading={resolveHumanInput.isPending}
|
|
/>
|
|
)}
|
|
|
|
{/* Previous Human Input Response */}
|
|
{feature.human_input_response && !feature.needs_human_input && (
|
|
<Alert className="border-green-500 bg-green-50 dark:bg-green-950/20">
|
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
|
<AlertDescription>
|
|
<h4 className="font-semibold mb-1 text-green-700 dark:text-green-400">Human Input Provided</h4>
|
|
<p className="text-sm text-green-600 dark:text-green-300">
|
|
Response submitted{feature.human_input_response.responded_at
|
|
? ` at ${new Date(feature.human_input_response.responded_at).toLocaleString()}`
|
|
: ''}.
|
|
</p>
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Description */}
|
|
<div>
|
|
<h3 className="font-semibold mb-2 text-sm uppercase tracking-wide text-muted-foreground">
|
|
Description
|
|
</h3>
|
|
<p className="text-foreground">{feature.description}</p>
|
|
</div>
|
|
|
|
{/* Blocked By Warning */}
|
|
{blockingDeps.length > 0 && (
|
|
<Alert variant="destructive" className="border-orange-500 bg-orange-50 dark:bg-orange-950/20">
|
|
<AlertTriangle className="h-4 w-4 text-orange-600" />
|
|
<AlertDescription>
|
|
<h4 className="font-semibold mb-1 text-orange-700 dark:text-orange-400">Blocked By</h4>
|
|
<p className="text-sm text-orange-600 dark:text-orange-300 mb-2">
|
|
This feature cannot start until the following dependencies are complete:
|
|
</p>
|
|
<ul className="space-y-1">
|
|
{blockingDeps.map(dep => (
|
|
<li key={dep.id} className="flex items-center gap-2 text-sm text-orange-600 dark:text-orange-300">
|
|
<Circle size={14} />
|
|
<span className="font-mono text-xs">#{dep.id}</span>
|
|
<span>{dep.name}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Dependencies */}
|
|
{dependencies.length > 0 && (
|
|
<div>
|
|
<h3 className="font-semibold mb-2 text-sm uppercase tracking-wide text-muted-foreground flex items-center gap-2">
|
|
<Link2 size={16} />
|
|
Depends On
|
|
</h3>
|
|
<ul className="space-y-1">
|
|
{dependencies.map(dep => (
|
|
<li
|
|
key={dep.id}
|
|
className="flex items-center gap-2 p-2 bg-muted rounded-md text-sm"
|
|
>
|
|
{dep.passes ? (
|
|
<CheckCircle2 size={16} className="text-primary" />
|
|
) : (
|
|
<Circle size={16} className="text-muted-foreground" />
|
|
)}
|
|
<span className="font-mono text-xs text-muted-foreground">#{dep.id}</span>
|
|
<span className={dep.passes ? 'text-primary' : ''}>{dep.name}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{/* Steps */}
|
|
{feature.steps.length > 0 && (
|
|
<div>
|
|
<h3 className="font-semibold mb-2 text-sm uppercase tracking-wide text-muted-foreground">
|
|
Test Steps
|
|
</h3>
|
|
<ol className="list-decimal list-inside space-y-2">
|
|
{feature.steps.map((step, index) => (
|
|
<li
|
|
key={index}
|
|
className="p-3 bg-muted rounded-md text-sm"
|
|
>
|
|
{step}
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
{!feature.passes && (
|
|
<>
|
|
<Separator />
|
|
<DialogFooter className="p-4 bg-muted/50">
|
|
{showDeleteConfirm ? (
|
|
<div className="w-full space-y-4">
|
|
<p className="font-medium text-center">
|
|
Are you sure you want to delete this feature?
|
|
</p>
|
|
<div className="flex gap-3">
|
|
<Button
|
|
variant="destructive"
|
|
onClick={handleDelete}
|
|
disabled={deleteFeature.isPending}
|
|
className="flex-1"
|
|
>
|
|
{deleteFeature.isPending ? (
|
|
<Loader2 size={18} className="animate-spin" />
|
|
) : (
|
|
'Yes, Delete'
|
|
)}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setShowDeleteConfirm(false)}
|
|
disabled={deleteFeature.isPending}
|
|
className="flex-1"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex gap-3 w-full">
|
|
<Button
|
|
onClick={() => setShowEdit(true)}
|
|
disabled={skipFeature.isPending}
|
|
className="flex-1"
|
|
>
|
|
<Pencil size={18} />
|
|
Edit
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={handleSkip}
|
|
disabled={skipFeature.isPending}
|
|
className="flex-1"
|
|
>
|
|
{skipFeature.isPending ? (
|
|
<Loader2 size={18} className="animate-spin" />
|
|
) : (
|
|
<>
|
|
<SkipForward size={18} />
|
|
Skip
|
|
</>
|
|
)}
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
size="icon"
|
|
onClick={() => setShowDeleteConfirm(true)}
|
|
disabled={skipFeature.isPending}
|
|
>
|
|
<Trash2 size={18} />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</DialogFooter>
|
|
</>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|