Merge pull request #231 from AutoForgeAI/ui-redesign

UI redesign
This commit is contained in:
Leon van Zyl
2026-04-01 14:10:00 +02:00
committed by GitHub
19 changed files with 2582 additions and 631 deletions

View File

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

@@ -56,7 +56,7 @@
},
"..": {
"name": "autoforge-ai",
"version": "0.1.20",
"version": "0.1.21",
"license": "AGPL-3.0",
"bin": {
"autoforge": "bin/autoforge.js"

View File

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

View File

@@ -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' },
]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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