mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-30 06:12:06 +00:00
basic ui
This commit is contained in:
16
ui/index.html
Normal file
16
ui/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Autonomous Coder</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4570
ui/package-lock.json
generated
Normal file
4570
ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
ui/package.json
Normal file
37
ui/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "autonomous-coding-ui",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.3",
|
||||
"@tanstack/react-query": "^5.60.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.460.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.13.0",
|
||||
"@tailwindcss/vite": "^4.0.0-beta.4",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"eslint": "^9.13.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.14",
|
||||
"globals": "^15.11.0",
|
||||
"tailwindcss": "^4.0.0-beta.4",
|
||||
"typescript": "~5.6.2",
|
||||
"typescript-eslint": "^8.11.0",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
6
ui/public/vite.svg
Normal file
6
ui/public/vite.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect x="10" y="10" width="80" height="80" fill="#ffd60a" stroke="#1a1a1a" stroke-width="6"/>
|
||||
<rect x="25" y="30" width="50" height="10" fill="#1a1a1a"/>
|
||||
<rect x="25" y="50" width="35" height="10" fill="#70e000"/>
|
||||
<rect x="25" y="70" width="20" height="10" fill="#00b4d8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 352 B |
130
ui/src/App.tsx
Normal file
130
ui/src/App.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useState } from 'react'
|
||||
import { useProjects, useFeatures } from './hooks/useProjects'
|
||||
import { useProjectWebSocket } from './hooks/useWebSocket'
|
||||
import { ProjectSelector } from './components/ProjectSelector'
|
||||
import { KanbanBoard } from './components/KanbanBoard'
|
||||
import { AgentControl } from './components/AgentControl'
|
||||
import { ProgressDashboard } from './components/ProgressDashboard'
|
||||
import { SetupWizard } from './components/SetupWizard'
|
||||
import { AddFeatureForm } from './components/AddFeatureForm'
|
||||
import { FeatureModal } from './components/FeatureModal'
|
||||
import { Plus } from 'lucide-react'
|
||||
import type { Feature } from './lib/types'
|
||||
|
||||
function App() {
|
||||
const [selectedProject, setSelectedProject] = useState<string | null>(null)
|
||||
const [showAddFeature, setShowAddFeature] = useState(false)
|
||||
const [selectedFeature, setSelectedFeature] = useState<Feature | null>(null)
|
||||
const [setupComplete, setSetupComplete] = useState(true) // Start optimistic
|
||||
|
||||
const { data: projects, isLoading: projectsLoading } = useProjects()
|
||||
const { data: features } = useFeatures(selectedProject)
|
||||
const wsState = useProjectWebSocket(selectedProject)
|
||||
|
||||
// Combine WebSocket progress with feature data
|
||||
const progress = wsState.progress.total > 0 ? wsState.progress : {
|
||||
passing: features?.done.length ?? 0,
|
||||
total: (features?.pending.length ?? 0) + (features?.in_progress.length ?? 0) + (features?.done.length ?? 0),
|
||||
percentage: 0,
|
||||
}
|
||||
|
||||
if (progress.total > 0 && progress.percentage === 0) {
|
||||
progress.percentage = Math.round((progress.passing / progress.total) * 100 * 10) / 10
|
||||
}
|
||||
|
||||
if (!setupComplete) {
|
||||
return <SetupWizard onComplete={() => setSetupComplete(true)} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--color-neo-bg)]">
|
||||
{/* Header */}
|
||||
<header className="bg-[var(--color-neo-text)] text-white border-b-4 border-[var(--color-neo-border)]">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Logo and Title */}
|
||||
<h1 className="font-display text-2xl font-bold tracking-tight uppercase">
|
||||
Autonomous Coder
|
||||
</h1>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-4">
|
||||
<ProjectSelector
|
||||
projects={projects ?? []}
|
||||
selectedProject={selectedProject}
|
||||
onSelectProject={setSelectedProject}
|
||||
isLoading={projectsLoading}
|
||||
/>
|
||||
|
||||
{selectedProject && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowAddFeature(true)}
|
||||
className="neo-btn neo-btn-primary text-sm"
|
||||
>
|
||||
<Plus size={18} />
|
||||
Add Feature
|
||||
</button>
|
||||
|
||||
<AgentControl
|
||||
projectName={selectedProject}
|
||||
status={wsState.agentStatus}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
{!selectedProject ? (
|
||||
<div className="neo-empty-state mt-12">
|
||||
<h2 className="font-display text-2xl font-bold mb-2">
|
||||
Welcome to Autonomous Coder
|
||||
</h2>
|
||||
<p className="text-[var(--color-neo-text-secondary)] mb-4">
|
||||
Select a project from the dropdown above or create a new one to get started.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{/* Progress Dashboard */}
|
||||
<ProgressDashboard
|
||||
passing={progress.passing}
|
||||
total={progress.total}
|
||||
percentage={progress.percentage}
|
||||
isConnected={wsState.isConnected}
|
||||
/>
|
||||
|
||||
{/* Kanban Board */}
|
||||
<KanbanBoard
|
||||
features={features}
|
||||
onFeatureClick={setSelectedFeature}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Add Feature Modal */}
|
||||
{showAddFeature && selectedProject && (
|
||||
<AddFeatureForm
|
||||
projectName={selectedProject}
|
||||
onClose={() => setShowAddFeature(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Feature Detail Modal */}
|
||||
{selectedFeature && selectedProject && (
|
||||
<FeatureModal
|
||||
feature={selectedFeature}
|
||||
projectName={selectedProject}
|
||||
onClose={() => setSelectedFeature(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
213
ui/src/components/AddFeatureForm.tsx
Normal file
213
ui/src/components/AddFeatureForm.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useState, useId } from 'react'
|
||||
import { X, Plus, Trash2, Loader2, AlertCircle } from 'lucide-react'
|
||||
import { useCreateFeature } from '../hooks/useProjects'
|
||||
|
||||
interface Step {
|
||||
id: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface AddFeatureFormProps {
|
||||
projectName: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function AddFeatureForm({ projectName, onClose }: AddFeatureFormProps) {
|
||||
const formId = useId()
|
||||
const [category, setCategory] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [steps, setSteps] = useState<Step[]>([{ id: `${formId}-step-0`, value: '' }])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [stepCounter, setStepCounter] = useState(1)
|
||||
|
||||
const createFeature = useCreateFeature(projectName)
|
||||
|
||||
const handleAddStep = () => {
|
||||
setSteps([...steps, { id: `${formId}-step-${stepCounter}`, value: '' }])
|
||||
setStepCounter(stepCounter + 1)
|
||||
}
|
||||
|
||||
const handleRemoveStep = (id: string) => {
|
||||
setSteps(steps.filter(step => step.id !== id))
|
||||
}
|
||||
|
||||
const handleStepChange = (id: string, value: string) => {
|
||||
setSteps(steps.map(step =>
|
||||
step.id === id ? { ...step, value } : step
|
||||
))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
// Filter out empty steps
|
||||
const filteredSteps = steps
|
||||
.map(s => s.value.trim())
|
||||
.filter(s => s.length > 0)
|
||||
|
||||
try {
|
||||
await createFeature.mutateAsync({
|
||||
category: category.trim(),
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
steps: filteredSteps,
|
||||
})
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create feature')
|
||||
}
|
||||
}
|
||||
|
||||
const isValid = category.trim() && name.trim() && description.trim()
|
||||
|
||||
return (
|
||||
<div className="neo-modal-backdrop" onClick={onClose}>
|
||||
<div
|
||||
className="neo-modal w-full max-w-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b-3 border-[var(--color-neo-border)]">
|
||||
<h2 className="font-display text-2xl font-bold">
|
||||
Add Feature
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="neo-btn neo-btn-ghost p-2"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-3 p-4 bg-[var(--color-neo-danger)] text-white border-3 border-[var(--color-neo-border)]">
|
||||
<AlertCircle size={20} />
|
||||
<span>{error}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setError(null)}
|
||||
className="ml-auto"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="block font-display font-bold mb-2 uppercase text-sm">
|
||||
Category
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
placeholder="e.g., Authentication, UI, API"
|
||||
className="neo-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block font-display font-bold mb-2 uppercase text-sm">
|
||||
Feature Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., User login form"
|
||||
className="neo-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block font-display font-bold mb-2 uppercase text-sm">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Describe what this feature should do..."
|
||||
className="neo-input min-h-[100px] resize-y"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<div>
|
||||
<label className="block font-display font-bold mb-2 uppercase text-sm">
|
||||
Test Steps (Optional)
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id} className="flex gap-2">
|
||||
<span className="neo-input w-12 text-center flex-shrink-0 flex items-center justify-center">
|
||||
{index + 1}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={step.value}
|
||||
onChange={(e) => handleStepChange(step.id, e.target.value)}
|
||||
placeholder="Describe this step..."
|
||||
className="neo-input flex-1"
|
||||
/>
|
||||
{steps.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveStep(step.id)}
|
||||
className="neo-btn neo-btn-ghost p-2"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddStep}
|
||||
className="neo-btn neo-btn-ghost mt-2 text-sm"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add Step
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-4 border-t-3 border-[var(--color-neo-border)]">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid || createFeature.isPending}
|
||||
className="neo-btn neo-btn-success flex-1"
|
||||
>
|
||||
{createFeature.isPending ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Plus size={18} />
|
||||
Create Feature
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="neo-btn neo-btn-ghost"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
144
ui/src/components/AgentControl.tsx
Normal file
144
ui/src/components/AgentControl.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { Play, Pause, Square, Loader2 } from 'lucide-react'
|
||||
import {
|
||||
useStartAgent,
|
||||
useStopAgent,
|
||||
usePauseAgent,
|
||||
useResumeAgent,
|
||||
} from '../hooks/useProjects'
|
||||
import type { AgentStatus } from '../lib/types'
|
||||
|
||||
interface AgentControlProps {
|
||||
projectName: string
|
||||
status: AgentStatus
|
||||
}
|
||||
|
||||
export function AgentControl({ projectName, status }: AgentControlProps) {
|
||||
const startAgent = useStartAgent(projectName)
|
||||
const stopAgent = useStopAgent(projectName)
|
||||
const pauseAgent = usePauseAgent(projectName)
|
||||
const resumeAgent = useResumeAgent(projectName)
|
||||
|
||||
const isLoading =
|
||||
startAgent.isPending ||
|
||||
stopAgent.isPending ||
|
||||
pauseAgent.isPending ||
|
||||
resumeAgent.isPending
|
||||
|
||||
const handleStart = () => startAgent.mutate()
|
||||
const handleStop = () => stopAgent.mutate()
|
||||
const handlePause = () => pauseAgent.mutate()
|
||||
const handleResume = () => resumeAgent.mutate()
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Status Indicator */}
|
||||
<StatusIndicator status={status} />
|
||||
|
||||
{/* Control Buttons */}
|
||||
<div className="flex gap-1">
|
||||
{status === 'stopped' || status === 'crashed' ? (
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={isLoading}
|
||||
className="neo-btn neo-btn-success text-sm py-2 px-3"
|
||||
title="Start Agent"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<Play size={18} />
|
||||
)}
|
||||
</button>
|
||||
) : status === 'running' ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handlePause}
|
||||
disabled={isLoading}
|
||||
className="neo-btn neo-btn-warning text-sm py-2 px-3"
|
||||
title="Pause Agent"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<Pause size={18} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStop}
|
||||
disabled={isLoading}
|
||||
className="neo-btn neo-btn-danger text-sm py-2 px-3"
|
||||
title="Stop Agent"
|
||||
>
|
||||
<Square size={18} />
|
||||
</button>
|
||||
</>
|
||||
) : status === 'paused' ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleResume}
|
||||
disabled={isLoading}
|
||||
className="neo-btn neo-btn-success text-sm py-2 px-3"
|
||||
title="Resume Agent"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<Play size={18} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStop}
|
||||
disabled={isLoading}
|
||||
className="neo-btn neo-btn-danger text-sm py-2 px-3"
|
||||
title="Stop Agent"
|
||||
>
|
||||
<Square size={18} />
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusIndicator({ status }: { status: AgentStatus }) {
|
||||
const statusConfig = {
|
||||
stopped: {
|
||||
color: 'var(--color-neo-text-secondary)',
|
||||
label: 'Stopped',
|
||||
pulse: false,
|
||||
},
|
||||
running: {
|
||||
color: 'var(--color-neo-done)',
|
||||
label: 'Running',
|
||||
pulse: true,
|
||||
},
|
||||
paused: {
|
||||
color: 'var(--color-neo-pending)',
|
||||
label: 'Paused',
|
||||
pulse: false,
|
||||
},
|
||||
crashed: {
|
||||
color: 'var(--color-neo-danger)',
|
||||
label: 'Crashed',
|
||||
pulse: true,
|
||||
},
|
||||
}
|
||||
|
||||
const config = statusConfig[status]
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-white border-3 border-[var(--color-neo-border)]">
|
||||
<span
|
||||
className={`w-3 h-3 rounded-full ${config.pulse ? 'animate-pulse' : ''}`}
|
||||
style={{ backgroundColor: config.color }}
|
||||
/>
|
||||
<span
|
||||
className="font-display font-bold text-sm uppercase"
|
||||
style={{ color: config.color }}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
86
ui/src/components/FeatureCard.tsx
Normal file
86
ui/src/components/FeatureCard.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { CheckCircle2, Circle, Loader2 } from 'lucide-react'
|
||||
import type { Feature } from '../lib/types'
|
||||
|
||||
interface FeatureCardProps {
|
||||
feature: Feature
|
||||
onClick: () => void
|
||||
isInProgress?: boolean
|
||||
}
|
||||
|
||||
// Generate consistent color for category
|
||||
function getCategoryColor(category: string): string {
|
||||
const colors = [
|
||||
'#ff006e', // pink
|
||||
'#00b4d8', // cyan
|
||||
'#70e000', // green
|
||||
'#ffd60a', // yellow
|
||||
'#ff5400', // orange
|
||||
'#8338ec', // purple
|
||||
'#3a86ff', // blue
|
||||
]
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
export function FeatureCard({ feature, onClick, isInProgress }: FeatureCardProps) {
|
||||
const categoryColor = getCategoryColor(feature.category)
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`
|
||||
w-full text-left neo-card p-4 cursor-pointer
|
||||
${isInProgress ? 'animate-pulse-neo' : ''}
|
||||
${feature.passes ? 'border-[var(--color-neo-done)]' : ''}
|
||||
`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<span
|
||||
className="neo-badge"
|
||||
style={{ backgroundColor: categoryColor, color: 'white' }}
|
||||
>
|
||||
{feature.category}
|
||||
</span>
|
||||
<span className="font-mono text-sm text-[var(--color-neo-text-secondary)]">
|
||||
#{feature.priority}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<h3 className="font-display font-bold mb-1 line-clamp-2">
|
||||
{feature.name}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-[var(--color-neo-text-secondary)] line-clamp-2 mb-3">
|
||||
{feature.description}
|
||||
</p>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{isInProgress ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin text-[var(--color-neo-progress)]" />
|
||||
<span className="text-[var(--color-neo-progress)] font-bold">Processing...</span>
|
||||
</>
|
||||
) : feature.passes ? (
|
||||
<>
|
||||
<CheckCircle2 size={16} className="text-[var(--color-neo-done)]" />
|
||||
<span className="text-[var(--color-neo-done)] font-bold">Complete</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Circle size={16} className="text-[var(--color-neo-text-secondary)]" />
|
||||
<span className="text-[var(--color-neo-text-secondary)]">Pending</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
190
ui/src/components/FeatureModal.tsx
Normal file
190
ui/src/components/FeatureModal.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useState } from 'react'
|
||||
import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle } from 'lucide-react'
|
||||
import { useSkipFeature, useDeleteFeature } from '../hooks/useProjects'
|
||||
import type { Feature } from '../lib/types'
|
||||
|
||||
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 skipFeature = useSkipFeature(projectName)
|
||||
const deleteFeature = useDeleteFeature(projectName)
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="neo-modal-backdrop" onClick={onClose}>
|
||||
<div
|
||||
className="neo-modal w-full max-w-2xl p-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between p-6 border-b-3 border-[var(--color-neo-border)]">
|
||||
<div>
|
||||
<span className="neo-badge bg-[var(--color-neo-accent)] text-white mb-2">
|
||||
{feature.category}
|
||||
</span>
|
||||
<h2 className="font-display text-2xl font-bold">
|
||||
{feature.name}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="neo-btn neo-btn-ghost p-2"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-3 p-4 bg-[var(--color-neo-danger)] text-white border-3 border-[var(--color-neo-border)]">
|
||||
<AlertCircle size={20} />
|
||||
<span>{error}</span>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="ml-auto"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-3 p-4 bg-[var(--color-neo-bg)] border-3 border-[var(--color-neo-border)]">
|
||||
{feature.passes ? (
|
||||
<>
|
||||
<CheckCircle2 size={24} className="text-[var(--color-neo-done)]" />
|
||||
<span className="font-display font-bold text-[var(--color-neo-done)]">
|
||||
COMPLETE
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Circle size={24} className="text-[var(--color-neo-text-secondary)]" />
|
||||
<span className="font-display font-bold text-[var(--color-neo-text-secondary)]">
|
||||
PENDING
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="ml-auto font-mono text-sm">
|
||||
Priority: #{feature.priority}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<h3 className="font-display font-bold mb-2 uppercase text-sm">
|
||||
Description
|
||||
</h3>
|
||||
<p className="text-[var(--color-neo-text-secondary)]">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
{feature.steps.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-display font-bold mb-2 uppercase text-sm">
|
||||
Test Steps
|
||||
</h3>
|
||||
<ol className="list-decimal list-inside space-y-2">
|
||||
{feature.steps.map((step, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="p-3 bg-[var(--color-neo-bg)] border-3 border-[var(--color-neo-border)]"
|
||||
>
|
||||
{step}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{!feature.passes && (
|
||||
<div className="p-6 border-t-3 border-[var(--color-neo-border)] bg-[var(--color-neo-bg)]">
|
||||
{showDeleteConfirm ? (
|
||||
<div className="space-y-4">
|
||||
<p className="font-bold text-center">
|
||||
Are you sure you want to delete this feature?
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleteFeature.isPending}
|
||||
className="neo-btn neo-btn-danger flex-1"
|
||||
>
|
||||
{deleteFeature.isPending ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
'Yes, Delete'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
disabled={deleteFeature.isPending}
|
||||
className="neo-btn neo-btn-ghost flex-1"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
disabled={skipFeature.isPending}
|
||||
className="neo-btn neo-btn-warning flex-1"
|
||||
>
|
||||
{skipFeature.isPending ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<SkipForward size={18} />
|
||||
Skip (Move to End)
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={skipFeature.isPending}
|
||||
className="neo-btn neo-btn-danger"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
ui/src/components/KanbanBoard.tsx
Normal file
52
ui/src/components/KanbanBoard.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { KanbanColumn } from './KanbanColumn'
|
||||
import type { Feature, FeatureListResponse } from '../lib/types'
|
||||
|
||||
interface KanbanBoardProps {
|
||||
features: FeatureListResponse | undefined
|
||||
onFeatureClick: (feature: Feature) => void
|
||||
}
|
||||
|
||||
export function KanbanBoard({ features, onFeatureClick }: KanbanBoardProps) {
|
||||
if (!features) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{['Pending', 'In Progress', 'Done'].map(title => (
|
||||
<div key={title} className="neo-card p-4">
|
||||
<div className="h-8 bg-[var(--color-neo-bg)] animate-pulse mb-4" />
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-24 bg-[var(--color-neo-bg)] animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<KanbanColumn
|
||||
title="Pending"
|
||||
count={features.pending.length}
|
||||
features={features.pending}
|
||||
color="pending"
|
||||
onFeatureClick={onFeatureClick}
|
||||
/>
|
||||
<KanbanColumn
|
||||
title="In Progress"
|
||||
count={features.in_progress.length}
|
||||
features={features.in_progress}
|
||||
color="progress"
|
||||
onFeatureClick={onFeatureClick}
|
||||
/>
|
||||
<KanbanColumn
|
||||
title="Done"
|
||||
count={features.done.length}
|
||||
features={features.done}
|
||||
color="done"
|
||||
onFeatureClick={onFeatureClick}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
ui/src/components/KanbanColumn.tsx
Normal file
65
ui/src/components/KanbanColumn.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { FeatureCard } from './FeatureCard'
|
||||
import type { Feature } from '../lib/types'
|
||||
|
||||
interface KanbanColumnProps {
|
||||
title: string
|
||||
count: number
|
||||
features: Feature[]
|
||||
color: 'pending' | 'progress' | 'done'
|
||||
onFeatureClick: (feature: Feature) => void
|
||||
}
|
||||
|
||||
const colorMap = {
|
||||
pending: 'var(--color-neo-pending)',
|
||||
progress: 'var(--color-neo-progress)',
|
||||
done: 'var(--color-neo-done)',
|
||||
}
|
||||
|
||||
export function KanbanColumn({
|
||||
title,
|
||||
count,
|
||||
features,
|
||||
color,
|
||||
onFeatureClick,
|
||||
}: KanbanColumnProps) {
|
||||
return (
|
||||
<div
|
||||
className="neo-card overflow-hidden"
|
||||
style={{ borderColor: colorMap[color] }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="px-4 py-3 border-b-3 border-[var(--color-neo-border)]"
|
||||
style={{ backgroundColor: colorMap[color] }}
|
||||
>
|
||||
<h2 className="font-display text-lg font-bold uppercase flex items-center justify-between text-[var(--color-neo-text)]">
|
||||
{title}
|
||||
<span className="neo-badge bg-white text-[var(--color-neo-text)]">{count}</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="p-4 space-y-3 max-h-[600px] overflow-y-auto bg-[var(--color-neo-bg)]">
|
||||
{features.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--color-neo-text-secondary)]">
|
||||
No features
|
||||
</div>
|
||||
) : (
|
||||
features.map((feature, index) => (
|
||||
<div
|
||||
key={feature.id}
|
||||
className="animate-slide-in"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
<FeatureCard
|
||||
feature={feature}
|
||||
onClick={() => onFeatureClick(feature)}
|
||||
isInProgress={color === 'progress'}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
77
ui/src/components/ProgressDashboard.tsx
Normal file
77
ui/src/components/ProgressDashboard.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Wifi, WifiOff } from 'lucide-react'
|
||||
|
||||
interface ProgressDashboardProps {
|
||||
passing: number
|
||||
total: number
|
||||
percentage: number
|
||||
isConnected: boolean
|
||||
}
|
||||
|
||||
export function ProgressDashboard({
|
||||
passing,
|
||||
total,
|
||||
percentage,
|
||||
isConnected,
|
||||
}: ProgressDashboardProps) {
|
||||
return (
|
||||
<div className="neo-card p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-display text-xl font-bold uppercase">
|
||||
Progress
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Wifi size={16} className="text-[var(--color-neo-done)]" />
|
||||
<span className="text-sm text-[var(--color-neo-done)]">Live</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WifiOff size={16} className="text-[var(--color-neo-danger)]" />
|
||||
<span className="text-sm text-[var(--color-neo-danger)]">Offline</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Large Percentage */}
|
||||
<div className="text-center mb-6">
|
||||
<span className="font-display text-6xl font-bold">
|
||||
{percentage.toFixed(1)}
|
||||
</span>
|
||||
<span className="font-display text-3xl font-bold text-[var(--color-neo-text-secondary)]">
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="neo-progress mb-4">
|
||||
<div
|
||||
className="neo-progress-fill"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex justify-center gap-8 text-center">
|
||||
<div>
|
||||
<span className="font-mono text-3xl font-bold text-[var(--color-neo-done)]">
|
||||
{passing}
|
||||
</span>
|
||||
<span className="block text-sm text-[var(--color-neo-text-secondary)] uppercase">
|
||||
Passing
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-4xl text-[var(--color-neo-text-secondary)]">/</div>
|
||||
<div>
|
||||
<span className="font-mono text-3xl font-bold">
|
||||
{total}
|
||||
</span>
|
||||
<span className="block text-sm text-[var(--color-neo-text-secondary)] uppercase">
|
||||
Total
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
172
ui/src/components/ProjectSelector.tsx
Normal file
172
ui/src/components/ProjectSelector.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, Plus, FolderOpen, Loader2 } from 'lucide-react'
|
||||
import { useCreateProject } from '../hooks/useProjects'
|
||||
import type { ProjectSummary } from '../lib/types'
|
||||
|
||||
interface ProjectSelectorProps {
|
||||
projects: ProjectSummary[]
|
||||
selectedProject: string | null
|
||||
onSelectProject: (name: string | null) => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export function ProjectSelector({
|
||||
projects,
|
||||
selectedProject,
|
||||
onSelectProject,
|
||||
isLoading,
|
||||
}: ProjectSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [newProjectName, setNewProjectName] = useState('')
|
||||
|
||||
const createProject = useCreateProject()
|
||||
|
||||
const handleCreateProject = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!newProjectName.trim()) return
|
||||
|
||||
try {
|
||||
const project = await createProject.mutateAsync({
|
||||
name: newProjectName.trim(),
|
||||
specMethod: 'manual',
|
||||
})
|
||||
onSelectProject(project.name)
|
||||
setNewProjectName('')
|
||||
setShowCreate(false)
|
||||
setIsOpen(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to create project:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedProjectData = projects.find(p => p.name === selectedProject)
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Dropdown Trigger */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="neo-btn bg-white text-[var(--color-neo-text)] min-w-[200px] justify-between"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : selectedProject ? (
|
||||
<>
|
||||
<span className="flex items-center gap-2">
|
||||
<FolderOpen size={18} />
|
||||
{selectedProject}
|
||||
</span>
|
||||
{selectedProjectData && selectedProjectData.stats.total > 0 && (
|
||||
<span className="neo-badge bg-[var(--color-neo-done)] ml-2">
|
||||
{selectedProjectData.stats.percentage}%
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[var(--color-neo-text-secondary)]">
|
||||
Select Project
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown size={18} className={`transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Menu */}
|
||||
<div className="absolute top-full left-0 mt-2 w-full neo-dropdown z-50 min-w-[280px]">
|
||||
{projects.length > 0 ? (
|
||||
<div className="max-h-[300px] overflow-auto">
|
||||
{projects.map(project => (
|
||||
<button
|
||||
key={project.name}
|
||||
onClick={() => {
|
||||
onSelectProject(project.name)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className={`w-full neo-dropdown-item flex items-center justify-between ${
|
||||
project.name === selectedProject
|
||||
? 'bg-[var(--color-neo-pending)]'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<FolderOpen size={16} />
|
||||
{project.name}
|
||||
</span>
|
||||
{project.stats.total > 0 && (
|
||||
<span className="text-sm font-mono">
|
||||
{project.stats.passing}/{project.stats.total}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 text-center text-[var(--color-neo-text-secondary)]">
|
||||
No projects yet
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t-3 border-[var(--color-neo-border)]" />
|
||||
|
||||
{/* Create New */}
|
||||
{showCreate ? (
|
||||
<form onSubmit={handleCreateProject} className="p-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newProjectName}
|
||||
onChange={(e) => setNewProjectName(e.target.value)}
|
||||
placeholder="project-name"
|
||||
className="neo-input text-sm mb-2"
|
||||
pattern="^[a-zA-Z0-9_-]+$"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="neo-btn neo-btn-success text-xs flex-1"
|
||||
disabled={createProject.isPending || !newProjectName.trim()}
|
||||
>
|
||||
{createProject.isPending ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
'Create'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCreate(false)
|
||||
setNewProjectName('')
|
||||
}}
|
||||
className="neo-btn neo-btn-ghost text-xs"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="w-full neo-dropdown-item flex items-center gap-2 font-bold"
|
||||
>
|
||||
<Plus size={16} />
|
||||
New Project
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
183
ui/src/components/SetupWizard.tsx
Normal file
183
ui/src/components/SetupWizard.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useEffect, useCallback } from 'react'
|
||||
import { CheckCircle2, XCircle, Loader2, ExternalLink } from 'lucide-react'
|
||||
import { useSetupStatus, useHealthCheck } from '../hooks/useProjects'
|
||||
|
||||
interface SetupWizardProps {
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
export function SetupWizard({ onComplete }: SetupWizardProps) {
|
||||
const { data: setupStatus, isLoading: setupLoading, error: setupError } = useSetupStatus()
|
||||
const { data: health, error: healthError } = useHealthCheck()
|
||||
|
||||
const isApiHealthy = health?.status === 'healthy' && !healthError
|
||||
const isReady = isApiHealthy && setupStatus?.claude_cli && setupStatus?.credentials
|
||||
|
||||
// Memoize the completion check to avoid infinite loops
|
||||
const checkAndComplete = useCallback(() => {
|
||||
if (isReady) {
|
||||
onComplete()
|
||||
}
|
||||
}, [isReady, onComplete])
|
||||
|
||||
// Auto-complete if everything is ready
|
||||
useEffect(() => {
|
||||
checkAndComplete()
|
||||
}, [checkAndComplete])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--color-neo-bg)] flex items-center justify-center p-4">
|
||||
<div className="neo-card w-full max-w-lg p-8">
|
||||
<h1 className="font-display text-3xl font-bold text-center mb-2">
|
||||
Setup Wizard
|
||||
</h1>
|
||||
<p className="text-center text-[var(--color-neo-text-secondary)] mb-8">
|
||||
Let's make sure everything is ready to go
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* API Health */}
|
||||
<SetupItem
|
||||
label="Backend Server"
|
||||
description="FastAPI server is running"
|
||||
status={healthError ? 'error' : isApiHealthy ? 'success' : 'loading'}
|
||||
/>
|
||||
|
||||
{/* Claude CLI */}
|
||||
<SetupItem
|
||||
label="Claude CLI"
|
||||
description="Claude Code CLI is installed"
|
||||
status={
|
||||
setupLoading
|
||||
? 'loading'
|
||||
: setupError
|
||||
? 'error'
|
||||
: setupStatus?.claude_cli
|
||||
? 'success'
|
||||
: 'error'
|
||||
}
|
||||
helpLink="https://docs.anthropic.com/claude/claude-code"
|
||||
helpText="Install Claude Code"
|
||||
/>
|
||||
|
||||
{/* Credentials */}
|
||||
<SetupItem
|
||||
label="Anthropic Credentials"
|
||||
description="API credentials are configured"
|
||||
status={
|
||||
setupLoading
|
||||
? 'loading'
|
||||
: setupError
|
||||
? 'error'
|
||||
: setupStatus?.credentials
|
||||
? 'success'
|
||||
: 'error'
|
||||
}
|
||||
helpLink="https://console.anthropic.com/account/keys"
|
||||
helpText="Get API Key"
|
||||
/>
|
||||
|
||||
{/* Node.js */}
|
||||
<SetupItem
|
||||
label="Node.js"
|
||||
description="Node.js is installed (for UI dev)"
|
||||
status={
|
||||
setupLoading
|
||||
? 'loading'
|
||||
: setupError
|
||||
? 'error'
|
||||
: setupStatus?.node
|
||||
? 'success'
|
||||
: 'warning'
|
||||
}
|
||||
helpLink="https://nodejs.org"
|
||||
helpText="Install Node.js"
|
||||
optional
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Continue Button */}
|
||||
{isReady && (
|
||||
<button
|
||||
onClick={onComplete}
|
||||
className="neo-btn neo-btn-success w-full mt-8"
|
||||
>
|
||||
Continue to Dashboard
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{(healthError || setupError) && (
|
||||
<div className="mt-6 p-4 bg-[var(--color-neo-danger)] text-white border-3 border-[var(--color-neo-border)]">
|
||||
<p className="font-bold mb-2">Setup Error</p>
|
||||
<p className="text-sm">
|
||||
{healthError
|
||||
? 'Cannot connect to the backend server. Make sure to run start_ui.py first.'
|
||||
: 'Failed to check setup status.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SetupItemProps {
|
||||
label: string
|
||||
description: string
|
||||
status: 'success' | 'error' | 'warning' | 'loading'
|
||||
helpLink?: string
|
||||
helpText?: string
|
||||
optional?: boolean
|
||||
}
|
||||
|
||||
function SetupItem({
|
||||
label,
|
||||
description,
|
||||
status,
|
||||
helpLink,
|
||||
helpText,
|
||||
optional,
|
||||
}: SetupItemProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-4 p-4 bg-[var(--color-neo-bg)] border-3 border-[var(--color-neo-border)]">
|
||||
{/* Status Icon */}
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
{status === 'success' ? (
|
||||
<CheckCircle2 size={24} className="text-[var(--color-neo-done)]" />
|
||||
) : status === 'error' ? (
|
||||
<XCircle size={24} className="text-[var(--color-neo-danger)]" />
|
||||
) : status === 'warning' ? (
|
||||
<XCircle size={24} className="text-[var(--color-neo-pending)]" />
|
||||
) : (
|
||||
<Loader2 size={24} className="animate-spin text-[var(--color-neo-progress)]" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-display font-bold">{label}</span>
|
||||
{optional && (
|
||||
<span className="text-xs text-[var(--color-neo-text-secondary)]">
|
||||
(optional)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--color-neo-text-secondary)]">
|
||||
{description}
|
||||
</p>
|
||||
{(status === 'error' || status === 'warning') && helpLink && (
|
||||
<a
|
||||
href={helpLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 mt-2 text-sm text-[var(--color-neo-accent)] hover:underline"
|
||||
>
|
||||
{helpText} <ExternalLink size={12} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
172
ui/src/hooks/useProjects.ts
Normal file
172
ui/src/hooks/useProjects.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* React Query hooks for project data
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import * as api from '../lib/api'
|
||||
import type { FeatureCreate } from '../lib/types'
|
||||
|
||||
// ============================================================================
|
||||
// Projects
|
||||
// ============================================================================
|
||||
|
||||
export function useProjects() {
|
||||
return useQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: api.listProjects,
|
||||
})
|
||||
}
|
||||
|
||||
export function useProject(name: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['project', name],
|
||||
queryFn: () => api.getProject(name!),
|
||||
enabled: !!name,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateProject() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ name, specMethod }: { name: string; specMethod?: 'claude' | 'manual' }) =>
|
||||
api.createProject(name, specMethod),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteProject() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (name: string) => api.deleteProject(name),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Features
|
||||
// ============================================================================
|
||||
|
||||
export function useFeatures(projectName: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['features', projectName],
|
||||
queryFn: () => api.listFeatures(projectName!),
|
||||
enabled: !!projectName,
|
||||
refetchInterval: 5000, // Refetch every 5 seconds for real-time updates
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateFeature(projectName: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (feature: FeatureCreate) => api.createFeature(projectName, feature),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['features', projectName] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteFeature(projectName: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (featureId: number) => api.deleteFeature(projectName, featureId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['features', projectName] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useSkipFeature(projectName: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (featureId: number) => api.skipFeature(projectName, featureId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['features', projectName] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Agent
|
||||
// ============================================================================
|
||||
|
||||
export function useAgentStatus(projectName: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['agent-status', projectName],
|
||||
queryFn: () => api.getAgentStatus(projectName!),
|
||||
enabled: !!projectName,
|
||||
refetchInterval: 3000, // Poll every 3 seconds
|
||||
})
|
||||
}
|
||||
|
||||
export function useStartAgent(projectName: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => api.startAgent(projectName),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-status', projectName] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useStopAgent(projectName: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => api.stopAgent(projectName),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-status', projectName] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePauseAgent(projectName: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => api.pauseAgent(projectName),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-status', projectName] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useResumeAgent(projectName: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => api.resumeAgent(projectName),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-status', projectName] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Setup
|
||||
// ============================================================================
|
||||
|
||||
export function useSetupStatus() {
|
||||
return useQuery({
|
||||
queryKey: ['setup-status'],
|
||||
queryFn: api.getSetupStatus,
|
||||
staleTime: 60000, // Cache for 1 minute
|
||||
})
|
||||
}
|
||||
|
||||
export function useHealthCheck() {
|
||||
return useQuery({
|
||||
queryKey: ['health'],
|
||||
queryFn: api.healthCheck,
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
161
ui/src/hooks/useWebSocket.ts
Normal file
161
ui/src/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* WebSocket Hook for Real-time Updates
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import type { WSMessage, AgentStatus } from '../lib/types'
|
||||
|
||||
interface WebSocketState {
|
||||
progress: {
|
||||
passing: number
|
||||
total: number
|
||||
percentage: number
|
||||
}
|
||||
agentStatus: AgentStatus
|
||||
logs: Array<{ line: string; timestamp: string }>
|
||||
isConnected: boolean
|
||||
}
|
||||
|
||||
const MAX_LOGS = 100 // Keep last 100 log lines
|
||||
|
||||
export function useProjectWebSocket(projectName: string | null) {
|
||||
const [state, setState] = useState<WebSocketState>({
|
||||
progress: { passing: 0, total: 0, percentage: 0 },
|
||||
agentStatus: 'stopped',
|
||||
logs: [],
|
||||
isConnected: false,
|
||||
})
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const reconnectTimeoutRef = useRef<number | null>(null)
|
||||
const reconnectAttempts = useRef(0)
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!projectName) return
|
||||
|
||||
// Build WebSocket URL
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const host = window.location.host
|
||||
const wsUrl = `${protocol}//${host}/ws/projects/${encodeURIComponent(projectName)}`
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
setState(prev => ({ ...prev, isConnected: true }))
|
||||
reconnectAttempts.current = 0
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message: WSMessage = JSON.parse(event.data)
|
||||
|
||||
switch (message.type) {
|
||||
case 'progress':
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
progress: {
|
||||
passing: message.passing,
|
||||
total: message.total,
|
||||
percentage: message.percentage,
|
||||
},
|
||||
}))
|
||||
break
|
||||
|
||||
case 'agent_status':
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
agentStatus: message.status,
|
||||
}))
|
||||
break
|
||||
|
||||
case 'log':
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
logs: [
|
||||
...prev.logs.slice(-MAX_LOGS + 1),
|
||||
{ line: message.line, timestamp: message.timestamp },
|
||||
],
|
||||
}))
|
||||
break
|
||||
|
||||
case 'feature_update':
|
||||
// Feature updates will trigger a refetch via React Query
|
||||
break
|
||||
|
||||
case 'pong':
|
||||
// Heartbeat response
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
console.error('Failed to parse WebSocket message')
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
setState(prev => ({ ...prev, isConnected: false }))
|
||||
wsRef.current = null
|
||||
|
||||
// Exponential backoff reconnection
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000)
|
||||
reconnectAttempts.current++
|
||||
|
||||
reconnectTimeoutRef.current = window.setTimeout(() => {
|
||||
connect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close()
|
||||
}
|
||||
} catch {
|
||||
// Failed to connect, will retry via onclose
|
||||
}
|
||||
}, [projectName])
|
||||
|
||||
// Send ping to keep connection alive
|
||||
const sendPing = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'ping' }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Connect when project changes
|
||||
useEffect(() => {
|
||||
if (!projectName) {
|
||||
// Disconnect if no project
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
// Ping every 30 seconds
|
||||
const pingInterval = setInterval(sendPing, 30000)
|
||||
|
||||
return () => {
|
||||
clearInterval(pingInterval)
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
}
|
||||
}, [projectName, connect, sendPing])
|
||||
|
||||
// Clear logs function
|
||||
const clearLogs = useCallback(() => {
|
||||
setState(prev => ({ ...prev, logs: [] }))
|
||||
}, [])
|
||||
|
||||
return {
|
||||
...state,
|
||||
clearLogs,
|
||||
}
|
||||
}
|
||||
148
ui/src/lib/api.ts
Normal file
148
ui/src/lib/api.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* API Client for the Autonomous Coding UI
|
||||
*/
|
||||
|
||||
import type {
|
||||
ProjectSummary,
|
||||
ProjectDetail,
|
||||
ProjectPrompts,
|
||||
FeatureListResponse,
|
||||
Feature,
|
||||
FeatureCreate,
|
||||
AgentStatusResponse,
|
||||
AgentActionResponse,
|
||||
SetupStatus,
|
||||
} from './types'
|
||||
|
||||
const API_BASE = '/api'
|
||||
|
||||
async function fetchJSON<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${url}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Unknown error' }))
|
||||
throw new Error(error.detail || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Projects API
|
||||
// ============================================================================
|
||||
|
||||
export async function listProjects(): Promise<ProjectSummary[]> {
|
||||
return fetchJSON('/projects')
|
||||
}
|
||||
|
||||
export async function createProject(name: string, specMethod: 'claude' | 'manual' = 'manual'): Promise<ProjectSummary> {
|
||||
return fetchJSON('/projects', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, spec_method: specMethod }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getProject(name: string): Promise<ProjectDetail> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(name)}`)
|
||||
}
|
||||
|
||||
export async function deleteProject(name: string): Promise<void> {
|
||||
await fetchJSON(`/projects/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export async function getProjectPrompts(name: string): Promise<ProjectPrompts> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(name)}/prompts`)
|
||||
}
|
||||
|
||||
export async function updateProjectPrompts(
|
||||
name: string,
|
||||
prompts: Partial<ProjectPrompts>
|
||||
): Promise<void> {
|
||||
await fetchJSON(`/projects/${encodeURIComponent(name)}/prompts`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(prompts),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Features API
|
||||
// ============================================================================
|
||||
|
||||
export async function listFeatures(projectName: string): Promise<FeatureListResponse> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/features`)
|
||||
}
|
||||
|
||||
export async function createFeature(projectName: string, feature: FeatureCreate): Promise<Feature> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/features`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(feature),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getFeature(projectName: string, featureId: number): Promise<Feature> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/features/${featureId}`)
|
||||
}
|
||||
|
||||
export async function deleteFeature(projectName: string, featureId: number): Promise<void> {
|
||||
await fetchJSON(`/projects/${encodeURIComponent(projectName)}/features/${featureId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export async function skipFeature(projectName: string, featureId: number): Promise<void> {
|
||||
await fetchJSON(`/projects/${encodeURIComponent(projectName)}/features/${featureId}/skip`, {
|
||||
method: 'PATCH',
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Agent API
|
||||
// ============================================================================
|
||||
|
||||
export async function getAgentStatus(projectName: string): Promise<AgentStatusResponse> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/agent/status`)
|
||||
}
|
||||
|
||||
export async function startAgent(projectName: string): Promise<AgentActionResponse> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/agent/start`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export async function stopAgent(projectName: string): Promise<AgentActionResponse> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/agent/stop`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export async function pauseAgent(projectName: string): Promise<AgentActionResponse> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/agent/pause`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export async function resumeAgent(projectName: string): Promise<AgentActionResponse> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/agent/resume`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Setup API
|
||||
// ============================================================================
|
||||
|
||||
export async function getSetupStatus(): Promise<SetupStatus> {
|
||||
return fetchJSON('/setup/status')
|
||||
}
|
||||
|
||||
export async function healthCheck(): Promise<{ status: string }> {
|
||||
return fetchJSON('/health')
|
||||
}
|
||||
112
ui/src/lib/types.ts
Normal file
112
ui/src/lib/types.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* TypeScript types for the Autonomous Coding UI
|
||||
*/
|
||||
|
||||
// Project types
|
||||
export interface ProjectStats {
|
||||
passing: number
|
||||
total: number
|
||||
percentage: number
|
||||
}
|
||||
|
||||
export interface ProjectSummary {
|
||||
name: string
|
||||
has_spec: boolean
|
||||
stats: ProjectStats
|
||||
}
|
||||
|
||||
export interface ProjectDetail extends ProjectSummary {
|
||||
prompts_dir: string
|
||||
}
|
||||
|
||||
export interface ProjectPrompts {
|
||||
app_spec: string
|
||||
initializer_prompt: string
|
||||
coding_prompt: string
|
||||
}
|
||||
|
||||
// Feature types
|
||||
export interface Feature {
|
||||
id: number
|
||||
priority: number
|
||||
category: string
|
||||
name: string
|
||||
description: string
|
||||
steps: string[]
|
||||
passes: boolean
|
||||
}
|
||||
|
||||
export interface FeatureListResponse {
|
||||
pending: Feature[]
|
||||
in_progress: Feature[]
|
||||
done: Feature[]
|
||||
}
|
||||
|
||||
export interface FeatureCreate {
|
||||
category: string
|
||||
name: string
|
||||
description: string
|
||||
steps: string[]
|
||||
priority?: number
|
||||
}
|
||||
|
||||
// Agent types
|
||||
export type AgentStatus = 'stopped' | 'running' | 'paused' | 'crashed'
|
||||
|
||||
export interface AgentStatusResponse {
|
||||
status: AgentStatus
|
||||
pid: number | null
|
||||
started_at: string | null
|
||||
}
|
||||
|
||||
export interface AgentActionResponse {
|
||||
success: boolean
|
||||
status: AgentStatus
|
||||
message: string
|
||||
}
|
||||
|
||||
// Setup types
|
||||
export interface SetupStatus {
|
||||
claude_cli: boolean
|
||||
credentials: boolean
|
||||
node: boolean
|
||||
npm: boolean
|
||||
}
|
||||
|
||||
// WebSocket message types
|
||||
export type WSMessageType = 'progress' | 'feature_update' | 'log' | 'agent_status' | 'pong'
|
||||
|
||||
export interface WSProgressMessage {
|
||||
type: 'progress'
|
||||
passing: number
|
||||
total: number
|
||||
percentage: number
|
||||
}
|
||||
|
||||
export interface WSFeatureUpdateMessage {
|
||||
type: 'feature_update'
|
||||
feature_id: number
|
||||
passes: boolean
|
||||
}
|
||||
|
||||
export interface WSLogMessage {
|
||||
type: 'log'
|
||||
line: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface WSAgentStatusMessage {
|
||||
type: 'agent_status'
|
||||
status: AgentStatus
|
||||
}
|
||||
|
||||
export interface WSPongMessage {
|
||||
type: 'pong'
|
||||
}
|
||||
|
||||
export type WSMessage =
|
||||
| WSProgressMessage
|
||||
| WSFeatureUpdateMessage
|
||||
| WSLogMessage
|
||||
| WSAgentStatusMessage
|
||||
| WSPongMessage
|
||||
22
ui/src/main.tsx
Normal file
22
ui/src/main.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App'
|
||||
import './styles/globals.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5000,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
342
ui/src/styles/globals.css
Normal file
342
ui/src/styles/globals.css
Normal file
@@ -0,0 +1,342 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ============================================================================
|
||||
Neobrutalism Design System
|
||||
============================================================================ */
|
||||
|
||||
@theme {
|
||||
/* Colors */
|
||||
--color-neo-bg: #fffef5;
|
||||
--color-neo-card: #ffffff;
|
||||
--color-neo-pending: #ffd60a;
|
||||
--color-neo-progress: #00b4d8;
|
||||
--color-neo-done: #70e000;
|
||||
--color-neo-accent: #ff006e;
|
||||
--color-neo-danger: #ff5400;
|
||||
--color-neo-border: #1a1a1a;
|
||||
--color-neo-text: #1a1a1a;
|
||||
--color-neo-text-secondary: #4a4a4a;
|
||||
|
||||
/* Fonts */
|
||||
--font-neo-display: 'Space Grotesk', sans-serif;
|
||||
--font-neo-sans: 'DM Sans', sans-serif;
|
||||
--font-neo-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-neo-sm: 2px 2px 0px rgba(0, 0, 0, 1);
|
||||
--shadow-neo-md: 4px 4px 0px rgba(0, 0, 0, 1);
|
||||
--shadow-neo-lg: 6px 6px 0px rgba(0, 0, 0, 1);
|
||||
--shadow-neo-xl: 8px 8px 0px rgba(0, 0, 0, 1);
|
||||
|
||||
/* Transitions */
|
||||
--transition-neo-fast: 0.15s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--transition-neo-normal: 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
body {
|
||||
font-family: var(--font-neo-sans);
|
||||
background-color: var(--color-neo-bg);
|
||||
color: var(--color-neo-text);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Component Classes
|
||||
============================================================================ */
|
||||
|
||||
/* Cards */
|
||||
.neo-card {
|
||||
background: var(--color-neo-card);
|
||||
border: 3px solid var(--color-neo-border);
|
||||
box-shadow: var(--shadow-neo-md);
|
||||
transition: transform var(--transition-neo-fast), box-shadow var(--transition-neo-fast);
|
||||
}
|
||||
|
||||
.neo-card:hover {
|
||||
transform: translate(-2px, -2px);
|
||||
box-shadow: var(--shadow-neo-lg);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.neo-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-family: var(--font-neo-display);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
color: var(--color-neo-text);
|
||||
background: white;
|
||||
border: 3px solid var(--color-neo-border);
|
||||
box-shadow: var(--shadow-neo-md);
|
||||
transition: transform var(--transition-neo-fast), box-shadow var(--transition-neo-fast);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.neo-btn:hover {
|
||||
transform: translate(-2px, -2px);
|
||||
box-shadow: var(--shadow-neo-lg);
|
||||
}
|
||||
|
||||
.neo-btn:active {
|
||||
transform: translate(2px, 2px);
|
||||
box-shadow: var(--shadow-neo-sm);
|
||||
}
|
||||
|
||||
.neo-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: var(--shadow-neo-sm);
|
||||
}
|
||||
|
||||
.neo-btn-primary {
|
||||
background: var(--color-neo-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.neo-btn-success {
|
||||
background: var(--color-neo-done);
|
||||
color: var(--color-neo-text);
|
||||
}
|
||||
|
||||
.neo-btn-warning {
|
||||
background: var(--color-neo-pending);
|
||||
color: var(--color-neo-text);
|
||||
}
|
||||
|
||||
.neo-btn-danger {
|
||||
background: var(--color-neo-danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.neo-btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--color-neo-text);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.neo-btn-ghost:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: var(--color-neo-text);
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Inputs */
|
||||
.neo-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
font-family: var(--font-neo-sans);
|
||||
font-size: 1rem;
|
||||
color: var(--color-neo-text);
|
||||
background: white;
|
||||
border: 3px solid var(--color-neo-border);
|
||||
box-shadow: 3px 3px 0px rgba(0, 0, 0, 0.1);
|
||||
transition: transform var(--transition-neo-fast), box-shadow var(--transition-neo-fast);
|
||||
}
|
||||
|
||||
.neo-input::placeholder {
|
||||
color: var(--color-neo-text-secondary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.neo-input:focus {
|
||||
outline: none;
|
||||
transform: translate(-1px, -1px);
|
||||
box-shadow: var(--shadow-neo-md);
|
||||
border-color: var(--color-neo-accent);
|
||||
}
|
||||
|
||||
/* Badge */
|
||||
.neo-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-family: var(--font-neo-display);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-neo-text);
|
||||
border: 2px solid var(--color-neo-border);
|
||||
}
|
||||
|
||||
/* Progress Bar */
|
||||
.neo-progress {
|
||||
width: 100%;
|
||||
height: 2rem;
|
||||
background: white;
|
||||
border: 3px solid var(--color-neo-border);
|
||||
box-shadow: var(--shadow-neo-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.neo-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--color-neo-done);
|
||||
transition: width 0.5s ease-out;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.neo-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(2px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.neo-modal {
|
||||
background: white;
|
||||
border: 4px solid var(--color-neo-border);
|
||||
box-shadow: var(--shadow-neo-xl);
|
||||
animation: popIn 0.3s var(--transition-neo-fast);
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* Dropdown */
|
||||
.neo-dropdown {
|
||||
background: white;
|
||||
border: 3px solid var(--color-neo-border);
|
||||
box-shadow: var(--shadow-neo-lg);
|
||||
}
|
||||
|
||||
.neo-dropdown-item {
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-neo-text);
|
||||
background: transparent;
|
||||
transition: background var(--transition-neo-fast), color var(--transition-neo-fast);
|
||||
}
|
||||
|
||||
.neo-dropdown-item:hover {
|
||||
background: var(--color-neo-pending);
|
||||
color: var(--color-neo-text);
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
.neo-tooltip {
|
||||
background: var(--color-neo-text);
|
||||
color: white;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
border: 2px solid var(--color-neo-border);
|
||||
box-shadow: var(--shadow-neo-sm);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.neo-empty-state {
|
||||
background: var(--color-neo-bg);
|
||||
border: 4px dashed var(--color-neo-border);
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Animations
|
||||
============================================================================ */
|
||||
|
||||
@keyframes popIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes neoPulse {
|
||||
0%, 100% {
|
||||
box-shadow: 6px 6px 0px rgba(0, 180, 216, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 6px 6px 0px rgba(0, 180, 216, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes completePop {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-pop-in {
|
||||
animation: popIn 0.3s var(--transition-neo-fast);
|
||||
}
|
||||
|
||||
.animate-pulse-neo {
|
||||
animation: neoPulse 2s infinite;
|
||||
}
|
||||
|
||||
.animate-complete {
|
||||
animation: completePop 0.5s var(--transition-neo-fast);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Utilities
|
||||
============================================================================ */
|
||||
|
||||
.font-display {
|
||||
font-family: var(--font-neo-display);
|
||||
}
|
||||
|
||||
.font-sans {
|
||||
font-family: var(--font-neo-sans);
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: var(--font-neo-mono);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-neo-bg);
|
||||
border: 2px solid var(--color-neo-border);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-neo-border);
|
||||
border: 2px solid var(--color-neo-border);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-neo-text-secondary);
|
||||
}
|
||||
1
ui/src/vite-env.d.ts
vendored
Normal file
1
ui/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
30
ui/tsconfig.json
Normal file
30
ui/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Paths */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
22
ui/tsconfig.node.json
Normal file
22
ui/tsconfig.node.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
1
ui/tsconfig.tsbuildinfo
Normal file
1
ui/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/addfeatureform.tsx","./src/components/agentcontrol.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.tsx","./src/components/kanbanboard.tsx","./src/components/kanbancolumn.tsx","./src/components/progressdashboard.tsx","./src/components/projectselector.tsx","./src/components/setupwizard.tsx","./src/hooks/useprojects.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/types.ts"],"version":"5.6.3"}
|
||||
29
ui/vite.config.ts
Normal file
29
ui/vite.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'path'
|
||||
|
||||
// Backend port - can be overridden via VITE_API_PORT env var
|
||||
const apiPort = process.env.VITE_API_PORT || '8000'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: `http://127.0.0.1:${apiPort}`,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: `ws://127.0.0.1:${apiPort}`,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user