mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-30 06:12:06 +00:00
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:
264
ui/package-lock.json
generated
264
ui/package-lock.json
generated
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
133
ui/src/App.tsx
133
ui/src/App.tsx
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
93
ui/src/components/ActivityFeed.tsx
Normal file
93
ui/src/components/ActivityFeed.tsx
Normal 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'
|
||||
}
|
||||
261
ui/src/components/AgentAvatar.tsx
Normal file
261
ui/src/components/AgentAvatar.tsx
Normal 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]
|
||||
}
|
||||
99
ui/src/components/AgentCard.tsx
Normal file
99
ui/src/components/AgentCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
121
ui/src/components/AgentMissionControl.tsx
Normal file
121
ui/src/components/AgentMissionControl.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
120
ui/src/components/CelebrationOverlay.tsx
Normal file
120
ui/src/components/CelebrationOverlay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
121
ui/src/components/DependencyBadge.tsx
Normal file
121
ui/src/components/DependencyBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
289
ui/src/components/DependencyGraph.tsx
Normal file
289
ui/src/components/DependencyGraph.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
93
ui/src/components/KeyboardShortcutsHelp.tsx
Normal file
93
ui/src/components/KeyboardShortcutsHelp.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
46
ui/src/components/ViewToggle.tsx
Normal file
46
ui/src/components/ViewToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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] })
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user