mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-04-02 02:33:09 +00:00
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "autoforge-ai",
|
"name": "autoforge-ai",
|
||||||
"version": "0.1.20",
|
"version": "0.1.21",
|
||||||
"description": "Autonomous coding agent with web UI - build complete apps with AI",
|
"description": "Autonomous coding agent with web UI - build complete apps with AI",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
2
ui/package-lock.json
generated
2
ui/package-lock.json
generated
@@ -56,7 +56,7 @@
|
|||||||
},
|
},
|
||||||
"..": {
|
"..": {
|
||||||
"name": "autoforge-ai",
|
"name": "autoforge-ai",
|
||||||
"version": "0.1.20",
|
"version": "0.1.21",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"autoforge": "bin/autoforge.js"
|
"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 { AppProvider } from './contexts/AppContext'
|
||||||
import { useQueryClient, useQuery } from '@tanstack/react-query'
|
import { AppShell } from './components/layout/AppShell'
|
||||||
import { useProjects, useFeatures, useAgentStatus, useSettings } from './hooks/useProjects'
|
import { Modals } from './components/layout/Modals'
|
||||||
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'
|
|
||||||
|
|
||||||
function App() {
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<AppProvider>
|
||||||
{/* Header */}
|
<AppShell />
|
||||||
<header className="sticky top-0 z-50 bg-card/80 backdrop-blur-md text-foreground border-b-2 border-border">
|
<Modals />
|
||||||
<div className="max-w-7xl mx-auto px-4 py-3">
|
</AppProvider>
|
||||||
<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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,13 +16,18 @@ interface Shortcut {
|
|||||||
|
|
||||||
const shortcuts: Shortcut[] = [
|
const shortcuts: Shortcut[] = [
|
||||||
{ key: '?', description: 'Show keyboard shortcuts' },
|
{ key: '?', description: 'Show keyboard shortcuts' },
|
||||||
{ key: 'D', description: 'Toggle debug panel' },
|
{ key: 'H', description: 'Dashboard view' },
|
||||||
{ key: 'T', description: 'Toggle terminal tab' },
|
{ 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: 'N', description: 'Add new feature', context: 'with project' },
|
||||||
{ key: 'E', description: 'Expand project with AI', context: 'with spec & features' },
|
{ key: 'E', description: 'Expand project with AI', context: 'with spec & features' },
|
||||||
{ key: 'A', description: 'Toggle AI assistant', context: 'with project' },
|
{ key: ',', description: 'Settings' },
|
||||||
{ key: 'G', description: 'Toggle Kanban/Graph view', context: 'with project' },
|
{ key: 'R', description: 'Reset project', context: 'with project' },
|
||||||
{ key: ',', description: 'Open settings' },
|
|
||||||
{ key: 'Esc', description: 'Close modal/panel' },
|
{ 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