mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-03-17 02:43:09 +00:00
feat: add "blocked for human input" feature across full stack
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>
This commit is contained in:
@@ -181,7 +181,7 @@ function App() {
|
||||
|
||||
// E : Expand project with AI (when project selected, has spec and has features)
|
||||
if ((e.key === 'e' || e.key === 'E') && selectedProject && hasSpec && features &&
|
||||
(features.pending.length + features.in_progress.length + features.done.length) > 0) {
|
||||
(features.pending.length + features.in_progress.length + features.done.length + (features.needs_human_input?.length || 0)) > 0) {
|
||||
e.preventDefault()
|
||||
setShowExpandProject(true)
|
||||
}
|
||||
@@ -443,6 +443,7 @@ function App() {
|
||||
features.pending.length === 0 &&
|
||||
features.in_progress.length === 0 &&
|
||||
features.done.length === 0 &&
|
||||
(features.needs_human_input?.length || 0) === 0 &&
|
||||
wsState.agentStatus === 'running' && (
|
||||
<Card className="p-8 text-center">
|
||||
<CardContent className="p-0">
|
||||
@@ -458,7 +459,7 @@ function App() {
|
||||
)}
|
||||
|
||||
{/* View Toggle - only show when there are features */}
|
||||
{features && (features.pending.length + features.in_progress.length + features.done.length) > 0 && (
|
||||
{features && (features.pending.length + features.in_progress.length + features.done.length + (features.needs_human_input?.length || 0)) > 0 && (
|
||||
<div className="flex justify-center">
|
||||
<ViewToggle viewMode={viewMode} onViewModeChange={setViewMode} />
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
Handle,
|
||||
} from '@xyflow/react'
|
||||
import dagre from 'dagre'
|
||||
import { CheckCircle2, Circle, Loader2, AlertTriangle, RefreshCw } from 'lucide-react'
|
||||
import { CheckCircle2, Circle, Loader2, AlertTriangle, RefreshCw, UserCircle } from 'lucide-react'
|
||||
import type { DependencyGraph as DependencyGraphData, GraphNode, ActiveAgent, AgentMascot, AgentState } from '../lib/types'
|
||||
import { AgentAvatar } from './AgentAvatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -93,18 +93,20 @@ class GraphErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryStat
|
||||
|
||||
// Custom node component
|
||||
function FeatureNode({ data }: { data: GraphNode & { onClick?: () => void; agent?: NodeAgentInfo } }) {
|
||||
const statusColors = {
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 border-yellow-300 dark:bg-yellow-900/30 dark:border-yellow-700',
|
||||
in_progress: 'bg-cyan-100 border-cyan-300 dark:bg-cyan-900/30 dark:border-cyan-700',
|
||||
done: 'bg-green-100 border-green-300 dark:bg-green-900/30 dark:border-green-700',
|
||||
blocked: 'bg-red-50 border-red-300 dark:bg-red-900/20 dark:border-red-700',
|
||||
needs_human_input: 'bg-amber-100 border-amber-300 dark:bg-amber-900/30 dark:border-amber-700',
|
||||
}
|
||||
|
||||
const textColors = {
|
||||
const textColors: Record<string, string> = {
|
||||
pending: 'text-yellow-900 dark:text-yellow-100',
|
||||
in_progress: 'text-cyan-900 dark:text-cyan-100',
|
||||
done: 'text-green-900 dark:text-green-100',
|
||||
blocked: 'text-red-900 dark:text-red-100',
|
||||
needs_human_input: 'text-amber-900 dark:text-amber-100',
|
||||
}
|
||||
|
||||
const StatusIcon = () => {
|
||||
@@ -115,6 +117,8 @@ function FeatureNode({ data }: { data: GraphNode & { onClick?: () => void; agent
|
||||
return <Loader2 size={16} className={`${textColors[data.status]} animate-spin`} />
|
||||
case 'blocked':
|
||||
return <AlertTriangle size={16} className="text-destructive" />
|
||||
case 'needs_human_input':
|
||||
return <UserCircle size={16} className={textColors[data.status]} />
|
||||
default:
|
||||
return <Circle size={16} className={textColors[data.status]} />
|
||||
}
|
||||
@@ -323,6 +327,8 @@ function DependencyGraphInner({ graphData, onNodeClick, activeAgents = [] }: Dep
|
||||
return '#06b6d4' // cyan-500
|
||||
case 'blocked':
|
||||
return '#ef4444' // red-500
|
||||
case 'needs_human_input':
|
||||
return '#f59e0b' // amber-500
|
||||
default:
|
||||
return '#eab308' // yellow-500
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CheckCircle2, Circle, Loader2, MessageCircle } from 'lucide-react'
|
||||
import { CheckCircle2, Circle, Loader2, MessageCircle, UserCircle } from 'lucide-react'
|
||||
import type { Feature, ActiveAgent } from '../lib/types'
|
||||
import { DependencyBadge } from './DependencyBadge'
|
||||
import { AgentAvatar } from './AgentAvatar'
|
||||
@@ -45,7 +45,8 @@ export function FeatureCard({ feature, onClick, isInProgress, allFeatures = [],
|
||||
cursor-pointer transition-all hover:border-primary py-3
|
||||
${isInProgress ? 'animate-pulse' : ''}
|
||||
${feature.passes ? 'border-primary/50' : ''}
|
||||
${isBlocked && !feature.passes ? 'border-destructive/50 opacity-80' : ''}
|
||||
${feature.needs_human_input ? 'border-amber-500/50' : ''}
|
||||
${isBlocked && !feature.passes && !feature.needs_human_input ? 'border-destructive/50 opacity-80' : ''}
|
||||
${hasActiveAgent ? 'ring-2 ring-primary ring-offset-2' : ''}
|
||||
`}
|
||||
>
|
||||
@@ -105,6 +106,11 @@ export function FeatureCard({ feature, onClick, isInProgress, allFeatures = [],
|
||||
<CheckCircle2 size={16} className="text-primary" />
|
||||
<span className="text-primary font-medium">Complete</span>
|
||||
</>
|
||||
) : feature.needs_human_input ? (
|
||||
<>
|
||||
<UserCircle size={16} className="text-amber-500" />
|
||||
<span className="text-amber-500 font-medium">Needs Your Input</span>
|
||||
</>
|
||||
) : isBlocked ? (
|
||||
<>
|
||||
<Circle size={16} className="text-destructive" />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle, Pencil, Link2, AlertTriangle } from 'lucide-react'
|
||||
import { useSkipFeature, useDeleteFeature, useFeatures } from '../hooks/useProjects'
|
||||
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,
|
||||
@@ -50,10 +51,12 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
|
||||
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].forEach(f => {
|
||||
;[...allFeatures.pending, ...allFeatures.in_progress, ...allFeatures.done, ...(allFeatures.needs_human_input || [])].forEach(f => {
|
||||
featureMap.set(f.id, f)
|
||||
})
|
||||
}
|
||||
@@ -141,6 +144,11 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
|
||||
<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" />
|
||||
@@ -152,6 +160,38 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
|
||||
</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">
|
||||
|
||||
150
ui/src/components/HumanInputForm.tsx
Normal file
150
ui/src/components/HumanInputForm.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useState } from 'react'
|
||||
import { Loader2, UserCircle, Send } from 'lucide-react'
|
||||
import type { HumanInputRequest } from '../lib/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
|
||||
interface HumanInputFormProps {
|
||||
request: HumanInputRequest
|
||||
onSubmit: (fields: Record<string, string | boolean | string[]>) => Promise<void>
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export function HumanInputForm({ request, onSubmit, isLoading }: HumanInputFormProps) {
|
||||
const [values, setValues] = useState<Record<string, string | boolean | string[]>>(() => {
|
||||
const initial: Record<string, string | boolean | string[]> = {}
|
||||
for (const field of request.fields) {
|
||||
if (field.type === 'boolean') {
|
||||
initial[field.id] = false
|
||||
} else {
|
||||
initial[field.id] = ''
|
||||
}
|
||||
}
|
||||
return initial
|
||||
})
|
||||
|
||||
const [validationError, setValidationError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Validate required fields
|
||||
for (const field of request.fields) {
|
||||
if (field.required) {
|
||||
const val = values[field.id]
|
||||
if (val === undefined || val === null || val === '') {
|
||||
setValidationError(`"${field.label}" is required`)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
setValidationError(null)
|
||||
await onSubmit(values)
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert className="border-amber-500 bg-amber-50 dark:bg-amber-950/20">
|
||||
<UserCircle className="h-5 w-5 text-amber-600" />
|
||||
<AlertDescription className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-semibold text-amber-700 dark:text-amber-400">Agent needs your help</h4>
|
||||
<p className="text-sm text-amber-600 dark:text-amber-300 mt-1">
|
||||
{request.prompt}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{request.fields.map((field) => (
|
||||
<div key={field.id} className="space-y-1.5">
|
||||
<Label htmlFor={`human-input-${field.id}`} className="text-sm font-medium text-foreground">
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
|
||||
{field.type === 'text' && (
|
||||
<Input
|
||||
id={`human-input-${field.id}`}
|
||||
value={values[field.id] as string}
|
||||
onChange={(e) => setValues(prev => ({ ...prev, [field.id]: e.target.value }))}
|
||||
placeholder={field.placeholder || ''}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.type === 'textarea' && (
|
||||
<Textarea
|
||||
id={`human-input-${field.id}`}
|
||||
value={values[field.id] as string}
|
||||
onChange={(e) => setValues(prev => ({ ...prev, [field.id]: e.target.value }))}
|
||||
placeholder={field.placeholder || ''}
|
||||
disabled={isLoading}
|
||||
rows={3}
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.type === 'select' && field.options && (
|
||||
<div className="space-y-1.5">
|
||||
{field.options.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className={`flex items-center gap-2 p-2 rounded-md border cursor-pointer transition-colors
|
||||
${values[field.id] === option.value
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50'}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`human-input-${field.id}`}
|
||||
value={option.value}
|
||||
checked={values[field.id] === option.value}
|
||||
onChange={(e) => setValues(prev => ({ ...prev, [field.id]: e.target.value }))}
|
||||
disabled={isLoading}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<span className="text-sm">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{field.type === 'boolean' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id={`human-input-${field.id}`}
|
||||
checked={values[field.id] as boolean}
|
||||
onCheckedChange={(checked) => setValues(prev => ({ ...prev, [field.id]: checked }))}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Label htmlFor={`human-input-${field.id}`} className="text-sm">
|
||||
{values[field.id] ? 'Yes' : 'No'}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{validationError && (
|
||||
<p className="text-sm text-destructive">{validationError}</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Send size={16} />
|
||||
Submit Response
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
@@ -13,13 +13,16 @@ interface KanbanBoardProps {
|
||||
}
|
||||
|
||||
export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandProject, activeAgents = [], onCreateSpec, hasSpec = true }: KanbanBoardProps) {
|
||||
const hasFeatures = features && (features.pending.length + features.in_progress.length + features.done.length) > 0
|
||||
const hasFeatures = features && (features.pending.length + features.in_progress.length + features.done.length + (features.needs_human_input?.length || 0)) > 0
|
||||
|
||||
// Combine all features for dependency status calculation
|
||||
const allFeatures = features
|
||||
? [...features.pending, ...features.in_progress, ...features.done]
|
||||
? [...features.pending, ...features.in_progress, ...features.done, ...(features.needs_human_input || [])]
|
||||
: []
|
||||
|
||||
const needsInputCount = features?.needs_human_input?.length || 0
|
||||
const showNeedsInput = needsInputCount > 0
|
||||
|
||||
if (!features) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
@@ -40,7 +43,7 @@ export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandPr
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className={`grid grid-cols-1 ${showNeedsInput ? 'md:grid-cols-4' : 'md:grid-cols-3'} gap-6`}>
|
||||
<KanbanColumn
|
||||
title="Pending"
|
||||
count={features.pending.length}
|
||||
@@ -64,6 +67,17 @@ export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandPr
|
||||
color="progress"
|
||||
onFeatureClick={onFeatureClick}
|
||||
/>
|
||||
{showNeedsInput && (
|
||||
<KanbanColumn
|
||||
title="Needs Input"
|
||||
count={needsInputCount}
|
||||
features={features.needs_human_input}
|
||||
allFeatures={allFeatures}
|
||||
activeAgents={activeAgents}
|
||||
color="human_input"
|
||||
onFeatureClick={onFeatureClick}
|
||||
/>
|
||||
)}
|
||||
<KanbanColumn
|
||||
title="Done"
|
||||
count={features.done.length}
|
||||
|
||||
@@ -11,7 +11,7 @@ interface KanbanColumnProps {
|
||||
features: Feature[]
|
||||
allFeatures?: Feature[]
|
||||
activeAgents?: ActiveAgent[]
|
||||
color: 'pending' | 'progress' | 'done'
|
||||
color: 'pending' | 'progress' | 'done' | 'human_input'
|
||||
onFeatureClick: (feature: Feature) => void
|
||||
onAddFeature?: () => void
|
||||
onExpandProject?: () => void
|
||||
@@ -24,6 +24,7 @@ const colorMap = {
|
||||
pending: 'border-t-4 border-t-muted',
|
||||
progress: 'border-t-4 border-t-primary',
|
||||
done: 'border-t-4 border-t-primary',
|
||||
human_input: 'border-t-4 border-t-amber-500',
|
||||
}
|
||||
|
||||
export function KanbanColumn({
|
||||
|
||||
@@ -137,6 +137,7 @@ function isAllComplete(features: FeatureListResponse | undefined): boolean {
|
||||
return (
|
||||
features.pending.length === 0 &&
|
||||
features.in_progress.length === 0 &&
|
||||
(features.needs_human_input?.length || 0) === 0 &&
|
||||
features.done.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
@@ -133,6 +133,18 @@ export function useUpdateFeature(projectName: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export function useResolveHumanInput(projectName: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ featureId, fields }: { featureId: number; fields: Record<string, string | boolean | string[]> }) =>
|
||||
api.resolveHumanInput(projectName, featureId, { fields }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['features', projectName] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Agent
|
||||
// ============================================================================
|
||||
|
||||
@@ -181,6 +181,17 @@ export async function createFeaturesBulk(
|
||||
})
|
||||
}
|
||||
|
||||
export async function resolveHumanInput(
|
||||
projectName: string,
|
||||
featureId: number,
|
||||
response: { fields: Record<string, string | boolean | string[]> }
|
||||
): Promise<Feature> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/features/${featureId}/resolve-human-input`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(response),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Dependency Graph API
|
||||
// ============================================================================
|
||||
|
||||
@@ -57,6 +57,26 @@ export interface ProjectPrompts {
|
||||
coding_prompt: string
|
||||
}
|
||||
|
||||
// Human input types
|
||||
export interface HumanInputField {
|
||||
id: string
|
||||
label: string
|
||||
type: 'text' | 'textarea' | 'select' | 'boolean'
|
||||
required: boolean
|
||||
placeholder?: string
|
||||
options?: { value: string; label: string }[]
|
||||
}
|
||||
|
||||
export interface HumanInputRequest {
|
||||
prompt: string
|
||||
fields: HumanInputField[]
|
||||
}
|
||||
|
||||
export interface HumanInputResponseData {
|
||||
fields: Record<string, string | boolean | string[]>
|
||||
responded_at?: string
|
||||
}
|
||||
|
||||
// Feature types
|
||||
export interface Feature {
|
||||
id: number
|
||||
@@ -70,10 +90,13 @@ export interface Feature {
|
||||
dependencies?: number[] // Optional for backwards compat
|
||||
blocked?: boolean // Computed by API
|
||||
blocking_dependencies?: number[] // Computed by API
|
||||
needs_human_input?: boolean
|
||||
human_input_request?: HumanInputRequest | null
|
||||
human_input_response?: HumanInputResponseData | null
|
||||
}
|
||||
|
||||
// Status type for graph nodes
|
||||
export type FeatureStatus = 'pending' | 'in_progress' | 'done' | 'blocked'
|
||||
export type FeatureStatus = 'pending' | 'in_progress' | 'done' | 'blocked' | 'needs_human_input'
|
||||
|
||||
// Graph visualization types
|
||||
export interface GraphNode {
|
||||
@@ -99,6 +122,7 @@ export interface FeatureListResponse {
|
||||
pending: Feature[]
|
||||
in_progress: Feature[]
|
||||
done: Feature[]
|
||||
needs_human_input: Feature[]
|
||||
}
|
||||
|
||||
export interface FeatureCreate {
|
||||
@@ -248,6 +272,7 @@ export interface WSProgressMessage {
|
||||
in_progress: number
|
||||
total: number
|
||||
percentage: number
|
||||
needs_human_input?: number
|
||||
}
|
||||
|
||||
export interface WSFeatureUpdateMessage {
|
||||
|
||||
Reference in New Issue
Block a user