Files
autocoder/ui/src/components/AgentThought.tsx
Auto 85f6940a54 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>
2026-01-17 12:59:42 +02:00

161 lines
4.8 KiB
TypeScript

import { useMemo, useState, useEffect } from 'react'
import { Brain, Sparkles } from 'lucide-react'
import type { AgentStatus } from '../lib/types'
interface AgentThoughtProps {
logs: Array<{ line: string; timestamp: string }>
agentStatus: AgentStatus
}
const IDLE_TIMEOUT = 30000 // 30 seconds
/**
* Determines if a log line is an agent "thought" (narrative text)
* vs. tool mechanics that should be hidden
*/
function isAgentThought(line: string): boolean {
const trimmed = line.trim()
// Skip tool mechanics
if (/^\[Tool:/.test(trimmed)) return false
if (/^\s*Input:\s*\{/.test(trimmed)) return false
if (/^\[(Done|Error)\]/.test(trimmed)) return false
if (/^\[Error\]/.test(trimmed)) return false
if (/^Output:/.test(trimmed)) return false
// Skip JSON and very short lines
if (/^[[{]/.test(trimmed)) 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 (looks like a sentence, relaxed filter)
return trimmed.length > 10
}
/**
* Extracts the latest agent thought from logs
*/
function getLatestThought(logs: Array<{ line: string; timestamp: string }>): string | null {
// Search from most recent
for (let i = logs.length - 1; i >= 0; i--) {
if (isAgentThought(logs[i].line)) {
return logs[i].line.trim()
}
}
return null
}
export function AgentThought({ logs, agentStatus }: AgentThoughtProps) {
const thought = useMemo(() => getLatestThought(logs), [logs])
const [displayedThought, setDisplayedThought] = useState<string | null>(null)
const [textVisible, setTextVisible] = useState(true)
const [isVisible, setIsVisible] = useState(false)
// Get last log timestamp for idle detection
const lastLogTimestamp = logs.length > 0
? new Date(logs[logs.length - 1].timestamp).getTime()
: 0
// Determine if component should be visible
const shouldShow = useMemo(() => {
if (!thought) return false
if (agentStatus === 'running') return true
if (agentStatus === 'paused') {
return Date.now() - lastLogTimestamp < IDLE_TIMEOUT
}
return false
}, [thought, agentStatus, lastLogTimestamp])
// Animate text changes using CSS transitions
useEffect(() => {
if (thought !== displayedThought && thought) {
// Fade out
setTextVisible(false)
// After fade out, update text and fade in
const timeout = setTimeout(() => {
setDisplayedThought(thought)
setTextVisible(true)
}, 150) // Match transition duration
return () => clearTimeout(timeout)
}
}, [thought, displayedThought])
// Handle visibility transitions
useEffect(() => {
if (shouldShow) {
setIsVisible(true)
} else {
// Delay hiding to allow exit animation
const timeout = setTimeout(() => setIsVisible(false), 300)
return () => clearTimeout(timeout)
}
}, [shouldShow])
if (!isVisible || !displayedThought) return null
const isRunning = agentStatus === 'running'
return (
<div
className={`
transition-all duration-300 ease-out overflow-hidden
${shouldShow ? 'opacity-100 max-h-20' : 'opacity-0 max-h-0'}
`}
>
<div
className={`
relative
bg-[var(--color-neo-card)]
border-3 border-[var(--color-neo-border)]
shadow-[var(--shadow-neo-sm)]
px-4 py-3
flex items-center gap-3
${isRunning ? 'animate-pulse-neo' : ''}
`}
>
{/* Brain Icon with subtle glow */}
<div className="relative shrink-0">
<Brain
size={22}
className="text-[var(--color-neo-progress)]"
strokeWidth={2.5}
/>
{isRunning && (
<Sparkles
size={10}
className="absolute -top-1 -right-1 text-[var(--color-neo-pending)] animate-pulse"
/>
)}
</div>
{/* Thought text with fade transition + shimmer effect when running */}
<p
className={`
font-mono text-sm truncate transition-all duration-150 ease-out
${isRunning ? 'animate-shimmer' : 'text-[var(--color-neo-text)]'}
`}
style={{
opacity: textVisible ? 1 : 0,
transform: textVisible ? 'translateY(0)' : 'translateY(-4px)',
}}
>
{displayedThought?.replace(/:$/, '')}
</p>
{/* Subtle running indicator bar */}
{isRunning && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-[var(--color-neo-progress)] opacity-50">
<div
className="h-full bg-[var(--color-neo-progress)] animate-pulse"
style={{ width: '100%' }}
/>
</div>
)}
</div>
</div>
)
}