Spaces:
Running
Running
| "use client"; | |
| import React, { useState, useRef, useEffect } from "react"; | |
| import { createPortal } from "react-dom"; | |
| import { | |
| Activity, | |
| X, | |
| Server, | |
| MessageSquare, | |
| Wrench, | |
| Layers, | |
| Clock, | |
| Zap, | |
| CheckCircle2, | |
| XCircle, | |
| Loader2, | |
| DollarSign, | |
| ChevronDown, | |
| ChevronRight, | |
| } from "lucide-react"; | |
| import { useObservability } from "@/hooks/useObservability"; | |
| import { Message } from "@/hooks/useConversation"; | |
| interface ObservabilityPanelProps { | |
| isOpen?: boolean; | |
| onToggle?: () => void; | |
| messages: Message[]; | |
| isSessionActive: boolean; | |
| isConnected: boolean; | |
| } | |
| export function ObservabilityPanel({ | |
| isOpen, | |
| onToggle, | |
| messages, | |
| isSessionActive, | |
| isConnected, | |
| }: ObservabilityPanelProps) { | |
| const data = useObservability({ messages, isSessionActive }); | |
| if (!isOpen) { | |
| return ( | |
| <button | |
| onClick={onToggle} | |
| className="flex flex-col items-center gap-1 p-2 text-gray-400 hover:text-white transition-all" | |
| title="Observability" | |
| > | |
| <Activity className="w-5 h-5" /> | |
| <span className="text-[10px]">Monitor</span> | |
| </button> | |
| ); | |
| } | |
| return ( | |
| <> | |
| <button onClick={onToggle} className="btn btn-secondary p-2"> | |
| <Activity className="w-5 h-5" /> | |
| </button> | |
| {createPortal( | |
| <div | |
| className="modal-overlay" | |
| onClick={(e) => { | |
| if (e.target === e.currentTarget) onToggle?.(); | |
| }} | |
| > | |
| <div className="modal-fullscreen p-0"> | |
| {/* Header — merged with status strip */} | |
| <div | |
| style={{ | |
| padding: "16px 24px", | |
| borderBottom: "1px solid rgba(255, 255, 255, 0.08)", | |
| background: "var(--color-surface-subtle)", | |
| display: "flex", | |
| alignItems: "center", | |
| justifyContent: "space-between", | |
| flexShrink: 0, | |
| }} | |
| > | |
| <div style={{ display: "flex", alignItems: "center", gap: 12 }}> | |
| <div | |
| style={{ | |
| width: 36, | |
| height: 36, | |
| borderRadius: 10, | |
| background: "var(--color-accent-pink)", | |
| display: "flex", | |
| alignItems: "center", | |
| justifyContent: "center", | |
| boxShadow: "0 2px 8px rgba(255, 193, 204, 0.3)", | |
| }} | |
| > | |
| <Activity className="w-5 h-5" style={{ color: "#1a1a1a" }} /> | |
| </div> | |
| <div> | |
| <h3 | |
| style={{ | |
| fontSize: 20, | |
| fontWeight: 700, | |
| color: "var(--color-text-primary)", | |
| margin: 0, | |
| letterSpacing: "-0.01em", | |
| }} | |
| > | |
| System Monitor | |
| </h3> | |
| <p | |
| style={{ | |
| fontSize: 11, | |
| color: "var(--color-text-muted)", | |
| textTransform: "uppercase", | |
| letterSpacing: "0.1em", | |
| fontWeight: 500, | |
| margin: 0, | |
| }} | |
| > | |
| Real-Time Observability | |
| </p> | |
| </div> | |
| </div> | |
| {/* Inline status indicators */} | |
| <div style={{ display: "flex", alignItems: "center", gap: 20 }}> | |
| {/* Connection */} | |
| <div style={{ display: "flex", alignItems: "center", gap: 6 }}> | |
| <div | |
| style={{ | |
| width: 8, | |
| height: 8, | |
| borderRadius: "50%", | |
| background: isConnected ? "var(--color-success)" : "var(--color-error)", | |
| boxShadow: isConnected | |
| ? "0 0 8px var(--color-success)" | |
| : "0 0 8px var(--color-error)", | |
| }} | |
| /> | |
| <span | |
| style={{ | |
| fontSize: 14, | |
| fontWeight: 500, | |
| color: isConnected ? "var(--color-success)" : "var(--color-error)", | |
| }} | |
| > | |
| {isConnected ? "Connected" : "Disconnected"} | |
| </span> | |
| </div> | |
| <div style={{ width: 1, height: 16, background: "rgba(255,255,255,0.1)" }} /> | |
| {/* Session uptime */} | |
| <div style={{ display: "flex", alignItems: "center", gap: 6 }}> | |
| <Clock className="w-3.5 h-3.5" style={{ color: "var(--color-text-muted)" }} /> | |
| <span style={{ fontSize: 14, color: "var(--color-text-secondary)" }}> | |
| {isSessionActive && data.sessionUptimeSeconds != null | |
| ? formatDuration(data.sessionUptimeSeconds) | |
| : "No session"} | |
| </span> | |
| </div> | |
| <div style={{ width: 1, height: 16, background: "rgba(255,255,255,0.1)" }} /> | |
| {/* Events/sec */} | |
| <div style={{ display: "flex", alignItems: "center", gap: 6 }}> | |
| <Zap className="w-3.5 h-3.5" style={{ color: "var(--color-accent-pink)" }} /> | |
| <span | |
| style={{ | |
| fontSize: 14, | |
| fontFamily: "var(--font-mono)", | |
| color: "var(--color-accent-pink)", | |
| }} | |
| > | |
| {data.eventsPerSecond.toFixed(1)} evt/s | |
| </span> | |
| </div> | |
| <div style={{ width: 1, height: 16, background: "rgba(255,255,255,0.1)" }} /> | |
| <button | |
| onClick={onToggle} | |
| className="btn btn-ghost p-1.5 hover:bg-surface-elevated rounded-lg transition-colors" | |
| > | |
| <X className="w-5 h-5 text-secondary" /> | |
| </button> | |
| </div> | |
| </div> | |
| {/* Content — 3-column bento grid */} | |
| <div | |
| style={{ | |
| flex: 1, | |
| overflow: "hidden", | |
| display: "grid", | |
| gridTemplateColumns: "1fr 1fr 1.5fr", | |
| gridTemplateRows: "1fr 1fr", | |
| gap: 0, | |
| minHeight: 0, | |
| }} | |
| > | |
| {/* ===== LEFT COLUMN: Messages + GenUI ===== */} | |
| <div | |
| style={{ | |
| padding: 20, | |
| borderRight: "1px solid rgba(255, 255, 255, 0.06)", | |
| display: "flex", | |
| flexDirection: "column", | |
| gap: 24, | |
| overflow: "auto", | |
| minHeight: 0, | |
| }} | |
| > | |
| {/* Messages */} | |
| <div> | |
| <SectionLabel icon={<MessageSquare className="w-3.5 h-3.5" />} label="Messages" /> | |
| <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 10, marginTop: 10 }}> | |
| <StatCard label="User" value={data.userMessageCount} color="var(--color-cta)" /> | |
| <StatCard label="Assistant" value={data.assistantMessageCount} color="var(--color-accent-cyan)" /> | |
| <StatCard label="Tool" value={data.toolMessageCount} color="var(--color-accent-pink)" /> | |
| </div> | |
| </div> | |
| {/* GenUI Components */} | |
| {data.genuiComponentCount > 0 && ( | |
| <div> | |
| <SectionLabel icon={<Layers className="w-3.5 h-3.5" />} label="GenUI Components" /> | |
| <div style={{ marginTop: 10, display: "flex", flexWrap: "wrap", gap: 8 }}> | |
| <span className="pill pill-lavender text-xs font-bold"> | |
| {data.genuiComponentCount} rendered | |
| </span> | |
| {data.genuiComponentNames.map((name) => ( | |
| <span key={name} className="pill pill-cyan text-xs"> | |
| {name} | |
| </span> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {/* ===== MIDDLE COLUMN: Token Usage + Server ===== */} | |
| <div | |
| style={{ | |
| padding: 20, | |
| borderRight: "1px solid rgba(255, 255, 255, 0.06)", | |
| display: "flex", | |
| flexDirection: "column", | |
| gap: 24, | |
| overflow: "auto", | |
| minHeight: 0, | |
| }} | |
| > | |
| {/* Token Usage */} | |
| <div> | |
| <SectionLabel icon={<DollarSign className="w-3.5 h-3.5" />} label="Token Usage" /> | |
| <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 10, marginTop: 10 }}> | |
| <StatCard | |
| label="Input" | |
| value={ | |
| data.costMetrics | |
| ? formatTokenCount(data.costMetrics.input_text + data.costMetrics.input_audio) | |
| : "—" | |
| } | |
| color="var(--color-cta)" | |
| /> | |
| <StatCard | |
| label="Output" | |
| value={ | |
| data.costMetrics | |
| ? formatTokenCount(data.costMetrics.output_text + data.costMetrics.output_audio) | |
| : "—" | |
| } | |
| color="var(--color-accent-cyan)" | |
| /> | |
| <StatCard | |
| label="Est. Cost" | |
| value={ | |
| data.costMetrics | |
| ? `$${data.costMetrics.total_cost.toFixed(4)}` | |
| : "—" | |
| } | |
| color="var(--color-success)" | |
| /> | |
| </div> | |
| {data.costMetrics && data.costMetrics.response_count > 0 && ( | |
| <div style={{ marginTop: 10, display: "flex", flexWrap: "wrap", gap: 6 }}> | |
| <span className="pill pill-cyan text-[9px]"> | |
| {data.costMetrics.response_count} responses | |
| </span> | |
| {data.costMetrics.input_audio > 0 && ( | |
| <span className="pill pill-lavender text-[9px]"> | |
| 🎙 {formatTokenCount(data.costMetrics.input_audio)} audio in | |
| </span> | |
| )} | |
| {data.costMetrics.output_audio > 0 && ( | |
| <span className="pill pill-pink text-[9px]"> | |
| 🔊 {formatTokenCount(data.costMetrics.output_audio)} audio out | |
| </span> | |
| )} | |
| {data.costMetrics.cache_hit_rate > 0 && ( | |
| <span | |
| className="pill text-[9px] font-bold" | |
| style={{ | |
| background: "rgba(34, 197, 94, 0.15)", | |
| border: "1px solid rgba(34, 197, 94, 0.3)", | |
| color: "var(--color-success)", | |
| }} | |
| > | |
| 💾 {Math.round(data.costMetrics.cache_hit_rate * 100)}% cached | |
| </span> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| {/* Server Stats */} | |
| <div> | |
| <SectionLabel icon={<Server className="w-3.5 h-3.5" />} label="Server" /> | |
| <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 10, marginTop: 10 }}> | |
| <StatCard | |
| label="Clients" | |
| value={data.serverMetrics?.connected_clients ?? "—"} | |
| color="var(--color-accent-cyan)" | |
| /> | |
| <StatCard | |
| label="Uptime" | |
| value={ | |
| data.serverMetrics?.uptime_seconds != null | |
| ? formatDuration(data.serverMetrics.uptime_seconds) | |
| : "—" | |
| } | |
| color="var(--color-success)" | |
| /> | |
| <StatCard | |
| label="Events" | |
| value={data.serverMetrics?.total_events_broadcast ?? "—"} | |
| color="var(--color-cta)" | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| {/* ===== RIGHT COLUMN: Tool Calls ===== */} | |
| <div | |
| style={{ | |
| padding: 20, | |
| display: "flex", | |
| flexDirection: "column", | |
| overflow: "hidden", | |
| minHeight: 0, | |
| }} | |
| > | |
| <SectionLabel icon={<Wrench className="w-3.5 h-3.5" />} label="Tool Calls" /> | |
| <div | |
| style={{ | |
| marginTop: 10, | |
| flex: 1, | |
| overflowY: "auto", | |
| display: "flex", | |
| flexDirection: "column", | |
| gap: 6, | |
| }} | |
| > | |
| {data.toolCalls.length === 0 ? ( | |
| <p | |
| style={{ | |
| fontSize: 12, | |
| color: "var(--color-text-muted)", | |
| fontStyle: "italic", | |
| textAlign: "center", | |
| padding: "24px 0", | |
| }} | |
| > | |
| No tool calls yet this session | |
| </p> | |
| ) : ( | |
| data.toolCalls.map((tool) => ( | |
| <ToolCallRow key={tool.id} tool={tool} /> | |
| )) | |
| )} | |
| </div> | |
| </div> | |
| {/* ===== BOTTOM ROW: Conversation Log (full-width) ===== */} | |
| <div | |
| style={{ | |
| gridColumn: "1 / -1", | |
| borderTop: "1px solid rgba(255, 255, 255, 0.06)", | |
| overflow: "hidden", | |
| minHeight: 0, | |
| }} | |
| > | |
| <ConversationLog messages={messages} /> | |
| </div> | |
| </div> | |
| {/* Footer */} | |
| <div | |
| style={{ | |
| padding: "12px 24px", | |
| background: "var(--color-surface-subtle)", | |
| borderTop: "1px solid rgba(255, 255, 255, 0.08)", | |
| display: "flex", | |
| alignItems: "center", | |
| justifyContent: "space-between", | |
| flexShrink: 0, | |
| }} | |
| > | |
| <div style={{ display: "flex", alignItems: "center", gap: 12 }}> | |
| <div | |
| style={{ | |
| padding: "2px 8px", | |
| borderRadius: 4, | |
| background: "rgba(255, 193, 204, 0.1)", | |
| border: "1px solid rgba(255, 193, 204, 0.2)", | |
| color: "var(--color-accent-pink)", | |
| fontSize: 9, | |
| fontWeight: 900, | |
| textTransform: "uppercase", | |
| letterSpacing: "0.15em", | |
| }} | |
| > | |
| Dev | |
| </div> | |
| <p style={{ fontSize: 11, color: "var(--color-text-muted)", fontWeight: 500, margin: 0 }}> | |
| WS events: <strong>{data.totalWsEvents}</strong> this session | |
| </p> | |
| </div> | |
| <Zap className="w-4 h-4" style={{ color: "rgba(255, 193, 204, 0.3)" }} /> | |
| </div> | |
| </div> | |
| </div>, | |
| document.body | |
| )} | |
| </> | |
| ); | |
| } | |
| // ---- Sub-components ---- | |
| function SectionLabel({ icon, label }: { icon: React.ReactNode; label: string }) { | |
| return ( | |
| <div | |
| style={{ | |
| display: "flex", | |
| alignItems: "center", | |
| gap: 8, | |
| fontSize: 12, | |
| fontWeight: 900, | |
| textTransform: "uppercase", | |
| letterSpacing: "0.15em", | |
| color: "var(--color-text-muted)", | |
| }} | |
| > | |
| {icon} | |
| {label} | |
| </div> | |
| ); | |
| } | |
| function StatCard({ | |
| label, | |
| value, | |
| color, | |
| }: { | |
| label: string; | |
| value: string | number; | |
| color: string; | |
| }) { | |
| return ( | |
| <div | |
| style={{ | |
| padding: "14px 12px", | |
| background: "rgba(36, 36, 36, 0.6)", | |
| border: "1px solid rgba(255, 255, 255, 0.08)", | |
| borderRadius: 12, | |
| textAlign: "center", | |
| }} | |
| > | |
| <div | |
| style={{ | |
| fontSize: 28, | |
| fontWeight: 700, | |
| color, | |
| fontFamily: "var(--font-mono)", | |
| lineHeight: 1.2, | |
| }} | |
| > | |
| {value} | |
| </div> | |
| <div | |
| style={{ | |
| fontSize: 12, | |
| fontWeight: 600, | |
| color: "var(--color-text-muted)", | |
| textTransform: "uppercase", | |
| letterSpacing: "0.1em", | |
| marginTop: 4, | |
| }} | |
| > | |
| {label} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function ToolCallRow({ tool }: { tool: { id: string; name: string; status: string; duration: number | null } }) { | |
| const statusIcon = | |
| tool.status === "completed" ? ( | |
| <CheckCircle2 className="w-3.5 h-3.5" style={{ color: "var(--color-success)" }} /> | |
| ) : tool.status === "error" ? ( | |
| <XCircle className="w-3.5 h-3.5" style={{ color: "var(--color-error)" }} /> | |
| ) : ( | |
| <Loader2 className="w-3.5 h-3.5 animate-spin" style={{ color: "var(--color-accent-cyan)" }} /> | |
| ); | |
| return ( | |
| <div | |
| style={{ | |
| display: "flex", | |
| alignItems: "center", | |
| justifyContent: "space-between", | |
| padding: "8px 12px", | |
| background: "rgba(36, 36, 36, 0.5)", | |
| border: "1px solid rgba(255, 255, 255, 0.06)", | |
| borderRadius: 8, | |
| }} | |
| > | |
| <div className="flex items-center gap-2"> | |
| {statusIcon} | |
| <span style={{ fontSize: 14, fontWeight: 500, color: "var(--color-text-primary)" }}> | |
| {tool.name} | |
| </span> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| {tool.duration != null && ( | |
| <span | |
| style={{ | |
| fontSize: 10, | |
| fontFamily: "var(--font-mono)", | |
| color: "var(--color-text-muted)", | |
| }} | |
| > | |
| {tool.duration}ms | |
| </span> | |
| )} | |
| <span | |
| className={`pill text-[9px] font-bold ${ | |
| tool.status === "completed" | |
| ? "pill-cyan" | |
| : tool.status === "error" | |
| ? "pill-pink" | |
| : "pill-lavender" | |
| }`} | |
| > | |
| {tool.status} | |
| </span> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // ---- Conversation Log (Chat-style) ---- | |
| function relativeTime(timestamp: string): string { | |
| try { | |
| const now = Date.now(); | |
| const then = new Date(timestamp).getTime(); | |
| const diff = Math.max(0, Math.floor((now - then) / 1000)); | |
| if (diff < 5) return "just now"; | |
| if (diff < 60) return `${diff}s ago`; | |
| const mins = Math.floor(diff / 60); | |
| if (mins < 60) return `${mins}m ago`; | |
| const hrs = Math.floor(mins / 60); | |
| return `${hrs}h ${mins % 60}m ago`; | |
| } catch { | |
| return ""; | |
| } | |
| } | |
| function fullTime(timestamp: string): string { | |
| try { | |
| return new Date(timestamp).toLocaleTimeString("en-GB", { | |
| hour: "2-digit", | |
| minute: "2-digit", | |
| second: "2-digit", | |
| }); | |
| } catch { | |
| return ""; | |
| } | |
| } | |
| /** Friendly tool name: "log_entry" → "Log Entry" */ | |
| function friendlyToolName(name: string): string { | |
| return name | |
| .replace(/_/g, " ") | |
| .replace(/\b\w/g, (c) => c.toUpperCase()); | |
| } | |
| function ToolDetailBlock({ data, label }: { data: Record<string, unknown>; label: string }) { | |
| const [expanded, setExpanded] = useState(false); | |
| const keys = Object.keys(data); | |
| const summary = | |
| keys.length <= 3 | |
| ? keys | |
| .map((k) => { | |
| const v = data[k]; | |
| const display = | |
| typeof v === "string" | |
| ? v.length > 40 | |
| ? v.slice(0, 40) + "…" | |
| : v | |
| : JSON.stringify(v); | |
| return `${k}: ${display}`; | |
| }) | |
| .join(", ") | |
| : `${keys.length} fields`; | |
| return ( | |
| <div style={{ marginTop: 4 }}> | |
| <button | |
| onClick={() => setExpanded((p) => !p)} | |
| style={{ | |
| background: "none", | |
| border: "none", | |
| cursor: "pointer", | |
| padding: 0, | |
| display: "inline-flex", | |
| alignItems: "center", | |
| gap: 4, | |
| fontSize: 11, | |
| color: "var(--color-text-muted)", | |
| }} | |
| > | |
| <span style={{ fontSize: 9, opacity: 0.7 }}>{expanded ? "▾" : "▸"}</span> | |
| <span | |
| style={{ | |
| padding: "1px 5px", | |
| borderRadius: 3, | |
| background: "rgba(255, 255, 255, 0.05)", | |
| border: "1px solid rgba(255, 255, 255, 0.08)", | |
| fontSize: 9, | |
| fontWeight: 700, | |
| textTransform: "uppercase", | |
| letterSpacing: "0.05em", | |
| }} | |
| > | |
| {label} | |
| </span> | |
| {!expanded && ( | |
| <span style={{ opacity: 0.5, fontSize: 11, fontFamily: "var(--font-mono)" }}> | |
| {summary} | |
| </span> | |
| )} | |
| </button> | |
| {expanded && ( | |
| <pre | |
| style={{ | |
| marginTop: 4, | |
| padding: "8px 10px", | |
| background: "rgba(20, 20, 20, 0.8)", | |
| border: "1px solid rgba(255, 255, 255, 0.06)", | |
| borderRadius: 6, | |
| fontSize: 11, | |
| lineHeight: 1.5, | |
| fontFamily: "var(--font-mono)", | |
| color: "var(--color-text-secondary)", | |
| overflowX: "auto", | |
| maxHeight: 200, | |
| whiteSpace: "pre-wrap", | |
| wordBreak: "break-word", | |
| }} | |
| > | |
| {JSON.stringify(data, null, 2)} | |
| </pre> | |
| )} | |
| </div> | |
| ); | |
| } | |
| function ConversationLog({ messages }: { messages: Message[] }) { | |
| const [isOpen, setIsOpen] = useState(true); | |
| const scrollRef = useRef<HTMLDivElement>(null); | |
| // Re-render every 30s so relative timestamps update | |
| const [, setTick] = useState(0); | |
| useEffect(() => { | |
| const id = setInterval(() => setTick((t) => t + 1), 30_000); | |
| return () => clearInterval(id); | |
| }, []); | |
| // No auto-scroll needed — newest messages are at the top | |
| // Newest first | |
| const logMessages = messages.filter((m) => !m.isPartial).slice().reverse(); | |
| return ( | |
| <div style={{ display: "flex", flexDirection: "column", height: "100%" }}> | |
| {/* Header toggle */} | |
| <button | |
| onClick={() => setIsOpen((prev) => !prev)} | |
| style={{ | |
| display: "flex", | |
| alignItems: "center", | |
| gap: 6, | |
| width: "100%", | |
| background: "none", | |
| border: "none", | |
| padding: "12px 20px", | |
| cursor: "pointer", | |
| }} | |
| > | |
| <SectionLabel | |
| icon={<MessageSquare className="w-3.5 h-3.5" />} | |
| label="Conversation" | |
| /> | |
| <div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 4 }}> | |
| {logMessages.length > 0 && ( | |
| <span className="pill pill-cyan text-[9px] font-bold">{logMessages.length}</span> | |
| )} | |
| {isOpen ? ( | |
| <ChevronDown className="w-3.5 h-3.5 text-muted" /> | |
| ) : ( | |
| <ChevronRight className="w-3.5 h-3.5 text-muted" /> | |
| )} | |
| </div> | |
| </button> | |
| {isOpen && ( | |
| <div | |
| ref={scrollRef} | |
| style={{ | |
| flex: 1, | |
| overflowY: "auto", | |
| background: "rgba(18, 18, 18, 0.95)", | |
| borderTop: "1px solid rgba(255, 255, 255, 0.06)", | |
| padding: "16px 20px", | |
| display: "flex", | |
| flexDirection: "column", | |
| gap: 6, | |
| }} | |
| > | |
| {logMessages.length === 0 ? ( | |
| <div | |
| style={{ | |
| display: "flex", | |
| flexDirection: "column", | |
| alignItems: "center", | |
| justifyContent: "center", | |
| gap: 8, | |
| padding: "32px 0", | |
| color: "var(--color-text-muted)", | |
| }} | |
| > | |
| <MessageSquare className="w-6 h-6" style={{ opacity: 0.3 }} /> | |
| <p style={{ fontSize: 13, fontStyle: "italic", margin: 0 }}> | |
| Start talking to see the conversation here | |
| </p> | |
| </div> | |
| ) : ( | |
| logMessages.map((msg) => { | |
| const isUser = msg.role === "user"; | |
| const isTool = msg.role === "tool" && msg.tool; | |
| const isSystem = msg.role === "system"; | |
| const isGenUI = msg.role === "assistant" && !!msg.component; | |
| // ── System event chip (centered) ── | |
| if (isSystem) { | |
| return ( | |
| <div | |
| key={msg.id} | |
| style={{ | |
| display: "flex", | |
| justifyContent: "center", | |
| padding: "4px 0", | |
| }} | |
| > | |
| <span | |
| title={fullTime(msg.timestamp)} | |
| style={{ | |
| display: "inline-flex", | |
| alignItems: "center", | |
| gap: 6, | |
| padding: "4px 14px", | |
| borderRadius: 20, | |
| background: "rgba(255, 255, 255, 0.04)", | |
| border: "1px solid rgba(255, 255, 255, 0.08)", | |
| fontSize: 11, | |
| color: "var(--color-text-muted)", | |
| fontWeight: 500, | |
| }} | |
| > | |
| {msg.content} | |
| </span> | |
| </div> | |
| ); | |
| } | |
| // ── Tool call badge (compact inline) ── | |
| if (isTool) { | |
| const toolStatus = msg.tool!.status; | |
| const statusEmoji = | |
| toolStatus === "completed" ? "✓" : toolStatus === "error" ? "✗" : "⋯"; | |
| const statusColor = | |
| toolStatus === "completed" | |
| ? "var(--color-success)" | |
| : toolStatus === "error" | |
| ? "var(--color-error)" | |
| : "var(--color-accent-cyan)"; | |
| return ( | |
| <div key={msg.id} style={{ padding: "2px 0" }}> | |
| <div | |
| style={{ | |
| display: "inline-flex", | |
| alignItems: "center", | |
| gap: 6, | |
| padding: "5px 12px", | |
| borderRadius: 8, | |
| background: "rgba(255, 193, 204, 0.06)", | |
| border: "1px solid rgba(255, 193, 204, 0.12)", | |
| fontSize: 12, | |
| color: "var(--color-text-secondary)", | |
| }} | |
| > | |
| <span style={{ fontSize: 13 }}>🛠</span> | |
| <span style={{ fontWeight: 600, color: "var(--color-accent-pink)" }}> | |
| {friendlyToolName(msg.tool!.name)} | |
| </span> | |
| <span | |
| style={{ | |
| fontWeight: 700, | |
| fontSize: 11, | |
| color: statusColor, | |
| }} | |
| > | |
| {statusEmoji} | |
| </span> | |
| <span | |
| title={fullTime(msg.timestamp)} | |
| style={{ fontSize: 10, color: "var(--color-text-muted)", marginLeft: 4 }} | |
| > | |
| {relativeTime(msg.timestamp)} | |
| </span> | |
| </div> | |
| {/* Expandable args/result (hidden by default) */} | |
| {msg.tool!.args && Object.keys(msg.tool!.args).length > 0 && ( | |
| <div style={{ marginLeft: 16, marginTop: 2 }}> | |
| <ToolDetailBlock data={msg.tool!.args} label="Input" /> | |
| </div> | |
| )} | |
| {msg.tool!.result && Object.keys(msg.tool!.result).length > 0 && ( | |
| <div style={{ marginLeft: 16, marginTop: 2 }}> | |
| <ToolDetailBlock data={msg.tool!.result} label="Output" /> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // ── GenUI badge (compact) ── | |
| if (isGenUI) { | |
| return ( | |
| <div key={msg.id} style={{ padding: "2px 0" }}> | |
| <div | |
| style={{ | |
| display: "inline-flex", | |
| alignItems: "center", | |
| gap: 6, | |
| padding: "5px 12px", | |
| borderRadius: 8, | |
| background: "rgba(168, 218, 220, 0.06)", | |
| border: "1px solid rgba(168, 218, 220, 0.12)", | |
| fontSize: 12, | |
| }} | |
| > | |
| <span style={{ fontSize: 13 }}>🖥</span> | |
| <span style={{ fontWeight: 600, color: "var(--color-accent-cyan)" }}> | |
| {msg.component!.name} | |
| </span> | |
| <span style={{ fontSize: 10, color: "var(--color-text-muted)" }}>shown</span> | |
| <span | |
| title={fullTime(msg.timestamp)} | |
| style={{ fontSize: 10, color: "var(--color-text-muted)", marginLeft: 4 }} | |
| > | |
| {relativeTime(msg.timestamp)} | |
| </span> | |
| </div> | |
| {Object.keys(msg.component!.props).length > 0 && ( | |
| <div style={{ marginLeft: 16, marginTop: 2 }}> | |
| <ToolDetailBlock data={msg.component!.props} label="Props" /> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // ── Chat bubble (user = left, assistant = right) ── | |
| return ( | |
| <div | |
| key={msg.id} | |
| style={{ | |
| display: "flex", | |
| flexDirection: "column", | |
| alignItems: isUser ? "flex-start" : "flex-end", | |
| maxWidth: "85%", | |
| alignSelf: isUser ? "flex-start" : "flex-end", | |
| }} | |
| > | |
| {/* Sender label */} | |
| <span | |
| style={{ | |
| fontSize: 10, | |
| fontWeight: 700, | |
| textTransform: "uppercase", | |
| letterSpacing: "0.08em", | |
| color: isUser ? "var(--color-cta)" : "var(--color-accent-cyan)", | |
| marginBottom: 3, | |
| paddingLeft: isUser ? 2 : 0, | |
| paddingRight: isUser ? 0 : 2, | |
| }} | |
| > | |
| {isUser ? "You" : "Reachy"} | |
| </span> | |
| {/* Bubble */} | |
| <div | |
| style={{ | |
| padding: "10px 14px", | |
| borderRadius: isUser ? "2px 14px 14px 14px" : "14px 2px 14px 14px", | |
| background: isUser | |
| ? "rgba(255, 255, 255, 0.06)" | |
| : "rgba(168, 218, 220, 0.10)", | |
| border: isUser | |
| ? "1px solid rgba(255, 255, 255, 0.10)" | |
| : "1px solid rgba(168, 218, 220, 0.18)", | |
| fontSize: 14, | |
| lineHeight: 1.6, | |
| color: "var(--color-text-primary)", | |
| wordBreak: "break-word", | |
| }} | |
| > | |
| {msg.content || "(no text)"} | |
| </div> | |
| {/* Timestamp */} | |
| <span | |
| title={fullTime(msg.timestamp)} | |
| style={{ | |
| fontSize: 10, | |
| color: "var(--color-text-muted)", | |
| marginTop: 3, | |
| paddingLeft: isUser ? 2 : 0, | |
| paddingRight: isUser ? 0 : 2, | |
| opacity: 0.7, | |
| }} | |
| > | |
| {relativeTime(msg.timestamp)} | |
| </span> | |
| </div> | |
| ); | |
| }) | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // ---- Helpers ---- | |
| function formatDuration(seconds: number): string { | |
| const mins = Math.floor(seconds / 60); | |
| const secs = Math.floor(seconds % 60); | |
| if (mins > 0) { | |
| return `${mins}m ${secs.toString().padStart(2, "0")}s`; | |
| } | |
| return `${secs}s`; | |
| } | |
| function formatTokenCount(count: number): string { | |
| if (count >= 1_000_000) { | |
| return `${(count / 1_000_000).toFixed(1)}M`; | |
| } | |
| if (count >= 10_000) { | |
| return `${(count / 1_000).toFixed(1)}k`; | |
| } | |
| return new Intl.NumberFormat().format(count); | |
| } | |