Initial commit — Director app (API + UI)

This commit is contained in:
2026-04-07 15:18:16 -05:00
commit 5f29db67f3
44 changed files with 6727 additions and 0 deletions

141
ui/src/App.tsx Normal file
View File

@@ -0,0 +1,141 @@
import { useEffect, useState, useCallback } from 'react'
import { api } from './lib/api'
import type { Plan, Agent, QueueItem, PendingQuestion } from './lib/api'
import { useWebSocket } from './hooks/useWebSocket'
import { AgentStatus } from './components/AgentStatus'
import { PlanBoard } from './components/PlanBoard'
import { DirectorQueue } from './components/DirectorQueue'
import { PendingQuestions } from './components/PendingQuestions'
import { PlanDetail } from './components/PlanDetail'
import { NewPlanModal } from './components/NewPlanModal'
import { ClaudeModeToggle } from './components/ClaudeModeToggle'
type RightTab = 'queue' | 'questions'
function App() {
const [plans, setPlans] = useState<Plan[]>([])
const [agents, setAgents] = useState<Agent[]>([])
const [queue, setQueue] = useState<QueueItem[]>([])
const [questions, setQuestions] = useState<PendingQuestion[]>([])
const [rightTab, setRightTab] = useState<RightTab>('queue')
const [selectedPlan, setSelectedPlan] = useState<Plan | null>(null)
const [reviewItem, setReviewItem] = useState<QueueItem | null>(null)
const [showNewPlan, setShowNewPlan] = useState(false)
const [connected, setConnected] = useState(false)
const loadData = useCallback(() => {
api.plans.list().then(setPlans).catch(() => {})
api.agents.list().then(setAgents).catch(() => {})
api.queue.list().then(setQueue).catch(() => {})
api.questions.list().then(setQuestions).catch(() => {})
}, [])
useEffect(() => {
loadData()
}, [loadData])
useWebSocket(useCallback((event: string, _data: unknown) => {
if (event.startsWith('plan:')) loadData()
if (event.startsWith('task:')) loadData()
if (event.startsWith('agent:')) api.agents.list().then(setAgents)
if (event.startsWith('queue:')) api.queue.list().then(setQueue)
if (event.startsWith('question:')) api.questions.list().then(setQuestions)
if (event.startsWith('message:')) loadData()
setConnected(true)
}, [loadData]))
const handleReview = useCallback(async (item: QueueItem) => {
if (!item.plan_id) return
try {
const plan = await api.plans.get(item.plan_id)
setReviewItem(item)
setSelectedPlan(plan)
} catch (err) {
console.error('failed to load plan for review', err)
}
}, [])
const closeDetail = useCallback(() => {
setSelectedPlan(null)
setReviewItem(null)
}, [])
return (
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<header className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-3">
<div className="flex items-center justify-between max-w-7xl mx-auto">
<h1 className="font-display text-xl font-medium">Director</h1>
<div className="flex items-center gap-3">
<ClaudeModeToggle />
<button
onClick={() => setShowNewPlan(true)}
className="text-sm px-3 py-1.5 rounded bg-blue-600 hover:bg-blue-700 text-white cursor-pointer"
>
+ New Plan
</button>
<span className={`w-2 h-2 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`} />
<span className="text-xs text-gray-500">{connected ? 'Connected' : 'Disconnected'}</span>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto p-6 space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-1">
<AgentStatus agents={agents} />
</div>
<div className="lg:col-span-2">
{/* Tab toggle for queue vs questions */}
<div className="flex items-center gap-1 mb-3">
<button
onClick={() => setRightTab('queue')}
className={`font-mono text-[10px] uppercase tracking-wider px-3 py-1.5 rounded-md transition-colors cursor-pointer ${
rightTab === 'queue'
? 'bg-blue-600 text-white'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800'
}`}
>
queue
{queue.length > 0 && (
<span className="ml-1.5 tabular">{queue.length}</span>
)}
</button>
<button
onClick={() => setRightTab('questions')}
className={`font-mono text-[10px] uppercase tracking-wider px-3 py-1.5 rounded-md transition-colors cursor-pointer ${
rightTab === 'questions'
? 'bg-blue-600 text-white'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800'
} ${questions.length > 0 && rightTab !== 'questions' ? 'ring-1 ring-amber-400' : ''}`}
>
questions
{questions.length > 0 && (
<span className="ml-1.5 tabular">{questions.length}</span>
)}
</button>
</div>
{rightTab === 'queue' && <DirectorQueue items={queue} onReview={handleReview} />}
{rightTab === 'questions' && <PendingQuestions questions={questions} onChange={loadData} />}
</div>
</div>
<PlanBoard plans={plans} onSelect={setSelectedPlan} />
</main>
{selectedPlan && (
<PlanDetail
plan={selectedPlan}
onClose={closeDetail}
reviewItem={reviewItem ?? undefined}
onReviewActioned={loadData}
/>
)}
{showNewPlan && (
<NewPlanModal onClose={() => setShowNewPlan(false)} onCreated={loadData} />
)}
</div>
)
}
export default App