feat: add dedicated testing agents and enhanced parallel orchestration

Introduce a new testing agent architecture that runs regression tests
independently from coding agents, improving quality assurance in
parallel mode.

Key changes:

Testing Agent System:
- Add testing_prompt.template.md for dedicated testing agent role
- Add feature_mark_failing MCP tool for regression detection
- Add --agent-type flag to select initializer/coding/testing mode
- Remove regression testing from coding prompt (now handled by testing agents)

Parallel Orchestrator Enhancements:
- Add testing agent spawning with configurable ratio (--testing-agent-ratio)
- Add comprehensive debug logging system (DebugLog class)
- Improve database session management to prevent stale reads
- Add engine.dispose() calls to refresh connections after subprocess commits
- Fix f-string linting issues (remove unnecessary f-prefixes)

UI Improvements:
- Add testing agent mascot (Chip) to AgentAvatar
- Enhance AgentCard to display testing agent status
- Add testing agent ratio slider in SettingsModal
- Update WebSocket handling for testing agent updates
- Improve ActivityFeed to show testing agent activity

API & Server Updates:
- Add testing_agent_ratio to settings schema and endpoints
- Update process manager to support testing agent type
- Enhance WebSocket messages for agent_update events

Template Changes:
- Delete coding_prompt_yolo.template.md (consolidated into main prompt)
- Update initializer_prompt.template.md with improved structure
- Streamline coding_prompt.template.md workflow

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Auto
2026-01-18 13:49:50 +02:00
parent 5f786078fa
commit 13128361b0
27 changed files with 1885 additions and 536 deletions

View File

@@ -83,11 +83,30 @@ export function ActivityFeed({ activities, maxItems = 5, showHeader = true }: Ac
function getMascotColor(name: AgentMascot): string {
const colors: Record<AgentMascot, string> = {
// Original 5
Spark: '#3B82F6',
Fizz: '#F97316',
Octo: '#8B5CF6',
Hoot: '#22C55E',
Buzz: '#EAB308',
// Tech-inspired
Pixel: '#EC4899',
Byte: '#06B6D4',
Nova: '#F43F5E',
Chip: '#84CC16',
Bolt: '#FBBF24',
// Energetic
Dash: '#14B8A6',
Zap: '#A855F7',
Gizmo: '#64748B',
Turbo: '#EF4444',
Blip: '#10B981',
// Playful
Neon: '#D946EF',
Widget: '#6366F1',
Zippy: '#F59E0B',
Quirk: '#0EA5E9',
Flux: '#7C3AED',
}
return colors[name] || '#6B7280'
}

View File

@@ -8,11 +8,30 @@ interface AgentAvatarProps {
}
const AVATAR_COLORS: Record<AgentMascot, { primary: string; secondary: string; accent: string }> = {
// Original 5
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
// Tech-inspired
Pixel: { primary: '#EC4899', secondary: '#F472B6', accent: '#FCE7F3' }, // Pink
Byte: { primary: '#06B6D4', secondary: '#22D3EE', accent: '#CFFAFE' }, // Cyan
Nova: { primary: '#F43F5E', secondary: '#FB7185', accent: '#FFE4E6' }, // Rose
Chip: { primary: '#84CC16', secondary: '#A3E635', accent: '#ECFCCB' }, // Lime
Bolt: { primary: '#FBBF24', secondary: '#FCD34D', accent: '#FEF3C7' }, // Amber
// Energetic
Dash: { primary: '#14B8A6', secondary: '#2DD4BF', accent: '#CCFBF1' }, // Teal
Zap: { primary: '#A855F7', secondary: '#C084FC', accent: '#F3E8FF' }, // Violet
Gizmo: { primary: '#64748B', secondary: '#94A3B8', accent: '#F1F5F9' }, // Slate
Turbo: { primary: '#EF4444', secondary: '#F87171', accent: '#FEE2E2' }, // Red
Blip: { primary: '#10B981', secondary: '#34D399', accent: '#D1FAE5' }, // Emerald
// Playful
Neon: { primary: '#D946EF', secondary: '#E879F9', accent: '#FAE8FF' }, // Fuchsia
Widget: { primary: '#6366F1', secondary: '#818CF8', accent: '#E0E7FF' }, // Indigo
Zippy: { primary: '#F59E0B', secondary: '#FBBF24', accent: '#FEF3C7' }, // Orange-yellow
Quirk: { primary: '#0EA5E9', secondary: '#38BDF8', accent: '#E0F2FE' }, // Sky
Flux: { primary: '#7C3AED', secondary: '#8B5CF6', accent: '#EDE9FE' }, // Purple
}
const SIZES = {
@@ -150,12 +169,335 @@ function BuzzSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Buzz; size: nu
)
}
// Pixel - cute pixel art style character
function PixelSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Pixel; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
{/* Blocky body */}
<rect x="20" y="28" width="24" height="28" fill={colors.primary} />
<rect x="16" y="32" width="8" height="20" fill={colors.secondary} />
<rect x="40" y="32" width="8" height="20" fill={colors.secondary} />
{/* Head */}
<rect x="16" y="8" width="32" height="24" fill={colors.primary} />
{/* Eyes */}
<rect x="20" y="14" width="8" height="8" fill="white" />
<rect x="36" y="14" width="8" height="8" fill="white" />
<rect x="24" y="16" width="4" height="4" fill="#1a1a1a" />
<rect x="38" y="16" width="4" height="4" fill="#1a1a1a" />
{/* Mouth */}
<rect x="26" y="26" width="12" height="4" fill={colors.accent} />
</svg>
)
}
// Byte - data cube character
function ByteSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Byte; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
{/* 3D cube body */}
<polygon points="32,8 56,20 56,44 32,56 8,44 8,20" fill={colors.primary} />
<polygon points="32,8 56,20 32,32 8,20" fill={colors.secondary} />
<polygon points="32,32 56,20 56,44 32,56" fill={colors.accent} opacity="0.6" />
{/* Face */}
<circle cx="24" cy="28" r="4" fill="white" />
<circle cx="40" cy="28" r="4" fill="white" />
<circle cx="25" cy="29" r="2" fill="#1a1a1a" />
<circle cx="41" cy="29" r="2" fill="#1a1a1a" />
<path d="M26,38 Q32,42 38,38" stroke="white" strokeWidth="2" fill="none" strokeLinecap="round" />
</svg>
)
}
// Nova - star character
function NovaSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Nova; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
{/* Star points */}
<polygon points="32,2 38,22 58,22 42,36 48,56 32,44 16,56 22,36 6,22 26,22" fill={colors.primary} />
<circle cx="32" cy="32" r="14" fill={colors.secondary} />
{/* Face */}
<circle cx="27" cy="30" r="3" fill="white" />
<circle cx="37" cy="30" r="3" fill="white" />
<circle cx="28" cy="31" r="1.5" fill="#1a1a1a" />
<circle cx="38" cy="31" r="1.5" fill="#1a1a1a" />
<path d="M28,37 Q32,40 36,37" stroke="#1a1a1a" strokeWidth="1.5" fill="none" strokeLinecap="round" />
</svg>
)
}
// Chip - circuit board character
function ChipSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Chip; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
{/* Chip body */}
<rect x="16" y="16" width="32" height="32" rx="4" fill={colors.primary} />
{/* Pins */}
<rect x="20" y="10" width="4" height="8" fill={colors.secondary} />
<rect x="30" y="10" width="4" height="8" fill={colors.secondary} />
<rect x="40" y="10" width="4" height="8" fill={colors.secondary} />
<rect x="20" y="46" width="4" height="8" fill={colors.secondary} />
<rect x="30" y="46" width="4" height="8" fill={colors.secondary} />
<rect x="40" y="46" width="4" height="8" fill={colors.secondary} />
{/* Face */}
<circle cx="26" cy="28" r="4" fill={colors.accent} />
<circle cx="38" cy="28" r="4" fill={colors.accent} />
<circle cx="26" cy="28" r="2" fill="#1a1a1a" />
<circle cx="38" cy="28" r="2" fill="#1a1a1a" />
<rect x="26" y="38" width="12" height="3" rx="1" fill={colors.accent} />
</svg>
)
}
// Bolt - lightning character
function BoltSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Bolt; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
{/* Lightning bolt body */}
<polygon points="36,4 20,28 30,28 24,60 48,32 36,32 44,4" fill={colors.primary} />
<polygon points="34,8 24,26 32,26 28,52 42,34 34,34 40,8" fill={colors.secondary} />
{/* Face */}
<circle cx="30" cy="30" r="3" fill="white" />
<circle cx="38" cy="26" r="3" fill="white" />
<circle cx="31" cy="31" r="1.5" fill="#1a1a1a" />
<circle cx="39" cy="27" r="1.5" fill="#1a1a1a" />
</svg>
)
}
// Dash - speedy character
function DashSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Dash; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
{/* Speed lines */}
<rect x="4" y="28" width="12" height="3" rx="1" fill={colors.accent} opacity="0.6" />
<rect x="8" y="34" width="10" height="3" rx="1" fill={colors.accent} opacity="0.4" />
{/* Aerodynamic body */}
<ellipse cx="36" cy="32" rx="20" ry="16" fill={colors.primary} />
<ellipse cx="40" cy="32" rx="14" ry="12" fill={colors.secondary} />
{/* Face */}
<circle cx="38" cy="28" r="4" fill="white" />
<circle cx="48" cy="28" r="4" fill="white" />
<circle cx="39" cy="29" r="2" fill="#1a1a1a" />
<circle cx="49" cy="29" r="2" fill="#1a1a1a" />
<path d="M40,36 Q44,39 48,36" stroke="#1a1a1a" strokeWidth="1.5" fill="none" strokeLinecap="round" />
</svg>
)
}
// Zap - electric orb
function ZapSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Zap; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
{/* Electric sparks */}
<path d="M12,32 L20,28 L16,32 L22,30" stroke={colors.secondary} strokeWidth="2" className="animate-pulse" />
<path d="M52,32 L44,28 L48,32 L42,30" stroke={colors.secondary} strokeWidth="2" className="animate-pulse" />
{/* Orb */}
<circle cx="32" cy="32" r="18" fill={colors.primary} />
<circle cx="32" cy="32" r="14" fill={colors.secondary} />
{/* Face */}
<circle cx="26" cy="30" r="4" fill="white" />
<circle cx="38" cy="30" r="4" fill="white" />
<circle cx="27" cy="31" r="2" fill={colors.primary} />
<circle cx="39" cy="31" r="2" fill={colors.primary} />
<path d="M28,40 Q32,44 36,40" stroke="white" strokeWidth="2" fill="none" strokeLinecap="round" />
</svg>
)
}
// Gizmo - gear character
function GizmoSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Gizmo; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
{/* Gear teeth */}
<rect x="28" y="4" width="8" height="8" fill={colors.primary} />
<rect x="28" y="52" width="8" height="8" fill={colors.primary} />
<rect x="4" y="28" width="8" height="8" fill={colors.primary} />
<rect x="52" y="28" width="8" height="8" fill={colors.primary} />
{/* Gear body */}
<circle cx="32" cy="32" r="20" fill={colors.primary} />
<circle cx="32" cy="32" r="14" fill={colors.secondary} />
{/* Face */}
<circle cx="26" cy="30" r="4" fill="white" />
<circle cx="38" cy="30" r="4" fill="white" />
<circle cx="27" cy="31" r="2" fill="#1a1a1a" />
<circle cx="39" cy="31" r="2" fill="#1a1a1a" />
<path d="M28,40 Q32,43 36,40" stroke="#1a1a1a" strokeWidth="2" fill="none" strokeLinecap="round" />
</svg>
)
}
// Turbo - rocket character
function TurboSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Turbo; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
{/* Flames */}
<ellipse cx="32" cy="58" rx="8" ry="6" fill="#FBBF24" className="animate-pulse" />
<ellipse cx="32" cy="56" rx="5" ry="4" fill="#FCD34D" />
{/* Rocket body */}
<ellipse cx="32" cy="32" rx="14" ry="24" fill={colors.primary} />
{/* Nose cone */}
<ellipse cx="32" cy="12" rx="8" ry="10" fill={colors.secondary} />
{/* Fins */}
<polygon points="18,44 10,56 18,52" fill={colors.secondary} />
<polygon points="46,44 54,56 46,52" fill={colors.secondary} />
{/* Window/Face */}
<circle cx="32" cy="28" r="8" fill={colors.accent} />
<circle cx="29" cy="27" r="2" fill="#1a1a1a" />
<circle cx="35" cy="27" r="2" fill="#1a1a1a" />
<path d="M29,32 Q32,34 35,32" stroke="#1a1a1a" strokeWidth="1" fill="none" />
</svg>
)
}
// Blip - radar dot character
function BlipSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Blip; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
{/* Radar rings */}
<circle cx="32" cy="32" r="28" stroke={colors.accent} strokeWidth="2" fill="none" opacity="0.3" />
<circle cx="32" cy="32" r="22" stroke={colors.accent} strokeWidth="2" fill="none" opacity="0.5" />
{/* Main dot */}
<circle cx="32" cy="32" r="14" fill={colors.primary} />
<circle cx="32" cy="32" r="10" fill={colors.secondary} />
{/* Face */}
<circle cx="28" cy="30" r="3" fill="white" />
<circle cx="36" cy="30" r="3" fill="white" />
<circle cx="29" cy="31" r="1.5" fill="#1a1a1a" />
<circle cx="37" cy="31" r="1.5" fill="#1a1a1a" />
<path d="M29,37 Q32,40 35,37" stroke="white" strokeWidth="1.5" fill="none" strokeLinecap="round" />
</svg>
)
}
// Neon - glowing character
function NeonSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Neon; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
{/* Glow effect */}
<circle cx="32" cy="32" r="26" fill={colors.accent} opacity="0.3" />
<circle cx="32" cy="32" r="22" fill={colors.accent} opacity="0.5" />
{/* Body */}
<circle cx="32" cy="32" r="18" fill={colors.primary} />
{/* Inner glow */}
<circle cx="32" cy="32" r="12" fill={colors.secondary} />
{/* Face */}
<circle cx="27" cy="30" r="4" fill="white" />
<circle cx="37" cy="30" r="4" fill="white" />
<circle cx="28" cy="31" r="2" fill={colors.primary} />
<circle cx="38" cy="31" r="2" fill={colors.primary} />
<path d="M28,38 Q32,42 36,38" stroke="white" strokeWidth="2" fill="none" strokeLinecap="round" />
</svg>
)
}
// Widget - UI component character
function WidgetSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Widget; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
{/* Window frame */}
<rect x="8" y="12" width="48" height="40" rx="4" fill={colors.primary} />
{/* Title bar */}
<rect x="8" y="12" width="48" height="10" rx="4" fill={colors.secondary} />
<circle cx="16" cy="17" r="2" fill="#EF4444" />
<circle cx="24" cy="17" r="2" fill="#FBBF24" />
<circle cx="32" cy="17" r="2" fill="#22C55E" />
{/* Content area / Face */}
<rect x="12" y="26" width="40" height="22" rx="2" fill={colors.accent} />
<circle cx="24" cy="34" r="4" fill="white" />
<circle cx="40" cy="34" r="4" fill="white" />
<circle cx="25" cy="35" r="2" fill={colors.primary} />
<circle cx="41" cy="35" r="2" fill={colors.primary} />
<rect x="28" y="42" width="8" height="3" rx="1" fill={colors.primary} />
</svg>
)
}
// Zippy - fast bunny-like character
function ZippySVG({ colors, size }: { colors: typeof AVATAR_COLORS.Zippy; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
{/* Ears */}
<ellipse cx="22" cy="14" rx="6" ry="14" fill={colors.primary} />
<ellipse cx="42" cy="14" rx="6" ry="14" fill={colors.primary} />
<ellipse cx="22" cy="14" rx="3" ry="10" fill={colors.accent} />
<ellipse cx="42" cy="14" rx="3" ry="10" fill={colors.accent} />
{/* Head */}
<circle cx="32" cy="38" r="20" fill={colors.primary} />
{/* Face */}
<circle cx="24" cy="34" r="5" fill="white" />
<circle cx="40" cy="34" r="5" fill="white" />
<circle cx="25" cy="35" r="2.5" fill="#1a1a1a" />
<circle cx="41" cy="35" r="2.5" fill="#1a1a1a" />
{/* Nose and mouth */}
<ellipse cx="32" cy="44" rx="3" ry="2" fill={colors.secondary} />
<path d="M32,46 L32,50 M28,52 Q32,56 36,52" stroke="#1a1a1a" strokeWidth="1.5" fill="none" />
</svg>
)
}
// Quirk - question mark character
function QuirkSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Quirk; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
{/* Question mark body */}
<path d="M24,20 Q24,8 32,8 Q44,8 44,20 Q44,28 32,32 L32,40"
stroke={colors.primary} strokeWidth="8" fill="none" strokeLinecap="round" />
<circle cx="32" cy="52" r="6" fill={colors.primary} />
{/* Face on the dot */}
<circle cx="29" cy="51" r="1.5" fill="white" />
<circle cx="35" cy="51" r="1.5" fill="white" />
<circle cx="29" cy="51" r="0.75" fill="#1a1a1a" />
<circle cx="35" cy="51" r="0.75" fill="#1a1a1a" />
{/* Decorative swirl */}
<circle cx="32" cy="20" r="4" fill={colors.secondary} />
</svg>
)
}
// Flux - flowing wave character
function FluxSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Flux; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
{/* Wave body */}
<path d="M8,32 Q16,16 32,32 Q48,48 56,32" stroke={colors.primary} strokeWidth="16" fill="none" strokeLinecap="round" />
<path d="M8,32 Q16,16 32,32 Q48,48 56,32" stroke={colors.secondary} strokeWidth="10" fill="none" strokeLinecap="round" />
{/* Face */}
<circle cx="28" cy="28" r="4" fill="white" />
<circle cx="40" cy="36" r="4" fill="white" />
<circle cx="29" cy="29" r="2" fill="#1a1a1a" />
<circle cx="41" cy="37" r="2" fill="#1a1a1a" />
{/* Sparkles */}
<circle cx="16" cy="24" r="2" fill={colors.accent} className="animate-pulse" />
<circle cx="48" cy="40" r="2" fill={colors.accent} className="animate-pulse" />
</svg>
)
}
const MASCOT_SVGS: Record<AgentMascot, typeof SparkSVG> = {
// Original 5
Spark: SparkSVG,
Fizz: FizzSVG,
Octo: OctoSVG,
Hoot: HootSVG,
Buzz: BuzzSVG,
// Tech-inspired
Pixel: PixelSVG,
Byte: ByteSVG,
Nova: NovaSVG,
Chip: ChipSVG,
Bolt: BoltSVG,
// Energetic
Dash: DashSVG,
Zap: ZapSVG,
Gizmo: GizmoSVG,
Turbo: TurboSVG,
Blip: BlipSVG,
// Playful
Neon: NeonSVG,
Widget: WidgetSVG,
Zippy: ZippySVG,
Quirk: QuirkSVG,
Flux: FluxSVG,
}
// Animation classes based on state
@@ -256,6 +598,6 @@ export function AgentAvatar({ name, state, size = 'md', showName = false }: Agen
// Get mascot name by index (cycles through available mascots)
export function getMascotName(index: number): AgentMascot {
const mascots: AgentMascot[] = ['Spark', 'Fizz', 'Octo', 'Hoot', 'Buzz']
const mascots = Object.keys(MASCOT_SVGS) as AgentMascot[]
return mascots[index % mascots.length]
}

View File

@@ -1,8 +1,8 @@
import { MessageCircle, ScrollText, X, Copy, Check } from 'lucide-react'
import { MessageCircle, ScrollText, X, Copy, Check, Code, FlaskConical } from 'lucide-react'
import { useState } from 'react'
import { createPortal } from 'react-dom'
import { AgentAvatar } from './AgentAvatar'
import type { ActiveAgent, AgentLogEntry } from '../lib/types'
import type { ActiveAgent, AgentLogEntry, AgentType } from '../lib/types'
interface AgentCardProps {
agent: ActiveAgent
@@ -50,9 +50,28 @@ function getStateColor(state: ActiveAgent['state']): string {
}
}
// Get agent type badge config
function getAgentTypeBadge(agentType: AgentType): { label: string; className: string; icon: typeof Code } {
if (agentType === 'testing') {
return {
label: 'TEST',
className: 'bg-purple-100 text-purple-700 border-purple-300',
icon: FlaskConical,
}
}
// Default to coding
return {
label: 'CODE',
className: 'bg-blue-100 text-blue-700 border-blue-300',
icon: Code,
}
}
export function AgentCard({ agent, onShowLogs }: AgentCardProps) {
const isActive = ['thinking', 'working', 'testing'].includes(agent.state)
const hasLogs = agent.logs && agent.logs.length > 0
const typeBadge = getAgentTypeBadge(agent.agentType || 'coding')
const TypeIcon = typeBadge.icon
return (
<div
@@ -62,6 +81,20 @@ export function AgentCard({ agent, onShowLogs }: AgentCardProps) {
transition-all duration-300
`}
>
{/* Agent type badge */}
<div className="flex justify-end mb-1">
<span
className={`
inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-bold
uppercase tracking-wide rounded border
${typeBadge.className}
`}
>
<TypeIcon size={10} />
{typeBadge.label}
</span>
</div>
{/* Header with avatar and name */}
<div className="flex items-center gap-2 mb-2">
<AgentAvatar name={agent.agentName} state={agent.state} size="sm" />
@@ -122,6 +155,8 @@ interface AgentLogModalProps {
export function AgentLogModal({ agent, logs, onClose }: AgentLogModalProps) {
const [copied, setCopied] = useState(false)
const typeBadge = getAgentTypeBadge(agent.agentType || 'coding')
const TypeIcon = typeBadge.icon
const handleCopy = async () => {
const logText = logs
@@ -159,9 +194,21 @@ export function AgentLogModal({ agent, logs, onClose }: AgentLogModalProps) {
<div className="flex items-center gap-3">
<AgentAvatar name={agent.agentName} state={agent.state} size="sm" />
<div>
<h2 className="font-display font-bold text-lg">
{agent.agentName} Logs
</h2>
<div className="flex items-center gap-2">
<h2 className="font-display font-bold text-lg">
{agent.agentName} Logs
</h2>
<span
className={`
inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-bold
uppercase tracking-wide rounded border
${typeBadge.className}
`}
>
<TypeIcon size={10} />
{typeBadge.label}
</span>
</div>
<p className="text-sm text-neo-text-secondary">
Feature #{agent.featureId}: {agent.featureName}
</p>

View File

@@ -24,21 +24,24 @@ export function AgentControl({ projectName, status }: AgentControlProps) {
const isLoading = startAgent.isPending || stopAgent.isPending
const isRunning = status === 'running' || status === 'paused'
const isLoadingStatus = status === 'loading' // Status unknown, waiting for WebSocket
const isParallel = concurrency > 1
const handleStart = () => startAgent.mutate({
yoloMode,
parallelMode: isParallel,
maxConcurrency: isParallel ? concurrency : undefined,
maxConcurrency: concurrency, // Always pass concurrency (1-5)
testingAgentRatio: settings?.testing_agent_ratio,
countTestingInConcurrency: settings?.count_testing_in_concurrency,
})
const handleStop = () => stopAgent.mutate()
// Simplified: either show Start (when stopped/crashed) or Stop (when running/paused)
// Simplified: either show Start (when stopped/crashed), Stop (when running/paused), or loading spinner
const isStopped = status === 'stopped' || status === 'crashed'
return (
<div className="flex items-center gap-2">
{/* Concurrency slider - always visible when stopped */}
{/* Concurrency slider - visible when stopped (not during loading or running) */}
{isStopped && (
<div className="flex items-center gap-2">
<GitBranch size={16} className={isParallel ? 'text-[var(--color-neo-primary)]' : 'text-gray-400'} />
@@ -67,7 +70,16 @@ export function AgentControl({ projectName, status }: AgentControlProps) {
</div>
)}
{isStopped ? (
{isLoadingStatus ? (
<button
disabled
className="neo-btn text-sm py-2 px-3 opacity-50 cursor-not-allowed"
title="Loading agent status..."
aria-label="Loading agent status"
>
<Loader2 size={18} className="animate-spin" />
</button>
) : isStopped ? (
<button
onClick={handleStart}
disabled={isLoading}

View File

@@ -129,7 +129,11 @@ export function NewProjectModal({
// Auto-start the initializer agent
setInitializerStatus('starting')
try {
await startAgent(projectName.trim(), { yoloMode })
// Use default concurrency of 3 to match AgentControl.tsx default
await startAgent(projectName.trim(), {
yoloMode,
maxConcurrency: 3,
})
// Success - navigate to project
changeStep('complete')
setTimeout(() => {

View File

@@ -70,6 +70,18 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
}
}
const handleTestingRatioChange = (ratio: number) => {
if (!updateSettings.isPending) {
updateSettings.mutate({ testing_agent_ratio: ratio })
}
}
const handleCountTestingToggle = () => {
if (settings && !updateSettings.isPending) {
updateSettings.mutate({ count_testing_in_concurrency: !settings.count_testing_in_concurrency })
}
}
const models = modelsData?.models ?? []
const isSaving = updateSettings.isPending
@@ -199,6 +211,76 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
</div>
</div>
{/* Testing Agent Ratio */}
<div>
<label
id="testing-ratio-label"
className="font-display font-bold text-base block mb-1"
>
Testing Agents per Coding Agent
</label>
<p className="text-sm text-[var(--color-neo-text-secondary)] mb-2">
Regression testing agents spawned per coding agent (0 = disabled)
</p>
<div
className="flex border-3 border-[var(--color-neo-border)]"
role="radiogroup"
aria-labelledby="testing-ratio-label"
>
{[0, 1, 2, 3].map((ratio) => (
<button
key={ratio}
onClick={() => handleTestingRatioChange(ratio)}
disabled={isSaving}
role="radio"
aria-checked={settings.testing_agent_ratio === ratio}
className={`flex-1 py-2 px-3 font-display font-bold text-sm transition-colors ${
settings.testing_agent_ratio === ratio
? 'bg-[var(--color-neo-progress)] text-[var(--color-neo-text)]'
: 'bg-[var(--color-neo-card)] text-[var(--color-neo-text)] hover:bg-[var(--color-neo-hover-subtle)]'
} ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{ratio}
</button>
))}
</div>
</div>
{/* Count Testing in Concurrency Toggle */}
<div>
<div className="flex items-center justify-between">
<div>
<label
id="count-testing-label"
className="font-display font-bold text-base"
>
Count Testing in Concurrency
</label>
<p className="text-sm text-[var(--color-neo-text-secondary)] mt-1">
If enabled, testing agents count toward the concurrency limit
</p>
</div>
<button
onClick={handleCountTestingToggle}
disabled={isSaving}
className={`relative w-14 h-8 rounded-none border-3 border-[var(--color-neo-border)] transition-colors ${
settings.count_testing_in_concurrency
? 'bg-[var(--color-neo-progress)]'
: 'bg-[var(--color-neo-card)]'
} ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
role="switch"
aria-checked={settings.count_testing_in_concurrency}
aria-labelledby="count-testing-label"
>
<span
className={`absolute top-1 w-5 h-5 bg-[var(--color-neo-border)] transition-transform ${
settings.count_testing_in_concurrency ? 'left-7' : 'left-1'
}`}
/>
</button>
</div>
</div>
{/* Update Error */}
{updateSettings.isError && (
<div className="p-3 bg-[var(--color-neo-error-bg)] border-3 border-[var(--color-neo-error-border)] text-[var(--color-neo-error-text)] text-sm">

View File

@@ -6,7 +6,7 @@
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { Send, X, CheckCircle2, AlertCircle, Wifi, WifiOff, RotateCcw, Loader2, ArrowRight, Zap, Paperclip, ExternalLink } from 'lucide-react'
import { Send, X, CheckCircle2, AlertCircle, Wifi, WifiOff, RotateCcw, Loader2, ArrowRight, Zap, Paperclip, ExternalLink, FileText } from 'lucide-react'
import { useSpecChat } from '../hooks/useSpecChat'
import { ChatMessage } from './ChatMessage'
import { QuestionOptions } from './QuestionOptions'
@@ -17,6 +17,24 @@ import type { ImageAttachment } from '../lib/types'
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png']
// Sample prompt for quick testing
const SAMPLE_PROMPT = `Let's call it Simple Todo. This is a really simple web app that I can use to track my to-do items using a Kanban board. I should be able to add to-dos and then drag and drop them through the Kanban board. The different columns in the Kanban board are:
- To Do
- In Progress
- Done
The app should use a neobrutalism design.
There is no need for user authentication either. All the to-dos will be stored in local storage, so each user has access to all of their to-dos when they open their browser. So do not worry about implementing a backend with user authentication or a database. Simply store everything in local storage. As for the design, please try to avoid AI slop, so use your front-end design skills to design something beautiful and practical. As for the content of the to-dos, we should store:
- The name or the title at the very least
- Optionally, we can also set tags, due dates, and priorities which should be represented as beautiful little badges on the to-do card
Users should have the ability to easily clear out all the completed To-Dos. They should also be able to filter and search for To-Dos as well.
You choose the rest. Keep it simple. Should be 25 features.`
type InitializerStatus = 'idle' | 'starting' | 'error'
interface SpecCreationChatProps {
@@ -223,6 +241,23 @@ export function SpecCreationChat({
</span>
)}
{/* Load Sample Prompt */}
<button
onClick={() => {
setInput(SAMPLE_PROMPT)
// Also resize the textarea to fit content
if (inputRef.current) {
inputRef.current.style.height = 'auto'
inputRef.current.style.height = `${Math.min(inputRef.current.scrollHeight, 200)}px`
}
}}
className="neo-btn neo-btn-ghost text-sm py-2"
title="Load sample prompt (Simple Todo app)"
>
<FileText size={16} />
Load Sample
</button>
{/* Exit to Project - always visible escape hatch */}
<button
onClick={onExitToProject}

View File

@@ -127,6 +127,8 @@ export function useStartAgent(projectName: string) {
yoloMode?: boolean
parallelMode?: boolean
maxConcurrency?: number
testingAgentRatio?: number
countTestingInConcurrency?: boolean
} = {}) => api.startAgent(projectName, options),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['agent-status', projectName] })
@@ -234,6 +236,8 @@ const DEFAULT_SETTINGS: Settings = {
yolo_mode: false,
model: 'claude-opus-4-5-20251101',
glm_mode: false,
testing_agent_ratio: 1,
count_testing_in_concurrency: false,
}
export function useAvailableModels() {

View File

@@ -57,7 +57,7 @@ const MAX_AGENT_LOGS = 500 // Keep last 500 log lines per agent
export function useProjectWebSocket(projectName: string | null) {
const [state, setState] = useState<WebSocketState>({
progress: { passing: 0, in_progress: 0, total: 0, percentage: 0 },
agentStatus: 'stopped',
agentStatus: 'loading',
logs: [],
isConnected: false,
devServerStatus: 'stopped',
@@ -188,6 +188,7 @@ export function useProjectWebSocket(projectName: string | null) {
newAgents[existingAgentIdx] = {
agentIndex: message.agentIndex,
agentName: message.agentName,
agentType: message.agentType || 'coding', // Default to coding for backwards compat
featureId: message.featureId,
featureName: message.featureName,
state: message.state,
@@ -202,6 +203,7 @@ export function useProjectWebSocket(projectName: string | null) {
{
agentIndex: message.agentIndex,
agentName: message.agentName,
agentType: message.agentType || 'coding', // Default to coding for backwards compat
featureId: message.featureId,
featureName: message.featureName,
state: message.state,
@@ -328,9 +330,10 @@ export function useProjectWebSocket(projectName: string | null) {
// Connect when project changes
useEffect(() => {
// Reset state when project changes to clear stale data
// Use 'loading' for agentStatus to show loading indicator until WebSocket provides actual status
setState({
progress: { passing: 0, in_progress: 0, total: 0, percentage: 0 },
agentStatus: 'stopped',
agentStatus: 'loading',
logs: [],
isConnected: false,
devServerStatus: 'stopped',

View File

@@ -200,6 +200,8 @@ export async function startAgent(
yoloMode?: boolean
parallelMode?: boolean
maxConcurrency?: number
testingAgentRatio?: number
countTestingInConcurrency?: boolean
} = {}
): Promise<AgentActionResponse> {
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/agent/start`, {
@@ -208,6 +210,8 @@ export async function startAgent(
yolo_mode: options.yoloMode ?? false,
parallel_mode: options.parallelMode ?? false,
max_concurrency: options.maxConcurrency,
testing_agent_ratio: options.testingAgentRatio,
count_testing_in_concurrency: options.countTestingInConcurrency,
}),
})
}

View File

@@ -119,7 +119,7 @@ export interface FeatureUpdate {
}
// Agent types
export type AgentStatus = 'stopped' | 'running' | 'paused' | 'crashed'
export type AgentStatus = 'stopped' | 'running' | 'paused' | 'crashed' | 'loading'
export interface AgentStatusResponse {
status: AgentStatus
@@ -127,8 +127,10 @@ export interface AgentStatusResponse {
started_at: string | null
yolo_mode: boolean
model: string | null // Model being used by running agent
parallel_mode: boolean
parallel_mode: boolean // DEPRECATED: Always true now (unified orchestrator)
max_concurrency: number | null
testing_agent_ratio: number // Testing agents per coding agent (0-3)
count_testing_in_concurrency: boolean // Count testing toward concurrency limit
}
export interface AgentActionResponse {
@@ -171,12 +173,20 @@ export interface TerminalInfo {
}
// Agent mascot names for multi-agent UI
export const AGENT_MASCOTS = ['Spark', 'Fizz', 'Octo', 'Hoot', 'Buzz'] as const
export const AGENT_MASCOTS = [
'Spark', 'Fizz', 'Octo', 'Hoot', 'Buzz', // Original 5
'Pixel', 'Byte', 'Nova', 'Chip', 'Bolt', // Tech-inspired
'Dash', 'Zap', 'Gizmo', 'Turbo', 'Blip', // Energetic
'Neon', 'Widget', 'Zippy', 'Quirk', 'Flux', // Playful
] 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 type (coding vs testing)
export type AgentType = 'coding' | 'testing'
// Individual log entry for an agent
export interface AgentLogEntry {
line: string
@@ -188,6 +198,7 @@ export interface AgentLogEntry {
export interface ActiveAgent {
agentIndex: number
agentName: AgentMascot
agentType: AgentType // "coding" or "testing"
featureId: number
featureName: string
state: AgentState
@@ -226,6 +237,7 @@ export interface WSAgentUpdateMessage {
type: 'agent_update'
agentIndex: number
agentName: AgentMascot
agentType: AgentType // "coding" or "testing"
featureId: number
featureName: string
state: AgentState
@@ -467,9 +479,13 @@ export interface Settings {
yolo_mode: boolean
model: string
glm_mode: boolean
testing_agent_ratio: number // Testing agents per coding agent (0-3)
count_testing_in_concurrency: boolean // Count testing toward concurrency limit
}
export interface SettingsUpdate {
yolo_mode?: boolean
model?: string
testing_agent_ratio?: number
count_testing_in_concurrency?: boolean
}