feat: add concurrent agents with dependency system and delightful UI

Major feature implementation for parallel agent execution with dependency-aware
scheduling and an engaging multi-agent UI experience.

Backend Changes:
- Add parallel_orchestrator.py for concurrent feature processing
- Add api/dependency_resolver.py with cycle detection (Kahn's algorithm + DFS)
- Add atomic feature_claim_next() with retry limit and exponential backoff
- Fix circular dependency check arguments in 4 locations
- Add AgentTracker class for parsing agent output and emitting updates
- Add browser isolation with --isolated flag for Playwright MCP
- Extend WebSocket protocol with agent_update messages and log attribution
- Add WSAgentUpdateMessage schema with agent states and mascot names
- Fix WSProgressMessage to include in_progress field

New UI Components:
- AgentMissionControl: Dashboard showing active agents with collapsible activity
- AgentCard: Individual agent status with avatar and thought bubble
- AgentAvatar: SVG mascots (Spark, Fizz, Octo, Hoot, Buzz) with animations
- ActivityFeed: Recent activity stream with stable keys (no flickering)
- CelebrationOverlay: Confetti animation with click/Escape dismiss
- DependencyGraph: Interactive node graph visualization with dagre layout
- DependencyBadge: Visual indicator for feature dependencies
- ViewToggle: Switch between Kanban and Graph views
- KeyboardShortcutsHelp: Help overlay accessible via ? key

UI/UX Improvements:
- Celebration queue system to handle rapid success messages
- Accessibility attributes on AgentAvatar (role, aria-label, aria-live)
- Collapsible Recent Activity section with persisted preference
- Agent count display in header
- Keyboard shortcut G to toggle Kanban/Graph view
- Real-time thought bubbles and state animations

Bug Fixes:
- Fix circular dependency validation (swapped source/target arguments)
- Add MAX_CLAIM_RETRIES=10 to prevent stack overflow under contention
- Fix THOUGHT_PATTERNS to match actual [Tool: name] format
- Fix ActivityFeed key prop to prevent re-renders on new items
- Add featureId/agentIndex to log messages for proper attribution

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Auto
2026-01-17 12:59:42 +02:00
parent 91cc00a9d0
commit 85f6940a54
39 changed files with 4532 additions and 157 deletions

264
ui/package-lock.json generated
View File

@@ -15,8 +15,10 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"@xyflow/react": "^12.10.0",
"canvas-confetti": "^1.9.4",
"clsx": "^2.1.1",
"dagre": "^0.8.5",
"lucide-react": "^0.460.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
@@ -25,6 +27,7 @@
"@eslint/js": "^9.13.0",
"@tailwindcss/vite": "^4.0.0-beta.4",
"@types/canvas-confetti": "^1.9.0",
"@types/dagre": "^0.7.53",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
@@ -2299,6 +2302,62 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/dagre": {
"version": "0.7.53",
"resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.53.tgz",
"integrity": "sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2652,6 +2711,38 @@
"addons/*"
]
},
"node_modules/@xyflow/react": {
"version": "12.10.0",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.0.tgz",
"integrity": "sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==",
"license": "MIT",
"dependencies": {
"@xyflow/system": "0.0.74",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@xyflow/system": {
"version": "0.0.74",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.74.tgz",
"integrity": "sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==",
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-selection": "^3.0.10",
"@types/d3-transition": "^3.0.8",
"@types/d3-zoom": "^3.0.8",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -2847,6 +2938,12 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -2912,6 +3009,121 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/dagre": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",
"integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==",
"license": "MIT",
"dependencies": {
"graphlib": "^2.1.8",
"lodash": "^4.17.15"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -3370,6 +3582,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/graphlib": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
"integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.15"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -3824,6 +4045,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -4503,6 +4730,15 @@
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
@@ -4608,6 +4844,34 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
}
}
}

View File

@@ -17,8 +17,10 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"@xyflow/react": "^12.10.0",
"canvas-confetti": "^1.9.4",
"clsx": "^2.1.1",
"dagre": "^0.8.5",
"lucide-react": "^0.460.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
@@ -27,6 +29,7 @@
"@eslint/js": "^9.13.0",
"@tailwindcss/vite": "^4.0.0-beta.4",
"@types/canvas-confetti": "^1.9.0",
"@types/dagre": "^0.7.53",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
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'
@@ -13,16 +13,23 @@ import { AddFeatureForm } from './components/AddFeatureForm'
import { FeatureModal } from './components/FeatureModal'
import { DebugLogViewer, type TabType } from './components/DebugLogViewer'
import { AgentThought } from './components/AgentThought'
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 { 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 { getDependencyGraph } from './lib/api'
import { Loader2, Settings, Moon, Sun } from 'lucide-react'
import type { Feature } from './lib/types'
const STORAGE_KEY = 'autocoder-selected-project'
const DARK_MODE_KEY = 'autocoder-dark-mode'
const VIEW_MODE_KEY = 'autocoder-view-mode'
function App() {
// Initialize selected project from localStorage
@@ -42,6 +49,7 @@ function App() {
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 [darkMode, setDarkMode] = useState(() => {
try {
@@ -50,6 +58,14 @@ function App() {
return false
}
})
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()
@@ -58,6 +74,14 @@ function App() {
useAgentStatus(selectedProject) // Keep polling for status updates
const wsState = useProjectWebSocket(selectedProject)
// 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
})
// Apply dark mode class to document
useEffect(() => {
if (darkMode) {
@@ -72,6 +96,15 @@ function App() {
}
}, [darkMode])
// 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)
@@ -154,9 +187,23 @@ function App() {
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)
}
// Escape : Close modals
if (e.key === 'Escape') {
if (showExpandProject) {
if (showKeyboardHelp) {
setShowKeyboardHelp(false)
} else if (showExpandProject) {
setShowExpandProject(false)
} else if (showSettings) {
setShowSettings(false)
@@ -174,7 +221,7 @@ function App() {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, debugActiveTab, assistantOpen, features, showSettings, isSpecCreating])
}, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, debugActiveTab, assistantOpen, features, showSettings, showKeyboardHelp, isSpecCreating, viewMode])
// Combine WebSocket progress with feature data
const progress = wsState.progress.total > 0 ? wsState.progress : {
@@ -284,11 +331,21 @@ function App() {
isConnected={wsState.isConnected}
/>
{/* Agent Thought - shows latest agent narrative */}
<AgentThought
logs={wsState.logs}
agentStatus={wsState.agentStatus}
/>
{/* Agent Mission Control - shows active agents in parallel mode */}
{wsState.activeAgents.length > 0 && (
<AgentMissionControl
agents={wsState.activeAgents}
recentActivity={wsState.recentActivity}
/>
)}
{/* Agent Thought - shows latest agent narrative (single agent mode) */}
{wsState.activeAgents.length === 0 && (
<AgentThought
logs={wsState.logs}
agentStatus={wsState.agentStatus}
/>
)}
{/* Initializing Features State - show when agent is running but no features yet */}
{features &&
@@ -307,13 +364,45 @@ function App() {
</div>
)}
{/* Kanban Board */}
<KanbanBoard
features={features}
onFeatureClick={setSelectedFeature}
onAddFeature={() => setShowAddFeature(true)}
onExpandProject={() => setShowExpandProject(true)}
/>
{/* View Toggle - only show when there are features */}
{features && (features.pending.length + features.in_progress.length + features.done.length) > 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}
/>
) : (
<div className="neo-card overflow-hidden" style={{ height: '600px' }}>
{graphData ? (
<DependencyGraph
graphData={graphData}
onNodeClick={(nodeId) => {
// Find the feature and open the modal
const allFeatures = [
...(features?.pending ?? []),
...(features?.in_progress ?? []),
...(features?.done ?? [])
]
const feature = allFeatures.find(f => f.id === nodeId)
if (feature) setSelectedFeature(feature)
}}
/>
) : (
<div className="h-full flex items-center justify-center">
<Loader2 size={32} className="animate-spin text-neo-progress" />
</div>
)}
</div>
)}
</div>
)}
</main>
@@ -383,6 +472,20 @@ function App() {
{showSettings && (
<SettingsModal onClose={() => setShowSettings(false)} />
)}
{/* Keyboard Shortcuts Help */}
{showKeyboardHelp && (
<KeyboardShortcutsHelp onClose={() => setShowKeyboardHelp(false)} />
)}
{/* 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>
)
}

View File

@@ -0,0 +1,93 @@
import { Activity } from 'lucide-react'
import { AgentAvatar } from './AgentAvatar'
import type { AgentMascot } from '../lib/types'
interface ActivityItem {
agentName: string
thought: string
timestamp: string
featureId: number
}
interface ActivityFeedProps {
activities: ActivityItem[]
maxItems?: number
showHeader?: boolean
}
function formatTimestamp(timestamp: string): string {
const date = new Date(timestamp)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffSec = Math.floor(diffMs / 1000)
if (diffSec < 5) return 'just now'
if (diffSec < 60) return `${diffSec}s ago`
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
export function ActivityFeed({ activities, maxItems = 5, showHeader = true }: ActivityFeedProps) {
const displayedActivities = activities.slice(0, maxItems)
if (displayedActivities.length === 0) {
return null
}
return (
<div>
{showHeader && (
<div className="flex items-center gap-2 mb-2">
<Activity size={14} className="text-neo-text-secondary" />
<span className="text-xs font-bold text-neo-text-secondary uppercase tracking-wide">
Recent Activity
</span>
</div>
)}
<div className="space-y-2">
{displayedActivities.map((activity) => (
<div
key={`${activity.featureId}-${activity.timestamp}-${activity.thought.slice(0, 20)}`}
className="flex items-start gap-2 py-1.5 px-2 rounded bg-[var(--color-neo-bg)] border border-neo-border/20"
>
<AgentAvatar
name={activity.agentName as AgentMascot}
state="working"
size="sm"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-bold" style={{
color: getMascotColor(activity.agentName as AgentMascot)
}}>
{activity.agentName}
</span>
<span className="text-[10px] text-neo-text-muted">
#{activity.featureId}
</span>
<span className="text-[10px] text-neo-text-muted ml-auto">
{formatTimestamp(activity.timestamp)}
</span>
</div>
<p className="text-xs text-neo-text-secondary truncate" title={activity.thought}>
{activity.thought}
</p>
</div>
</div>
))}
</div>
</div>
)
}
function getMascotColor(name: AgentMascot): string {
const colors: Record<AgentMascot, string> = {
Spark: '#3B82F6',
Fizz: '#F97316',
Octo: '#8B5CF6',
Hoot: '#22C55E',
Buzz: '#EAB308',
}
return colors[name] || '#6B7280'
}

View File

@@ -0,0 +1,261 @@
import { type AgentMascot, type AgentState } from '../lib/types'
interface AgentAvatarProps {
name: AgentMascot
state: AgentState
size?: 'sm' | 'md' | 'lg'
showName?: boolean
}
const AVATAR_COLORS: Record<AgentMascot, { primary: string; secondary: string; accent: string }> = {
Spark: { primary: '#3B82F6', secondary: '#60A5FA', accent: '#DBEAFE' }, // Blue robot
Fizz: { primary: '#F97316', secondary: '#FB923C', accent: '#FFEDD5' }, // Orange fox
Octo: { primary: '#8B5CF6', secondary: '#A78BFA', accent: '#EDE9FE' }, // Purple octopus
Hoot: { primary: '#22C55E', secondary: '#4ADE80', accent: '#DCFCE7' }, // Green owl
Buzz: { primary: '#EAB308', secondary: '#FACC15', accent: '#FEF9C3' }, // Yellow bee
}
const SIZES = {
sm: { svg: 32, font: 'text-xs' },
md: { svg: 48, font: 'text-sm' },
lg: { svg: 64, font: 'text-base' },
}
// SVG mascot definitions - simple cute characters
function SparkSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Spark; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
{/* Robot body */}
<rect x="16" y="20" width="32" height="28" rx="4" fill={colors.primary} />
{/* Robot head */}
<rect x="12" y="8" width="40" height="24" rx="4" fill={colors.secondary} />
{/* Antenna */}
<circle cx="32" cy="4" r="4" fill={colors.primary} className="animate-pulse" />
<rect x="30" y="4" width="4" height="8" fill={colors.primary} />
{/* Eyes */}
<circle cx="24" cy="18" r="4" fill="white" />
<circle cx="40" cy="18" r="4" fill="white" />
<circle cx="25" cy="18" r="2" fill={colors.primary} />
<circle cx="41" cy="18" r="2" fill={colors.primary} />
{/* Mouth */}
<rect x="26" y="24" width="12" height="2" rx="1" fill="white" />
{/* Arms */}
<rect x="6" y="24" width="8" height="4" rx="2" fill={colors.primary} />
<rect x="50" y="24" width="8" height="4" rx="2" fill={colors.primary} />
</svg>
)
}
function FizzSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Fizz; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
{/* Ears */}
<polygon points="12,12 20,28 4,28" fill={colors.primary} />
<polygon points="52,12 60,28 44,28" fill={colors.primary} />
<polygon points="14,14 18,26 8,26" fill={colors.accent} />
<polygon points="50,14 56,26 44,26" fill={colors.accent} />
{/* Head */}
<ellipse cx="32" cy="36" rx="24" ry="22" fill={colors.primary} />
{/* Face */}
<ellipse cx="32" cy="40" rx="18" ry="14" fill={colors.accent} />
{/* Eyes */}
<ellipse cx="24" cy="32" rx="4" ry="5" fill="white" />
<ellipse cx="40" cy="32" rx="4" ry="5" fill="white" />
<circle cx="25" cy="33" r="2" fill="#1a1a1a" />
<circle cx="41" cy="33" r="2" fill="#1a1a1a" />
{/* Nose */}
<ellipse cx="32" cy="42" rx="4" ry="3" fill={colors.primary} />
{/* Whiskers */}
<line x1="8" y1="38" x2="18" y2="40" stroke={colors.primary} strokeWidth="2" />
<line x1="8" y1="44" x2="18" y2="44" stroke={colors.primary} strokeWidth="2" />
<line x1="46" y1="40" x2="56" y2="38" stroke={colors.primary} strokeWidth="2" />
<line x1="46" y1="44" x2="56" y2="44" stroke={colors.primary} strokeWidth="2" />
</svg>
)
}
function OctoSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Octo; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
{/* Tentacles */}
<path d="M12,48 Q8,56 12,60 Q16,64 20,58" fill={colors.secondary} />
<path d="M22,50 Q20,58 24,62" fill={colors.secondary} />
<path d="M32,52 Q32,60 36,62" fill={colors.secondary} />
<path d="M42,50 Q44,58 40,62" fill={colors.secondary} />
<path d="M52,48 Q56,56 52,60 Q48,64 44,58" fill={colors.secondary} />
{/* Head */}
<ellipse cx="32" cy="32" rx="22" ry="24" fill={colors.primary} />
{/* Eyes */}
<ellipse cx="24" cy="28" rx="6" ry="8" fill="white" />
<ellipse cx="40" cy="28" rx="6" ry="8" fill="white" />
<ellipse cx="25" cy="30" rx="3" ry="4" fill={colors.primary} />
<ellipse cx="41" cy="30" rx="3" ry="4" fill={colors.primary} />
{/* Smile */}
<path d="M24,42 Q32,48 40,42" stroke={colors.accent} strokeWidth="2" fill="none" strokeLinecap="round" />
</svg>
)
}
function HootSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Hoot; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
{/* Ear tufts */}
<polygon points="14,8 22,24 6,20" fill={colors.primary} />
<polygon points="50,8 58,20 42,24" fill={colors.primary} />
{/* Body */}
<ellipse cx="32" cy="40" rx="20" ry="18" fill={colors.primary} />
{/* Head */}
<circle cx="32" cy="28" r="20" fill={colors.secondary} />
{/* Eye circles */}
<circle cx="24" cy="26" r="10" fill={colors.accent} />
<circle cx="40" cy="26" r="10" fill={colors.accent} />
{/* Eyes */}
<circle cx="24" cy="26" r="6" fill="white" />
<circle cx="40" cy="26" r="6" fill="white" />
<circle cx="25" cy="27" r="3" fill="#1a1a1a" />
<circle cx="41" cy="27" r="3" fill="#1a1a1a" />
{/* Beak */}
<polygon points="32,32 28,40 36,40" fill="#F97316" />
{/* Belly */}
<ellipse cx="32" cy="46" rx="10" ry="8" fill={colors.accent} />
</svg>
)
}
function BuzzSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Buzz; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
{/* Wings */}
<ellipse cx="14" cy="32" rx="10" ry="14" fill={colors.accent} opacity="0.8" className="animate-pulse" />
<ellipse cx="50" cy="32" rx="10" ry="14" fill={colors.accent} opacity="0.8" className="animate-pulse" />
{/* Body stripes */}
<ellipse cx="32" cy="36" rx="14" ry="20" fill={colors.primary} />
<ellipse cx="32" cy="30" rx="12" ry="6" fill="#1a1a1a" />
<ellipse cx="32" cy="44" rx="12" ry="6" fill="#1a1a1a" />
{/* Head */}
<circle cx="32" cy="16" r="12" fill={colors.primary} />
{/* Antennae */}
<line x1="26" y1="8" x2="22" y2="2" stroke="#1a1a1a" strokeWidth="2" />
<line x1="38" y1="8" x2="42" y2="2" stroke="#1a1a1a" strokeWidth="2" />
<circle cx="22" cy="2" r="2" fill="#1a1a1a" />
<circle cx="42" cy="2" r="2" fill="#1a1a1a" />
{/* Eyes */}
<circle cx="28" cy="14" r="4" fill="white" />
<circle cx="36" cy="14" r="4" fill="white" />
<circle cx="29" cy="15" r="2" fill="#1a1a1a" />
<circle cx="37" cy="15" r="2" fill="#1a1a1a" />
{/* Smile */}
<path d="M28,20 Q32,24 36,20" stroke="#1a1a1a" strokeWidth="1.5" fill="none" strokeLinecap="round" />
</svg>
)
}
const MASCOT_SVGS: Record<AgentMascot, typeof SparkSVG> = {
Spark: SparkSVG,
Fizz: FizzSVG,
Octo: OctoSVG,
Hoot: HootSVG,
Buzz: BuzzSVG,
}
// Animation classes based on state
function getStateAnimation(state: AgentState): string {
switch (state) {
case 'idle':
return 'animate-bounce-gentle'
case 'thinking':
return 'animate-thinking'
case 'working':
return 'animate-working'
case 'testing':
return 'animate-testing'
case 'success':
return 'animate-celebrate'
case 'error':
case 'struggling':
return 'animate-shake-gentle'
default:
return ''
}
}
// Glow effect based on state
function getStateGlow(state: AgentState): string {
switch (state) {
case 'working':
return 'shadow-[0_0_12px_rgba(0,180,216,0.5)]'
case 'thinking':
return 'shadow-[0_0_8px_rgba(255,214,10,0.4)]'
case 'success':
return 'shadow-[0_0_16px_rgba(112,224,0,0.6)]'
case 'error':
case 'struggling':
return 'shadow-[0_0_12px_rgba(255,84,0,0.5)]'
default:
return ''
}
}
// Get human-readable state description for accessibility
function getStateDescription(state: AgentState): string {
switch (state) {
case 'idle':
return 'waiting'
case 'thinking':
return 'analyzing'
case 'working':
return 'coding'
case 'testing':
return 'running tests'
case 'success':
return 'completed successfully'
case 'error':
return 'encountered an error'
case 'struggling':
return 'having difficulty'
default:
return state
}
}
export function AgentAvatar({ name, state, size = 'md', showName = false }: AgentAvatarProps) {
const colors = AVATAR_COLORS[name]
const { svg: svgSize, font } = SIZES[size]
const SvgComponent = MASCOT_SVGS[name]
const stateDesc = getStateDescription(state)
const ariaLabel = `Agent ${name} is ${stateDesc}`
return (
<div
className="flex flex-col items-center gap-1"
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
<div
className={`
rounded-full p-1 transition-all duration-300
${getStateAnimation(state)}
${getStateGlow(state)}
`}
style={{ backgroundColor: colors.accent }}
title={ariaLabel}
role="img"
aria-hidden="true"
>
<SvgComponent colors={colors} size={svgSize} />
</div>
{showName && (
<span className={`${font} font-bold text-neo-text`} style={{ color: colors.primary }}>
{name}
</span>
)}
</div>
)
}
// Get mascot name by index (cycles through available mascots)
export function getMascotName(index: number): AgentMascot {
const mascots: AgentMascot[] = ['Spark', 'Fizz', 'Octo', 'Hoot', 'Buzz']
return mascots[index % mascots.length]
}

View File

@@ -0,0 +1,99 @@
import { MessageCircle } from 'lucide-react'
import { AgentAvatar } from './AgentAvatar'
import type { ActiveAgent } from '../lib/types'
interface AgentCardProps {
agent: ActiveAgent
}
// Get a friendly state description
function getStateText(state: ActiveAgent['state']): string {
switch (state) {
case 'idle':
return 'Waiting...'
case 'thinking':
return 'Thinking...'
case 'working':
return 'Coding...'
case 'testing':
return 'Testing...'
case 'success':
return 'Done!'
case 'error':
return 'Hit an issue'
case 'struggling':
return 'Retrying...'
default:
return 'Working...'
}
}
// Get state color
function getStateColor(state: ActiveAgent['state']): string {
switch (state) {
case 'success':
return 'text-neo-done'
case 'error':
case 'struggling':
return 'text-neo-danger'
case 'working':
case 'testing':
return 'text-neo-progress'
case 'thinking':
return 'text-neo-pending'
default:
return 'text-neo-text-secondary'
}
}
export function AgentCard({ agent }: AgentCardProps) {
const isActive = ['thinking', 'working', 'testing'].includes(agent.state)
return (
<div
className={`
neo-card p-3 min-w-[180px] max-w-[220px]
${isActive ? 'animate-pulse-neo' : ''}
transition-all duration-300
`}
>
{/* Header with avatar and name */}
<div className="flex items-center gap-2 mb-2">
<AgentAvatar name={agent.agentName} state={agent.state} size="sm" />
<div className="flex-1 min-w-0">
<div className="font-display font-bold text-sm truncate">
{agent.agentName}
</div>
<div className={`text-xs ${getStateColor(agent.state)}`}>
{getStateText(agent.state)}
</div>
</div>
</div>
{/* Feature info */}
<div className="mb-2">
<div className="text-xs text-neo-text-secondary mb-0.5">
Feature #{agent.featureId}
</div>
<div className="text-sm font-medium truncate" title={agent.featureName}>
{agent.featureName}
</div>
</div>
{/* Thought bubble */}
{agent.thought && (
<div className="relative mt-2 pt-2 border-t-2 border-neo-border/30">
<div className="flex items-start gap-1.5">
<MessageCircle size={14} className="text-neo-progress shrink-0 mt-0.5" />
<p
className="text-xs text-neo-text-secondary line-clamp-2 italic"
title={agent.thought}
>
{agent.thought}
</p>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,4 +1,5 @@
import { Play, Square, Loader2 } from 'lucide-react'
import { useState } from 'react'
import { Play, Square, Loader2, GitBranch } from 'lucide-react'
import {
useStartAgent,
useStopAgent,
@@ -15,19 +16,57 @@ export function AgentControl({ projectName, status }: AgentControlProps) {
const { data: settings } = useSettings()
const yoloMode = settings?.yolo_mode ?? false
// Concurrency: 1 = single agent, 2-5 = parallel
const [concurrency, setConcurrency] = useState(3)
const startAgent = useStartAgent(projectName)
const stopAgent = useStopAgent(projectName)
const isLoading = startAgent.isPending || stopAgent.isPending
const isRunning = status === 'running' || status === 'paused'
const isParallel = concurrency > 1
const handleStart = () => startAgent.mutate(yoloMode)
const handleStart = () => startAgent.mutate({
yoloMode,
parallelMode: isParallel,
maxConcurrency: isParallel ? concurrency : undefined,
})
const handleStop = () => stopAgent.mutate()
// Simplified: either show Start (when stopped/crashed) or Stop (when running/paused)
const isStopped = status === 'stopped' || status === 'crashed'
return (
<div className="flex items-center">
<div className="flex items-center gap-2">
{/* Concurrency slider - always visible when stopped */}
{isStopped && (
<div className="flex items-center gap-2">
<GitBranch size={16} className={isParallel ? 'text-[var(--color-neo-primary)]' : 'text-gray-400'} />
<input
type="range"
min={1}
max={5}
value={concurrency}
onChange={(e) => setConcurrency(Number(e.target.value))}
disabled={isLoading}
className="w-16 h-2 accent-[var(--color-neo-primary)] cursor-pointer"
title={`${concurrency} concurrent agent${concurrency > 1 ? 's' : ''}`}
aria-label="Set number of concurrent agents"
/>
<span className="text-xs font-bold min-w-[1.5rem] text-center">
{concurrency}x
</span>
</div>
)}
{/* Show concurrency indicator when running with multiple agents */}
{isRunning && isParallel && (
<div className="flex items-center gap-1 text-xs text-[var(--color-neo-primary)] font-bold">
<GitBranch size={14} />
<span>{concurrency}x</span>
</div>
)}
{isStopped ? (
<button
onClick={handleStart}

View File

@@ -0,0 +1,121 @@
import { Rocket, ChevronDown, ChevronUp, Activity } from 'lucide-react'
import { useState } from 'react'
import { AgentCard } from './AgentCard'
import { ActivityFeed } from './ActivityFeed'
import type { ActiveAgent } from '../lib/types'
const ACTIVITY_COLLAPSED_KEY = 'autocoder-activity-collapsed'
interface AgentMissionControlProps {
agents: ActiveAgent[]
recentActivity: Array<{
agentName: string
thought: string
timestamp: string
featureId: number
}>
isExpanded?: boolean
}
export function AgentMissionControl({
agents,
recentActivity,
isExpanded: defaultExpanded = true,
}: AgentMissionControlProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded)
const [activityCollapsed, setActivityCollapsed] = useState(() => {
try {
return localStorage.getItem(ACTIVITY_COLLAPSED_KEY) === 'true'
} catch {
return false
}
})
const toggleActivityCollapsed = () => {
const newValue = !activityCollapsed
setActivityCollapsed(newValue)
try {
localStorage.setItem(ACTIVITY_COLLAPSED_KEY, String(newValue))
} catch {
// localStorage not available
}
}
// Don't render if no agents
if (agents.length === 0) {
return null
}
return (
<div className="neo-card mb-6 overflow-hidden">
{/* Header */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-4 py-3 bg-[var(--color-neo-progress)] hover:brightness-105 transition-all"
>
<div className="flex items-center gap-2">
<Rocket size={20} className="text-neo-text-on-bright" />
<span className="font-display font-bold text-neo-text-on-bright uppercase tracking-wide">
Mission Control
</span>
<span className="neo-badge neo-badge-sm bg-white text-neo-text ml-2">
{agents.length} {agents.length === 1 ? 'agent' : 'agents'} active
</span>
</div>
{isExpanded ? (
<ChevronUp size={20} className="text-neo-text-on-bright" />
) : (
<ChevronDown size={20} className="text-neo-text-on-bright" />
)}
</button>
{/* Content */}
<div
className={`
transition-all duration-300 ease-out overflow-hidden
${isExpanded ? 'max-h-[500px] opacity-100' : 'max-h-0 opacity-0'}
`}
>
<div className="p-4">
{/* Agent Cards Row */}
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-thin">
{agents.map((agent) => (
<AgentCard key={`agent-${agent.agentIndex}`} agent={agent} />
))}
</div>
{/* Collapsible Activity Feed */}
{recentActivity.length > 0 && (
<div className="mt-4 pt-4 border-t-2 border-neo-border/30">
<button
onClick={toggleActivityCollapsed}
className="flex items-center gap-2 mb-2 hover:opacity-80 transition-opacity"
>
<Activity size={14} className="text-neo-text-secondary" />
<span className="text-xs font-bold text-neo-text-secondary uppercase tracking-wide">
Recent Activity
</span>
<span className="text-xs text-neo-muted">
({recentActivity.length})
</span>
{activityCollapsed ? (
<ChevronDown size={14} className="text-neo-text-secondary" />
) : (
<ChevronUp size={14} className="text-neo-text-secondary" />
)}
</button>
<div
className={`
transition-all duration-200 ease-out overflow-hidden
${activityCollapsed ? 'max-h-0 opacity-0' : 'max-h-[300px] opacity-100'}
`}
>
<ActivityFeed activities={recentActivity} maxItems={5} showHeader={false} />
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -25,14 +25,14 @@ function isAgentThought(line: string): boolean {
// Skip JSON and very short lines
if (/^[[{]/.test(trimmed)) return false
if (trimmed.length < 15) return false
if (trimmed.length < 10) return false
// Skip lines that are just paths or technical output
if (/^[A-Za-z]:\\/.test(trimmed)) return false
if (/^\/[a-z]/.test(trimmed)) return false
// Keep narrative text (starts with capital, looks like a sentence)
return /^[A-Z]/.test(trimmed) && trimmed.length > 20
// Keep narrative text (looks like a sentence, relaxed filter)
return trimmed.length > 10
}
/**

View File

@@ -0,0 +1,120 @@
import { useCallback, useEffect, useState } from 'react'
import { Sparkles, PartyPopper } from 'lucide-react'
import { AgentAvatar } from './AgentAvatar'
import type { AgentMascot } from '../lib/types'
interface CelebrationOverlayProps {
agentName: AgentMascot
featureName: string
onComplete?: () => void
}
// Generate random confetti particles
function generateConfetti(count: number) {
return Array.from({ length: count }, (_, i) => ({
id: i,
x: Math.random() * 100,
delay: Math.random() * 0.5,
duration: 1 + Math.random() * 1,
color: ['#ff006e', '#ffd60a', '#70e000', '#00b4d8', '#8338ec'][Math.floor(Math.random() * 5)],
rotation: Math.random() * 360,
}))
}
export function CelebrationOverlay({ agentName, featureName, onComplete }: CelebrationOverlayProps) {
const [isVisible, setIsVisible] = useState(true)
const [confetti] = useState(() => generateConfetti(30))
const dismiss = useCallback(() => {
setIsVisible(false)
setTimeout(() => onComplete?.(), 300) // Wait for fade animation
}, [onComplete])
useEffect(() => {
// Auto-dismiss after 3 seconds
const timer = setTimeout(dismiss, 3000)
// Escape key to dismiss early
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
dismiss()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => {
clearTimeout(timer)
window.removeEventListener('keydown', handleKeyDown)
}
}, [dismiss])
if (!isVisible) {
return null
}
return (
<div
className={`
fixed inset-0 z-50 flex items-center justify-center
pointer-events-none
transition-opacity duration-300
${isVisible ? 'opacity-100' : 'opacity-0'}
`}
>
{/* Confetti particles */}
<div className="absolute inset-0 overflow-hidden">
{confetti.map((particle) => (
<div
key={particle.id}
className="absolute w-3 h-3 animate-confetti"
style={{
left: `${particle.x}%`,
top: '-20px',
backgroundColor: particle.color,
animationDelay: `${particle.delay}s`,
animationDuration: `${particle.duration}s`,
transform: `rotate(${particle.rotation}deg)`,
}}
/>
))}
</div>
{/* Celebration card - click to dismiss */}
<button
type="button"
onClick={dismiss}
className="neo-card p-6 bg-[var(--color-neo-done)] animate-bounce-in pointer-events-auto cursor-pointer hover:scale-105 transition-transform focus:outline-none focus:ring-2 focus:ring-neo-accent"
>
<div className="flex flex-col items-center gap-4">
{/* Icons */}
<div className="flex items-center gap-2">
<Sparkles size={24} className="text-neo-pending animate-pulse" />
<PartyPopper size={28} className="text-neo-accent" />
<Sparkles size={24} className="text-neo-pending animate-pulse" />
</div>
{/* Avatar celebrating */}
<AgentAvatar name={agentName} state="success" size="lg" />
{/* Message */}
<div className="text-center">
<h3 className="font-display text-lg font-bold text-neo-text-on-bright mb-1">
Feature Complete!
</h3>
<p className="text-sm text-neo-text-on-bright/80 max-w-[200px] truncate">
{featureName}
</p>
<p className="text-xs text-neo-text-on-bright/60 mt-2">
Great job, {agentName}!
</p>
</div>
{/* Dismiss hint */}
<p className="text-xs text-neo-text-on-bright/40 mt-1">
Click or press Esc to dismiss
</p>
</div>
</button>
</div>
)
}

View File

@@ -0,0 +1,121 @@
import { AlertTriangle, GitBranch, Check } from 'lucide-react'
import type { Feature } from '../lib/types'
interface DependencyBadgeProps {
feature: Feature
allFeatures?: Feature[]
compact?: boolean
}
/**
* Badge component showing dependency status for a feature.
* Shows:
* - Blocked status with count of blocking dependencies
* - Dependency count for features with satisfied dependencies
* - Nothing if feature has no dependencies
*/
export function DependencyBadge({ feature, allFeatures = [], compact = false }: DependencyBadgeProps) {
const dependencies = feature.dependencies || []
if (dependencies.length === 0) {
return null
}
// Use API-computed blocked status if available, otherwise compute locally
const isBlocked = feature.blocked ??
(feature.blocking_dependencies && feature.blocking_dependencies.length > 0) ??
false
const blockingCount = feature.blocking_dependencies?.length ?? 0
// Compute satisfied count from allFeatures if available
let satisfiedCount = dependencies.length - blockingCount
if (allFeatures.length > 0 && !feature.blocking_dependencies) {
const passingIds = new Set(allFeatures.filter(f => f.passes).map(f => f.id))
satisfiedCount = dependencies.filter(d => passingIds.has(d)).length
}
if (compact) {
// Compact view for card displays
return (
<div
className={`
inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-mono
${isBlocked
? 'bg-neo-danger/20 text-neo-danger'
: 'bg-neo-neutral-200 text-neo-text-secondary'
}
`}
title={isBlocked
? `Blocked by ${blockingCount} ${blockingCount === 1 ? 'dependency' : 'dependencies'}`
: `${satisfiedCount}/${dependencies.length} dependencies satisfied`
}
>
{isBlocked ? (
<>
<AlertTriangle size={12} />
<span>{blockingCount}</span>
</>
) : (
<>
<GitBranch size={12} />
<span>{satisfiedCount}/{dependencies.length}</span>
</>
)}
</div>
)
}
// Full view with more details
return (
<div className="flex items-center gap-2">
{isBlocked ? (
<div className="flex items-center gap-1.5 text-sm text-neo-danger">
<AlertTriangle size={14} />
<span className="font-medium">
Blocked by {blockingCount} {blockingCount === 1 ? 'dependency' : 'dependencies'}
</span>
</div>
) : (
<div className="flex items-center gap-1.5 text-sm text-neo-text-secondary">
<Check size={14} className="text-neo-done" />
<span>
All {dependencies.length} {dependencies.length === 1 ? 'dependency' : 'dependencies'} satisfied
</span>
</div>
)}
</div>
)
}
/**
* Small inline indicator for dependency status
*/
export function DependencyIndicator({ feature }: { feature: Feature }) {
const dependencies = feature.dependencies || []
const isBlocked = feature.blocked || (feature.blocking_dependencies && feature.blocking_dependencies.length > 0)
if (dependencies.length === 0) {
return null
}
if (isBlocked) {
return (
<span
className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-neo-danger/20 text-neo-danger"
title={`Blocked by ${feature.blocking_dependencies?.length || 0} dependencies`}
>
<AlertTriangle size={12} />
</span>
)
}
return (
<span
className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-neo-neutral-200 text-neo-text-secondary"
title={`${dependencies.length} dependencies (all satisfied)`}
>
<GitBranch size={12} />
</span>
)
}

View File

@@ -0,0 +1,289 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
ReactFlow,
Background,
Controls,
MiniMap,
useNodesState,
useEdgesState,
Node,
Edge,
Position,
MarkerType,
ConnectionMode,
Handle,
} from '@xyflow/react'
import dagre from 'dagre'
import { CheckCircle2, Circle, Loader2, AlertTriangle } from 'lucide-react'
import type { DependencyGraph as DependencyGraphData, GraphNode } from '../lib/types'
import '@xyflow/react/dist/style.css'
// Node dimensions
const NODE_WIDTH = 220
const NODE_HEIGHT = 80
interface DependencyGraphProps {
graphData: DependencyGraphData
onNodeClick?: (nodeId: number) => void
}
// Custom node component
function FeatureNode({ data }: { data: GraphNode & { onClick?: () => void } }) {
const statusColors = {
pending: 'bg-neo-pending border-neo-border',
in_progress: 'bg-neo-progress border-neo-border',
done: 'bg-neo-done border-neo-border',
blocked: 'bg-neo-danger/20 border-neo-danger',
}
const StatusIcon = () => {
switch (data.status) {
case 'done':
return <CheckCircle2 size={16} className="text-neo-text-on-bright" />
case 'in_progress':
return <Loader2 size={16} className="text-neo-text-on-bright animate-spin" />
case 'blocked':
return <AlertTriangle size={16} className="text-neo-danger" />
default:
return <Circle size={16} className="text-neo-text-on-bright" />
}
}
return (
<>
<Handle type="target" position={Position.Left} className="!bg-neo-border !w-2 !h-2" />
<div
className={`
px-4 py-3 rounded-lg border-2 cursor-pointer
transition-all hover:shadow-neo-md
${statusColors[data.status]}
`}
onClick={data.onClick}
style={{ minWidth: NODE_WIDTH - 20, maxWidth: NODE_WIDTH }}
>
<div className="flex items-center gap-2 mb-1">
<StatusIcon />
<span className="text-xs font-mono text-neo-text-on-bright/70">
#{data.priority}
</span>
</div>
<div className="font-bold text-sm text-neo-text-on-bright truncate" title={data.name}>
{data.name}
</div>
<div className="text-xs text-neo-text-on-bright/70 truncate" title={data.category}>
{data.category}
</div>
</div>
<Handle type="source" position={Position.Right} className="!bg-neo-border !w-2 !h-2" />
</>
)
}
const nodeTypes = {
feature: FeatureNode,
}
// Layout nodes using dagre
function getLayoutedElements(
nodes: Node[],
edges: Edge[],
direction: 'TB' | 'LR' = 'LR'
): { nodes: Node[]; edges: Edge[] } {
const dagreGraph = new dagre.graphlib.Graph()
dagreGraph.setDefaultEdgeLabel(() => ({}))
const isHorizontal = direction === 'LR'
dagreGraph.setGraph({
rankdir: direction,
nodesep: 50,
ranksep: 100,
marginx: 50,
marginy: 50,
})
nodes.forEach((node) => {
dagreGraph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT })
})
edges.forEach((edge) => {
dagreGraph.setEdge(edge.source, edge.target)
})
dagre.layout(dagreGraph)
const layoutedNodes = nodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id)
return {
...node,
position: {
x: nodeWithPosition.x - NODE_WIDTH / 2,
y: nodeWithPosition.y - NODE_HEIGHT / 2,
},
sourcePosition: isHorizontal ? Position.Right : Position.Bottom,
targetPosition: isHorizontal ? Position.Left : Position.Top,
}
})
return { nodes: layoutedNodes, edges }
}
export function DependencyGraph({ graphData, onNodeClick }: DependencyGraphProps) {
const [direction, setDirection] = useState<'TB' | 'LR'>('LR')
// Convert graph data to React Flow format
const initialElements = useMemo(() => {
const nodes: Node[] = graphData.nodes.map((node) => ({
id: String(node.id),
type: 'feature',
position: { x: 0, y: 0 },
data: {
...node,
onClick: () => onNodeClick?.(node.id),
},
}))
const edges: Edge[] = graphData.edges.map((edge, index) => ({
id: `e${edge.source}-${edge.target}-${index}`,
source: String(edge.source),
target: String(edge.target),
type: 'smoothstep',
animated: false,
style: { stroke: 'var(--color-neo-border)', strokeWidth: 2 },
markerEnd: {
type: MarkerType.ArrowClosed,
color: 'var(--color-neo-border)',
},
}))
return getLayoutedElements(nodes, edges, direction)
}, [graphData, direction, onNodeClick])
const [nodes, setNodes, onNodesChange] = useNodesState(initialElements.nodes)
const [edges, setEdges, onEdgesChange] = useEdgesState(initialElements.edges)
// Update layout when data or direction changes
useEffect(() => {
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(
initialElements.nodes,
initialElements.edges,
direction
)
setNodes(layoutedNodes)
setEdges(layoutedEdges)
}, [graphData, direction, setNodes, setEdges, initialElements])
const onLayout = useCallback(
(newDirection: 'TB' | 'LR') => {
setDirection(newDirection)
},
[]
)
// Color nodes for minimap
const nodeColor = useCallback((node: Node) => {
const status = (node.data as unknown as GraphNode).status
switch (status) {
case 'done':
return 'var(--color-neo-done)'
case 'in_progress':
return 'var(--color-neo-progress)'
case 'blocked':
return 'var(--color-neo-danger)'
default:
return 'var(--color-neo-pending)'
}
}, [])
if (graphData.nodes.length === 0) {
return (
<div className="h-full w-full flex items-center justify-center bg-neo-neutral-100">
<div className="text-center">
<div className="text-neo-text-secondary mb-2">No features to display</div>
<div className="text-sm text-neo-text-muted">
Create features to see the dependency graph
</div>
</div>
</div>
)
}
return (
<div className="h-full w-full relative bg-neo-neutral-50">
{/* Layout toggle */}
<div className="absolute top-4 left-4 z-10 flex gap-2">
<button
onClick={() => onLayout('LR')}
className={`
px-3 py-1.5 text-sm font-medium rounded border-2 border-neo-border transition-all
${direction === 'LR'
? 'bg-neo-accent text-white shadow-neo-sm'
: 'bg-white text-neo-text hover:bg-neo-neutral-100'
}
`}
>
Horizontal
</button>
<button
onClick={() => onLayout('TB')}
className={`
px-3 py-1.5 text-sm font-medium rounded border-2 border-neo-border transition-all
${direction === 'TB'
? 'bg-neo-accent text-white shadow-neo-sm'
: 'bg-white text-neo-text hover:bg-neo-neutral-100'
}
`}
>
Vertical
</button>
</div>
{/* Legend */}
<div className="absolute top-4 right-4 z-10 bg-white border-2 border-neo-border rounded-lg p-3 shadow-neo-sm">
<div className="text-xs font-bold mb-2">Status</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2 text-xs">
<div className="w-3 h-3 rounded bg-neo-pending border border-neo-border" />
<span>Pending</span>
</div>
<div className="flex items-center gap-2 text-xs">
<div className="w-3 h-3 rounded bg-neo-progress border border-neo-border" />
<span>In Progress</span>
</div>
<div className="flex items-center gap-2 text-xs">
<div className="w-3 h-3 rounded bg-neo-done border border-neo-border" />
<span>Done</span>
</div>
<div className="flex items-center gap-2 text-xs">
<div className="w-3 h-3 rounded bg-neo-danger/20 border border-neo-danger" />
<span>Blocked</span>
</div>
</div>
</div>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
connectionMode={ConnectionMode.Loose}
fitView
fitViewOptions={{ padding: 0.2 }}
attributionPosition="bottom-left"
minZoom={0.1}
maxZoom={2}
>
<Background color="var(--color-neo-neutral-300)" gap={20} size={1} />
<Controls
className="!bg-white !border-2 !border-neo-border !rounded-lg !shadow-neo-sm"
showInteractive={false}
/>
<MiniMap
nodeColor={nodeColor}
className="!bg-white !border-2 !border-neo-border !rounded-lg !shadow-neo-sm"
maskColor="rgba(0, 0, 0, 0.1)"
/>
</ReactFlow>
</div>
)
}

View File

@@ -1,10 +1,14 @@
import { CheckCircle2, Circle, Loader2 } from 'lucide-react'
import type { Feature } from '../lib/types'
import { CheckCircle2, Circle, Loader2, MessageCircle } from 'lucide-react'
import type { Feature, ActiveAgent } from '../lib/types'
import { DependencyBadge } from './DependencyBadge'
import { AgentAvatar } from './AgentAvatar'
interface FeatureCardProps {
feature: Feature
onClick: () => void
isInProgress?: boolean
allFeatures?: Feature[]
activeAgent?: ActiveAgent // Agent working on this feature
}
// Generate consistent color for category using CSS variable references
@@ -28,26 +32,33 @@ function getCategoryColor(category: string): string {
return colors[Math.abs(hash) % colors.length]
}
export function FeatureCard({ feature, onClick, isInProgress }: FeatureCardProps) {
export function FeatureCard({ feature, onClick, isInProgress, allFeatures = [], activeAgent }: FeatureCardProps) {
const categoryColor = getCategoryColor(feature.category)
const isBlocked = feature.blocked || (feature.blocking_dependencies && feature.blocking_dependencies.length > 0)
const hasActiveAgent = !!activeAgent
return (
<button
onClick={onClick}
className={`
w-full text-left neo-card p-4 cursor-pointer
w-full text-left neo-card p-4 cursor-pointer relative
${isInProgress ? 'animate-pulse-neo' : ''}
${feature.passes ? 'border-neo-done' : ''}
${isBlocked && !feature.passes ? 'border-neo-danger opacity-80' : ''}
${hasActiveAgent ? 'ring-2 ring-neo-progress ring-offset-2' : ''}
`}
>
{/* Header */}
<div className="flex items-start justify-between gap-2 mb-2">
<span
className="neo-badge"
style={{ backgroundColor: categoryColor, color: 'var(--color-neo-text-on-bright)' }}
>
{feature.category}
</span>
<div className="flex items-center gap-2">
<span
className="neo-badge"
style={{ backgroundColor: categoryColor, color: 'var(--color-neo-text-on-bright)' }}
>
{feature.category}
</span>
<DependencyBadge feature={feature} allFeatures={allFeatures} compact />
</div>
<span className="font-mono text-sm text-neo-text-secondary">
#{feature.priority}
</span>
@@ -63,6 +74,26 @@ export function FeatureCard({ feature, onClick, isInProgress }: FeatureCardProps
{feature.description}
</p>
{/* Agent working on this feature */}
{activeAgent && (
<div className="flex items-center gap-2 mb-3 py-2 px-2 rounded bg-[var(--color-neo-progress)]/10 border border-[var(--color-neo-progress)]/30">
<AgentAvatar name={activeAgent.agentName} state={activeAgent.state} size="sm" />
<div className="flex-1 min-w-0">
<div className="text-xs font-bold text-neo-progress">
{activeAgent.agentName} is working on this!
</div>
{activeAgent.thought && (
<div className="flex items-center gap-1 mt-0.5">
<MessageCircle size={10} className="text-neo-text-secondary shrink-0" />
<p className="text-[10px] text-neo-text-secondary truncate italic">
{activeAgent.thought}
</p>
</div>
)}
</div>
</div>
)}
{/* Status */}
<div className="flex items-center gap-2 text-sm">
{isInProgress ? (
@@ -75,6 +106,11 @@ export function FeatureCard({ feature, onClick, isInProgress }: FeatureCardProps
<CheckCircle2 size={16} className="text-neo-done" />
<span className="text-neo-done font-bold">Complete</span>
</>
) : isBlocked ? (
<>
<Circle size={16} className="text-neo-danger" />
<span className="text-neo-danger">Blocked</span>
</>
) : (
<>
<Circle size={16} className="text-neo-text-secondary" />

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'
import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle, Pencil } from 'lucide-react'
import { useSkipFeature, useDeleteFeature } from '../hooks/useProjects'
import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle, Pencil, Link2, AlertTriangle } from 'lucide-react'
import { useSkipFeature, useDeleteFeature, useFeatures } from '../hooks/useProjects'
import { EditFeatureForm } from './EditFeatureForm'
import type { Feature } from '../lib/types'
@@ -37,6 +37,25 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
const skipFeature = useSkipFeature(projectName)
const deleteFeature = useDeleteFeature(projectName)
const { data: allFeatures } = useFeatures(projectName)
// Build a map of feature ID to feature for looking up dependency names
const featureMap = new Map<number, Feature>()
if (allFeatures) {
;[...allFeatures.pending, ...allFeatures.in_progress, ...allFeatures.done].forEach(f => {
featureMap.set(f.id, f)
})
}
// Get dependency features
const dependencies = (feature.dependencies || [])
.map(id => featureMap.get(id))
.filter((f): f is Feature => f !== undefined)
// Get blocking dependencies (unmet dependencies)
const blockingDeps = (feature.blocking_dependencies || [])
.map(id => featureMap.get(id))
.filter((f): f is Feature => f !== undefined)
const handleSkip = async () => {
setError(null)
@@ -145,6 +164,57 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
</p>
</div>
{/* Blocked By Warning */}
{blockingDeps.length > 0 && (
<div className="p-4 bg-[var(--color-neo-warning-bg)] border-3 border-[var(--color-neo-warning-border)]">
<h3 className="font-display font-bold mb-2 uppercase text-sm flex items-center gap-2 text-[var(--color-neo-warning-text)]">
<AlertTriangle size={16} />
Blocked By
</h3>
<p className="text-sm text-[var(--color-neo-warning-text)] mb-2">
This feature cannot start until the following dependencies are complete:
</p>
<ul className="space-y-1">
{blockingDeps.map(dep => (
<li
key={dep.id}
className="flex items-center gap-2 text-sm"
>
<Circle size={14} className="text-[var(--color-neo-warning-text)]" />
<span className="font-mono text-xs text-[var(--color-neo-warning-text)]">#{dep.id}</span>
<span className="text-[var(--color-neo-warning-text)]">{dep.name}</span>
</li>
))}
</ul>
</div>
)}
{/* Dependencies */}
{dependencies.length > 0 && (
<div>
<h3 className="font-display font-bold mb-2 uppercase text-sm flex items-center gap-2">
<Link2 size={16} />
Depends On
</h3>
<ul className="space-y-1">
{dependencies.map(dep => (
<li
key={dep.id}
className="flex items-center gap-2 p-2 bg-[var(--color-neo-bg)] border-2 border-[var(--color-neo-border)]"
>
{dep.passes ? (
<CheckCircle2 size={16} className="text-[var(--color-neo-done)]" />
) : (
<Circle size={16} className="text-[var(--color-neo-text-secondary)]" />
)}
<span className="font-mono text-xs text-[var(--color-neo-text-secondary)]">#{dep.id}</span>
<span className={dep.passes ? 'text-[var(--color-neo-done)]' : ''}>{dep.name}</span>
</li>
))}
</ul>
</div>
)}
{/* Steps */}
{feature.steps.length > 0 && (
<div>

View File

@@ -1,16 +1,22 @@
import { KanbanColumn } from './KanbanColumn'
import type { Feature, FeatureListResponse } from '../lib/types'
import type { Feature, FeatureListResponse, ActiveAgent } from '../lib/types'
interface KanbanBoardProps {
features: FeatureListResponse | undefined
onFeatureClick: (feature: Feature) => void
onAddFeature?: () => void
onExpandProject?: () => void
activeAgents?: ActiveAgent[]
}
export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandProject }: KanbanBoardProps) {
export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandProject, activeAgents = [] }: KanbanBoardProps) {
const hasFeatures = features && (features.pending.length + features.in_progress.length + features.done.length) > 0
// Combine all features for dependency status calculation
const allFeatures = features
? [...features.pending, ...features.in_progress, ...features.done]
: []
if (!features) {
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
@@ -34,6 +40,8 @@ export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandPr
title="Pending"
count={features.pending.length}
features={features.pending}
allFeatures={allFeatures}
activeAgents={activeAgents}
color="pending"
onFeatureClick={onFeatureClick}
onAddFeature={onAddFeature}
@@ -44,6 +52,8 @@ export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandPr
title="In Progress"
count={features.in_progress.length}
features={features.in_progress}
allFeatures={allFeatures}
activeAgents={activeAgents}
color="progress"
onFeatureClick={onFeatureClick}
/>
@@ -51,6 +61,8 @@ export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandPr
title="Done"
count={features.done.length}
features={features.done}
allFeatures={allFeatures}
activeAgents={activeAgents}
color="done"
onFeatureClick={onFeatureClick}
/>

View File

@@ -1,11 +1,13 @@
import { FeatureCard } from './FeatureCard'
import { Plus, Sparkles } from 'lucide-react'
import type { Feature } from '../lib/types'
import type { Feature, ActiveAgent } from '../lib/types'
interface KanbanColumnProps {
title: string
count: number
features: Feature[]
allFeatures?: Feature[] // For dependency status calculation
activeAgents?: ActiveAgent[] // Active agents for showing which agent is working on a feature
color: 'pending' | 'progress' | 'done'
onFeatureClick: (feature: Feature) => void
onAddFeature?: () => void
@@ -23,12 +25,18 @@ export function KanbanColumn({
title,
count,
features,
allFeatures = [],
activeAgents = [],
color,
onFeatureClick,
onAddFeature,
onExpandProject,
showExpandButton,
}: KanbanColumnProps) {
// Create a map of feature ID to active agent for quick lookup
const agentByFeatureId = new Map(
activeAgents.map(agent => [agent.featureId, agent])
)
return (
<div
className="neo-card overflow-hidden"
@@ -86,6 +94,8 @@ export function KanbanColumn({
feature={feature}
onClick={() => onFeatureClick(feature)}
isInProgress={color === 'progress'}
allFeatures={allFeatures}
activeAgent={agentByFeatureId.get(feature.id)}
/>
</div>
))

View File

@@ -0,0 +1,93 @@
import { useEffect, useCallback } from 'react'
import { X, Keyboard } from 'lucide-react'
interface Shortcut {
key: string
description: string
context?: string
}
const shortcuts: Shortcut[] = [
{ key: '?', description: 'Show keyboard shortcuts' },
{ key: 'D', description: 'Toggle debug panel' },
{ key: 'T', description: 'Toggle terminal tab' },
{ key: 'N', description: 'Add new feature', context: 'with project' },
{ key: 'E', description: 'Expand project with AI', context: 'with 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: 'Esc', description: 'Close modal/panel' },
]
interface KeyboardShortcutsHelpProps {
onClose: () => void
}
export function KeyboardShortcutsHelp({ onClose }: KeyboardShortcutsHelpProps) {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape' || e.key === '?') {
e.preventDefault()
onClose()
}
},
[onClose]
)
useEffect(() => {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleKeyDown])
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={onClose}
>
<div
className="neo-card p-6 max-w-md w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Keyboard size={20} className="text-neo-accent" />
<h2 className="font-display text-lg font-bold">Keyboard Shortcuts</h2>
</div>
<button
onClick={onClose}
className="neo-btn p-1.5"
aria-label="Close"
>
<X size={16} />
</button>
</div>
{/* Shortcuts list */}
<ul className="space-y-2">
{shortcuts.map((shortcut) => (
<li
key={shortcut.key}
className="flex items-center justify-between py-2 border-b border-neo-border/30 last:border-0"
>
<div className="flex items-center gap-2">
<kbd className="px-2 py-1 text-sm font-mono bg-neo-bg rounded border border-neo-border shadow-neo-sm min-w-[2rem] text-center">
{shortcut.key}
</kbd>
<span className="text-neo-text">{shortcut.description}</span>
</div>
{shortcut.context && (
<span className="text-xs text-neo-muted">{shortcut.context}</span>
)}
</li>
))}
</ul>
{/* Footer */}
<p className="text-xs text-neo-muted text-center mt-6">
Press ? or Esc to close
</p>
</div>
</div>
)
}

View File

@@ -129,7 +129,7 @@ export function NewProjectModal({
// Auto-start the initializer agent
setInitializerStatus('starting')
try {
await startAgent(projectName.trim(), yoloMode)
await startAgent(projectName.trim(), { yoloMode })
// Success - navigate to project
changeStep('complete')
setTimeout(() => {

View File

@@ -0,0 +1,46 @@
import { LayoutGrid, GitBranch } from 'lucide-react'
export type ViewMode = 'kanban' | 'graph'
interface ViewToggleProps {
viewMode: ViewMode
onViewModeChange: (mode: ViewMode) => void
}
/**
* Toggle button to switch between Kanban and Graph views
*/
export function ViewToggle({ viewMode, onViewModeChange }: ViewToggleProps) {
return (
<div className="inline-flex rounded-lg border-2 border-neo-border p-1 bg-white shadow-neo-sm">
<button
onClick={() => onViewModeChange('kanban')}
className={`
flex items-center gap-1.5 px-3 py-1.5 rounded-md font-medium text-sm transition-all
${viewMode === 'kanban'
? 'bg-neo-accent text-white shadow-neo-sm'
: 'text-neo-text hover:bg-neo-neutral-100'
}
`}
title="Kanban View"
>
<LayoutGrid size={16} />
<span>Kanban</span>
</button>
<button
onClick={() => onViewModeChange('graph')}
className={`
flex items-center gap-1.5 px-3 py-1.5 rounded-md font-medium text-sm transition-all
${viewMode === 'graph'
? 'bg-neo-accent text-white shadow-neo-sm'
: 'text-neo-text hover:bg-neo-neutral-100'
}
`}
title="Dependency Graph View"
>
<GitBranch size={16} />
<span>Graph</span>
</button>
</div>
)
}

View File

@@ -123,7 +123,11 @@ export function useStartAgent(projectName: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (yoloMode: boolean = false) => api.startAgent(projectName, yoloMode),
mutationFn: (options: {
yoloMode?: boolean
parallelMode?: boolean
maxConcurrency?: number
} = {}) => api.startAgent(projectName, options),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['agent-status', projectName] })
},

View File

@@ -3,7 +3,28 @@
*/
import { useEffect, useRef, useState, useCallback } from 'react'
import type { WSMessage, AgentStatus, DevServerStatus } from '../lib/types'
import type {
WSMessage,
AgentStatus,
DevServerStatus,
ActiveAgent,
AgentMascot,
} from '../lib/types'
// Activity item for the feed
interface ActivityItem {
agentName: string
thought: string
timestamp: string
featureId: number
}
// Celebration trigger for overlay
interface CelebrationTrigger {
agentName: AgentMascot
featureName: string
featureId: number
}
interface WebSocketState {
progress: {
@@ -13,14 +34,21 @@ interface WebSocketState {
percentage: number
}
agentStatus: AgentStatus
logs: Array<{ line: string; timestamp: string }>
logs: Array<{ line: string; timestamp: string; featureId?: number; agentIndex?: number }>
isConnected: boolean
devServerStatus: DevServerStatus
devServerUrl: string | null
devLogs: Array<{ line: string; timestamp: string }>
// Multi-agent state
activeAgents: ActiveAgent[]
recentActivity: ActivityItem[]
// Celebration queue to handle rapid successes without race conditions
celebrationQueue: CelebrationTrigger[]
celebration: CelebrationTrigger | null
}
const MAX_LOGS = 100 // Keep last 100 log lines
const MAX_ACTIVITY = 20 // Keep last 20 activity items
export function useProjectWebSocket(projectName: string | null) {
const [state, setState] = useState<WebSocketState>({
@@ -31,6 +59,10 @@ export function useProjectWebSocket(projectName: string | null) {
devServerStatus: 'stopped',
devServerUrl: null,
devLogs: [],
activeAgents: [],
recentActivity: [],
celebrationQueue: [],
celebration: null,
})
const wsRef = useRef<WebSocket | null>(null)
@@ -83,7 +115,12 @@ export function useProjectWebSocket(projectName: string | null) {
...prev,
logs: [
...prev.logs.slice(-MAX_LOGS + 1),
{ line: message.line, timestamp: message.timestamp },
{
line: message.line,
timestamp: message.timestamp,
featureId: message.featureId,
agentIndex: message.agentIndex,
},
],
}))
break
@@ -92,6 +129,91 @@ export function useProjectWebSocket(projectName: string | null) {
// Feature updates will trigger a refetch via React Query
break
case 'agent_update':
setState(prev => {
// Update or add the agent in activeAgents
const agentIndex = prev.activeAgents.findIndex(
a => a.agentIndex === message.agentIndex
)
let newAgents: ActiveAgent[]
if (message.state === 'success') {
// Remove agent from active list on success
newAgents = prev.activeAgents.filter(
a => a.agentIndex !== message.agentIndex
)
} else if (agentIndex >= 0) {
// Update existing agent
newAgents = [...prev.activeAgents]
newAgents[agentIndex] = {
agentIndex: message.agentIndex,
agentName: message.agentName,
featureId: message.featureId,
featureName: message.featureName,
state: message.state,
thought: message.thought,
timestamp: message.timestamp,
}
} else {
// Add new agent
newAgents = [
...prev.activeAgents,
{
agentIndex: message.agentIndex,
agentName: message.agentName,
featureId: message.featureId,
featureName: message.featureName,
state: message.state,
thought: message.thought,
timestamp: message.timestamp,
},
]
}
// Add to activity feed if there's a thought
let newActivity = prev.recentActivity
if (message.thought) {
newActivity = [
{
agentName: message.agentName,
thought: message.thought,
timestamp: message.timestamp,
featureId: message.featureId,
},
...prev.recentActivity.slice(0, MAX_ACTIVITY - 1),
]
}
// Handle celebration queue on success
let newCelebrationQueue = prev.celebrationQueue
let newCelebration = prev.celebration
if (message.state === 'success') {
const newCelebrationItem: CelebrationTrigger = {
agentName: message.agentName,
featureName: message.featureName,
featureId: message.featureId,
}
// If no celebration is showing, show this one immediately
// Otherwise, add to queue
if (!prev.celebration) {
newCelebration = newCelebrationItem
} else {
newCelebrationQueue = [...prev.celebrationQueue, newCelebrationItem]
}
}
return {
...prev,
activeAgents: newAgents,
recentActivity: newActivity,
celebrationQueue: newCelebrationQueue,
celebration: newCelebration,
}
})
break
case 'dev_log':
setState(prev => ({
...prev,
@@ -147,6 +269,19 @@ export function useProjectWebSocket(projectName: string | null) {
}
}, [])
// Clear celebration and show next one from queue if available
const clearCelebration = useCallback(() => {
setState(prev => {
// Pop the next celebration from the queue if available
const [nextCelebration, ...remainingQueue] = prev.celebrationQueue
return {
...prev,
celebration: nextCelebration || null,
celebrationQueue: remainingQueue,
}
})
}, [])
// Connect when project changes
useEffect(() => {
// Reset state when project changes to clear stale data
@@ -158,6 +293,10 @@ export function useProjectWebSocket(projectName: string | null) {
devServerStatus: 'stopped',
devServerUrl: null,
devLogs: [],
activeAgents: [],
recentActivity: [],
celebrationQueue: [],
celebration: null,
})
if (!projectName) {
@@ -200,5 +339,6 @@ export function useProjectWebSocket(projectName: string | null) {
...state,
clearLogs,
clearDevLogs,
clearCelebration,
}
}

View File

@@ -12,6 +12,7 @@ import type {
FeatureUpdate,
FeatureBulkCreate,
FeatureBulkCreateResponse,
DependencyGraph,
AgentStatusResponse,
AgentActionResponse,
SetupStatus,
@@ -141,6 +142,50 @@ export async function createFeaturesBulk(
})
}
// ============================================================================
// Dependency Graph API
// ============================================================================
export async function getDependencyGraph(projectName: string): Promise<DependencyGraph> {
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/features/graph`)
}
export async function addDependency(
projectName: string,
featureId: number,
dependencyId: number
): Promise<{ success: boolean; feature_id: number; dependencies: number[] }> {
return fetchJSON(
`/projects/${encodeURIComponent(projectName)}/features/${featureId}/dependencies/${dependencyId}`,
{ method: 'POST' }
)
}
export async function removeDependency(
projectName: string,
featureId: number,
dependencyId: number
): Promise<{ success: boolean; feature_id: number; dependencies: number[] }> {
return fetchJSON(
`/projects/${encodeURIComponent(projectName)}/features/${featureId}/dependencies/${dependencyId}`,
{ method: 'DELETE' }
)
}
export async function setDependencies(
projectName: string,
featureId: number,
dependencyIds: number[]
): Promise<{ success: boolean; feature_id: number; dependencies: number[] }> {
return fetchJSON(
`/projects/${encodeURIComponent(projectName)}/features/${featureId}/dependencies`,
{
method: 'PUT',
body: JSON.stringify({ dependency_ids: dependencyIds }),
}
)
}
// ============================================================================
// Agent API
// ============================================================================
@@ -151,11 +196,19 @@ export async function getAgentStatus(projectName: string): Promise<AgentStatusRe
export async function startAgent(
projectName: string,
yoloMode: boolean = false
options: {
yoloMode?: boolean
parallelMode?: boolean
maxConcurrency?: number
} = {}
): Promise<AgentActionResponse> {
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/agent/start`, {
method: 'POST',
body: JSON.stringify({ yolo_mode: yoloMode }),
body: JSON.stringify({
yolo_mode: options.yoloMode ?? false,
parallel_mode: options.parallelMode ?? false,
max_concurrency: options.maxConcurrency,
}),
})
}

View File

@@ -66,6 +66,32 @@ export interface Feature {
steps: string[]
passes: boolean
in_progress: boolean
dependencies?: number[] // Optional for backwards compat
blocked?: boolean // Computed by API
blocking_dependencies?: number[] // Computed by API
}
// Status type for graph nodes
export type FeatureStatus = 'pending' | 'in_progress' | 'done' | 'blocked'
// Graph visualization types
export interface GraphNode {
id: number
name: string
category: string
status: FeatureStatus
priority: number
dependencies: number[]
}
export interface GraphEdge {
source: number
target: number
}
export interface DependencyGraph {
nodes: GraphNode[]
edges: GraphEdge[]
}
export interface FeatureListResponse {
@@ -80,6 +106,7 @@ export interface FeatureCreate {
description: string
steps: string[]
priority?: number
dependencies?: number[]
}
export interface FeatureUpdate {
@@ -88,6 +115,7 @@ export interface FeatureUpdate {
description?: string
steps?: string[]
priority?: number
dependencies?: number[]
}
// Agent types
@@ -99,6 +127,8 @@ export interface AgentStatusResponse {
started_at: string | null
yolo_mode: boolean
model: string | null // Model being used by running agent
parallel_mode: boolean
max_concurrency: number | null
}
export interface AgentActionResponse {
@@ -140,8 +170,26 @@ export interface TerminalInfo {
created_at: string
}
// Agent mascot names for multi-agent UI
export const AGENT_MASCOTS = ['Spark', 'Fizz', 'Octo', 'Hoot', 'Buzz'] as const
export type AgentMascot = typeof AGENT_MASCOTS[number]
// Agent state for Mission Control
export type AgentState = 'idle' | 'thinking' | 'working' | 'testing' | 'success' | 'error' | 'struggling'
// Agent update from backend
export interface ActiveAgent {
agentIndex: number
agentName: AgentMascot
featureId: number
featureName: string
state: AgentState
thought?: string
timestamp: string
}
// WebSocket message types
export type WSMessageType = 'progress' | 'feature_update' | 'log' | 'agent_status' | 'pong' | 'dev_log' | 'dev_server_status'
export type WSMessageType = 'progress' | 'feature_update' | 'log' | 'agent_status' | 'pong' | 'dev_log' | 'dev_server_status' | 'agent_update'
export interface WSProgressMessage {
type: 'progress'
@@ -161,6 +209,20 @@ export interface WSLogMessage {
type: 'log'
line: string
timestamp: string
featureId?: number
agentIndex?: number
agentName?: AgentMascot
}
export interface WSAgentUpdateMessage {
type: 'agent_update'
agentIndex: number
agentName: AgentMascot
featureId: number
featureName: string
state: AgentState
thought?: string
timestamp: string
}
export interface WSAgentStatusMessage {
@@ -189,6 +251,7 @@ export type WSMessage =
| WSFeatureUpdateMessage
| WSLogMessage
| WSAgentStatusMessage
| WSAgentUpdateMessage
| WSPongMessage
| WSDevLogMessage
| WSDevServerStatusMessage

View File

@@ -870,6 +870,96 @@
}
}
/* ============================================================================
Agent Mascot Animations
============================================================================ */
@keyframes bounce-gentle {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
@keyframes thinking {
0%, 100% {
transform: translateY(0) scale(1);
}
25% {
transform: translateY(-2px) scale(1.02);
}
50% {
transform: translateY(0) scale(1);
}
75% {
transform: translateY(-2px) scale(0.98);
}
}
@keyframes working {
0%, 100% {
transform: translateX(0);
}
25% {
transform: translateX(-1px);
}
75% {
transform: translateX(1px);
}
}
@keyframes testing {
0%, 100% {
transform: rotate(0deg);
}
25% {
transform: rotate(-3deg);
}
75% {
transform: rotate(3deg);
}
}
@keyframes celebrate {
0%, 100% {
transform: scale(1) rotate(0deg);
}
25% {
transform: scale(1.1) rotate(-5deg);
}
50% {
transform: scale(1.15) rotate(0deg);
}
75% {
transform: scale(1.1) rotate(5deg);
}
}
@keyframes shake-gentle {
0%, 100% {
transform: translateX(0);
}
20%, 60% {
transform: translateX(-2px);
}
40%, 80% {
transform: translateX(2px);
}
}
@keyframes confetti {
0% {
transform: translateY(0) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(100vh) rotate(720deg);
opacity: 0;
}
}
/* ============================================================================
Utilities Layer
============================================================================ */
@@ -970,6 +1060,35 @@
.font-mono {
font-family: var(--font-neo-mono);
}
/* Agent mascot animation utilities */
.animate-bounce-gentle {
animation: bounce-gentle 2s ease-in-out infinite;
}
.animate-thinking {
animation: thinking 1.5s ease-in-out infinite;
}
.animate-working {
animation: working 0.3s ease-in-out infinite;
}
.animate-testing {
animation: testing 0.8s ease-in-out infinite;
}
.animate-celebrate {
animation: celebrate 0.6s ease-in-out;
}
.animate-shake-gentle {
animation: shake-gentle 0.5s ease-in-out infinite;
}
.animate-confetti {
animation: confetti 2s ease-out forwards;
}
}
/* ============================================================================