Initial commit — Director app (API + UI)
This commit is contained in:
141
ui/src/App.tsx
Normal file
141
ui/src/App.tsx
Normal 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
|
||||
Reference in New Issue
Block a user