This commit is contained in:
Auto
2025-12-30 11:56:39 +02:00
parent dd7c1ddd82
commit a2efec159d
40 changed files with 9112 additions and 3 deletions

16
ui/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

37
ui/package.json Normal file
View 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
View 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
View 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

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
View 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,
})
}

View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

30
ui/tsconfig.json Normal file
View 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
View 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
View 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
View 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,
},
},
},
})