diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 45164f1..dd6b726 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -5,7 +5,16 @@ import type { MouseEvent, ReactNode } from 'react' import * as Dialog from '@radix-ui/react-dialog' import { Activity, + Bot, + Brain, + Check, + ChevronDown, ChevronRight, + ChevronUp, + FileText, + Loader2, + MessageSquare, + Pencil, Download, Globe, LayoutDashboard, @@ -13,12 +22,15 @@ import { Play, Plus, Rocket, + Send, Settings, + Terminal, Trash2, Users, X, + Zap, } from 'lucide-react' -import { effectiveDomains, normalizeProjectDomain } from '@ainyc/canonry-contracts' +import { effectiveDomains, normalizeProjectDomain, MODEL_REGISTRY } from '@ainyc/canonry-contracts' import { Badge } from './components/ui/badge.js' import { Button } from './components/ui/button.js' @@ -86,6 +98,14 @@ import { type ApiGscInspection, type ApiGscDeindexedRow, type GroundingSource, + createAgentThread, + fetchAgentThreads, + fetchAgentThread, + sendAgentMessage, + renameAgentThread, + deleteAgentThread, + type ApiAgentThread, + type ApiAgentMessage, } from './api.js' import { buildDashboard } from './build-dashboard.js' import type { ProjectData } from './build-dashboard.js' @@ -132,6 +152,7 @@ type AppRoute = | { kind: 'runs'; path: '/runs' } | { kind: 'settings'; path: '/settings' } | { kind: 'setup'; path: '/setup' } + | { kind: 'aero'; path: '/aero' } | { kind: 'not-found'; path: string } type DrawerState = @@ -247,6 +268,10 @@ function resolveRoute(pathname: string, dashboard: DashboardVm): AppRoute { return { kind: 'setup', path: '/setup' } } + if (normalized === '/aero') { + return { kind: 'aero', path: '/aero' } + } + if (normalized === '/projects') { return { kind: 'projects', path: '/projects' } } @@ -469,7 +494,7 @@ function createNavigationHandler(navigate: (to: string) => void, to: string) { } } -function isNavActive(route: AppRoute, section: 'overview' | 'projects' | 'project' | 'runs' | 'settings'): boolean { +function isNavActive(route: AppRoute, section: 'overview' | 'projects' | 'project' | 'runs' | 'aero' | 'settings'): boolean { if (section === 'projects') { return route.kind === 'projects' || route.kind === 'project' } @@ -5381,6 +5406,941 @@ function SetupPage({ ) } +// ── Aero (Agent Chat) ───────────────────────────────────────── + +function formatRelativeDate(dateStr: string): string { + const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000) + if (seconds < 60) return 'just now' + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago` + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago` + if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago` + return new Date(dateStr).toLocaleDateString() +} + +/** Lightweight markdown → React renderer for chat messages */ +function renderMarkdown(text: string): React.ReactNode { + const lines = text.split('\n') + const elements: React.ReactNode[] = [] + let i = 0 + + function inlineFormat(s: string): React.ReactNode { + const parts: React.ReactNode[] = [] + // Process inline: code, bold, italic, links + const rx = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)]+\))/g + let last = 0 + let m: RegExpExecArray | null + let ki = 0 + while ((m = rx.exec(s)) !== null) { + if (m.index > last) parts.push(s.slice(last, m.index)) + const token = m[0] + if (token.startsWith('`')) { + parts.push({token.slice(1, -1)}) + } else if (token.startsWith('**')) { + parts.push({token.slice(2, -2)}) + } else if (token.startsWith('*')) { + parts.push({token.slice(1, -1)}) + } else if (token.startsWith('[')) { + const lm = token.match(/\[([^\]]+)\]\(([^)]+)\)/) + if (lm) parts.push({lm[1]}) + } + last = m.index + token.length + } + if (last < s.length) parts.push(s.slice(last)) + return parts.length === 1 ? parts[0] : parts + } + + while (i < lines.length) { + const line = lines[i] + + // Code block + if (line.trimStart().startsWith('```')) { + const codeLines: string[] = [] + i++ + while (i < lines.length && !lines[i].trimStart().startsWith('```')) { + codeLines.push(lines[i]) + i++ + } + i++ // skip closing ``` + elements.push(
{codeLines.join('\n')}
) + continue + } + + // Blank line + if (line.trim() === '') { i++; continue } + + // Horizontal rule + if (/^---+$/.test(line.trim())) { + elements.push(
) + i++ + continue + } + + // Headings + const hm = line.match(/^(#{1,4})\s+(.+)/) + if (hm) { + const level = hm[1].length + const cls = level === 1 ? 'text-[15px] font-bold text-zinc-100 mt-4 mb-2' + : level === 2 ? 'text-[14px] font-bold text-zinc-100 mt-3 mb-1.5' + : 'text-[13px] font-semibold text-zinc-200 mt-2 mb-1' + elements.push(
{inlineFormat(hm[2])}
) + i++ + continue + } + + // List items (- or *) + if (/^\s*[-*]\s/.test(line)) { + const items: React.ReactNode[] = [] + while (i < lines.length && /^\s*[-*]\s/.test(lines[i])) { + items.push(
  • {inlineFormat(lines[i].replace(/^\s*[-*]\s+/, ''))}
  • ) + i++ + } + elements.push() + continue + } + + // Numbered list + if (/^\s*\d+[.)]\s/.test(line)) { + const items: React.ReactNode[] = [] + while (i < lines.length && /^\s*\d+[.)]\s/.test(lines[i])) { + items.push(
  • {inlineFormat(lines[i].replace(/^\s*\d+[.)]\s+/, ''))}
  • ) + i++ + } + elements.push(
      {items}
    ) + continue + } + + // Paragraph + elements.push(

    {inlineFormat(line)}

    ) + i++ + } + return <>{elements} +} + +// ── Aero tool call rendering helpers ──────────────────────────────── + +type AeroTurn = + | { type: 'user'; message: ApiAgentMessage } + | { type: 'assistant'; toolCalls: Array<{ call: ApiAgentMessage; result: ApiAgentMessage | null }>; response: ApiAgentMessage | null } + +function groupMessagesIntoTurns(messages: ApiAgentMessage[]): AeroTurn[] { + const turns: AeroTurn[] = [] + let i = 0 + while (i < messages.length) { + const msg = messages[i] + if (msg.role === 'user') { + turns.push({ type: 'user', message: msg }) + i++ + } else if (msg.role === 'assistant' && msg.toolName) { + // Start collecting tool calls for this assistant turn + const toolCalls: Array<{ call: ApiAgentMessage; result: ApiAgentMessage | null }> = [] + while (i < messages.length && messages[i].role === 'assistant' && messages[i].toolName) { + const call = messages[i] + // Look for matching tool result + const resultIdx = messages.findIndex((m, j) => j > i && m.role === 'tool' && m.toolCallId === call.toolCallId) + toolCalls.push({ call, result: resultIdx !== -1 ? messages[resultIdx] : null }) + i++ + } + // Skip past tool result messages we already consumed + while (i < messages.length && messages[i].role === 'tool') i++ + // Check if next message is the final text response + let response: ApiAgentMessage | null = null + if (i < messages.length && messages[i].role === 'assistant' && !messages[i].toolName) { + response = messages[i] + i++ + } + turns.push({ type: 'assistant', toolCalls, response }) + } else if (msg.role === 'assistant') { + turns.push({ type: 'assistant', toolCalls: [], response: msg }) + i++ + } else { + i++ // skip orphaned tool results + } + } + return turns +} + +function toolCategory(name: string): 'read' | 'write' | 'system' | 'memory' { + if (name === 'get_memory' || name === 'save_memory') return 'memory' + if (name === 'run_command' || name === 'read_file' || name === 'write_file' || name === 'list_files' || name === 'http_request') return 'system' + if (name.startsWith('get_') || name.startsWith('list_') || name === 'inspect_url') return 'read' + return 'write' +} + +function toolDisplayLabel(name: string, args: string | null): string { + const labels: Record = { + get_status: 'Checking project status', + get_evidence: 'Fetching citation evidence', + get_timeline: 'Loading visibility timeline', + get_run_details: 'Loading run details', + list_keywords: 'Listing keywords', + list_competitors: 'Listing competitors', + get_gsc_performance: 'Fetching GSC performance', + get_gsc_coverage: 'Checking index coverage', + inspect_url: 'Inspecting URL', + run_sweep: 'Running visibility sweep', + add_keywords: 'Adding keywords', + remove_keywords: 'Removing keywords', + add_competitors: 'Adding competitors', + remove_competitors: 'Removing competitors', + update_project: 'Updating project settings', + get_memory: 'Loading memory', + save_memory: 'Saving observations', + list_files: 'Listing files', + } + if (name === 'run_command' && args) { + try { const p = JSON.parse(args); if (p.command) return `$ ${String(p.command).slice(0, 60)}${String(p.command).length > 60 ? '...' : ''}` } catch { /* */ } + return 'Executing command' + } + if (name === 'read_file' && args) { + try { const p = JSON.parse(args); if (p.path) return `Reading ${String(p.path).split('/').pop()}` } catch { /* */ } + return 'Reading file' + } + if (name === 'write_file' && args) { + try { const p = JSON.parse(args); if (p.path) return `Writing ${String(p.path).split('/').pop()}` } catch { /* */ } + return 'Writing file' + } + if (name === 'http_request' && args) { + try { const p = JSON.parse(args); return `${String(p.method || 'GET')} ${String(p.url).slice(0, 50)}${String(p.url).length > 50 ? '...' : ''}` } catch { /* */ } + return 'HTTP request' + } + return labels[name] || name +} + +function toolIcon(name: string): React.ReactNode { + const cat = toolCategory(name) + const cls = 'h-3.5 w-3.5 shrink-0' + if (name === 'run_command') return + if (name === 'read_file' || name === 'write_file' || name === 'list_files') return + if (name === 'http_request') return + if (cat === 'memory') return + if (name === 'run_sweep') return + if (name.includes('keyword')) return + if (name.includes('competitor')) return + return +} + +function renderToolResult(name: string, content: string): React.ReactNode { + const cat = toolCategory(name) + + // System tools: terminal-style rendering + if (name === 'run_command') { + return ( +
    {content}
    + ) + } + + if (name === 'read_file') { + return
    {content}
    + } + + if (name === 'http_request') { + const firstLine = content.split('\n')[0] || '' + const statusMatch = firstLine.match(/^HTTP (\d+)/) + const status = statusMatch ? parseInt(statusMatch[1], 10) : 0 + const statusCls = status >= 200 && status < 300 ? 'text-emerald-400' : status >= 400 ? 'text-rose-400' : 'text-amber-400' + return ( +
    + {status > 0 && {firstLine}} +
    {content.split('\n').slice(status > 0 ? 2 : 0).join('\n')}
    +
    + ) + } + + // Memory tools: minimal + if (name === 'get_memory' || name === 'save_memory') { + if (name === 'save_memory') { + try { const d = JSON.parse(content); return Saved {d.bytes} bytes } catch { /* */ } + } + return {content.length > 100 ? 'Memory loaded' : content} + } + + // Write tool results: compact + if (name === 'write_file') { + try { const d = JSON.parse(content); return Wrote {d.bytes} bytes to {d.written} } catch { /* */ } + } + + // Try to parse JSON for structured rendering + try { + const data = JSON.parse(content) + + // list_keywords / list_competitors: pill chips + if ((name === 'list_keywords' || name === 'list_competitors') && Array.isArray(data)) { + const items = data.map((item: unknown) => { + if (typeof item === 'string') return item + if (typeof item === 'object' && item !== null) return (item as Record).keyword || (item as Record).phrase || (item as Record).domain || (item as Record).name || JSON.stringify(item) + return String(item) + }) + return ( +
    + {items.map((item, idx) => ( + {String(item)} + ))} +
    + ) + } + + // add/remove keywords/competitors: action result + if (name === 'add_keywords' || name === 'remove_keywords') { + const action = name === 'add_keywords' ? 'Added' : 'Removed' + const kws = data.added || data.removed || [] + return {action} {kws.length} keyword{kws.length !== 1 ? 's' : ''}: {kws.join(', ')} + } + if (name === 'add_competitors' || name === 'remove_competitors') { + const action = name === 'add_competitors' ? 'Added' : 'Removed' + const domains = data.added || data.removed || [] + return {action} {domains.length} competitor{domains.length !== 1 ? 's' : ''} + } + + // run_sweep: status card + if (name === 'run_sweep') { + return ( +
    + Started + {data.id && Run {String(data.id).slice(0, 8)}} +
    + ) + } + + // get_status: project summary + if (name === 'get_status' && data.project) { + const p = data.project + const runs = data.latestRuns || [] + return ( +
    +
    + {p.displayName || p.name} + · + {p.canonicalDomain || p.domain} + · + {p.country}, {p.language} +
    + {runs.length > 0 && ( +
    + {runs.map((r: Record, idx: number) => { + const st = String(r.status || 'unknown') + const cls = st === 'completed' ? 'bg-emerald-950/40 border-emerald-800/40 text-emerald-400' + : st === 'partial' ? 'bg-amber-950/40 border-amber-800/40 text-amber-400' + : st === 'failed' ? 'bg-rose-950/40 border-rose-800/40 text-rose-400' + : 'bg-zinc-800/40 border-zinc-700/40 text-zinc-400' + return {st} {r.id ? `#${String(r.id).slice(0, 6)}` : ''} + })} +
    + )} +
    + ) + } + + // get_evidence: citation table + if (name === 'get_evidence' && Array.isArray(data)) { + const rows = data.slice(0, 15) // limit rows + return ( +
    + + + + + + + + + + {rows.map((row: Record, idx: number) => { + const state = String(row.citationState || row.state || row.cited || 'unknown') + const stateCls = state === 'cited' ? 'text-emerald-400' : state === 'not-cited' ? 'text-zinc-500' : state === 'lost' ? 'text-rose-400' : state === 'emerging' ? 'text-amber-400' : 'text-zinc-500' + return ( + + + + + + ) + })} + +
    KeywordProviderStatus
    {String(row.keyword || row.phrase || '')}{String(row.provider || row.model || '')}{state}
    + {data.length > 15 && +{data.length - 15} more rows} +
    + ) + } + + // get_timeline: text summary + if (name === 'get_timeline') { + if (Array.isArray(data) && data.length > 0) { + const latest = data[data.length - 1] + const earliest = data[0] + return ( + + {data.length} data points · Latest visibility: {latest?.rate != null ? `${Math.round(Number(latest.rate) * 100)}%` : 'N/A'} + {earliest?.rate != null && latest?.rate != null ? ` (from ${Math.round(Number(earliest.rate) * 100)}%)` : ''} + + ) + } + } + + // get_gsc_performance: compact metrics + if (name === 'get_gsc_performance') { + if (data.rows && Array.isArray(data.rows)) { + return ( +
    + + + + {data.rows.slice(0, 10).map((r: Record, idx: number) => ( + + + + + + + + ))} + +
    QueryClicksImpressionsCTRPosition
    {String((r.keys as string[])?.[0] || '')}{String(r.clicks || 0)}{String(r.impressions || 0)}{r.ctr != null ? `${(Number(r.ctr) * 100).toFixed(1)}%` : '-'}{r.position != null ? Number(r.position).toFixed(1) : '-'}
    +
    + ) + } + } + + // Fallback: formatted JSON + return
    {JSON.stringify(data, null, 2)}
    + + } catch { + // Not JSON — render as text + if (cat === 'system') { + return
    {content}
    + } + return {content.length > 300 ? content.slice(0, 300) + '...' : content} + } +} + +function ToolCallCard({ call, result, isActive }: { call: ApiAgentMessage; result: ApiAgentMessage | null; isActive: boolean }) { + const [expanded, setExpanded] = useState(false) + const cat = toolCategory(call.toolName!) + const borderCls = cat === 'system' ? 'aero-tool-card-system' + : cat === 'memory' ? 'aero-tool-card-memory' + : cat === 'write' ? 'aero-tool-card-write' + : 'aero-tool-card-read' + + return ( +
    + + {expanded && result && ( +
    + {renderToolResult(call.toolName!, result.content)} +
    + )} +
    + ) +} + +const AERO_QUICK_ACTIONS = [ + { label: "How's my visibility across providers?", icon: Activity }, + { label: 'Run a fresh visibility sweep', icon: Play }, + { label: 'Which keywords am I losing?', icon: Zap }, + { label: 'Show my competitor landscape', icon: Users }, + { label: 'What changed since my last run?', icon: Activity }, +] + +const AERO_INPUT_CHIPS = [ + { label: 'Run sweep', msg: 'Run a fresh visibility sweep across all providers' }, + { label: 'Check visibility', msg: "How's my visibility looking?" }, + { label: 'Add keyword', msg: 'Add keywords: ' }, +] + +function AeroPage({ projects, providers }: { + projects: Array<{ name: string; displayName?: string }> + providers: Array<{ name: string; state: string }> +}) { + const [selectedProject, setSelectedProject] = useState(projects[0]?.name ?? '') + const [selectedProvider, setSelectedProvider] = useState('') + const [selectedModel, setSelectedModel] = useState('') + const [threads, setThreads] = useState([]) + const [activeThreadId, setActiveThreadId] = useState(null) + const [messages, setMessages] = useState([]) + const [input, setInput] = useState('') + const [processing, setProcessing] = useState(false) + const [error, setError] = useState(null) + const [editingThreadId, setEditingThreadId] = useState(null) + const [editTitle, setEditTitle] = useState('') + const messagesEndRef = useRef(null) + const pollRef = useRef | null>(null) + + const configuredProviders = providers.filter(p => p.state === 'ready') + + // Stop polling on unmount + useEffect(() => { + return () => { if (pollRef.current) clearInterval(pollRef.current) } + }, []) + + // Load threads when project changes + useEffect(() => { + if (!selectedProject) return + fetchAgentThreads(selectedProject).then(setThreads).catch(() => setThreads([])) + }, [selectedProject]) + + // Load messages when thread changes; start polling if thread is processing + useEffect(() => { + if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null } + if (!selectedProject || !activeThreadId) { + setMessages([]) + setProcessing(false) + return + } + + function loadThread() { + fetchAgentThread(selectedProject, activeThreadId!) + .then(data => { + setMessages(data.messages) + if (data.status === 'processing') { + setProcessing(true) + // Start polling if not already + if (!pollRef.current) { + pollRef.current = setInterval(loadThread, 1500) + } + } else { + setProcessing(false) + if (data.error) setError(data.error) + if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null } + } + }) + .catch(() => setMessages([])) + } + loadThread() + + return () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null } } + }, [selectedProject, activeThreadId]) + + // Scroll to bottom on new messages + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages]) + + async function handleRenameThread(threadId: string) { + if (!selectedProject || !editTitle.trim()) return + try { + const updated = await renameAgentThread(selectedProject, threadId, editTitle.trim()) + setThreads(prev => prev.map(t => t.id === threadId ? { ...t, title: updated.title } : t)) + } catch { /* ignore */ } + setEditingThreadId(null) + } + + async function handleNewThread() { + if (!selectedProject) return + const thread = await createAgentThread(selectedProject) + setThreads(prev => [thread, ...prev]) + setActiveThreadId(thread.id) + setMessages([]) + setError(null) + } + + async function handleDeleteThread(threadId: string) { + if (!selectedProject) return + await deleteAgentThread(selectedProject, threadId) + setThreads(prev => prev.filter(t => t.id !== threadId)) + if (activeThreadId === threadId) { + setActiveThreadId(null) + setMessages([]) + } + } + + async function handleSend() { + if (!input.trim() || !selectedProject || !activeThreadId || processing) return + const msg = input.trim() + setInput('') + setError(null) + + // Optimistically add user message + const optimisticUser: ApiAgentMessage = { + id: `temp-${Date.now()}`, + threadId: activeThreadId, + role: 'user', + content: msg, + toolName: null, + toolArgs: null, + toolCallId: null, + createdAt: new Date().toISOString(), + } + setMessages(prev => [...prev, optimisticUser]) + setProcessing(true) + + try { + await sendAgentMessage( + selectedProject, + activeThreadId, + msg, + selectedProvider || undefined, + selectedModel || undefined, + ) + + // Start polling for the response + if (pollRef.current) clearInterval(pollRef.current) + const tid = activeThreadId + pollRef.current = setInterval(() => { + fetchAgentThread(selectedProject, tid) + .then(data => { + setMessages(data.messages) + if (data.status !== 'processing') { + setProcessing(false) + if (data.error) setError(data.error) + if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null } + // Refresh threads to update titles + fetchAgentThreads(selectedProject).then(setThreads).catch(() => {}) + } + }) + .catch(() => {}) + }, 1500) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + setProcessing(false) + } + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSend() + } + } + + const turns = useMemo(() => groupMessagesIntoTurns(messages), [messages]) + + // Check if the last assistant turn has tool calls without a final response yet (still working) + const lastTurn = turns[turns.length - 1] + const hasActiveToolCalls = processing && lastTurn?.type === 'assistant' && lastTurn.toolCalls.length > 0 && !lastTurn.response + + async function handleQuickAction(msg: string) { + if (!selectedProject) return + const thread = await createAgentThread(selectedProject) + setThreads(prev => [thread, ...prev]) + setActiveThreadId(thread.id) + setMessages([]) + setError(null) + // Small delay to let state settle, then send the message + setTimeout(async () => { + const optimisticUser: ApiAgentMessage = { + id: `temp-${Date.now()}`, + threadId: thread.id, + role: 'user', + content: msg, + toolName: null, + toolArgs: null, + toolCallId: null, + createdAt: new Date().toISOString(), + } + setMessages([optimisticUser]) + setProcessing(true) + try { + await sendAgentMessage(selectedProject, thread.id, msg, selectedProvider || undefined, selectedModel || undefined) + if (pollRef.current) clearInterval(pollRef.current) + pollRef.current = setInterval(() => { + fetchAgentThread(selectedProject, thread.id) + .then(data => { + setMessages(data.messages) + if (data.status !== 'processing') { + setProcessing(false) + if (data.error) setError(data.error) + if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null } + fetchAgentThreads(selectedProject).then(setThreads).catch(() => {}) + } + }) + .catch(() => {}) + }, 1500) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + setProcessing(false) + } + }, 100) + } + + function handleChipClick(msg: string) { + setInput(msg) + } + + // Count visible (non-tool) messages for empty state check + const hasVisibleContent = turns.some(t => t.type === 'user' || (t.type === 'assistant' && (t.response || t.toolCalls.length > 0))) + + return ( +
    +
    +
    +

    + Aero +

    +

    AI-powered AEO analyst

    +
    +
    + + + {selectedProvider && MODEL_REGISTRY[selectedProvider as keyof typeof MODEL_REGISTRY] && ( + + )} +
    +
    + +
    + {/* Thread list sidebar */} +
    +
    +

    Threads

    + +
    + {threads.length === 0 ? ( +

    No threads yet.

    + ) : ( +
    + {threads.map(t => ( +
    { if (editingThreadId !== t.id) setActiveThreadId(t.id) }} + > + +
    + {editingThreadId === t.id ? ( +
    { e.preventDefault(); handleRenameThread(t.id) }} + > + setEditTitle(e.target.value)} + onBlur={() => handleRenameThread(t.id)} + onKeyDown={e => { if (e.key === 'Escape') setEditingThreadId(null) }} + autoFocus + /> + +
    + ) : ( + + {t.title ?? 'Untitled'} + + )} + + {formatRelativeDate(t.updatedAt)} + +
    + {editingThreadId !== t.id && ( +
    + + +
    + )} +
    + ))} +
    + )} +
    + + {/* Chat area */} +
    + {!activeThreadId ? ( + /* ── Enhanced empty state: capability showcase ── */ +
    + +

    Aero

    +

    AEO analyst with full system access

    + + {/* Capability pills */} +
    + Citation Analysis + Run Sweeps + Persistent Memory + System Access +
    + + {/* Quick actions */} +
    +

    Quick actions

    +
    + {AERO_QUICK_ACTIONS.map((action, idx) => ( + + ))} +
    +
    +
    + ) : ( + <> + {/* Messages — rendered as turns */} +
    + {!hasVisibleContent && !processing && ( +
    + +

    Send a message to get started.

    + {/* Suggested prompts for empty thread */} +
    + {AERO_QUICK_ACTIONS.slice(0, 3).map((action, idx) => ( + + ))} +
    +
    + )} + {turns.map((turn, tidx) => { + if (turn.type === 'user') { + return ( +
    +
    You
    +
    {turn.message.content}
    +
    + ) + } + + // Assistant turn + const isLastTurn = tidx === turns.length - 1 + return ( +
    +
    Aero
    + {/* Tool call cards */} + {turn.toolCalls.length > 0 && ( +
    + {turn.toolCalls.map((tc, tcIdx) => ( + + ))} +
    + )} + {/* Final text response */} + {turn.response && ( +
    + {renderMarkdown(turn.response.content)} +
    + )} +
    + ) + })} + {/* Show thinking indicator only when processing and no tool calls visible yet */} + {processing && !hasActiveToolCalls && turns[turns.length - 1]?.type !== 'assistant' && ( +
    +
    Aero
    +
    + Thinking +
    +
    + )} + {error && ( +
    + {error} +
    + )} +
    +
    + + {/* Quick chips + Input */} +
    + {!processing && messages.length === 0 && ( +
    + {AERO_INPUT_CHIPS.map((chip, idx) => ( + + ))} +
    + )} +
    +