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

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