nyk
feat(refactor): ready for manual QA after main sync (#274)
b6ecafa unverified
'use client'
import Image from 'next/image'
import { useState } from 'react'
import { ChatMessage } from '@/store'
import { detectTextDirection } from '@/lib/chat-utils'
const AGENT_COLORS: Record<string, { bg: string; text: string; border: string }> = {
coordinator: { bg: 'bg-purple-500/10', text: 'text-purple-400', border: 'border-purple-500/20' },
aegis: { bg: 'bg-red-500/10', text: 'text-red-400', border: 'border-red-500/20' },
research: { bg: 'bg-green-500/10', text: 'text-green-400', border: 'border-green-500/20' },
design: { bg: 'bg-pink-500/10', text: 'text-pink-400', border: 'border-pink-500/20' },
quant: { bg: 'bg-yellow-500/10', text: 'text-yellow-400', border: 'border-yellow-500/20' },
ops: { bg: 'bg-orange-500/10', text: 'text-orange-400', border: 'border-orange-500/20' },
reviewer: { bg: 'bg-teal-500/10', text: 'text-teal-400', border: 'border-teal-500/20' },
content: { bg: 'bg-indigo-500/10', text: 'text-indigo-400', border: 'border-indigo-500/20' },
seo: { bg: 'bg-cyan-500/10', text: 'text-cyan-400', border: 'border-cyan-500/20' },
security: { bg: 'bg-rose-500/10', text: 'text-rose-400', border: 'border-rose-500/20' },
ai: { bg: 'bg-violet-500/10', text: 'text-violet-400', border: 'border-violet-500/20' },
'frontend-dev': { bg: 'bg-sky-500/10', text: 'text-sky-400', border: 'border-sky-500/20' },
'backend-dev': { bg: 'bg-emerald-500/10', text: 'text-emerald-400', border: 'border-emerald-500/20' },
'solana-dev': { bg: 'bg-amber-500/10', text: 'text-amber-400', border: 'border-amber-500/20' },
system: { bg: 'bg-muted/50', text: 'text-muted-foreground', border: 'border-border' },
human: { bg: 'bg-primary/10', text: 'text-primary', border: 'border-primary/20' },
}
function getAgentTheme(name: string) {
return AGENT_COLORS[name.toLowerCase()] || { bg: 'bg-muted/50', text: 'text-muted-foreground', border: 'border-border' }
}
function formatTime(timestamp: number): string {
const date = new Date(timestamp * 1000)
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
// Simple markdown-lite: bold, italic, code, links
function renderContent(text: string) {
// Split by code blocks first
const parts = text.split(/(```[\s\S]*?```|`[^`]+`)/g)
return parts.map((part, i) => {
// Multi-line code block
if (part.startsWith('```') && part.endsWith('```')) {
const code = part.slice(3, -3).replace(/^\w+\n/, '') // strip language hint
return (
<pre key={i} className="bg-black/30 rounded-md px-3 py-2 my-1 text-xs font-mono overflow-x-auto whitespace-pre-wrap">
{code}
</pre>
)
}
// Inline code
if (part.startsWith('`') && part.endsWith('`')) {
return (
<code key={i} className="bg-black/20 rounded px-1 py-0.5 text-xs font-mono">
{part.slice(1, -1)}
</code>
)
}
// Regular text with bold/italic
return (
<span key={i}>
{part.split(/(\*\*[^*]+\*\*|\*[^*]+\*)/g).map((segment, j) => {
if (segment.startsWith('**') && segment.endsWith('**')) {
return <strong key={j} className="font-semibold">{segment.slice(2, -2)}</strong>
}
if (segment.startsWith('*') && segment.endsWith('*')) {
return <em key={j}>{segment.slice(1, -1)}</em>
}
return segment
})}
</span>
)
})
}
interface MessageBubbleProps {
message: ChatMessage
isHuman: boolean
isGrouped: boolean
}
function ToolCallBubble({ message }: { message: ChatMessage }) {
const [expanded, setExpanded] = useState(false)
const meta = asRecord(message.metadata)
const toolName = typeof meta.toolName === 'string' ? meta.toolName : 'unknown_tool'
const toolArgs = meta.toolArgs
const toolOutput = meta.toolOutput
const toolStatus = meta.toolStatus === 'running' || meta.toolStatus === 'success' || meta.toolStatus === 'error'
? meta.toolStatus
: undefined
const durationMs = typeof meta.durationMs === 'number' ? meta.durationMs : undefined
const theme = getAgentTheme(message.from_agent)
const statusIcon = toolStatus === 'running' ? '...' : toolStatus === 'error' ? 'x' : '>'
const statusColor = toolStatus === 'running'
? 'text-yellow-400'
: toolStatus === 'error'
? 'text-red-400'
: 'text-green-400'
return (
<div className="flex gap-2 mt-2">
<div className="w-7 flex-shrink-0" />
<div className="max-w-[85%] min-w-0">
<button
onClick={() => setExpanded(e => !e)}
className="flex items-center gap-2 w-full text-left group"
>
<span className={`font-mono text-xs font-bold ${statusColor}`}>{statusIcon}</span>
<span className="font-mono text-xs text-muted-foreground">
<span className={`font-medium ${theme.text}`}>{message.from_agent}</span>
{' called '}
<span className="text-foreground font-semibold">{toolName}</span>
</span>
{toolStatus === 'running' && (
<span className="inline-block w-1.5 h-1.5 rounded-full bg-yellow-400 animate-pulse" />
)}
{durationMs != null && toolStatus !== 'running' && (
<span className="text-[10px] text-muted-foreground/50">{durationMs}ms</span>
)}
<svg
className={`w-3 h-3 text-muted-foreground/40 transition-transform ${expanded ? 'rotate-90' : ''}`}
viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"
>
<path d="M5 3l6 5-6 5" />
</svg>
</button>
{expanded && (
<div className="mt-1 ml-5 space-y-1">
{toolArgs != null && (
<div>
<div className="text-[10px] text-muted-foreground/60 uppercase tracking-wider mb-0.5">Args</div>
<pre className="bg-black/20 rounded-md px-2 py-1.5 text-[11px] font-mono text-muted-foreground overflow-x-auto max-h-32 whitespace-pre-wrap">
{typeof toolArgs === 'string' ? toolArgs : JSON.stringify(toolArgs, null, 2)}
</pre>
</div>
)}
{toolOutput != null && (
<div>
<div className="text-[10px] text-muted-foreground/60 uppercase tracking-wider mb-0.5">Output</div>
<pre className={`rounded-md px-2 py-1.5 text-[11px] font-mono overflow-x-auto max-h-48 whitespace-pre-wrap ${
toolStatus === 'error' ? 'bg-red-500/10 text-red-300' : 'bg-black/20 text-muted-foreground'
}`}>
{typeof toolOutput === 'string' ? toolOutput : JSON.stringify(toolOutput, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
</div>
)
}
export function MessageBubble({ message, isHuman, isGrouped }: MessageBubbleProps) {
const isSystem = message.message_type === 'system'
const isHandoff = message.message_type === 'handoff'
const isCommand = message.message_type === 'command'
const isToolCall = message.message_type === 'tool_call'
const theme = getAgentTheme(message.from_agent)
if (isSystem) {
return (
<div className="flex justify-center my-3">
<div className="text-[11px] text-muted-foreground/70 bg-surface-1 px-3 py-1 rounded-full border border-border/30">
{message.content}
</div>
</div>
)
}
if (isHandoff) {
return (
<div className="flex justify-center my-3">
<div className="flex items-center gap-2 text-[11px] text-amber-400/80 bg-amber-500/5 px-3 py-1.5 rounded-full border border-amber-500/20">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M5 3l6 5-6 5" />
</svg>
<span>{message.from_agent} handed off to {message.to_agent}</span>
</div>
</div>
)
}
if (isToolCall) {
return <ToolCallBubble message={message} />
}
return (
<div className={`flex gap-2 ${isHuman ? 'flex-row-reverse' : 'flex-row'} ${isGrouped ? 'mt-0.5' : 'mt-3'}`}>
{/* Avatar */}
{!isGrouped ? (
<div className={`w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0 text-[10px] font-bold ${theme.bg} ${theme.text} border ${theme.border}`}>
{message.from_agent.charAt(0).toUpperCase()}
</div>
) : (
<div className="w-7 flex-shrink-0" />
)}
{/* Content */}
<div className={`max-w-[80%] min-w-0 ${isHuman ? 'items-end' : 'items-start'}`}>
{/* Name + recipient */}
{!isGrouped && (
<div className={`flex items-center gap-1.5 mb-0.5 ${isHuman ? 'flex-row-reverse' : 'flex-row'}`}>
<span className={`text-[11px] font-medium ${theme.text}`}>
{message.from_agent}
</span>
{message.to_agent && (
<span className="text-[10px] text-muted-foreground/50 flex items-center gap-0.5">
<svg width="8" height="8" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M5 3l6 5-6 5" />
</svg>
{message.to_agent}
</span>
)}
<span className="text-[10px] text-muted-foreground/40">
{formatTime(message.created_at)}
</span>
</div>
)}
{/* Bubble */}
<div className={`rounded-lg px-3 py-2 text-sm leading-relaxed ${
isHuman
? 'bg-primary text-primary-foreground rounded-tr-sm'
: isCommand
? `${theme.bg} border ${theme.border} font-mono text-xs rounded-tl-sm`
: `bg-surface-2 text-foreground ${isGrouped ? 'rounded-tl-sm' : 'rounded-tl-sm'}`
}`}>
{/* Attachment thumbnails */}
{message.attachments && message.attachments.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-1.5">
{message.attachments.map((att, idx) => (
att.type.startsWith('image/') ? (
<Image
key={idx}
src={att.dataUrl}
alt={att.name}
width={200}
height={160}
unoptimized
className="max-w-[200px] max-h-[160px] rounded-md object-cover border border-border/30"
/>
) : (
<div key={idx} className="flex items-center gap-1.5 bg-black/20 rounded-md px-2 py-1 text-xs text-muted-foreground">
<span className="font-medium">{att.name}</span>
<span className="text-[10px] text-muted-foreground/50">{att.size < 1024 ? `${att.size} B` : att.size < 1024 * 1024 ? `${(att.size / 1024).toFixed(1)} KB` : `${(att.size / (1024 * 1024)).toFixed(1)} MB`}</span>
</div>
)
))}
</div>
)}
{isCommand ? (
<pre className="whitespace-pre-wrap">{message.content}</pre>
) : (
<div className="whitespace-pre-wrap break-words" dir={detectTextDirection(message.content)}>{renderContent(message.content)}</div>
)}
</div>
</div>
</div>
)
}