mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-04-02 18:53:10 +00:00
324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
import { MessageCircle, ScrollText, X, Copy, Check, Code, FlaskConical, Maximize2 } from 'lucide-react'
|
|
import { useState } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { AgentAvatar } from './AgentAvatar'
|
|
import type { ActiveAgent, AgentLogEntry, AgentType, BrowserScreenshot } from '../lib/types'
|
|
import { AGENT_MASCOTS } from '../lib/types'
|
|
import { Card, CardContent } from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
|
|
interface AgentCardProps {
|
|
agent: ActiveAgent
|
|
onShowLogs?: (agentIndex: number) => void
|
|
browserScreenshot?: BrowserScreenshot
|
|
}
|
|
|
|
// Get a friendly state description
|
|
function getStateText(state: ActiveAgent['state']): string {
|
|
switch (state) {
|
|
case 'idle':
|
|
return 'Standing by...'
|
|
case 'thinking':
|
|
return 'Pondering...'
|
|
case 'working':
|
|
return 'Coding away...'
|
|
case 'testing':
|
|
return 'Checking work...'
|
|
case 'success':
|
|
return 'Nailed it!'
|
|
case 'error':
|
|
return 'Trying plan B...'
|
|
case 'struggling':
|
|
return 'Being persistent...'
|
|
default:
|
|
return 'Busy...'
|
|
}
|
|
}
|
|
|
|
// Get state color class
|
|
function getStateColor(state: ActiveAgent['state']): string {
|
|
switch (state) {
|
|
case 'success':
|
|
return 'text-primary'
|
|
case 'error':
|
|
return 'text-yellow-600'
|
|
case 'struggling':
|
|
return 'text-orange-500'
|
|
case 'working':
|
|
case 'testing':
|
|
return 'text-primary'
|
|
case 'thinking':
|
|
return 'text-yellow-600'
|
|
default:
|
|
return 'text-muted-foreground'
|
|
}
|
|
}
|
|
|
|
// 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 dark:bg-purple-900/30 dark:text-purple-300',
|
|
icon: FlaskConical,
|
|
}
|
|
}
|
|
return {
|
|
label: 'CODE',
|
|
className: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
|
icon: Code,
|
|
}
|
|
}
|
|
|
|
export function AgentCard({ agent, onShowLogs, browserScreenshot }: 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
|
|
const [screenshotExpanded, setScreenshotExpanded] = useState(false)
|
|
|
|
return (
|
|
<>
|
|
{/* Expanded screenshot overlay */}
|
|
{screenshotExpanded && browserScreenshot && createPortal(
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
|
|
onClick={() => setScreenshotExpanded(false)}
|
|
>
|
|
<div
|
|
className="relative max-w-[90vw] max-h-[90vh] bg-card border-2 border-border rounded-lg overflow-hidden"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-center justify-between px-3 py-2 bg-muted border-b border-border">
|
|
<div className="flex items-center gap-2">
|
|
<TypeIcon size={14} className={agent.agentType === 'testing' ? 'text-purple-500' : 'text-blue-500'} />
|
|
<span className="font-mono text-sm font-bold">
|
|
{AGENT_MASCOTS[agent.agentIndex % AGENT_MASCOTS.length]}
|
|
</span>
|
|
<Badge variant="outline" className="text-[10px] h-4">
|
|
{agent.agentType || 'coding'}
|
|
</Badge>
|
|
<span className="text-xs text-muted-foreground truncate">
|
|
{agent.featureName}
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={() => setScreenshotExpanded(false)}
|
|
className="p-1 hover:bg-accent rounded transition-colors cursor-pointer"
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
<img
|
|
src={browserScreenshot.imageDataUrl}
|
|
alt={`Browser view - ${agent.featureName}`}
|
|
className="max-w-full max-h-[calc(90vh-3rem)] object-contain"
|
|
/>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
|
|
<Card className={`min-w-[180px] ${browserScreenshot ? 'max-w-[280px]' : 'max-w-[220px]'} py-3 ${isActive ? 'animate-pulse' : ''}`}>
|
|
<CardContent className="p-3 space-y-2">
|
|
{/* Agent type badge */}
|
|
<div className="flex justify-end">
|
|
<Badge variant="outline" className={`text-[10px] ${typeBadge.className}`}>
|
|
<TypeIcon size={10} />
|
|
{typeBadge.label}
|
|
</Badge>
|
|
</div>
|
|
|
|
{/* Header with avatar and name */}
|
|
<div className="flex items-center gap-2">
|
|
<AgentAvatar name={agent.agentName} state={agent.state} size="sm" />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-semibold text-sm truncate">
|
|
{agent.agentName}
|
|
</div>
|
|
<div className={`text-xs ${getStateColor(agent.state)}`}>
|
|
{getStateText(agent.state)}
|
|
</div>
|
|
</div>
|
|
{/* Log button */}
|
|
{hasLogs && onShowLogs && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
onClick={() => onShowLogs(agent.agentIndex)}
|
|
title={`View logs (${agent.logs?.length || 0} entries)`}
|
|
>
|
|
<ScrollText size={14} className="text-muted-foreground" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Feature info */}
|
|
<div>
|
|
{agent.featureIds && agent.featureIds.length > 1 ? (
|
|
<>
|
|
<div className="text-xs text-muted-foreground mb-0.5">
|
|
Batch: {agent.featureIds.map(id => `#${id}`).join(', ')}
|
|
</div>
|
|
<div className="text-sm font-bold truncate">
|
|
Active: Feature #{agent.featureId}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="text-xs text-muted-foreground mb-0.5">
|
|
Feature #{agent.featureId}
|
|
</div>
|
|
<div className="text-sm font-medium truncate" title={agent.featureName}>
|
|
{agent.featureName}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Browser screenshot thumbnail with expand */}
|
|
{browserScreenshot && (
|
|
<div className="pt-1 relative group">
|
|
<div
|
|
className="cursor-pointer overflow-hidden rounded border border-border/50"
|
|
onClick={() => setScreenshotExpanded(true)}
|
|
>
|
|
<img
|
|
src={browserScreenshot.imageDataUrl}
|
|
alt="Browser view"
|
|
className="w-full h-auto max-h-[120px] object-cover object-top"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={() => setScreenshotExpanded(true)}
|
|
className="absolute top-2.5 right-1.5 p-0.5 bg-black/50 hover:bg-black/70 rounded transition-opacity opacity-0 group-hover:opacity-100 cursor-pointer"
|
|
title="Expand screenshot"
|
|
>
|
|
<Maximize2 size={12} className="text-white" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Thought bubble */}
|
|
{agent.thought && (
|
|
<div className="pt-2 border-t border-border/50">
|
|
<div className="flex items-start gap-1.5">
|
|
<MessageCircle size={14} className="text-primary shrink-0 mt-0.5" />
|
|
<p
|
|
className="text-xs text-muted-foreground line-clamp-2 italic"
|
|
title={agent.thought}
|
|
>
|
|
{agent.thought}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</>
|
|
)
|
|
}
|
|
|
|
// Log viewer modal component
|
|
interface AgentLogModalProps {
|
|
agent: ActiveAgent
|
|
logs: AgentLogEntry[]
|
|
onClose: () => void
|
|
}
|
|
|
|
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
|
|
.map(log => `[${log.timestamp}] ${log.line}`)
|
|
.join('\n')
|
|
await navigator.clipboard.writeText(logText)
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 2000)
|
|
}
|
|
|
|
const getLogColor = (type: AgentLogEntry['type']) => {
|
|
switch (type) {
|
|
case 'error':
|
|
return 'text-destructive'
|
|
case 'state_change':
|
|
return 'text-primary'
|
|
default:
|
|
return 'text-foreground'
|
|
}
|
|
}
|
|
|
|
return createPortal(
|
|
<div
|
|
className="fixed inset-0 flex items-center justify-center p-4 bg-black/50"
|
|
style={{ zIndex: 9999 }}
|
|
onClick={(e) => {
|
|
if (e.target === e.currentTarget) onClose()
|
|
}}
|
|
>
|
|
<Card className="w-full max-w-4xl max-h-[80vh] flex flex-col py-0">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-4 border-b">
|
|
<div className="flex items-center gap-3">
|
|
<AgentAvatar name={agent.agentName} state={agent.state} size="sm" />
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="font-semibold text-lg">
|
|
{agent.agentName} Logs
|
|
</h2>
|
|
<Badge variant="outline" className={`text-[10px] ${typeBadge.className}`}>
|
|
<TypeIcon size={10} />
|
|
{typeBadge.label}
|
|
</Badge>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">
|
|
{agent.featureIds && agent.featureIds.length > 1
|
|
? `Batch: ${agent.featureIds.map(id => `#${id}`).join(', ')}`
|
|
: `Feature #${agent.featureId}: ${agent.featureName}`
|
|
}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" size="sm" onClick={handleCopy}>
|
|
{copied ? <Check size={14} /> : <Copy size={14} />}
|
|
{copied ? 'Copied!' : 'Copy'}
|
|
</Button>
|
|
<Button variant="ghost" size="icon-sm" onClick={onClose}>
|
|
<X size={20} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Log content */}
|
|
<div className="flex-1 min-h-0 overflow-y-auto p-4 bg-muted/50">
|
|
<div className="font-mono text-xs space-y-1">
|
|
{logs.length === 0 ? (
|
|
<p className="text-muted-foreground italic">No logs available</p>
|
|
) : (
|
|
logs.map((log, idx) => (
|
|
<div key={idx} className={`${getLogColor(log.type)} whitespace-pre-wrap break-all`}>
|
|
<span className="text-muted-foreground">
|
|
[{new Date(log.timestamp).toLocaleTimeString()}]
|
|
</span>{' '}
|
|
{log.line}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="p-3 border-t text-xs text-muted-foreground">
|
|
{logs.length} log entries
|
|
</div>
|
|
</Card>
|
|
</div>,
|
|
document.body
|
|
)
|
|
}
|