diff --git a/package.json b/package.json index de8b4fc..c0b98f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "autoforge-ai", - "version": "0.1.20", + "version": "0.1.21", "description": "Autonomous coding agent with web UI - build complete apps with AI", "license": "AGPL-3.0", "bin": { diff --git a/ui/package-lock.json b/ui/package-lock.json index 4020396..d2aa0b7 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -56,7 +56,7 @@ }, "..": { "name": "autoforge-ai", - "version": "0.1.20", + "version": "0.1.21", "license": "AGPL-3.0", "bin": { "autoforge": "bin/autoforge.js" diff --git a/ui/src/App.tsx b/ui/src/App.tsx index e06ee81..2026f36 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,630 +1,13 @@ -import { useState, useEffect, useCallback } from 'react' -import { useQueryClient, useQuery } from '@tanstack/react-query' -import { useProjects, useFeatures, useAgentStatus, useSettings } from './hooks/useProjects' -import { useProjectWebSocket } from './hooks/useWebSocket' -import { useFeatureSound } from './hooks/useFeatureSound' -import { useCelebration } from './hooks/useCelebration' -import { useTheme } from './hooks/useTheme' -import { ProjectSelector } from './components/ProjectSelector' -import { KanbanBoard } from './components/KanbanBoard' -import { AgentControl } from './components/AgentControl' -import { ProgressDashboard } from './components/ProgressDashboard' -import { SetupWizard } from './components/SetupWizard' -import { AddFeatureForm } from './components/AddFeatureForm' -import { FeatureModal } from './components/FeatureModal' -import { DebugLogViewer, type TabType } from './components/DebugLogViewer' -import { AgentMissionControl } from './components/AgentMissionControl' -import { CelebrationOverlay } from './components/CelebrationOverlay' -import { AssistantFAB } from './components/AssistantFAB' -import { AssistantPanel } from './components/AssistantPanel' -import { ExpandProjectModal } from './components/ExpandProjectModal' -import { SpecCreationChat } from './components/SpecCreationChat' -import { SettingsModal } from './components/SettingsModal' -import { DevServerControl } from './components/DevServerControl' -import { ViewToggle, type ViewMode } from './components/ViewToggle' -import { DependencyGraph } from './components/DependencyGraph' -import { KeyboardShortcutsHelp } from './components/KeyboardShortcutsHelp' -import { ThemeSelector } from './components/ThemeSelector' -import { ResetProjectModal } from './components/ResetProjectModal' -import { ProjectSetupRequired } from './components/ProjectSetupRequired' -import { getDependencyGraph, startAgent } from './lib/api' -import { Loader2, Settings, Moon, Sun, RotateCcw, BookOpen } from 'lucide-react' -import type { Feature } from './lib/types' -import { Button } from '@/components/ui/button' -import { Card, CardContent } from '@/components/ui/card' -import { Badge } from '@/components/ui/badge' -import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' - -const STORAGE_KEY = 'autoforge-selected-project' -const VIEW_MODE_KEY = 'autoforge-view-mode' - -// Bottom padding for main content when debug panel is collapsed (40px header + 8px margin) -const COLLAPSED_DEBUG_PANEL_CLEARANCE = 48 - -type InitializerStatus = 'idle' | 'starting' | 'error' +import { AppProvider } from './contexts/AppContext' +import { AppShell } from './components/layout/AppShell' +import { Modals } from './components/layout/Modals' function App() { - // Initialize selected project from localStorage - const [selectedProject, setSelectedProject] = useState(() => { - try { - return localStorage.getItem(STORAGE_KEY) - } catch { - return null - } - }) - const [showAddFeature, setShowAddFeature] = useState(false) - const [showExpandProject, setShowExpandProject] = useState(false) - const [selectedFeature, setSelectedFeature] = useState(null) - const [setupComplete, setSetupComplete] = useState(true) // Start optimistic - const [debugOpen, setDebugOpen] = useState(false) - const [debugPanelHeight, setDebugPanelHeight] = useState(288) // Default height - const [debugActiveTab, setDebugActiveTab] = useState('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('idle') - const [specInitializerError, setSpecInitializerError] = useState(null) - const [viewMode, setViewMode] = useState(() => { - 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 setSetupComplete(true)} /> - } - return ( -
- {/* Header */} -
-
- - {/* Row 1: Branding + Project + Utility icons */} -
- {/* Logo and Title */} -
- AutoForge -

- AutoForge -

-
- - {/* Project selector */} - - - {/* Spacer */} -
- - {/* Ollama Mode Indicator */} - {selectedProject && settings?.ollama_mode && ( -
- Ollama - Ollama -
- )} - - {/* GLM Mode Badge */} - {selectedProject && settings?.glm_mode && ( - - GLM - - )} - - {/* Utility icons - always visible */} - - - - - Docs - - - - - - - - - Toggle theme - -
- - {/* Row 2: Project controls - only when a project is selected */} - {selectedProject && ( -
- - - - -
- - - - - - Settings (,) - - - - - - - Reset (R) - -
- )} - -
-
- - {/* Main Content */} -
- {!selectedProject ? ( -
-

- Welcome to AutoForge -

-

- Select a project from the dropdown above or create a new one to get started. -

-
- ) : !hasSpec ? ( - setShowSpecChat(true)} - onEditManually={() => { - // Open debug panel for the user to see the project path - setDebugOpen(true) - }} - /> - ) : ( -
- {/* Progress Dashboard */} - - - {/* Agent Mission Control - shows orchestrator status and active agents in parallel mode */} - - - - {/* 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' && ( - - - -

- Initializing Features... -

-

- The agent is reading your spec and creating features. This may take a moment. -

-
-
- )} - - {/* 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 && ( -
- -
- )} - - {/* Kanban Board or Dependency Graph based on view mode */} - {viewMode === 'kanban' ? ( - setShowAddFeature(true)} - onExpandProject={() => setShowExpandProject(true)} - activeAgents={wsState.activeAgents} - onCreateSpec={() => setShowSpecChat(true)} - hasSpec={hasSpec} - /> - ) : ( - - {graphData ? ( - - ) : ( -
- -
- )} -
- )} -
- )} -
- - {/* Add Feature Modal */} - {showAddFeature && selectedProject && ( - setShowAddFeature(false)} - /> - )} - - {/* Feature Detail Modal */} - {selectedFeature && selectedProject && ( - setSelectedFeature(null)} - /> - )} - - {/* Expand Project Modal - AI-powered bulk feature creation */} - {showExpandProject && selectedProject && hasSpec && ( - 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 && ( -
- { - 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') - }} - /> -
- )} - - {/* Debug Log Viewer - fixed to bottom */} - {selectedProject && ( - 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 && ( - <> - setAssistantOpen(!assistantOpen)} - isOpen={assistantOpen} - /> - setAssistantOpen(false)} - /> - - )} - - {/* Settings Modal */} - setShowSettings(false)} /> - - {/* Keyboard Shortcuts Help */} - setShowKeyboardHelp(false)} /> - - {/* Reset Project Modal */} - {showResetModal && selectedProject && ( - 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 && ( - - )} -
+ + + + ) } diff --git a/ui/src/components/KeyboardShortcutsHelp.tsx b/ui/src/components/KeyboardShortcutsHelp.tsx index aa82a42..ce39548 100644 --- a/ui/src/components/KeyboardShortcutsHelp.tsx +++ b/ui/src/components/KeyboardShortcutsHelp.tsx @@ -16,13 +16,18 @@ interface Shortcut { const shortcuts: Shortcut[] = [ { key: '?', description: 'Show keyboard shortcuts' }, - { key: 'D', description: 'Toggle debug panel' }, - { key: 'T', description: 'Toggle terminal tab' }, + { key: 'H', description: 'Dashboard view' }, + { key: 'K', description: 'Kanban board' }, + { key: 'G', description: 'Dependency graph' }, + { key: 'B', description: 'Browser screenshots' }, + { key: 'T', description: 'Terminal' }, + { key: 'D', description: 'Logs' }, + { key: 'A', description: 'Toggle AI assistant', context: 'with project' }, + { key: '[', description: 'Toggle sidebar' }, { key: 'N', description: 'Add new feature', context: 'with project' }, { key: 'E', description: 'Expand project with AI', context: 'with spec & features' }, - { key: 'A', description: 'Toggle AI assistant', context: 'with project' }, - { key: 'G', description: 'Toggle Kanban/Graph view', context: 'with project' }, - { key: ',', description: 'Open settings' }, + { key: ',', description: 'Settings' }, + { key: 'R', description: 'Reset project', context: 'with project' }, { key: 'Esc', description: 'Close modal/panel' }, ] diff --git a/ui/src/components/layout/AppShell.tsx b/ui/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..a83bd65 --- /dev/null +++ b/ui/src/components/layout/AppShell.tsx @@ -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 ( +
+ +
+ {selectedProject && } + +
+
+ ) +} diff --git a/ui/src/components/layout/ContentArea.tsx b/ui/src/components/layout/ContentArea.tsx new file mode 100644 index 0000000..96f4f6c --- /dev/null +++ b/ui/src/components/layout/ContentArea.tsx @@ -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 setSetupComplete(true)} /> + } + + // Settings is always accessible regardless of project state + if (activeView === 'settings') { + return + } + + // Step 2: No project selected - show welcome message + if (!selectedProject) { + return ( +
+
+

Welcome to AutoForge

+

Select a project from the sidebar to get started.

+
+
+ ) + } + + // Step 3: Project exists but has no spec - prompt user to create one + if (!hasSpec) { + return ( +
+ setShowSpecChat(true)} + onEditManually={() => { + /* Could navigate to terminal view */ + }} + /> +
+ ) + } + + // Step 4: Render the active view + switch (activeView) { + case 'dashboard': + return + case 'kanban': + return + case 'graph': + return + case 'browsers': + return + case 'terminal': + return + case 'logs': + return + case 'assistant': + return + default: + return + } +} diff --git a/ui/src/components/layout/ControlBar.tsx b/ui/src/components/layout/ControlBar.tsx new file mode 100644 index 0000000..4801436 --- /dev/null +++ b/ui/src/components/layout/ControlBar.tsx @@ -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 ( +
+ + + + +
+ + {settings?.ollama_mode && ( +
+ Ollama + Ollama +
+ )} + + {settings?.glm_mode && ( + + GLM + + )} + + + + + + Reset (R) + +
+ ) +} diff --git a/ui/src/components/layout/Modals.tsx b/ui/src/components/layout/Modals.tsx new file mode 100644 index 0000000..1501c3b --- /dev/null +++ b/ui/src/components/layout/Modals.tsx @@ -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 && ( + setShowAddFeature(false)} + /> + )} + + {/* Feature Detail Modal */} + {selectedFeature && selectedProject && ( + setSelectedFeature(null)} + /> + )} + + {/* Expand Project Modal */} + {showExpandProject && selectedProject && hasSpec && ( + setShowExpandProject(false)} + onFeaturesAdded={() => { + queryClient.invalidateQueries({ queryKey: ['features', selectedProject] }) + }} + /> + )} + + {/* Spec Creation Chat - full screen overlay */} + {showSpecChat && selectedProject && ( +
+ { + 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') + }} + /> +
+ )} + + {/* Assistant FAB and Panel - hide when expand modal or spec creation is open */} + {selectedProject && !showExpandProject && !isSpecCreating && !showSpecChat && ( + <> + setAssistantOpen(!assistantOpen)} + isOpen={assistantOpen} + /> + setAssistantOpen(false)} + /> + + )} + + {/* Keyboard Shortcuts Help */} + setShowKeyboardHelp(false)} /> + + {/* Reset Project Modal */} + {showResetModal && selectedProject && ( + setShowResetModal(false)} + onResetComplete={(wasFullReset) => { + if (wasFullReset) { + setShowSpecChat(true) + } + }} + /> + )} + + {/* Celebration Overlay - shows when a feature is completed by an agent */} + {wsState.celebration && ( + + )} + + ) +} diff --git a/ui/src/components/layout/Sidebar.tsx b/ui/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..05f09f3 --- /dev/null +++ b/ui/src/components/layout/Sidebar.tsx @@ -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 ( + + ) +} + +/* ───────────────────────────────────────────────────────────────────────────── + 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 ( + + + + + + {label} + {shortcut && {shortcut}} + + + ) + } + + return ( + + ) +} diff --git a/ui/src/components/layout/SidebarItem.tsx b/ui/src/components/layout/SidebarItem.tsx new file mode 100644 index 0000000..48af72a --- /dev/null +++ b/ui/src/components/layout/SidebarItem.tsx @@ -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 = ( + + ) + + // In collapsed mode, wrap with a tooltip so the label is discoverable + if (isCollapsed) { + return ( + + {button} + + {label} + {shortcutKey && ( + {shortcutKey} + )} + + + ) + } + + return button +} diff --git a/ui/src/components/views/AssistantView.tsx b/ui/src/components/views/AssistantView.tsx new file mode 100644 index 0000000..694609e --- /dev/null +++ b/ui/src/components/views/AssistantView.tsx @@ -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(() => + 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 ( +
+ {/* Header */} +
+
+ +
+
+

Project Assistant

+ {projectName && ( +

{projectName}

+ )} +
+
+ + {/* Chat area */} +
+ {projectName && ( + + )} +
+
+ ) +} diff --git a/ui/src/components/views/BrowsersView.tsx b/ui/src/components/views/BrowsersView.tsx new file mode 100644 index 0000000..33ca07d --- /dev/null +++ b/ui/src/components/views/BrowsersView.tsx @@ -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 ( +
+ +
+ ) +} diff --git a/ui/src/components/views/DashboardView.tsx b/ui/src/components/views/DashboardView.tsx new file mode 100644 index 0000000..492ef7b --- /dev/null +++ b/ui/src/components/views/DashboardView.tsx @@ -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 ( +
+ {/* Progress overview */} + + + {/* Agent Mission Control - orchestrator status and active agents */} + + + {/* Initializing Features - shown when agent is running but no features exist yet */} + {isInitializingFeatures && ( + + + +

+ Initializing Features... +

+

+ The agent is reading your spec and creating features. This may take a moment. +

+
+
+ )} +
+ ) +} diff --git a/ui/src/components/views/GraphView.tsx b/ui/src/components/views/GraphView.tsx new file mode 100644 index 0000000..17972d7 --- /dev/null +++ b/ui/src/components/views/GraphView.tsx @@ -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 ( +
+ {graphData ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+ ) +} diff --git a/ui/src/components/views/KanbanView.tsx b/ui/src/components/views/KanbanView.tsx new file mode 100644 index 0000000..01a2af0 --- /dev/null +++ b/ui/src/components/views/KanbanView.tsx @@ -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 ( +
+ setShowAddFeature(true)} + onExpandProject={() => setShowExpandProject(true)} + activeAgents={wsState.activeAgents} + onCreateSpec={() => setShowSpecChat(true)} + hasSpec={hasSpec} + /> +
+ ) +} diff --git a/ui/src/components/views/LogsView.tsx b/ui/src/components/views/LogsView.tsx new file mode 100644 index 0000000..2649ef4 --- /dev/null +++ b/ui/src/components/views/LogsView.tsx @@ -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(() => { + 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(null) + const devScrollRef = useRef(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) => { + 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) => { + 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 ( +
+ {/* Tab header bar */} +
+ + + + {/* Spacer */} +
+ + {/* Auto-scroll paused indicator */} + {isScrollPaused && ( + + Paused + + )} + + {/* Clear logs button */} + +
+ + {/* Log content area */} + {activeLogTab === 'agent' ? ( +
+ {wsState.logs.length === 0 ? ( +
+ No logs yet. Start the agent to see output. +
+ ) : ( +
+ {wsState.logs.map((log, index) => { + const level = getLogLevel(log.line) + const colorClass = getLogColor(level) + const timestamp = formatTimestamp(log.timestamp) + + return ( +
+ + {timestamp} + + + {log.line} + +
+ ) + })} +
+ )} +
+ ) : ( +
+ {wsState.devLogs.length === 0 ? ( +
+ No dev server logs yet. +
+ ) : ( +
+ {wsState.devLogs.map((log, index) => { + const level = getLogLevel(log.line) + const colorClass = getLogColor(level) + const timestamp = formatTimestamp(log.timestamp) + + return ( +
+ + {timestamp} + + + {log.line} + +
+ ) + })} +
+ )} +
+ )} +
+ ) +} diff --git a/ui/src/components/views/SettingsView.tsx b/ui/src/components/views/SettingsView.tsx new file mode 100644 index 0000000..bf8e6e5 --- /dev/null +++ b/ui/src/components/views/SettingsView.tsx @@ -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 = { + 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 ( +
+
+ {/* Header */} +
+ +

Settings

+ {isSaving && } +
+ + {/* Loading State */} + {isLoading && ( +
+ + Loading settings... +
+ )} + + {/* Error State */} + {isError && ( + + + + Failed to load settings + + + + )} + + {/* Settings Content */} + {settings && !isLoading && ( + <> + {/* Appearance Card */} + + + Appearance + + + {/* Theme Selection */} +
+ +
+ {THEMES.map((themeOption) => ( + + ))} +
+
+ + {/* Dark Mode Toggle */} +
+
+ +

+ Switch between light and dark appearance +

+
+ +
+
+
+ + {/* API Configuration Card */} + + + API Configuration + + + {/* API Provider Selection */} +
+ +
+ {providers.map((provider) => ( + + ))} +
+

+ {PROVIDER_INFO_TEXT[currentProvider] ?? ''} +

+ + {currentProvider === 'claude' && ( + + + + Anthropic's policy may not permit using subscription-based auth (claude login) with third-party agents. Consider using an API key provider or setting the ANTHROPIC_API_KEY environment variable to avoid potential account issues. + + + )} + + {/* Auth Token Field */} + {showAuthField && ( +
+ + {settings.api_has_auth_token && !authTokenInput && ( +
+ + Configured + +
+ )} + {(!settings.api_has_auth_token || authTokenInput) && ( +
+
+ 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" + /> + +
+ +
+ )} +
+ )} + + {/* Custom Base URL Field */} + {showBaseUrlField && ( +
+ + {settings.api_base_url && !customBaseUrlInput && ( +
+ + {settings.api_base_url} + +
+ )} + {(!settings.api_base_url || customBaseUrlInput) && ( +
+ 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" + /> + +
+ )} +
+ )} +
+ + {/* Model Selection */} +
+ + {models.length > 0 && ( +
+ {models.map((model) => ( + + ))} +
+ )} + {/* Custom model input for Ollama/Custom */} + {showCustomModelInput && ( +
+ 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()} + /> + +
+ )} +
+
+
+ + {/* Agent Configuration Card */} + + + Agent Configuration + + + {/* YOLO Mode Toggle */} +
+
+ +

+ Skip testing for rapid prototyping +

+
+ +
+ + {/* Regression Agents */} +
+ +

+ Number of regression testing agents (0 = disabled) +

+
+ {[0, 1, 2, 3].map((ratio) => ( + + ))} +
+
+ + {/* Features per Coding Agent */} +
+ +

+ Number of features assigned to each coding agent session +

+ +
+ + {/* Features per Testing Agent */} +
+ +

+ Number of features assigned to each testing agent session +

+ +
+
+
+ + {/* Update Error */} + {updateSettings.isError && ( + + + Failed to save settings. Please try again. + + + )} + + )} +
+
+ ) +} diff --git a/ui/src/components/views/TerminalView.tsx b/ui/src/components/views/TerminalView.tsx new file mode 100644 index 0000000..160768d --- /dev/null +++ b/ui/src/components/views/TerminalView.tsx @@ -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([]) + const [activeTerminalId, setActiveTerminalId] = useState(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 ( +
+ {/* Tab bar */} + {terminals.length > 0 && ( + + )} + + {/* Terminal content area */} +
+ {isLoadingTerminals ? ( +
+ Loading terminals... +
+ ) : terminals.length === 0 ? ( +
+ No terminal available +
+ ) : ( + /* + * 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 ( +
+ +
+ ) + }) + )} +
+
+ ) +} diff --git a/ui/src/contexts/AppContext.tsx b/ui/src/contexts/AppContext.tsx new file mode 100644 index 0000000..98435aa --- /dev/null +++ b/ui/src/contexts/AppContext.tsx @@ -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 + +/** 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(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(null) + +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + +export function AppProvider({ children }: { children: ReactNode }) { + // ---- Project ---- + const [selectedProject, setSelectedProjectRaw] = useState(() => + readStorage(STORAGE_KEYS.selectedProject, '') || null, + ) + + const setSelectedProject = useCallback((project: string | null) => { + setSelectedProjectRaw(project) + writeStorage(STORAGE_KEYS.selectedProject, project) + }, []) + + // ---- View navigation ---- + const [activeView, setActiveViewRaw] = useState(() => { + 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(() => { + 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(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('idle') + const [specInitializerError, setSpecInitializerError] = useState(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(() => { + // 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( + () => ({ + // 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 ( + + + {children} + + + ) +} + +// --------------------------------------------------------------------------- +// Consumer hook +// --------------------------------------------------------------------------- + +/** + * Access the global application context. + * Must be called inside ``. + */ +export function useAppContext(): AppContextValue { + const ctx = useContext(AppContext) + if (!ctx) { + throw new Error('useAppContext must be used within an ') + } + return ctx +}