mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-04-01 18:23:10 +00:00
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "autoforge-ai",
|
||||
"version": "0.1.20",
|
||||
"version": "0.1.21",
|
||||
"description": "Autonomous coding agent with web UI - build complete apps with AI",
|
||||
"license": "AGPL-3.0",
|
||||
"bin": {
|
||||
|
||||
2
ui/package-lock.json
generated
2
ui/package-lock.json
generated
@@ -56,7 +56,7 @@
|
||||
},
|
||||
"..": {
|
||||
"name": "autoforge-ai",
|
||||
"version": "0.1.20",
|
||||
"version": "0.1.21",
|
||||
"license": "AGPL-3.0",
|
||||
"bin": {
|
||||
"autoforge": "bin/autoforge.js"
|
||||
|
||||
631
ui/src/App.tsx
631
ui/src/App.tsx
@@ -1,630 +1,13 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useQueryClient, useQuery } from '@tanstack/react-query'
|
||||
import { useProjects, useFeatures, useAgentStatus, useSettings } from './hooks/useProjects'
|
||||
import { useProjectWebSocket } from './hooks/useWebSocket'
|
||||
import { useFeatureSound } from './hooks/useFeatureSound'
|
||||
import { useCelebration } from './hooks/useCelebration'
|
||||
import { useTheme } from './hooks/useTheme'
|
||||
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 { DebugLogViewer, type TabType } from './components/DebugLogViewer'
|
||||
import { AgentMissionControl } from './components/AgentMissionControl'
|
||||
import { CelebrationOverlay } from './components/CelebrationOverlay'
|
||||
import { AssistantFAB } from './components/AssistantFAB'
|
||||
import { AssistantPanel } from './components/AssistantPanel'
|
||||
import { ExpandProjectModal } from './components/ExpandProjectModal'
|
||||
import { SpecCreationChat } from './components/SpecCreationChat'
|
||||
import { SettingsModal } from './components/SettingsModal'
|
||||
import { DevServerControl } from './components/DevServerControl'
|
||||
import { ViewToggle, type ViewMode } from './components/ViewToggle'
|
||||
import { DependencyGraph } from './components/DependencyGraph'
|
||||
import { KeyboardShortcutsHelp } from './components/KeyboardShortcutsHelp'
|
||||
import { ThemeSelector } from './components/ThemeSelector'
|
||||
import { ResetProjectModal } from './components/ResetProjectModal'
|
||||
import { ProjectSetupRequired } from './components/ProjectSetupRequired'
|
||||
import { getDependencyGraph, startAgent } from './lib/api'
|
||||
import { Loader2, Settings, Moon, Sun, RotateCcw, BookOpen } from 'lucide-react'
|
||||
import type { Feature } from './lib/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||
|
||||
const STORAGE_KEY = 'autoforge-selected-project'
|
||||
const VIEW_MODE_KEY = 'autoforge-view-mode'
|
||||
|
||||
// Bottom padding for main content when debug panel is collapsed (40px header + 8px margin)
|
||||
const COLLAPSED_DEBUG_PANEL_CLEARANCE = 48
|
||||
|
||||
type InitializerStatus = 'idle' | 'starting' | 'error'
|
||||
import { AppProvider } from './contexts/AppContext'
|
||||
import { AppShell } from './components/layout/AppShell'
|
||||
import { Modals } from './components/layout/Modals'
|
||||
|
||||
function App() {
|
||||
// Initialize selected project from localStorage
|
||||
const [selectedProject, setSelectedProject] = useState<string | null>(() => {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
const [showAddFeature, setShowAddFeature] = useState(false)
|
||||
const [showExpandProject, setShowExpandProject] = useState(false)
|
||||
const [selectedFeature, setSelectedFeature] = useState<Feature | null>(null)
|
||||
const [setupComplete, setSetupComplete] = useState(true) // Start optimistic
|
||||
const [debugOpen, setDebugOpen] = useState(false)
|
||||
const [debugPanelHeight, setDebugPanelHeight] = useState(288) // Default height
|
||||
const [debugActiveTab, setDebugActiveTab] = useState<TabType>('agent')
|
||||
const [assistantOpen, setAssistantOpen] = useState(false)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false)
|
||||
const [isSpecCreating, setIsSpecCreating] = useState(false)
|
||||
const [showResetModal, setShowResetModal] = useState(false)
|
||||
const [showSpecChat, setShowSpecChat] = useState(false) // For "Create Spec" button in empty kanban
|
||||
const [specInitializerStatus, setSpecInitializerStatus] = useState<InitializerStatus>('idle')
|
||||
const [specInitializerError, setSpecInitializerError] = useState<string | null>(null)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(VIEW_MODE_KEY)
|
||||
return (stored === 'graph' ? 'graph' : 'kanban') as ViewMode
|
||||
} catch {
|
||||
return 'kanban'
|
||||
}
|
||||
})
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { data: projects, isLoading: projectsLoading } = useProjects()
|
||||
const { data: features } = useFeatures(selectedProject)
|
||||
const { data: settings } = useSettings()
|
||||
useAgentStatus(selectedProject) // Keep polling for status updates
|
||||
const wsState = useProjectWebSocket(selectedProject)
|
||||
const { theme, setTheme, darkMode, toggleDarkMode, themes } = useTheme()
|
||||
|
||||
// Get has_spec from the selected project
|
||||
const selectedProjectData = projects?.find(p => p.name === selectedProject)
|
||||
const hasSpec = selectedProjectData?.has_spec ?? true
|
||||
|
||||
// Fetch graph data when in graph view
|
||||
const { data: graphData } = useQuery({
|
||||
queryKey: ['dependencyGraph', selectedProject],
|
||||
queryFn: () => getDependencyGraph(selectedProject!),
|
||||
enabled: !!selectedProject && viewMode === 'graph',
|
||||
refetchInterval: 5000, // Refresh every 5 seconds
|
||||
})
|
||||
|
||||
// Persist view mode to localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(VIEW_MODE_KEY, viewMode)
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
}, [viewMode])
|
||||
|
||||
// Play sounds when features move between columns
|
||||
useFeatureSound(features)
|
||||
|
||||
// Celebrate when all features are complete
|
||||
useCelebration(features, selectedProject)
|
||||
|
||||
// Persist selected project to localStorage
|
||||
const handleSelectProject = useCallback((project: string | null) => {
|
||||
setSelectedProject(project)
|
||||
try {
|
||||
if (project) {
|
||||
localStorage.setItem(STORAGE_KEY, project)
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Handle graph node click - memoized to prevent DependencyGraph re-renders
|
||||
const handleGraphNodeClick = useCallback((nodeId: number) => {
|
||||
const allFeatures = [
|
||||
...(features?.pending ?? []),
|
||||
...(features?.in_progress ?? []),
|
||||
...(features?.done ?? []),
|
||||
...(features?.needs_human_input ?? [])
|
||||
]
|
||||
const feature = allFeatures.find(f => f.id === nodeId)
|
||||
if (feature) setSelectedFeature(feature)
|
||||
}, [features])
|
||||
|
||||
// Validate stored project exists (clear if project was deleted)
|
||||
useEffect(() => {
|
||||
if (selectedProject && projects && !projects.some(p => p.name === selectedProject)) {
|
||||
handleSelectProject(null)
|
||||
}
|
||||
}, [selectedProject, projects, handleSelectProject])
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Ignore if user is typing in an input
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return
|
||||
}
|
||||
|
||||
// D : Toggle debug window
|
||||
if (e.key === 'd' || e.key === 'D') {
|
||||
e.preventDefault()
|
||||
setDebugOpen(prev => !prev)
|
||||
}
|
||||
|
||||
// T : Toggle terminal tab in debug panel
|
||||
if (e.key === 't' || e.key === 'T') {
|
||||
e.preventDefault()
|
||||
if (!debugOpen) {
|
||||
// If panel is closed, open it and switch to terminal tab
|
||||
setDebugOpen(true)
|
||||
setDebugActiveTab('terminal')
|
||||
} else if (debugActiveTab === 'terminal') {
|
||||
// If already on terminal tab, close the panel
|
||||
setDebugOpen(false)
|
||||
} else {
|
||||
// If open but on different tab, switch to terminal
|
||||
setDebugActiveTab('terminal')
|
||||
}
|
||||
}
|
||||
|
||||
// N : Add new feature (when project selected)
|
||||
if ((e.key === 'n' || e.key === 'N') && selectedProject) {
|
||||
e.preventDefault()
|
||||
setShowAddFeature(true)
|
||||
}
|
||||
|
||||
// E : Expand project with AI (when project selected, has spec and has features)
|
||||
if ((e.key === 'e' || e.key === 'E') && selectedProject && hasSpec && features &&
|
||||
(features.pending.length + features.in_progress.length + features.done.length + (features.needs_human_input?.length || 0)) > 0) {
|
||||
e.preventDefault()
|
||||
setShowExpandProject(true)
|
||||
}
|
||||
|
||||
// A : Toggle assistant panel (when project selected and not in spec creation)
|
||||
if ((e.key === 'a' || e.key === 'A') && selectedProject && !isSpecCreating) {
|
||||
e.preventDefault()
|
||||
setAssistantOpen(prev => !prev)
|
||||
}
|
||||
|
||||
// , : Open settings
|
||||
if (e.key === ',') {
|
||||
e.preventDefault()
|
||||
setShowSettings(true)
|
||||
}
|
||||
|
||||
// G : Toggle between Kanban and Graph view (when project selected)
|
||||
if ((e.key === 'g' || e.key === 'G') && selectedProject) {
|
||||
e.preventDefault()
|
||||
setViewMode(prev => prev === 'kanban' ? 'graph' : 'kanban')
|
||||
}
|
||||
|
||||
// ? : Show keyboard shortcuts help
|
||||
if (e.key === '?') {
|
||||
e.preventDefault()
|
||||
setShowKeyboardHelp(true)
|
||||
}
|
||||
|
||||
// R : Open reset modal (when project selected and agent not running/draining)
|
||||
if ((e.key === 'r' || e.key === 'R') && selectedProject && !['running', 'pausing', 'paused_graceful'].includes(wsState.agentStatus)) {
|
||||
e.preventDefault()
|
||||
setShowResetModal(true)
|
||||
}
|
||||
|
||||
// Escape : Close modals
|
||||
if (e.key === 'Escape') {
|
||||
if (showKeyboardHelp) {
|
||||
setShowKeyboardHelp(false)
|
||||
} else if (showResetModal) {
|
||||
setShowResetModal(false)
|
||||
} else if (showExpandProject) {
|
||||
setShowExpandProject(false)
|
||||
} else if (showSettings) {
|
||||
setShowSettings(false)
|
||||
} else if (assistantOpen) {
|
||||
setAssistantOpen(false)
|
||||
} else if (showAddFeature) {
|
||||
setShowAddFeature(false)
|
||||
} else if (selectedFeature) {
|
||||
setSelectedFeature(null)
|
||||
} else if (debugOpen) {
|
||||
setDebugOpen(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, debugActiveTab, assistantOpen, features, showSettings, showKeyboardHelp, isSpecCreating, viewMode, showResetModal, wsState.agentStatus, hasSpec])
|
||||
|
||||
// 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) + (features?.needs_human_input?.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-background">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 bg-card/80 backdrop-blur-md text-foreground border-b-2 border-border">
|
||||
<div className="max-w-7xl mx-auto px-4 py-3">
|
||||
<TooltipProvider>
|
||||
{/* Row 1: Branding + Project + Utility icons */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Logo and Title */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<img src="/logo.png" alt="AutoForge" className="h-9 w-9 rounded-full" />
|
||||
<h1 className="font-display text-2xl font-bold tracking-tight uppercase hidden md:block">
|
||||
AutoForge
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Project selector */}
|
||||
<ProjectSelector
|
||||
projects={projects ?? []}
|
||||
selectedProject={selectedProject}
|
||||
onSelectProject={handleSelectProject}
|
||||
isLoading={projectsLoading}
|
||||
onSpecCreatingChange={setIsSpecCreating}
|
||||
/>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Ollama Mode Indicator */}
|
||||
{selectedProject && settings?.ollama_mode && (
|
||||
<div
|
||||
className="hidden sm:flex items-center gap-1.5 px-2 py-1 bg-card rounded border-2 border-border shadow-sm"
|
||||
title="Using Ollama local models"
|
||||
>
|
||||
<img src="/ollama.png" alt="Ollama" className="w-5 h-5" />
|
||||
<span className="text-xs font-bold text-foreground">Ollama</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GLM Mode Badge */}
|
||||
{selectedProject && settings?.glm_mode && (
|
||||
<Badge
|
||||
className="hidden sm:inline-flex bg-purple-500 text-white hover:bg-purple-600"
|
||||
title="Using GLM API"
|
||||
>
|
||||
GLM
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Utility icons - always visible */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() => window.open('https://autoforge.cc', '_blank')}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="Open Documentation"
|
||||
>
|
||||
<BookOpen size={18} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Docs</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<ThemeSelector
|
||||
themes={themes}
|
||||
currentTheme={theme}
|
||||
onThemeChange={setTheme}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={toggleDarkMode}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="Toggle dark mode"
|
||||
>
|
||||
{darkMode ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Toggle theme</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Project controls - only when a project is selected */}
|
||||
{selectedProject && (
|
||||
<div className="flex items-center gap-3 mt-2 pt-2 border-t border-border/50">
|
||||
<AgentControl
|
||||
projectName={selectedProject}
|
||||
status={wsState.agentStatus}
|
||||
defaultConcurrency={selectedProjectData?.default_concurrency}
|
||||
/>
|
||||
|
||||
<DevServerControl
|
||||
projectName={selectedProject}
|
||||
status={wsState.devServerStatus}
|
||||
url={wsState.devServerUrl}
|
||||
/>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() => setShowSettings(true)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="Open Settings"
|
||||
>
|
||||
<Settings size={18} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Settings (,)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() => setShowResetModal(true)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="Reset Project"
|
||||
disabled={['running', 'pausing', 'paused_graceful'].includes(wsState.agentStatus)}
|
||||
>
|
||||
<RotateCcw size={18} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Reset (R)</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main
|
||||
className="max-w-7xl mx-auto px-4 py-8"
|
||||
style={{ paddingBottom: debugOpen ? debugPanelHeight + 32 : COLLAPSED_DEBUG_PANEL_CLEARANCE }}
|
||||
>
|
||||
{!selectedProject ? (
|
||||
<div className="text-center mt-12">
|
||||
<h2 className="font-display text-2xl font-bold mb-2">
|
||||
Welcome to AutoForge
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Select a project from the dropdown above or create a new one to get started.
|
||||
</p>
|
||||
</div>
|
||||
) : !hasSpec ? (
|
||||
<ProjectSetupRequired
|
||||
projectName={selectedProject}
|
||||
projectPath={selectedProjectData?.path}
|
||||
onCreateWithClaude={() => setShowSpecChat(true)}
|
||||
onEditManually={() => {
|
||||
// Open debug panel for the user to see the project path
|
||||
setDebugOpen(true)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{/* Progress Dashboard */}
|
||||
<ProgressDashboard
|
||||
passing={progress.passing}
|
||||
total={progress.total}
|
||||
percentage={progress.percentage}
|
||||
isConnected={wsState.isConnected}
|
||||
logs={wsState.activeAgents.length === 0 ? wsState.logs : undefined}
|
||||
agentStatus={wsState.activeAgents.length === 0 ? wsState.agentStatus : undefined}
|
||||
/>
|
||||
|
||||
{/* Agent Mission Control - shows orchestrator status and active agents in parallel mode */}
|
||||
<AgentMissionControl
|
||||
agents={wsState.activeAgents}
|
||||
orchestratorStatus={wsState.orchestratorStatus}
|
||||
recentActivity={wsState.recentActivity}
|
||||
getAgentLogs={wsState.getAgentLogs}
|
||||
browserScreenshots={wsState.browserScreenshots}
|
||||
/>
|
||||
|
||||
|
||||
{/* Initializing Features State - show when agent is running but no features yet */}
|
||||
{features &&
|
||||
features.pending.length === 0 &&
|
||||
features.in_progress.length === 0 &&
|
||||
features.done.length === 0 &&
|
||||
(features.needs_human_input?.length || 0) === 0 &&
|
||||
wsState.agentStatus === 'running' && (
|
||||
<Card className="p-8 text-center">
|
||||
<CardContent className="p-0">
|
||||
<Loader2 size={32} className="animate-spin mx-auto mb-4 text-primary" />
|
||||
<h3 className="font-display font-bold text-xl mb-2">
|
||||
Initializing Features...
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
The agent is reading your spec and creating features. This may take a moment.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* View Toggle - only show when there are features */}
|
||||
{features && (features.pending.length + features.in_progress.length + features.done.length + (features.needs_human_input?.length || 0)) > 0 && (
|
||||
<div className="flex justify-center">
|
||||
<ViewToggle viewMode={viewMode} onViewModeChange={setViewMode} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Kanban Board or Dependency Graph based on view mode */}
|
||||
{viewMode === 'kanban' ? (
|
||||
<KanbanBoard
|
||||
features={features}
|
||||
onFeatureClick={setSelectedFeature}
|
||||
onAddFeature={() => setShowAddFeature(true)}
|
||||
onExpandProject={() => setShowExpandProject(true)}
|
||||
activeAgents={wsState.activeAgents}
|
||||
onCreateSpec={() => setShowSpecChat(true)}
|
||||
hasSpec={hasSpec}
|
||||
/>
|
||||
) : (
|
||||
<Card className="overflow-hidden" style={{ height: '600px' }}>
|
||||
{graphData ? (
|
||||
<DependencyGraph
|
||||
graphData={graphData}
|
||||
onNodeClick={handleGraphNodeClick}
|
||||
activeAgents={wsState.activeAgents}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Loader2 size={32} className="animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Expand Project Modal - AI-powered bulk feature creation */}
|
||||
{showExpandProject && selectedProject && hasSpec && (
|
||||
<ExpandProjectModal
|
||||
isOpen={showExpandProject}
|
||||
projectName={selectedProject}
|
||||
onClose={() => setShowExpandProject(false)}
|
||||
onFeaturesAdded={() => {
|
||||
// Invalidate features query to refresh the kanban board
|
||||
queryClient.invalidateQueries({ queryKey: ['features', selectedProject] })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Spec Creation Chat - for creating spec from empty kanban */}
|
||||
{showSpecChat && selectedProject && (
|
||||
<div className="fixed inset-0 z-50 bg-background">
|
||||
<SpecCreationChat
|
||||
projectName={selectedProject}
|
||||
onComplete={async (_specPath, yoloMode) => {
|
||||
setSpecInitializerStatus('starting')
|
||||
try {
|
||||
await startAgent(selectedProject, {
|
||||
yoloMode: yoloMode ?? false,
|
||||
maxConcurrency: 3,
|
||||
})
|
||||
// Success — close chat and refresh
|
||||
setShowSpecChat(false)
|
||||
setSpecInitializerStatus('idle')
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['features', selectedProject] })
|
||||
} catch (err) {
|
||||
setSpecInitializerStatus('error')
|
||||
setSpecInitializerError(err instanceof Error ? err.message : 'Failed to start agent')
|
||||
}
|
||||
}}
|
||||
onCancel={() => { setShowSpecChat(false); setSpecInitializerStatus('idle') }}
|
||||
onExitToProject={() => { setShowSpecChat(false); setSpecInitializerStatus('idle') }}
|
||||
initializerStatus={specInitializerStatus}
|
||||
initializerError={specInitializerError}
|
||||
onRetryInitializer={() => {
|
||||
setSpecInitializerError(null)
|
||||
setSpecInitializerStatus('idle')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Debug Log Viewer - fixed to bottom */}
|
||||
{selectedProject && (
|
||||
<DebugLogViewer
|
||||
logs={wsState.logs}
|
||||
devLogs={wsState.devLogs}
|
||||
isOpen={debugOpen}
|
||||
onToggle={() => setDebugOpen(!debugOpen)}
|
||||
onClear={wsState.clearLogs}
|
||||
onClearDevLogs={wsState.clearDevLogs}
|
||||
onHeightChange={setDebugPanelHeight}
|
||||
projectName={selectedProject}
|
||||
activeTab={debugActiveTab}
|
||||
onTabChange={setDebugActiveTab}
|
||||
browserScreenshots={wsState.browserScreenshots}
|
||||
onSubscribeBrowserView={wsState.subscribeBrowserView}
|
||||
onUnsubscribeBrowserView={wsState.unsubscribeBrowserView}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Assistant FAB and Panel - hide when expand modal or spec creation is open */}
|
||||
{selectedProject && !showExpandProject && !isSpecCreating && !showSpecChat && (
|
||||
<>
|
||||
<AssistantFAB
|
||||
onClick={() => setAssistantOpen(!assistantOpen)}
|
||||
isOpen={assistantOpen}
|
||||
/>
|
||||
<AssistantPanel
|
||||
projectName={selectedProject}
|
||||
isOpen={assistantOpen}
|
||||
onClose={() => setAssistantOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Settings Modal */}
|
||||
<SettingsModal isOpen={showSettings} onClose={() => setShowSettings(false)} />
|
||||
|
||||
{/* Keyboard Shortcuts Help */}
|
||||
<KeyboardShortcutsHelp isOpen={showKeyboardHelp} onClose={() => setShowKeyboardHelp(false)} />
|
||||
|
||||
{/* Reset Project Modal */}
|
||||
{showResetModal && selectedProject && (
|
||||
<ResetProjectModal
|
||||
isOpen={showResetModal}
|
||||
projectName={selectedProject}
|
||||
onClose={() => setShowResetModal(false)}
|
||||
onResetComplete={(wasFullReset) => {
|
||||
// If full reset, the spec was deleted - show spec creation chat
|
||||
if (wasFullReset) {
|
||||
setShowSpecChat(true)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Celebration Overlay - shows when a feature is completed by an agent */}
|
||||
{wsState.celebration && (
|
||||
<CelebrationOverlay
|
||||
agentName={wsState.celebration.agentName}
|
||||
featureName={wsState.celebration.featureName}
|
||||
onComplete={wsState.clearCelebration}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<AppProvider>
|
||||
<AppShell />
|
||||
<Modals />
|
||||
</AppProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,13 +16,18 @@ interface Shortcut {
|
||||
|
||||
const shortcuts: Shortcut[] = [
|
||||
{ key: '?', description: 'Show keyboard shortcuts' },
|
||||
{ key: 'D', description: 'Toggle debug panel' },
|
||||
{ key: 'T', description: 'Toggle terminal tab' },
|
||||
{ key: 'H', description: 'Dashboard view' },
|
||||
{ key: 'K', description: 'Kanban board' },
|
||||
{ key: 'G', description: 'Dependency graph' },
|
||||
{ key: 'B', description: 'Browser screenshots' },
|
||||
{ key: 'T', description: 'Terminal' },
|
||||
{ key: 'D', description: 'Logs' },
|
||||
{ key: 'A', description: 'Toggle AI assistant', context: 'with project' },
|
||||
{ key: '[', description: 'Toggle sidebar' },
|
||||
{ key: 'N', description: 'Add new feature', context: 'with project' },
|
||||
{ key: 'E', description: 'Expand project with AI', context: 'with spec & features' },
|
||||
{ key: 'A', description: 'Toggle AI assistant', context: 'with project' },
|
||||
{ key: 'G', description: 'Toggle Kanban/Graph view', context: 'with project' },
|
||||
{ key: ',', description: 'Open settings' },
|
||||
{ key: ',', description: 'Settings' },
|
||||
{ key: 'R', description: 'Reset project', context: 'with project' },
|
||||
{ key: 'Esc', description: 'Close modal/panel' },
|
||||
]
|
||||
|
||||
|
||||
25
ui/src/components/layout/AppShell.tsx
Normal file
25
ui/src/components/layout/AppShell.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useAppContext } from '@/contexts/AppContext'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { ControlBar } from './ControlBar'
|
||||
import { ContentArea } from './ContentArea'
|
||||
|
||||
/**
|
||||
* Top-level layout component that composes the three structural regions:
|
||||
* Sidebar | ControlBar + ContentArea
|
||||
*
|
||||
* The sidebar sits on the left. The right column stacks the ControlBar
|
||||
* (shown only when a project is selected) above the scrollable ContentArea.
|
||||
*/
|
||||
export function AppShell() {
|
||||
const { selectedProject } = useAppContext()
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
{selectedProject && <ControlBar />}
|
||||
<ContentArea />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
88
ui/src/components/layout/ContentArea.tsx
Normal file
88
ui/src/components/layout/ContentArea.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Content Area - View Router
|
||||
*
|
||||
* Renders the active view based on the current `activeView` state from AppContext.
|
||||
* Also handles pre-conditions: setup wizard, project selection, and spec creation.
|
||||
*/
|
||||
|
||||
import { useAppContext } from '@/contexts/AppContext'
|
||||
import { SetupWizard } from '../SetupWizard'
|
||||
import { ProjectSetupRequired } from '../ProjectSetupRequired'
|
||||
import { DashboardView } from '../views/DashboardView'
|
||||
import { KanbanView } from '../views/KanbanView'
|
||||
import { GraphView } from '../views/GraphView'
|
||||
import { BrowsersView } from '../views/BrowsersView'
|
||||
import { TerminalView } from '../views/TerminalView'
|
||||
import { LogsView } from '../views/LogsView'
|
||||
import { AssistantView } from '../views/AssistantView'
|
||||
import { SettingsView } from '../views/SettingsView'
|
||||
|
||||
export function ContentArea() {
|
||||
const {
|
||||
selectedProject,
|
||||
hasSpec,
|
||||
setupComplete,
|
||||
setSetupComplete,
|
||||
setShowSpecChat,
|
||||
activeView,
|
||||
selectedProjectData,
|
||||
} = useAppContext()
|
||||
|
||||
// Step 1: First-run setup wizard
|
||||
if (!setupComplete) {
|
||||
return <SetupWizard onComplete={() => setSetupComplete(true)} />
|
||||
}
|
||||
|
||||
// Settings is always accessible regardless of project state
|
||||
if (activeView === 'settings') {
|
||||
return <SettingsView />
|
||||
}
|
||||
|
||||
// Step 2: No project selected - show welcome message
|
||||
if (!selectedProject) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="font-display text-2xl font-bold mb-2">Welcome to AutoForge</h2>
|
||||
<p className="text-muted-foreground">Select a project from the sidebar to get started.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Step 3: Project exists but has no spec - prompt user to create one
|
||||
if (!hasSpec) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<ProjectSetupRequired
|
||||
projectName={selectedProject}
|
||||
projectPath={selectedProjectData?.path}
|
||||
onCreateWithClaude={() => setShowSpecChat(true)}
|
||||
onEditManually={() => {
|
||||
/* Could navigate to terminal view */
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Step 4: Render the active view
|
||||
switch (activeView) {
|
||||
case 'dashboard':
|
||||
return <DashboardView />
|
||||
case 'kanban':
|
||||
return <KanbanView />
|
||||
case 'graph':
|
||||
return <GraphView />
|
||||
case 'browsers':
|
||||
return <BrowsersView />
|
||||
case 'terminal':
|
||||
return <TerminalView />
|
||||
case 'logs':
|
||||
return <LogsView />
|
||||
case 'assistant':
|
||||
return <AssistantView />
|
||||
default:
|
||||
return <DashboardView />
|
||||
}
|
||||
}
|
||||
68
ui/src/components/layout/ControlBar.tsx
Normal file
68
ui/src/components/layout/ControlBar.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useAppContext } from '@/contexts/AppContext'
|
||||
import { AgentControl } from '../AgentControl'
|
||||
import { DevServerControl } from '../DevServerControl'
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||
|
||||
/**
|
||||
* Compact horizontal control bar at the top of the content area.
|
||||
* Houses agent controls, dev server controls, mode badges, and reset.
|
||||
* Sticky within the scrollable content region.
|
||||
*/
|
||||
export function ControlBar() {
|
||||
const {
|
||||
selectedProject,
|
||||
selectedProjectData,
|
||||
wsState,
|
||||
settings,
|
||||
setShowResetModal,
|
||||
} = useAppContext()
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-20 w-full flex items-center gap-3 bg-card/80 backdrop-blur-md border-b border-border px-4 py-2 shrink-0">
|
||||
<AgentControl
|
||||
projectName={selectedProject!}
|
||||
status={wsState.agentStatus}
|
||||
defaultConcurrency={selectedProjectData?.default_concurrency}
|
||||
/>
|
||||
|
||||
<DevServerControl
|
||||
projectName={selectedProject!}
|
||||
status={wsState.devServerStatus}
|
||||
url={wsState.devServerUrl}
|
||||
/>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{settings?.ollama_mode && (
|
||||
<div className="hidden sm:flex items-center gap-1.5 px-2 py-1 bg-card rounded-lg border border-border shadow-sm">
|
||||
<img src="/ollama.png" alt="Ollama" className="w-4 h-4" />
|
||||
<span className="text-[11px] font-bold text-foreground">Ollama</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settings?.glm_mode && (
|
||||
<Badge className="hidden sm:inline-flex bg-purple-500 text-white hover:bg-purple-600 text-[11px]">
|
||||
GLM
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() => setShowResetModal(true)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={['running', 'pausing', 'paused_graceful'].includes(wsState.agentStatus)}
|
||||
className="h-8"
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Reset (R)</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
144
ui/src/components/layout/Modals.tsx
Normal file
144
ui/src/components/layout/Modals.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useAppContext } from '@/contexts/AppContext'
|
||||
import { AddFeatureForm } from '../AddFeatureForm'
|
||||
import { FeatureModal } from '../FeatureModal'
|
||||
import { ExpandProjectModal } from '../ExpandProjectModal'
|
||||
import { SpecCreationChat } from '../SpecCreationChat'
|
||||
import { KeyboardShortcutsHelp } from '../KeyboardShortcutsHelp'
|
||||
import { ResetProjectModal } from '../ResetProjectModal'
|
||||
import { AssistantFAB } from '../AssistantFAB'
|
||||
import { AssistantPanel } from '../AssistantPanel'
|
||||
import { CelebrationOverlay } from '../CelebrationOverlay'
|
||||
import { startAgent } from '@/lib/api'
|
||||
|
||||
/**
|
||||
* Renders all modal dialogs, overlays, and floating UI elements.
|
||||
*
|
||||
* Extracted from App.tsx so the main shell remains focused on layout while
|
||||
* this component owns the conditional rendering of every overlay surface.
|
||||
* All state is read from AppContext -- no props required.
|
||||
*/
|
||||
export function Modals() {
|
||||
const {
|
||||
selectedProject,
|
||||
selectedFeature, setSelectedFeature,
|
||||
showAddFeature, setShowAddFeature,
|
||||
showExpandProject, setShowExpandProject,
|
||||
showSpecChat, setShowSpecChat,
|
||||
showKeyboardHelp, setShowKeyboardHelp,
|
||||
showResetModal, setShowResetModal,
|
||||
assistantOpen, setAssistantOpen,
|
||||
isSpecCreating,
|
||||
hasSpec,
|
||||
specInitializerStatus, setSpecInitializerStatus,
|
||||
specInitializerError, setSpecInitializerError,
|
||||
wsState,
|
||||
queryClient,
|
||||
} = useAppContext()
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Add Feature Modal */}
|
||||
{showAddFeature && selectedProject && (
|
||||
<AddFeatureForm
|
||||
projectName={selectedProject}
|
||||
onClose={() => setShowAddFeature(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Feature Detail Modal */}
|
||||
{selectedFeature && selectedProject && (
|
||||
<FeatureModal
|
||||
feature={selectedFeature}
|
||||
projectName={selectedProject}
|
||||
onClose={() => setSelectedFeature(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Expand Project Modal */}
|
||||
{showExpandProject && selectedProject && hasSpec && (
|
||||
<ExpandProjectModal
|
||||
isOpen={showExpandProject}
|
||||
projectName={selectedProject}
|
||||
onClose={() => setShowExpandProject(false)}
|
||||
onFeaturesAdded={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['features', selectedProject] })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Spec Creation Chat - full screen overlay */}
|
||||
{showSpecChat && selectedProject && (
|
||||
<div className="fixed inset-0 z-50 bg-background">
|
||||
<SpecCreationChat
|
||||
projectName={selectedProject}
|
||||
onComplete={async (_specPath, yoloMode) => {
|
||||
setSpecInitializerStatus('starting')
|
||||
try {
|
||||
await startAgent(selectedProject, {
|
||||
yoloMode: yoloMode ?? false,
|
||||
maxConcurrency: 3,
|
||||
})
|
||||
setShowSpecChat(false)
|
||||
setSpecInitializerStatus('idle')
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['features', selectedProject] })
|
||||
} catch (err) {
|
||||
setSpecInitializerStatus('error')
|
||||
setSpecInitializerError(err instanceof Error ? err.message : 'Failed to start agent')
|
||||
}
|
||||
}}
|
||||
onCancel={() => { setShowSpecChat(false); setSpecInitializerStatus('idle') }}
|
||||
onExitToProject={() => { setShowSpecChat(false); setSpecInitializerStatus('idle') }}
|
||||
initializerStatus={specInitializerStatus}
|
||||
initializerError={specInitializerError}
|
||||
onRetryInitializer={() => {
|
||||
setSpecInitializerError(null)
|
||||
setSpecInitializerStatus('idle')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assistant FAB and Panel - hide when expand modal or spec creation is open */}
|
||||
{selectedProject && !showExpandProject && !isSpecCreating && !showSpecChat && (
|
||||
<>
|
||||
<AssistantFAB
|
||||
onClick={() => setAssistantOpen(!assistantOpen)}
|
||||
isOpen={assistantOpen}
|
||||
/>
|
||||
<AssistantPanel
|
||||
projectName={selectedProject}
|
||||
isOpen={assistantOpen}
|
||||
onClose={() => setAssistantOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Keyboard Shortcuts Help */}
|
||||
<KeyboardShortcutsHelp isOpen={showKeyboardHelp} onClose={() => setShowKeyboardHelp(false)} />
|
||||
|
||||
{/* Reset Project Modal */}
|
||||
{showResetModal && selectedProject && (
|
||||
<ResetProjectModal
|
||||
isOpen={showResetModal}
|
||||
projectName={selectedProject}
|
||||
onClose={() => setShowResetModal(false)}
|
||||
onResetComplete={(wasFullReset) => {
|
||||
if (wasFullReset) {
|
||||
setShowSpecChat(true)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Celebration Overlay - shows when a feature is completed by an agent */}
|
||||
{wsState.celebration && (
|
||||
<CelebrationOverlay
|
||||
agentName={wsState.celebration.agentName}
|
||||
featureName={wsState.celebration.featureName}
|
||||
onComplete={wsState.clearCelebration}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
310
ui/src/components/layout/Sidebar.tsx
Normal file
310
ui/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import { useAppContext } from '@/contexts/AppContext'
|
||||
import { SidebarItem } from './SidebarItem'
|
||||
import { ProjectSelector } from '../ProjectSelector'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Columns3,
|
||||
GitBranch,
|
||||
Monitor,
|
||||
Terminal,
|
||||
ScrollText,
|
||||
Bot,
|
||||
Settings,
|
||||
Moon,
|
||||
Sun,
|
||||
BookOpen,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Collapsible left sidebar for view navigation.
|
||||
*
|
||||
* Design approach: precision-engineered utility. Clean separation between
|
||||
* navigation groups, quiet bottom utility row, smooth width transitions.
|
||||
* All colours come from theme-aware --sidebar-* CSS variables.
|
||||
*/
|
||||
export function Sidebar() {
|
||||
const {
|
||||
activeView,
|
||||
setActiveView,
|
||||
sidebarCollapsed,
|
||||
toggleSidebar,
|
||||
selectedProject,
|
||||
projects,
|
||||
projectsLoading,
|
||||
setSelectedProject,
|
||||
darkMode,
|
||||
toggleDarkMode,
|
||||
wsState,
|
||||
setIsSpecCreating,
|
||||
} = useAppContext()
|
||||
|
||||
const browserCount = wsState.browserScreenshots.size
|
||||
const logCount = wsState.logs.length
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'h-screen flex flex-col shrink-0 z-30 overflow-hidden',
|
||||
'bg-sidebar text-sidebar-foreground',
|
||||
'border-r border-sidebar-border',
|
||||
'transition-[width] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)]',
|
||||
sidebarCollapsed ? 'w-[64px]' : 'w-[240px]',
|
||||
)}
|
||||
>
|
||||
{/* ───────────────────────────────────────────────────────────────────
|
||||
Header: logo, title, project selector
|
||||
─────────────────────────────────────────────────────────────────── */}
|
||||
<div className="flex-shrink-0 px-3 pt-4 pb-3">
|
||||
{/* Logo row */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center',
|
||||
sidebarCollapsed ? 'justify-center' : 'gap-2.5',
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="AutoForge"
|
||||
className={cn(
|
||||
'rounded-full shrink-0 transition-all duration-200',
|
||||
sidebarCollapsed ? 'h-8 w-8' : 'h-7 w-7',
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden transition-all duration-200',
|
||||
sidebarCollapsed ? 'w-0 opacity-0' : 'w-auto opacity-100',
|
||||
)}
|
||||
>
|
||||
<span className="font-bold text-base tracking-tight uppercase whitespace-nowrap block">
|
||||
AutoForge
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project selector — hidden when collapsed */}
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden transition-all duration-200',
|
||||
sidebarCollapsed ? 'max-h-0 opacity-0 mt-0' : 'max-h-20 opacity-100 mt-3',
|
||||
)}
|
||||
>
|
||||
<ProjectSelector
|
||||
projects={projects ?? []}
|
||||
selectedProject={selectedProject}
|
||||
onSelectProject={setSelectedProject}
|
||||
isLoading={projectsLoading}
|
||||
onSpecCreatingChange={setIsSpecCreating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtle divider */}
|
||||
<div className="mx-3 h-px bg-sidebar-border" />
|
||||
|
||||
{/* ───────────────────────────────────────────────────────────────────
|
||||
Navigation items
|
||||
─────────────────────────────────────────────────────────────────── */}
|
||||
<nav className="flex-1 overflow-y-auto py-3 px-2 space-y-0.5">
|
||||
{selectedProject ? (
|
||||
<>
|
||||
{/* Section label (expanded only) */}
|
||||
{!sidebarCollapsed && (
|
||||
<p className="px-3 pb-1 text-[10px] font-semibold uppercase tracking-widest text-sidebar-foreground/40">
|
||||
Views
|
||||
</p>
|
||||
)}
|
||||
|
||||
<SidebarItem
|
||||
icon={LayoutDashboard}
|
||||
label="Dashboard"
|
||||
isActive={activeView === 'dashboard'}
|
||||
isCollapsed={sidebarCollapsed}
|
||||
onClick={() => setActiveView('dashboard')}
|
||||
shortcutKey="H"
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={Columns3}
|
||||
label="Kanban"
|
||||
isActive={activeView === 'kanban'}
|
||||
isCollapsed={sidebarCollapsed}
|
||||
onClick={() => setActiveView('kanban')}
|
||||
shortcutKey="K"
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={GitBranch}
|
||||
label="Graph"
|
||||
isActive={activeView === 'graph'}
|
||||
isCollapsed={sidebarCollapsed}
|
||||
onClick={() => setActiveView('graph')}
|
||||
shortcutKey="G"
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={Monitor}
|
||||
label="Browsers"
|
||||
isActive={activeView === 'browsers'}
|
||||
isCollapsed={sidebarCollapsed}
|
||||
onClick={() => setActiveView('browsers')}
|
||||
shortcutKey="B"
|
||||
badge={browserCount > 0 ? browserCount : undefined}
|
||||
/>
|
||||
|
||||
{/* Divider between groups */}
|
||||
<div className="my-2 mx-2 h-px bg-sidebar-border/60" />
|
||||
|
||||
{/* Section label */}
|
||||
{!sidebarCollapsed && (
|
||||
<p className="px-3 pb-1 text-[10px] font-semibold uppercase tracking-widest text-sidebar-foreground/40">
|
||||
Tools
|
||||
</p>
|
||||
)}
|
||||
|
||||
<SidebarItem
|
||||
icon={Terminal}
|
||||
label="Terminal"
|
||||
isActive={activeView === 'terminal'}
|
||||
isCollapsed={sidebarCollapsed}
|
||||
onClick={() => setActiveView('terminal')}
|
||||
shortcutKey="T"
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={ScrollText}
|
||||
label="Logs"
|
||||
isActive={activeView === 'logs'}
|
||||
isCollapsed={sidebarCollapsed}
|
||||
onClick={() => setActiveView('logs')}
|
||||
shortcutKey="D"
|
||||
badge={logCount > 0 ? logCount : undefined}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={Bot}
|
||||
label="Assistant"
|
||||
isActive={activeView === 'assistant'}
|
||||
isCollapsed={sidebarCollapsed}
|
||||
onClick={() => setActiveView('assistant')}
|
||||
shortcutKey="A"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
/* Prompt when no project selected */
|
||||
!sidebarCollapsed && (
|
||||
<div className="px-3 py-8 text-center">
|
||||
<p className="text-sm text-sidebar-foreground/40 leading-relaxed">
|
||||
Select a project to get started
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* ───────────────────────────────────────────────────────────────────
|
||||
Bottom utility section
|
||||
─────────────────────────────────────────────────────────────────── */}
|
||||
<div className="flex-shrink-0 mt-auto px-2 py-2.5 border-t border-sidebar-border/60">
|
||||
<div className={cn('flex flex-col', sidebarCollapsed ? 'items-center gap-1' : 'gap-0.5')}>
|
||||
{/* Settings - navigates to settings view */}
|
||||
<UtilityButton
|
||||
icon={Settings}
|
||||
label="Settings"
|
||||
shortcut=","
|
||||
collapsed={sidebarCollapsed}
|
||||
onClick={() => setActiveView('settings')}
|
||||
/>
|
||||
|
||||
{/* Dark mode toggle */}
|
||||
<UtilityButton
|
||||
icon={darkMode ? Sun : Moon}
|
||||
label={darkMode ? 'Light mode' : 'Dark mode'}
|
||||
collapsed={sidebarCollapsed}
|
||||
onClick={toggleDarkMode}
|
||||
/>
|
||||
|
||||
{/* Docs link */}
|
||||
<UtilityButton
|
||||
icon={BookOpen}
|
||||
label="Docs"
|
||||
collapsed={sidebarCollapsed}
|
||||
onClick={() => window.open('https://autoforge.cc', '_blank')}
|
||||
/>
|
||||
|
||||
{/* Collapse / expand toggle */}
|
||||
<div className={cn('mt-1 pt-1', !sidebarCollapsed && 'border-t border-sidebar-border/40')}>
|
||||
<UtilityButton
|
||||
icon={sidebarCollapsed ? PanelLeftOpen : PanelLeftClose}
|
||||
label={sidebarCollapsed ? 'Expand sidebar' : 'Collapse'}
|
||||
shortcut="["
|
||||
collapsed={sidebarCollapsed}
|
||||
onClick={toggleSidebar}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────────
|
||||
Small utility button used in the bottom section.
|
||||
Separated to keep the main component readable.
|
||||
───────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function UtilityButton({
|
||||
icon: Icon,
|
||||
label,
|
||||
shortcut,
|
||||
collapsed,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ComponentType<{ size?: number; className?: string }>
|
||||
label: string
|
||||
shortcut?: string
|
||||
collapsed: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
className="h-8 w-8 text-sidebar-foreground/60 hover:text-sidebar-foreground hover:bg-sidebar-accent transition-colors duration-150"
|
||||
>
|
||||
<Icon size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{label}
|
||||
{shortcut && <kbd className="ml-2 text-[10px] font-mono opacity-60">{shortcut}</kbd>}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'group flex items-center gap-3 w-full h-8 px-3 rounded-lg text-sm',
|
||||
'text-sidebar-foreground/60 hover:text-sidebar-foreground',
|
||||
'hover:bg-sidebar-accent transition-all duration-150',
|
||||
'active:scale-[0.98]',
|
||||
)}
|
||||
>
|
||||
<Icon size={16} className="shrink-0" />
|
||||
<span className="flex-1 text-left truncate text-[13px]">{label}</span>
|
||||
{shortcut && (
|
||||
<kbd className="text-[10px] font-mono opacity-0 group-hover:opacity-60 transition-opacity duration-200">
|
||||
{shortcut}
|
||||
</kbd>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
131
ui/src/components/layout/SidebarItem.tsx
Normal file
131
ui/src/components/layout/SidebarItem.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { type LucideIcon } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
interface SidebarItemProps {
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
isActive: boolean
|
||||
isCollapsed: boolean
|
||||
onClick: () => void
|
||||
badge?: number | string
|
||||
shortcutKey?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A single sidebar navigation item that adapts between collapsed (icon-only)
|
||||
* and expanded (icon + label + optional badge/shortcut) states.
|
||||
*
|
||||
* Active state uses a subtle left-edge accent line and primary background.
|
||||
* Hover state applies a gentle lift and background shift for tactile feedback.
|
||||
*/
|
||||
export function SidebarItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
isActive,
|
||||
isCollapsed,
|
||||
onClick,
|
||||
badge,
|
||||
shortcutKey,
|
||||
}: SidebarItemProps) {
|
||||
const button = (
|
||||
<button
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
// Base layout
|
||||
'group relative flex items-center rounded-lg w-full',
|
||||
'transition-all duration-200 ease-[cubic-bezier(0.4,0,0.2,1)]',
|
||||
'outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring',
|
||||
|
||||
// Active state: vivid primary with left accent stripe
|
||||
isActive && [
|
||||
'bg-sidebar-primary text-sidebar-primary-foreground',
|
||||
'shadow-sm',
|
||||
],
|
||||
|
||||
// Inactive: subtle hover lift
|
||||
!isActive && [
|
||||
'text-sidebar-foreground/70',
|
||||
'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||
'hover:shadow-sm',
|
||||
'active:scale-[0.98]',
|
||||
],
|
||||
|
||||
// Sizing
|
||||
isCollapsed ? 'h-11 w-11 justify-center mx-auto' : 'h-9 px-3 gap-3',
|
||||
)}
|
||||
>
|
||||
{/* Left accent bar for active state (expanded only) */}
|
||||
{isActive && !isCollapsed && (
|
||||
<span
|
||||
className="absolute left-0 top-1.5 bottom-1.5 w-[3px] rounded-r-full bg-sidebar-primary-foreground/40"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Icon with subtle scale on active */}
|
||||
<Icon
|
||||
size={isCollapsed ? 20 : 18}
|
||||
className={cn(
|
||||
'shrink-0 transition-transform duration-200',
|
||||
isActive && 'scale-110',
|
||||
!isActive && 'group-hover:translate-x-0.5',
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Label and accessories — expanded mode only */}
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span className="truncate text-sm font-medium flex-1 text-left">
|
||||
{label}
|
||||
</span>
|
||||
|
||||
{badge !== undefined && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-[10px] px-1.5 py-0 h-5 min-w-5 tabular-nums',
|
||||
'transition-opacity duration-200',
|
||||
isActive && 'bg-sidebar-primary-foreground/20 text-sidebar-primary-foreground',
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{shortcutKey && badge === undefined && (
|
||||
<kbd
|
||||
className={cn(
|
||||
'text-[10px] font-mono leading-none px-1 py-0.5 rounded',
|
||||
'opacity-0 group-hover:opacity-100 transition-opacity duration-200',
|
||||
isActive
|
||||
? 'text-sidebar-primary-foreground/50'
|
||||
: 'text-muted-foreground/60 bg-sidebar-accent/50',
|
||||
)}
|
||||
>
|
||||
{shortcutKey}
|
||||
</kbd>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
||||
// In collapsed mode, wrap with a tooltip so the label is discoverable
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8} className="font-medium">
|
||||
{label}
|
||||
{shortcutKey && (
|
||||
<kbd className="ml-2 text-[10px] font-mono opacity-60">{shortcutKey}</kbd>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return button
|
||||
}
|
||||
134
ui/src/components/views/AssistantView.tsx
Normal file
134
ui/src/components/views/AssistantView.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Assistant View
|
||||
*
|
||||
* Full-page project assistant chat view with conversation management.
|
||||
* Reuses the same conversation persistence and lifecycle logic as
|
||||
* AssistantPanel, but renders inline rather than as a slide-in overlay.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useAppContext } from '@/contexts/AppContext'
|
||||
import { AssistantChat } from '../AssistantChat'
|
||||
import { useConversation } from '@/hooks/useConversations'
|
||||
import { Bot } from 'lucide-react'
|
||||
import type { ChatMessage } from '@/lib/types'
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'assistant-conversation-'
|
||||
|
||||
function getStoredConversationId(projectName: string): number | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(`${STORAGE_KEY_PREFIX}${projectName}`)
|
||||
if (stored) {
|
||||
const data = JSON.parse(stored)
|
||||
return data.conversationId || null
|
||||
}
|
||||
} catch {
|
||||
// Invalid stored data, ignore
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function setStoredConversationId(projectName: string, conversationId: number | null) {
|
||||
const key = `${STORAGE_KEY_PREFIX}${projectName}`
|
||||
if (conversationId) {
|
||||
localStorage.setItem(key, JSON.stringify({ conversationId }))
|
||||
} else {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
}
|
||||
|
||||
export function AssistantView() {
|
||||
const { selectedProject } = useAppContext()
|
||||
|
||||
const projectName = selectedProject ?? ''
|
||||
|
||||
// Load the last-used conversation ID from localStorage
|
||||
const [conversationId, setConversationId] = useState<number | null>(() =>
|
||||
getStoredConversationId(projectName),
|
||||
)
|
||||
|
||||
// Fetch conversation details when we have a valid ID
|
||||
const {
|
||||
data: conversationDetail,
|
||||
isLoading: isLoadingConversation,
|
||||
error: conversationError,
|
||||
} = useConversation(projectName || null, conversationId)
|
||||
|
||||
// Clear stored conversation ID on 404 (conversation was deleted or never existed)
|
||||
useEffect(() => {
|
||||
if (conversationError && conversationId) {
|
||||
const message = conversationError.message.toLowerCase()
|
||||
if (message.includes('not found') || message.includes('404')) {
|
||||
console.warn(`Conversation ${conversationId} not found, clearing stored ID`)
|
||||
setConversationId(null)
|
||||
}
|
||||
}
|
||||
}, [conversationError, conversationId])
|
||||
|
||||
// Convert API message format to the ChatMessage format expected by AssistantChat
|
||||
const initialMessages: ChatMessage[] | undefined = conversationDetail?.messages.map(msg => ({
|
||||
id: `db-${msg.id}`,
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
timestamp: msg.timestamp ? new Date(msg.timestamp) : new Date(),
|
||||
}))
|
||||
|
||||
// Persist conversation ID changes to localStorage
|
||||
useEffect(() => {
|
||||
if (projectName) {
|
||||
setStoredConversationId(projectName, conversationId)
|
||||
}
|
||||
}, [projectName, conversationId])
|
||||
|
||||
// Reset conversation ID when the project changes
|
||||
useEffect(() => {
|
||||
setConversationId(getStoredConversationId(projectName))
|
||||
}, [projectName])
|
||||
|
||||
// Start a brand-new chat
|
||||
const handleNewChat = useCallback(() => {
|
||||
setConversationId(null)
|
||||
}, [])
|
||||
|
||||
// Select a conversation from the history list
|
||||
const handleSelectConversation = useCallback((id: number) => {
|
||||
setConversationId(id)
|
||||
}, [])
|
||||
|
||||
// WebSocket notifies us that a new conversation was created
|
||||
const handleConversationCreated = useCallback((id: number) => {
|
||||
setConversationId(id)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-border bg-primary text-primary-foreground">
|
||||
<div className="bg-card text-foreground border border-border p-1.5 rounded">
|
||||
<Bot size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold">Project Assistant</h2>
|
||||
{projectName && (
|
||||
<p className="text-xs opacity-80 font-mono">{projectName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat area */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{projectName && (
|
||||
<AssistantChat
|
||||
projectName={projectName}
|
||||
conversationId={conversationId}
|
||||
initialMessages={initialMessages}
|
||||
isLoadingConversation={isLoadingConversation}
|
||||
onNewChat={handleNewChat}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
onConversationCreated={handleConversationCreated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
ui/src/components/views/BrowsersView.tsx
Normal file
23
ui/src/components/views/BrowsersView.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Browsers View
|
||||
*
|
||||
* Full-page live browser screenshots from each agent's browser session.
|
||||
* BrowserViewPanel handles subscribe/unsubscribe internally via useEffect.
|
||||
*/
|
||||
|
||||
import { useAppContext } from '@/contexts/AppContext'
|
||||
import { BrowserViewPanel } from '../BrowserViewPanel'
|
||||
|
||||
export function BrowsersView() {
|
||||
const { wsState } = useAppContext()
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<BrowserViewPanel
|
||||
screenshots={wsState.browserScreenshots}
|
||||
onSubscribe={wsState.subscribeBrowserView}
|
||||
onUnsubscribe={wsState.unsubscribeBrowserView}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
68
ui/src/components/views/DashboardView.tsx
Normal file
68
ui/src/components/views/DashboardView.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Dashboard View
|
||||
*
|
||||
* The command center: shows project progress and agent mission control.
|
||||
* The kanban board is a separate view accessible from the sidebar.
|
||||
*/
|
||||
|
||||
import { useAppContext } from '@/contexts/AppContext'
|
||||
import { ProgressDashboard } from '../ProgressDashboard'
|
||||
import { AgentMissionControl } from '../AgentMissionControl'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
|
||||
export function DashboardView() {
|
||||
const {
|
||||
progress,
|
||||
wsState,
|
||||
features,
|
||||
} = useAppContext()
|
||||
|
||||
// Determine whether the agent is initializing features: the feature lists
|
||||
// are all empty, yet the agent is running (reading the spec and creating them).
|
||||
const isInitializingFeatures =
|
||||
features &&
|
||||
features.pending.length === 0 &&
|
||||
features.in_progress.length === 0 &&
|
||||
features.done.length === 0 &&
|
||||
(features.needs_human_input?.length || 0) === 0 &&
|
||||
wsState.agentStatus === 'running'
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto flex-1 p-6 space-y-6">
|
||||
{/* Progress overview */}
|
||||
<ProgressDashboard
|
||||
passing={progress.passing}
|
||||
total={progress.total}
|
||||
percentage={progress.percentage}
|
||||
isConnected={wsState.isConnected}
|
||||
logs={wsState.activeAgents.length === 0 ? wsState.logs : undefined}
|
||||
agentStatus={wsState.activeAgents.length === 0 ? wsState.agentStatus : undefined}
|
||||
/>
|
||||
|
||||
{/* Agent Mission Control - orchestrator status and active agents */}
|
||||
<AgentMissionControl
|
||||
agents={wsState.activeAgents}
|
||||
orchestratorStatus={wsState.orchestratorStatus}
|
||||
recentActivity={wsState.recentActivity}
|
||||
getAgentLogs={wsState.getAgentLogs}
|
||||
browserScreenshots={wsState.browserScreenshots}
|
||||
/>
|
||||
|
||||
{/* Initializing Features - shown when agent is running but no features exist yet */}
|
||||
{isInitializingFeatures && (
|
||||
<Card className="p-8 text-center">
|
||||
<CardContent className="p-0">
|
||||
<Loader2 size={32} className="animate-spin mx-auto mb-4 text-primary" />
|
||||
<h3 className="font-display font-bold text-xl mb-2">
|
||||
Initializing Features...
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
The agent is reading your spec and creating features. This may take a moment.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
ui/src/components/views/GraphView.tsx
Normal file
33
ui/src/components/views/GraphView.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Graph View
|
||||
*
|
||||
* Full-page dependency graph visualization.
|
||||
* Shows feature nodes and their dependency edges using dagre layout.
|
||||
* Falls back to a loading spinner when graph data is not yet available.
|
||||
*/
|
||||
|
||||
import { useAppContext } from '@/contexts/AppContext'
|
||||
import { DependencyGraph } from '../DependencyGraph'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
export function GraphView() {
|
||||
const { graphData, handleGraphNodeClick, wsState } = useAppContext()
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden p-6">
|
||||
{graphData ? (
|
||||
<div className="flex-1 min-h-0">
|
||||
<DependencyGraph
|
||||
graphData={graphData}
|
||||
onNodeClick={handleGraphNodeClick}
|
||||
activeAgents={wsState.activeAgents}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 size={32} className="animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
35
ui/src/components/views/KanbanView.tsx
Normal file
35
ui/src/components/views/KanbanView.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Kanban View
|
||||
*
|
||||
* Full-page kanban board for managing features across columns
|
||||
* (pending, in progress, done, needs human input).
|
||||
*/
|
||||
|
||||
import { useAppContext } from '@/contexts/AppContext'
|
||||
import { KanbanBoard } from '../KanbanBoard'
|
||||
|
||||
export function KanbanView() {
|
||||
const {
|
||||
features,
|
||||
hasSpec,
|
||||
wsState,
|
||||
setSelectedFeature,
|
||||
setShowAddFeature,
|
||||
setShowExpandProject,
|
||||
setShowSpecChat,
|
||||
} = useAppContext()
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto flex-1 p-6">
|
||||
<KanbanBoard
|
||||
features={features}
|
||||
onFeatureClick={setSelectedFeature}
|
||||
onAddFeature={() => setShowAddFeature(true)}
|
||||
onExpandProject={() => setShowExpandProject(true)}
|
||||
activeAgents={wsState.activeAgents}
|
||||
onCreateSpec={() => setShowSpecChat(true)}
|
||||
hasSpec={hasSpec}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
262
ui/src/components/views/LogsView.tsx
Normal file
262
ui/src/components/views/LogsView.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Logs View
|
||||
*
|
||||
* Full-page log viewer with sub-tabs for Agent and Dev Server logs.
|
||||
* Extracted from the log rendering logic previously in DebugLogViewer.
|
||||
* Supports auto-scroll, log-level colorization, and timestamp formatting.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useAppContext } from '@/contexts/AppContext'
|
||||
import { Trash2, Cpu, Server } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
type LogTab = 'agent' | 'devserver'
|
||||
type LogLevel = 'error' | 'warn' | 'debug' | 'info'
|
||||
|
||||
const TAB_STORAGE_KEY = 'autoforge-logs-tab'
|
||||
|
||||
/** Parse log level from line content. */
|
||||
function getLogLevel(line: string): LogLevel {
|
||||
const lower = line.toLowerCase()
|
||||
if (lower.includes('error') || lower.includes('exception') || lower.includes('traceback')) {
|
||||
return 'error'
|
||||
}
|
||||
if (lower.includes('warn') || lower.includes('warning')) {
|
||||
return 'warn'
|
||||
}
|
||||
if (lower.includes('debug')) {
|
||||
return 'debug'
|
||||
}
|
||||
return 'info'
|
||||
}
|
||||
|
||||
/** Map log level to a Tailwind text-color class. */
|
||||
function getLogColor(level: LogLevel): string {
|
||||
switch (level) {
|
||||
case 'error':
|
||||
return 'text-red-500'
|
||||
case 'warn':
|
||||
return 'text-yellow-500'
|
||||
case 'debug':
|
||||
return 'text-blue-400'
|
||||
case 'info':
|
||||
default:
|
||||
return 'text-foreground'
|
||||
}
|
||||
}
|
||||
|
||||
/** Format an ISO timestamp to HH:MM:SS for compact log display. */
|
||||
function formatTimestamp(timestamp: string): string {
|
||||
try {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function LogsView() {
|
||||
const { wsState } = useAppContext()
|
||||
|
||||
// Sub-tab state, persisted to localStorage
|
||||
const [activeLogTab, setActiveLogTab] = useState<LogTab>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(TAB_STORAGE_KEY)
|
||||
return stored === 'devserver' ? 'devserver' : 'agent'
|
||||
} catch {
|
||||
return 'agent'
|
||||
}
|
||||
})
|
||||
|
||||
// Auto-scroll tracking per tab
|
||||
const [autoScroll, setAutoScroll] = useState(true)
|
||||
const [devAutoScroll, setDevAutoScroll] = useState(true)
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const devScrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Persist the active tab to localStorage
|
||||
const handleTabChange = useCallback((tab: LogTab) => {
|
||||
setActiveLogTab(tab)
|
||||
try {
|
||||
localStorage.setItem(TAB_STORAGE_KEY, tab)
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Auto-scroll agent logs when new entries arrive
|
||||
useEffect(() => {
|
||||
if (autoScroll && scrollRef.current && activeLogTab === 'agent') {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [wsState.logs, autoScroll, activeLogTab])
|
||||
|
||||
// Auto-scroll dev server logs when new entries arrive
|
||||
useEffect(() => {
|
||||
if (devAutoScroll && devScrollRef.current && activeLogTab === 'devserver') {
|
||||
devScrollRef.current.scrollTop = devScrollRef.current.scrollHeight
|
||||
}
|
||||
}, [wsState.devLogs, devAutoScroll, activeLogTab])
|
||||
|
||||
// Detect whether the user has scrolled away from the bottom (agent tab)
|
||||
const handleAgentScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget
|
||||
const isAtBottom = el.scrollHeight - el.scrollTop <= el.clientHeight + 50
|
||||
setAutoScroll(isAtBottom)
|
||||
}
|
||||
|
||||
// Detect whether the user has scrolled away from the bottom (devserver tab)
|
||||
const handleDevScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget
|
||||
const isAtBottom = el.scrollHeight - el.scrollTop <= el.clientHeight + 50
|
||||
setDevAutoScroll(isAtBottom)
|
||||
}
|
||||
|
||||
// Clear handler dispatches to the correct log source
|
||||
const handleClear = () => {
|
||||
if (activeLogTab === 'agent') {
|
||||
wsState.clearLogs()
|
||||
} else {
|
||||
wsState.clearDevLogs()
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if auto-scroll is paused for the active tab
|
||||
const isScrollPaused = activeLogTab === 'agent' ? !autoScroll : !devAutoScroll
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{/* Tab header bar */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-card/50">
|
||||
<Button
|
||||
variant={activeLogTab === 'agent' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => handleTabChange('agent')}
|
||||
className="h-7 text-xs font-mono gap-1.5"
|
||||
>
|
||||
<Cpu size={12} />
|
||||
Agent
|
||||
{wsState.logs.length > 0 && (
|
||||
<Badge variant="default" className="h-4 px-1.5 text-[10px]">
|
||||
{wsState.logs.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeLogTab === 'devserver' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => handleTabChange('devserver')}
|
||||
className="h-7 text-xs font-mono gap-1.5"
|
||||
>
|
||||
<Server size={12} />
|
||||
Dev Server
|
||||
{wsState.devLogs.length > 0 && (
|
||||
<Badge variant="default" className="h-4 px-1.5 text-[10px]">
|
||||
{wsState.devLogs.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Auto-scroll paused indicator */}
|
||||
{isScrollPaused && (
|
||||
<Badge variant="default" className="bg-yellow-500 text-yellow-950">
|
||||
Paused
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Clear logs button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleClear}
|
||||
className="h-7 w-7"
|
||||
title="Clear logs"
|
||||
>
|
||||
<Trash2 size={14} className="text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Log content area */}
|
||||
{activeLogTab === 'agent' ? (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleAgentScroll}
|
||||
className="flex-1 overflow-y-auto p-3 font-mono text-sm"
|
||||
>
|
||||
{wsState.logs.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
No logs yet. Start the agent to see output.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{wsState.logs.map((log, index) => {
|
||||
const level = getLogLevel(log.line)
|
||||
const colorClass = getLogColor(level)
|
||||
const timestamp = formatTimestamp(log.timestamp)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${log.timestamp}-${index}`}
|
||||
className="flex gap-2 hover:bg-muted px-1 py-0.5 rounded"
|
||||
>
|
||||
<span className="text-muted-foreground select-none shrink-0">
|
||||
{timestamp}
|
||||
</span>
|
||||
<span className={`${colorClass} whitespace-pre-wrap break-all`}>
|
||||
{log.line}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={devScrollRef}
|
||||
onScroll={handleDevScroll}
|
||||
className="flex-1 overflow-y-auto p-3 font-mono text-sm"
|
||||
>
|
||||
{wsState.devLogs.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
No dev server logs yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{wsState.devLogs.map((log, index) => {
|
||||
const level = getLogLevel(log.line)
|
||||
const colorClass = getLogColor(level)
|
||||
const timestamp = formatTimestamp(log.timestamp)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${log.timestamp}-${index}`}
|
||||
className="flex gap-2 hover:bg-muted px-1 py-0.5 rounded"
|
||||
>
|
||||
<span className="text-muted-foreground select-none shrink-0">
|
||||
{timestamp}
|
||||
</span>
|
||||
<span className={`${colorClass} whitespace-pre-wrap break-all`}>
|
||||
{log.line}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
493
ui/src/components/views/SettingsView.tsx
Normal file
493
ui/src/components/views/SettingsView.tsx
Normal file
@@ -0,0 +1,493 @@
|
||||
/**
|
||||
* Settings View
|
||||
*
|
||||
* Full-page settings view with the same controls as SettingsModal,
|
||||
* rendered in a scrollable centered layout with Card-based section
|
||||
* groupings instead of inside a Dialog.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Loader2, AlertCircle, AlertTriangle, Check, Moon, Sun, Eye, EyeOff, ShieldCheck, Settings } from 'lucide-react'
|
||||
import { useSettings, useUpdateSettings, useAvailableModels, useAvailableProviders } from '@/hooks/useProjects'
|
||||
import { useTheme, THEMES } from '@/hooks/useTheme'
|
||||
import type { ProviderInfo } from '@/lib/types'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
const PROVIDER_INFO_TEXT: Record<string, string> = {
|
||||
claude: 'Default provider. Uses Claude CLI credentials. API key auth is recommended.',
|
||||
kimi: 'Get an API key at kimi.com',
|
||||
glm: 'Get an API key at open.bigmodel.cn',
|
||||
ollama: 'Run models locally. Install from ollama.com',
|
||||
custom: 'Connect to any OpenAI-compatible API endpoint.',
|
||||
}
|
||||
|
||||
export function SettingsView() {
|
||||
const { data: settings, isLoading, isError, refetch } = useSettings()
|
||||
const { data: modelsData } = useAvailableModels()
|
||||
const { data: providersData } = useAvailableProviders()
|
||||
const updateSettings = useUpdateSettings()
|
||||
const { theme, setTheme, darkMode, toggleDarkMode } = useTheme()
|
||||
|
||||
const [showAuthToken, setShowAuthToken] = useState(false)
|
||||
const [authTokenInput, setAuthTokenInput] = useState('')
|
||||
const [customModelInput, setCustomModelInput] = useState('')
|
||||
const [customBaseUrlInput, setCustomBaseUrlInput] = useState('')
|
||||
|
||||
const handleYoloToggle = () => {
|
||||
if (settings && !updateSettings.isPending) {
|
||||
updateSettings.mutate({ yolo_mode: !settings.yolo_mode })
|
||||
}
|
||||
}
|
||||
|
||||
const handleModelChange = (modelId: string) => {
|
||||
if (!updateSettings.isPending) {
|
||||
updateSettings.mutate({ api_model: modelId })
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestingRatioChange = (ratio: number) => {
|
||||
if (!updateSettings.isPending) {
|
||||
updateSettings.mutate({ testing_agent_ratio: ratio })
|
||||
}
|
||||
}
|
||||
|
||||
const handleBatchSizeChange = (size: number) => {
|
||||
if (!updateSettings.isPending) {
|
||||
updateSettings.mutate({ batch_size: size })
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestingBatchSizeChange = (size: number) => {
|
||||
if (!updateSettings.isPending) {
|
||||
updateSettings.mutate({ testing_batch_size: size })
|
||||
}
|
||||
}
|
||||
|
||||
const handleProviderChange = (providerId: string) => {
|
||||
if (!updateSettings.isPending) {
|
||||
updateSettings.mutate({ api_provider: providerId })
|
||||
setAuthTokenInput('')
|
||||
setShowAuthToken(false)
|
||||
setCustomModelInput('')
|
||||
setCustomBaseUrlInput('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveAuthToken = () => {
|
||||
if (authTokenInput.trim() && !updateSettings.isPending) {
|
||||
updateSettings.mutate({ api_auth_token: authTokenInput.trim() })
|
||||
setAuthTokenInput('')
|
||||
setShowAuthToken(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveCustomBaseUrl = () => {
|
||||
if (customBaseUrlInput.trim() && !updateSettings.isPending) {
|
||||
updateSettings.mutate({ api_base_url: customBaseUrlInput.trim() })
|
||||
setCustomBaseUrlInput('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveCustomModel = () => {
|
||||
if (customModelInput.trim() && !updateSettings.isPending) {
|
||||
updateSettings.mutate({ api_model: customModelInput.trim() })
|
||||
setCustomModelInput('')
|
||||
}
|
||||
}
|
||||
|
||||
const providers = providersData?.providers ?? []
|
||||
const models = modelsData?.models ?? []
|
||||
const isSaving = updateSettings.isPending
|
||||
const currentProvider = settings?.api_provider ?? 'claude'
|
||||
const currentProviderInfo: ProviderInfo | undefined = providers.find(p => p.id === currentProvider)
|
||||
const isAlternativeProvider = currentProvider !== 'claude'
|
||||
const showAuthField = isAlternativeProvider && currentProviderInfo?.requires_auth
|
||||
const showBaseUrlField = currentProvider === 'custom' || currentProvider === 'azure'
|
||||
const showCustomModelInput = currentProvider === 'custom' || currentProvider === 'ollama'
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Settings size={24} className="text-primary" />
|
||||
<h1 className="text-2xl font-bold">Settings</h1>
|
||||
{isSaving && <Loader2 className="animate-spin" size={16} />}
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="animate-spin" size={24} />
|
||||
<span className="ml-2">Loading settings...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{isError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Failed to load settings
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => refetch()}
|
||||
className="ml-2 p-0 h-auto"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Settings Content */}
|
||||
{settings && !isLoading && (
|
||||
<>
|
||||
{/* Appearance Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Appearance</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Theme Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label className="font-medium">Theme</Label>
|
||||
<div className="grid gap-2">
|
||||
{THEMES.map((themeOption) => (
|
||||
<button
|
||||
key={themeOption.id}
|
||||
onClick={() => setTheme(themeOption.id)}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border-2 transition-colors text-left ${
|
||||
theme === themeOption.id
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50 hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
{/* Color swatches */}
|
||||
<div className="flex gap-0.5 shrink-0">
|
||||
<div
|
||||
className="w-5 h-5 rounded-sm border border-border/50"
|
||||
style={{ backgroundColor: themeOption.previewColors.background }}
|
||||
/>
|
||||
<div
|
||||
className="w-5 h-5 rounded-sm border border-border/50"
|
||||
style={{ backgroundColor: themeOption.previewColors.primary }}
|
||||
/>
|
||||
<div
|
||||
className="w-5 h-5 rounded-sm border border-border/50"
|
||||
style={{ backgroundColor: themeOption.previewColors.accent }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Theme info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm">{themeOption.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{themeOption.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Checkmark */}
|
||||
{theme === themeOption.id && (
|
||||
<Check size={18} className="text-primary shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dark Mode Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="dark-mode" className="font-medium">
|
||||
Dark Mode
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Switch between light and dark appearance
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
id="dark-mode"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={toggleDarkMode}
|
||||
className="gap-2"
|
||||
>
|
||||
{darkMode ? <Sun size={16} /> : <Moon size={16} />}
|
||||
{darkMode ? 'Light' : 'Dark'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* API Configuration Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* API Provider Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label className="font-medium">API Provider</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{providers.map((provider) => (
|
||||
<button
|
||||
key={provider.id}
|
||||
onClick={() => handleProviderChange(provider.id)}
|
||||
disabled={isSaving}
|
||||
className={`py-1.5 px-3 text-sm font-medium rounded-md border transition-colors ${
|
||||
currentProvider === provider.id
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background text-foreground border-border hover:bg-muted'
|
||||
} ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{provider.name.split(' (')[0]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{PROVIDER_INFO_TEXT[currentProvider] ?? ''}
|
||||
</p>
|
||||
|
||||
{currentProvider === 'claude' && (
|
||||
<Alert className="border-amber-500/50 bg-amber-50 dark:bg-amber-950/20 mt-2">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600" />
|
||||
<AlertDescription className="text-xs text-amber-700 dark:text-amber-300">
|
||||
Anthropic's policy may not permit using subscription-based auth (<code className="text-xs">claude login</code>) with third-party agents. Consider using an API key provider or setting the <code className="text-xs">ANTHROPIC_API_KEY</code> environment variable to avoid potential account issues.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Auth Token Field */}
|
||||
{showAuthField && (
|
||||
<div className="space-y-2 pt-1">
|
||||
<Label className="text-sm">API Key</Label>
|
||||
{settings.api_has_auth_token && !authTokenInput && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<ShieldCheck size={14} className="text-green-500" />
|
||||
<span>Configured</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto py-0.5 px-2 text-xs"
|
||||
onClick={() => setAuthTokenInput(' ')}
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{(!settings.api_has_auth_token || authTokenInput) && (
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showAuthToken ? 'text' : 'password'}
|
||||
value={authTokenInput.trim()}
|
||||
onChange={(e) => setAuthTokenInput(e.target.value)}
|
||||
placeholder="Enter API key..."
|
||||
className="w-full py-1.5 px-3 pe-9 text-sm border rounded-md bg-background"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAuthToken(!showAuthToken)}
|
||||
className="absolute end-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showAuthToken ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveAuthToken}
|
||||
disabled={!authTokenInput.trim() || isSaving}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Base URL Field */}
|
||||
{showBaseUrlField && (
|
||||
<div className="space-y-2 pt-1">
|
||||
<Label className="text-sm">Base URL</Label>
|
||||
{settings.api_base_url && !customBaseUrlInput && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<ShieldCheck size={14} className="text-green-500" />
|
||||
<span className="truncate">{settings.api_base_url}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto py-0.5 px-2 text-xs shrink-0"
|
||||
onClick={() => setCustomBaseUrlInput(settings.api_base_url || '')}
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{(!settings.api_base_url || customBaseUrlInput) && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={customBaseUrlInput}
|
||||
onChange={(e) => setCustomBaseUrlInput(e.target.value)}
|
||||
placeholder={currentProvider === 'azure' ? 'https://your-resource.services.ai.azure.com/anthropic' : 'https://api.example.com/v1'}
|
||||
className="flex-1 py-1.5 px-3 text-sm border rounded-md bg-background"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveCustomBaseUrl}
|
||||
disabled={!customBaseUrlInput.trim() || isSaving}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Model Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label className="font-medium">Model</Label>
|
||||
{models.length > 0 && (
|
||||
<div className="flex rounded-lg border overflow-hidden">
|
||||
{models.map((model) => (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => handleModelChange(model.id)}
|
||||
disabled={isSaving}
|
||||
className={`flex-1 py-2 px-3 text-sm font-medium transition-colors ${
|
||||
(settings.api_model ?? settings.model) === model.id
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-background text-foreground hover:bg-muted'
|
||||
} ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<span className="block">{model.name}</span>
|
||||
<span className="block text-xs opacity-60">{model.id}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Custom model input for Ollama/Custom */}
|
||||
{showCustomModelInput && (
|
||||
<div className="flex gap-2 pt-1">
|
||||
<input
|
||||
type="text"
|
||||
value={customModelInput}
|
||||
onChange={(e) => setCustomModelInput(e.target.value)}
|
||||
placeholder="Custom model name..."
|
||||
className="flex-1 py-1.5 px-3 text-sm border rounded-md bg-background"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSaveCustomModel()}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveCustomModel}
|
||||
disabled={!customModelInput.trim() || isSaving}
|
||||
>
|
||||
Set
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Agent Configuration Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Agent Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* YOLO Mode Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="yolo-mode" className="font-medium">
|
||||
YOLO Mode
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Skip testing for rapid prototyping
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="yolo-mode"
|
||||
checked={settings.yolo_mode}
|
||||
onCheckedChange={handleYoloToggle}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Regression Agents */}
|
||||
<div className="space-y-2">
|
||||
<Label className="font-medium">Regression Agents</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Number of regression testing agents (0 = disabled)
|
||||
</p>
|
||||
<div className="flex rounded-lg border overflow-hidden">
|
||||
{[0, 1, 2, 3].map((ratio) => (
|
||||
<button
|
||||
key={ratio}
|
||||
onClick={() => handleTestingRatioChange(ratio)}
|
||||
disabled={isSaving}
|
||||
className={`flex-1 py-2 px-3 text-sm font-medium transition-colors ${
|
||||
settings.testing_agent_ratio === ratio
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-background text-foreground hover:bg-muted'
|
||||
} ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{ratio}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features per Coding Agent */}
|
||||
<div className="space-y-2">
|
||||
<Label className="font-medium">Features per Coding Agent</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Number of features assigned to each coding agent session
|
||||
</p>
|
||||
<Slider
|
||||
min={1}
|
||||
max={15}
|
||||
value={settings.batch_size ?? 3}
|
||||
onChange={handleBatchSizeChange}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Features per Testing Agent */}
|
||||
<div className="space-y-2">
|
||||
<Label className="font-medium">Features per Testing Agent</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Number of features assigned to each testing agent session
|
||||
</p>
|
||||
<Slider
|
||||
min={1}
|
||||
max={15}
|
||||
value={settings.testing_batch_size ?? 3}
|
||||
onChange={handleTestingBatchSizeChange}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Update Error */}
|
||||
{updateSettings.isError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
Failed to save settings. Please try again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
171
ui/src/components/views/TerminalView.tsx
Normal file
171
ui/src/components/views/TerminalView.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Terminal View
|
||||
*
|
||||
* Full-page terminal with tab management. Owns the terminal lifecycle
|
||||
* state (create, rename, close) that was previously embedded in DebugLogViewer.
|
||||
* Terminal buffers are preserved across tab switches by rendering all terminals
|
||||
* stacked and using CSS transforms to show/hide the active one.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useAppContext } from '@/contexts/AppContext'
|
||||
import { Terminal } from '../Terminal'
|
||||
import { TerminalTabs } from '../TerminalTabs'
|
||||
import { listTerminals, createTerminal, renameTerminal, deleteTerminal } from '@/lib/api'
|
||||
import type { TerminalInfo } from '@/lib/types'
|
||||
|
||||
export function TerminalView() {
|
||||
const { selectedProject } = useAppContext()
|
||||
|
||||
const projectName = selectedProject ?? ''
|
||||
|
||||
// Terminal management state
|
||||
const [terminals, setTerminals] = useState<TerminalInfo[]>([])
|
||||
const [activeTerminalId, setActiveTerminalId] = useState<string | null>(null)
|
||||
const [isLoadingTerminals, setIsLoadingTerminals] = useState(false)
|
||||
|
||||
// Fetch all terminals for the current project
|
||||
const fetchTerminals = useCallback(async () => {
|
||||
if (!projectName) return
|
||||
|
||||
setIsLoadingTerminals(true)
|
||||
try {
|
||||
const terminalList = await listTerminals(projectName)
|
||||
setTerminals(terminalList)
|
||||
|
||||
// Default to the first terminal if the active one is gone
|
||||
if (terminalList.length > 0) {
|
||||
setActiveTerminalId(prev => {
|
||||
if (!prev || !terminalList.find(t => t.id === prev)) {
|
||||
return terminalList[0].id
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch terminals:', err)
|
||||
} finally {
|
||||
setIsLoadingTerminals(false)
|
||||
}
|
||||
}, [projectName])
|
||||
|
||||
// Create a new terminal session
|
||||
const handleCreateTerminal = useCallback(async () => {
|
||||
if (!projectName) return
|
||||
|
||||
try {
|
||||
const newTerminal = await createTerminal(projectName)
|
||||
setTerminals(prev => [...prev, newTerminal])
|
||||
setActiveTerminalId(newTerminal.id)
|
||||
} catch (err) {
|
||||
console.error('Failed to create terminal:', err)
|
||||
}
|
||||
}, [projectName])
|
||||
|
||||
// Rename an existing terminal
|
||||
const handleRenameTerminal = useCallback(
|
||||
async (terminalId: string, newName: string) => {
|
||||
if (!projectName) return
|
||||
|
||||
try {
|
||||
const updated = await renameTerminal(projectName, terminalId, newName)
|
||||
setTerminals(prev =>
|
||||
prev.map(t => (t.id === terminalId ? updated : t)),
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Failed to rename terminal:', err)
|
||||
}
|
||||
},
|
||||
[projectName],
|
||||
)
|
||||
|
||||
// Close a terminal (minimum one must remain)
|
||||
const handleCloseTerminal = useCallback(
|
||||
async (terminalId: string) => {
|
||||
if (!projectName || terminals.length <= 1) return
|
||||
|
||||
try {
|
||||
await deleteTerminal(projectName, terminalId)
|
||||
setTerminals(prev => prev.filter(t => t.id !== terminalId))
|
||||
|
||||
// If the closed terminal was active, switch to the first remaining one
|
||||
if (activeTerminalId === terminalId) {
|
||||
const remaining = terminals.filter(t => t.id !== terminalId)
|
||||
if (remaining.length > 0) {
|
||||
setActiveTerminalId(remaining[0].id)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to close terminal:', err)
|
||||
}
|
||||
},
|
||||
[projectName, terminals, activeTerminalId],
|
||||
)
|
||||
|
||||
// Re-fetch terminals whenever the project changes
|
||||
useEffect(() => {
|
||||
if (projectName) {
|
||||
fetchTerminals()
|
||||
} else {
|
||||
setTerminals([])
|
||||
setActiveTerminalId(null)
|
||||
}
|
||||
}, [projectName]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{/* Tab bar */}
|
||||
{terminals.length > 0 && (
|
||||
<TerminalTabs
|
||||
terminals={terminals}
|
||||
activeTerminalId={activeTerminalId}
|
||||
onSelect={setActiveTerminalId}
|
||||
onCreate={handleCreateTerminal}
|
||||
onRename={handleRenameTerminal}
|
||||
onClose={handleCloseTerminal}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Terminal content area */}
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
{isLoadingTerminals ? (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground font-mono text-sm">
|
||||
Loading terminals...
|
||||
</div>
|
||||
) : terminals.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground font-mono text-sm">
|
||||
No terminal available
|
||||
</div>
|
||||
) : (
|
||||
/*
|
||||
* Render all terminals stacked on top of each other.
|
||||
* The active terminal is visible and receives input.
|
||||
* Inactive terminals are moved off-screen with `transform` so
|
||||
* xterm.js IntersectionObserver pauses rendering while preserving
|
||||
* the terminal buffer contents.
|
||||
*/
|
||||
terminals.map(terminal => {
|
||||
const isActive = terminal.id === activeTerminalId
|
||||
return (
|
||||
<div
|
||||
key={terminal.id}
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
zIndex: isActive ? 10 : 1,
|
||||
transform: isActive ? 'none' : 'translateX(-200%)',
|
||||
pointerEvents: isActive ? 'auto' : 'none',
|
||||
}}
|
||||
>
|
||||
<Terminal
|
||||
projectName={projectName}
|
||||
terminalId={terminal.id}
|
||||
isActive={isActive}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
578
ui/src/contexts/AppContext.tsx
Normal file
578
ui/src/contexts/AppContext.tsx
Normal file
@@ -0,0 +1,578 @@
|
||||
/**
|
||||
* AppContext - Central state provider for the AutoForge UI.
|
||||
*
|
||||
* Extracts all application state from the monolithic App.tsx into a shared
|
||||
* React context so that deeply nested components can access state without
|
||||
* prop-drilling. Provides the `useAppContext()` hook for consumption.
|
||||
*/
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { useQueryClient, useQuery, type QueryClient } from '@tanstack/react-query'
|
||||
import { useProjects, useFeatures, useAgentStatus, useSettings } from '../hooks/useProjects'
|
||||
import { useProjectWebSocket } from '../hooks/useWebSocket'
|
||||
import { useFeatureSound } from '../hooks/useFeatureSound'
|
||||
import { useCelebration } from '../hooks/useCelebration'
|
||||
import { useTheme, type ThemeId, type ThemeOption } from '../hooks/useTheme'
|
||||
import { getDependencyGraph } from '../lib/api'
|
||||
import type {
|
||||
Feature,
|
||||
FeatureListResponse,
|
||||
ProjectSummary,
|
||||
Settings,
|
||||
DependencyGraph,
|
||||
} from '../lib/types'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ViewId = 'dashboard' | 'kanban' | 'graph' | 'browsers' | 'terminal' | 'logs' | 'assistant' | 'settings'
|
||||
|
||||
type InitializerStatus = 'idle' | 'starting' | 'error'
|
||||
|
||||
/** Progress summary derived from WebSocket state and feature data. */
|
||||
interface Progress {
|
||||
passing: number
|
||||
total: number
|
||||
percentage: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The full return type of `useProjectWebSocket`. We reference it structurally
|
||||
* rather than importing a non-exported interface to keep the coupling minimal.
|
||||
*/
|
||||
type WebSocketState = ReturnType<typeof useProjectWebSocket>
|
||||
|
||||
/** Shape of the value exposed by AppContext. */
|
||||
interface AppContextValue {
|
||||
// -- Project selection --
|
||||
selectedProject: string | null
|
||||
setSelectedProject: (project: string | null) => void
|
||||
|
||||
// -- View navigation --
|
||||
activeView: ViewId
|
||||
setActiveView: (view: ViewId) => void
|
||||
|
||||
// -- Sidebar --
|
||||
sidebarCollapsed: boolean
|
||||
setSidebarCollapsed: (collapsed: boolean) => void
|
||||
toggleSidebar: () => void
|
||||
|
||||
// -- Modals --
|
||||
showAddFeature: boolean
|
||||
setShowAddFeature: (open: boolean) => void
|
||||
showExpandProject: boolean
|
||||
setShowExpandProject: (open: boolean) => void
|
||||
selectedFeature: Feature | null
|
||||
setSelectedFeature: (feature: Feature | null) => void
|
||||
showSettings: boolean
|
||||
setShowSettings: (open: boolean) => void
|
||||
showKeyboardHelp: boolean
|
||||
setShowKeyboardHelp: (open: boolean) => void
|
||||
showResetModal: boolean
|
||||
setShowResetModal: (open: boolean) => void
|
||||
showSpecChat: boolean
|
||||
setShowSpecChat: (open: boolean) => void
|
||||
isSpecCreating: boolean
|
||||
setIsSpecCreating: (creating: boolean) => void
|
||||
assistantOpen: boolean
|
||||
setAssistantOpen: (open: boolean) => void
|
||||
|
||||
// -- Setup --
|
||||
setupComplete: boolean
|
||||
setSetupComplete: (complete: boolean) => void
|
||||
|
||||
// -- Spec initializer --
|
||||
specInitializerStatus: InitializerStatus
|
||||
setSpecInitializerStatus: (status: InitializerStatus) => void
|
||||
specInitializerError: string | null
|
||||
setSpecInitializerError: (error: string | null) => void
|
||||
|
||||
// -- Queries / data --
|
||||
projects: ProjectSummary[] | undefined
|
||||
projectsLoading: boolean
|
||||
features: FeatureListResponse | undefined
|
||||
settings: Settings | undefined
|
||||
wsState: WebSocketState
|
||||
theme: ThemeId
|
||||
setTheme: (theme: ThemeId) => void
|
||||
darkMode: boolean
|
||||
toggleDarkMode: () => void
|
||||
themes: ThemeOption[]
|
||||
currentTheme: ThemeOption
|
||||
queryClient: QueryClient
|
||||
|
||||
// -- Derived state --
|
||||
selectedProjectData: ProjectSummary | undefined
|
||||
hasSpec: boolean
|
||||
progress: Progress
|
||||
|
||||
// -- Graph --
|
||||
graphData: DependencyGraph | undefined
|
||||
handleGraphNodeClick: (nodeId: number) => void
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LocalStorage helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
selectedProject: 'autoforge-selected-project',
|
||||
activeView: 'autoforge-active-view',
|
||||
sidebarCollapsed: 'autoforge-sidebar-collapsed',
|
||||
} as const
|
||||
|
||||
function readStorage<T extends string>(key: string, fallback: T): T {
|
||||
try {
|
||||
const stored = localStorage.getItem(key)
|
||||
return (stored ?? fallback) as T
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function writeStorage(key: string, value: string | null): void {
|
||||
try {
|
||||
if (value === null) {
|
||||
localStorage.removeItem(key)
|
||||
} else {
|
||||
localStorage.setItem(key, value)
|
||||
}
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AppContext = createContext<AppContextValue | null>(null)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AppProvider({ children }: { children: ReactNode }) {
|
||||
// ---- Project ----
|
||||
const [selectedProject, setSelectedProjectRaw] = useState<string | null>(() =>
|
||||
readStorage(STORAGE_KEYS.selectedProject, '') || null,
|
||||
)
|
||||
|
||||
const setSelectedProject = useCallback((project: string | null) => {
|
||||
setSelectedProjectRaw(project)
|
||||
writeStorage(STORAGE_KEYS.selectedProject, project)
|
||||
}, [])
|
||||
|
||||
// ---- View navigation ----
|
||||
const [activeView, setActiveViewRaw] = useState<ViewId>(() => {
|
||||
const stored = readStorage(STORAGE_KEYS.activeView, 'dashboard')
|
||||
const valid: ViewId[] = ['dashboard', 'kanban', 'graph', 'browsers', 'terminal', 'logs', 'assistant', 'settings']
|
||||
return valid.includes(stored as ViewId) ? (stored as ViewId) : 'dashboard'
|
||||
})
|
||||
|
||||
const setActiveView = useCallback((view: ViewId) => {
|
||||
setActiveViewRaw(view)
|
||||
writeStorage(STORAGE_KEYS.activeView, view)
|
||||
}, [])
|
||||
|
||||
// ---- Sidebar ----
|
||||
const [sidebarCollapsed, setSidebarCollapsedRaw] = useState<boolean>(() => {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEYS.sidebarCollapsed) === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const setSidebarCollapsed = useCallback((collapsed: boolean) => {
|
||||
setSidebarCollapsedRaw(collapsed)
|
||||
writeStorage(STORAGE_KEYS.sidebarCollapsed, String(collapsed))
|
||||
}, [])
|
||||
|
||||
const toggleSidebar = useCallback(() => {
|
||||
setSidebarCollapsedRaw(prev => {
|
||||
const next = !prev
|
||||
writeStorage(STORAGE_KEYS.sidebarCollapsed, String(next))
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// ---- Modals ----
|
||||
const [showAddFeature, setShowAddFeature] = useState(false)
|
||||
const [showExpandProject, setShowExpandProject] = useState(false)
|
||||
const [selectedFeature, setSelectedFeature] = useState<Feature | null>(null)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false)
|
||||
const [showResetModal, setShowResetModal] = useState(false)
|
||||
const [showSpecChat, setShowSpecChat] = useState(false)
|
||||
const [isSpecCreating, setIsSpecCreating] = useState(false)
|
||||
const [assistantOpen, setAssistantOpen] = useState(false)
|
||||
|
||||
// ---- Setup ----
|
||||
const [setupComplete, setSetupComplete] = useState(true) // optimistic default
|
||||
|
||||
// ---- Spec initializer ----
|
||||
const [specInitializerStatus, setSpecInitializerStatus] = useState<InitializerStatus>('idle')
|
||||
const [specInitializerError, setSpecInitializerError] = useState<string | null>(null)
|
||||
|
||||
// ---- Queries ----
|
||||
const queryClient = useQueryClient()
|
||||
const { data: projects, isLoading: projectsLoading } = useProjects()
|
||||
const { data: features } = useFeatures(selectedProject)
|
||||
const { data: settings } = useSettings()
|
||||
useAgentStatus(selectedProject) // keep polling for status updates
|
||||
const wsState = useProjectWebSocket(selectedProject)
|
||||
const { theme, setTheme, darkMode, toggleDarkMode, themes, currentTheme } = useTheme()
|
||||
|
||||
// ---- Derived state ----
|
||||
const selectedProjectData = projects?.find(p => p.name === selectedProject)
|
||||
const hasSpec = selectedProjectData?.has_spec ?? true
|
||||
|
||||
const progress = useMemo<Progress>(() => {
|
||||
// Prefer WebSocket progress when available; fall back to feature counts
|
||||
if (wsState.progress.total > 0) {
|
||||
return {
|
||||
passing: wsState.progress.passing,
|
||||
total: wsState.progress.total,
|
||||
percentage: wsState.progress.percentage,
|
||||
}
|
||||
}
|
||||
|
||||
const total =
|
||||
(features?.pending.length ?? 0) +
|
||||
(features?.in_progress.length ?? 0) +
|
||||
(features?.done.length ?? 0) +
|
||||
(features?.needs_human_input?.length ?? 0)
|
||||
const passing = features?.done.length ?? 0
|
||||
const percentage = total > 0 ? Math.round((passing / total) * 100 * 10) / 10 : 0
|
||||
|
||||
return { passing, total, percentage }
|
||||
}, [wsState.progress, features])
|
||||
|
||||
// ---- Graph data query ----
|
||||
const { data: graphData } = useQuery({
|
||||
queryKey: ['dependencyGraph', selectedProject],
|
||||
queryFn: () => getDependencyGraph(selectedProject!),
|
||||
enabled: !!selectedProject && activeView === 'graph',
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
// ---- Graph node click handler ----
|
||||
const handleGraphNodeClick = useCallback((nodeId: number) => {
|
||||
const allFeatures = [
|
||||
...(features?.pending ?? []),
|
||||
...(features?.in_progress ?? []),
|
||||
...(features?.done ?? []),
|
||||
...(features?.needs_human_input ?? []),
|
||||
]
|
||||
const feature = allFeatures.find(f => f.id === nodeId)
|
||||
if (feature) setSelectedFeature(feature)
|
||||
}, [features])
|
||||
|
||||
// ---- Side-effects ----
|
||||
|
||||
// Play sounds when features move between columns
|
||||
useFeatureSound(features)
|
||||
|
||||
// Celebrate when all features are complete
|
||||
useCelebration(features, selectedProject)
|
||||
|
||||
// Validate stored project exists (clear if project was deleted)
|
||||
useEffect(() => {
|
||||
if (selectedProject && projects && !projects.some(p => p.name === selectedProject)) {
|
||||
setSelectedProject(null)
|
||||
}
|
||||
}, [selectedProject, projects, setSelectedProject])
|
||||
|
||||
// ---- Keyboard shortcuts ----
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Skip if the user is typing in an input or textarea
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return
|
||||
}
|
||||
|
||||
// -- View navigation shortcuts --
|
||||
|
||||
if (e.key === 'h' || e.key === 'H') {
|
||||
e.preventDefault()
|
||||
setActiveView('dashboard')
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'k' || e.key === 'K') {
|
||||
e.preventDefault()
|
||||
setActiveView('kanban')
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'g' || e.key === 'G') {
|
||||
e.preventDefault()
|
||||
setActiveView('graph')
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'b' || e.key === 'B') {
|
||||
e.preventDefault()
|
||||
setActiveView('browsers')
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 't' || e.key === 'T') {
|
||||
e.preventDefault()
|
||||
setActiveView('terminal')
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'd' || e.key === 'D') {
|
||||
e.preventDefault()
|
||||
setActiveView('logs')
|
||||
return
|
||||
}
|
||||
|
||||
// A : Toggle assistant panel (overlay, not view navigation)
|
||||
if ((e.key === 'a' || e.key === 'A') && selectedProject && !isSpecCreating) {
|
||||
e.preventDefault()
|
||||
setAssistantOpen(prev => !prev)
|
||||
return
|
||||
}
|
||||
|
||||
// [ : Toggle sidebar
|
||||
if (e.key === '[') {
|
||||
e.preventDefault()
|
||||
toggleSidebar()
|
||||
return
|
||||
}
|
||||
|
||||
// -- Modal shortcuts --
|
||||
|
||||
// N : Add new feature (when project selected)
|
||||
if ((e.key === 'n' || e.key === 'N') && selectedProject) {
|
||||
e.preventDefault()
|
||||
setShowAddFeature(true)
|
||||
return
|
||||
}
|
||||
|
||||
// E : Expand project with AI (when project selected, has spec, and has features)
|
||||
if (
|
||||
(e.key === 'e' || e.key === 'E') &&
|
||||
selectedProject &&
|
||||
hasSpec &&
|
||||
features &&
|
||||
(features.pending.length +
|
||||
features.in_progress.length +
|
||||
features.done.length +
|
||||
(features.needs_human_input?.length || 0)) > 0
|
||||
) {
|
||||
e.preventDefault()
|
||||
setShowExpandProject(true)
|
||||
return
|
||||
}
|
||||
|
||||
// , : Navigate to settings view
|
||||
if (e.key === ',') {
|
||||
e.preventDefault()
|
||||
setActiveView('settings')
|
||||
return
|
||||
}
|
||||
|
||||
// ? : Show keyboard shortcuts help
|
||||
if (e.key === '?') {
|
||||
e.preventDefault()
|
||||
setShowKeyboardHelp(true)
|
||||
return
|
||||
}
|
||||
|
||||
// R : Open reset modal (when project selected and agent not running/draining)
|
||||
if (
|
||||
(e.key === 'r' || e.key === 'R') &&
|
||||
selectedProject &&
|
||||
!['running', 'pausing', 'paused_graceful'].includes(wsState.agentStatus)
|
||||
) {
|
||||
e.preventDefault()
|
||||
setShowResetModal(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Escape : Close modals in priority order
|
||||
if (e.key === 'Escape') {
|
||||
if (showKeyboardHelp) {
|
||||
setShowKeyboardHelp(false)
|
||||
} else if (showResetModal) {
|
||||
setShowResetModal(false)
|
||||
} else if (showExpandProject) {
|
||||
setShowExpandProject(false)
|
||||
} else if (showSettings) {
|
||||
setShowSettings(false)
|
||||
} else if (assistantOpen) {
|
||||
setAssistantOpen(false)
|
||||
} else if (showAddFeature) {
|
||||
setShowAddFeature(false)
|
||||
} else if (selectedFeature) {
|
||||
setSelectedFeature(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [
|
||||
selectedProject,
|
||||
showAddFeature,
|
||||
showExpandProject,
|
||||
selectedFeature,
|
||||
assistantOpen,
|
||||
features,
|
||||
showSettings,
|
||||
showKeyboardHelp,
|
||||
isSpecCreating,
|
||||
showResetModal,
|
||||
wsState.agentStatus,
|
||||
hasSpec,
|
||||
setActiveView,
|
||||
toggleSidebar,
|
||||
])
|
||||
|
||||
// ---- Assemble context value (memoised to avoid unnecessary re-renders) ----
|
||||
const value = useMemo<AppContextValue>(
|
||||
() => ({
|
||||
// Project
|
||||
selectedProject,
|
||||
setSelectedProject,
|
||||
|
||||
// View navigation
|
||||
activeView,
|
||||
setActiveView,
|
||||
|
||||
// Sidebar
|
||||
sidebarCollapsed,
|
||||
setSidebarCollapsed,
|
||||
toggleSidebar,
|
||||
|
||||
// Modals
|
||||
showAddFeature,
|
||||
setShowAddFeature,
|
||||
showExpandProject,
|
||||
setShowExpandProject,
|
||||
selectedFeature,
|
||||
setSelectedFeature,
|
||||
showSettings,
|
||||
setShowSettings,
|
||||
showKeyboardHelp,
|
||||
setShowKeyboardHelp,
|
||||
showResetModal,
|
||||
setShowResetModal,
|
||||
showSpecChat,
|
||||
setShowSpecChat,
|
||||
isSpecCreating,
|
||||
setIsSpecCreating,
|
||||
assistantOpen,
|
||||
setAssistantOpen,
|
||||
|
||||
// Setup
|
||||
setupComplete,
|
||||
setSetupComplete,
|
||||
|
||||
// Spec initializer
|
||||
specInitializerStatus,
|
||||
setSpecInitializerStatus,
|
||||
specInitializerError,
|
||||
setSpecInitializerError,
|
||||
|
||||
// Queries / data
|
||||
projects,
|
||||
projectsLoading,
|
||||
features,
|
||||
settings,
|
||||
wsState,
|
||||
theme,
|
||||
setTheme,
|
||||
darkMode,
|
||||
toggleDarkMode,
|
||||
themes,
|
||||
currentTheme,
|
||||
queryClient,
|
||||
|
||||
// Derived
|
||||
selectedProjectData,
|
||||
hasSpec,
|
||||
progress,
|
||||
|
||||
// Graph
|
||||
graphData,
|
||||
handleGraphNodeClick,
|
||||
}),
|
||||
[
|
||||
selectedProject,
|
||||
setSelectedProject,
|
||||
activeView,
|
||||
setActiveView,
|
||||
sidebarCollapsed,
|
||||
setSidebarCollapsed,
|
||||
toggleSidebar,
|
||||
showAddFeature,
|
||||
showExpandProject,
|
||||
selectedFeature,
|
||||
showSettings,
|
||||
showKeyboardHelp,
|
||||
showResetModal,
|
||||
showSpecChat,
|
||||
isSpecCreating,
|
||||
assistantOpen,
|
||||
setupComplete,
|
||||
specInitializerStatus,
|
||||
specInitializerError,
|
||||
projects,
|
||||
projectsLoading,
|
||||
features,
|
||||
settings,
|
||||
wsState,
|
||||
theme,
|
||||
setTheme,
|
||||
darkMode,
|
||||
toggleDarkMode,
|
||||
themes,
|
||||
currentTheme,
|
||||
queryClient,
|
||||
selectedProjectData,
|
||||
hasSpec,
|
||||
progress,
|
||||
graphData,
|
||||
handleGraphNodeClick,
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
<AppContext.Provider value={value}>
|
||||
<TooltipProvider>
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
</AppContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Consumer hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Access the global application context.
|
||||
* Must be called inside `<AppProvider>`.
|
||||
*/
|
||||
export function useAppContext(): AppContextValue {
|
||||
const ctx = useContext(AppContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useAppContext must be used within an <AppProvider>')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
Reference in New Issue
Block a user