import { useMemo } from "react"; import { useHive } from "../store"; import { fmtCost, fmtNum } from "../lib/format"; import { hhmmss, statusColorVar, statusKey } from "../lib/agents"; import { bundleEvents, itemAgent, mergeThinking, type ActivityItem as LiveItem } from "../lib/activity"; import type { ScopeAgent } from "../store"; import type { AgentRuntime, HiveEvent } from "../types"; type Kind = "DELEGATE" | "TOOL" | "MSG" | "DONE" | "ERROR" | "THINK"; const KIND_COLOR: Record = { DELEGATE: "var(--run)", TOOL: "var(--ink-dim)", MSG: "var(--ink-dim)", DONE: "var(--done)", ERROR: "var(--crit)", THINK: "var(--brand)", }; // Map a bundled activity item onto the spec's five-kind vocabulary. function kindOf(item: LiveItem): Kind { if (item.kind === "thinking") return "THINK"; if (item.kind === "tool") { return item.end?.payload?.isError ? "ERROR" : "TOOL"; } const e = item.event; const p = e.payload || {}; switch (e.type) { case "delegation_start": return "DELEGATE"; case "delegation_end": return p.type === "error" || p.isError ? "ERROR" : "DONE"; case "error": return "ERROR"; case "worker_retry": return "ERROR"; case "assistant_message": case "user_message": return "MSG"; default: return p.isError ? "ERROR" : "MSG"; } } // Line-2 message text. function messageOf(item: LiveItem): string { if (item.kind === "thinking") return item.text.replace(/\*\*/g, "").replace(/\s+/g, " ").trim(); if (item.kind === "tool") { const p = (item.end || item.start)?.payload || {}; const args = typeof p.args === "string" ? p.args : ""; return [p.toolName, args].filter(Boolean).join(" ") || p.toolName || "tool call"; } const e = item.event; const p = e.payload || {}; switch (e.type) { case "delegation_start": return p.task || `spawned ${p.to || "worker"}`; case "delegation_end": return p.message || "task complete"; case "assistant_message": case "user_message": return p.text || ""; case "error": return p.message || "error"; // worker_retry carries attempt/maxAttempts/errorMessage (K6). The "start" // phase is the informative one; the "end" phase just reports success. case "worker_retry": return p.phase === "end" ? (p.success ? "retry succeeded" : "retry failed") : `retry ${p.attempt ?? "?"}/${p.maxAttempts ?? "?"}${p.errorMessage ? ` — ${p.errorMessage}` : ""}`; default: return typeof p.text === "string" ? p.text : e.type; } } // Whether an item's payload was clipped at source (J6) — drives a "… truncated" // affordance so a reader knows the preview is partial. function isTruncated(item: LiveItem): boolean { if (item.kind === "tool") return (item.end || item.start)?.payload?.truncated === true; if (item.kind === "event") return item.event?.payload?.truncated === true; return false; } // Right-aligned line-2 metadata: delegation target, token delta, duration, retry. function metaOf(item: LiveItem): string { if (item.kind === "thinking") return item.tokens ? `+${fmtNum(item.tokens)} tok` : ""; if (item.kind === "tool") { if (item.start && item.end) { const ms = new Date(item.end.ts).getTime() - new Date(item.start.ts).getTime(); return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`; } return "running"; } const e = item.event; const p = e.payload || {}; switch (e.type) { case "delegation_start": return p.to ? `→ ${p.to}` : ""; case "delegation_end": { const rt = p.runtime || {}; const tok = Number(rt.inputTokens || 0) + Number(rt.outputTokens || 0); if (tok) return `+${fmtNum(tok)} tok`; if (p.costUsd) return fmtCost(p.costUsd); return p.elapsedMs ? `${Math.round(p.elapsedMs / 1000)}s` : ""; } // worker_retry meta shows the attempt counter (K6). The dead `p.retry` read // on `error` events is gone — retries arrive as their own worker_retry event. case "worker_retry": return p.attempt != null ? `attempt ${p.attempt}/${p.maxAttempts ?? "?"}` : ""; default: return ""; } } export default function LiveActivity(props: { limit?: number; events?: HiveEvent[]; // Replay (M4): when replaying, `replayTs` is the cursor timestamp so we can // clip the live-polled thinking feed to the playhead, and `replayStatus` is // the replay-derived status map (from the same slice) so status dots reflect // the rewound state rather than the live roster. replayTs?: string; replayStatus?: Map; }) { const scopedEventsLive = useHive((s) => s.scopedEvents); const scopedAgents = useHive((s) => s.scopedAgents); const scopedSessions = useHive((s) => s.scopedSessions); const thinkingBySession = useHive((s) => s.thinkingBySession); // Replay (K5) passes its own event slice; live SSE never mutates it. const scopedEvents = props.events ?? scopedEventsLive; const replayTs = props.replayTs; const replayStatus = props.replayStatus; // Single roster: id → model/status, shared with the topology graph. In replay // mode the status dot comes from the replay-derived map (statusFor); the roster // still supplies identity colour. const roster = useMemo(() => { const m = new Map(); for (const a of scopedAgents) if (!m.has(a.name)) m.set(a.name, a); return m; }, [scopedAgents]); // Thinking entries for the sessions in scope, merged into the feed by time. In // replay mode, clip to the cursor timestamp so future thinking never leaks in. const thinking = useMemo(() => { const out: Array<{ agent: string; ts: string; text: string }> = []; for (const s of scopedSessions) for (const t of thinkingBySession.get(s.session_id) || []) { if (replayTs && t.ts > replayTs) continue; out.push(t); } return out; }, [scopedSessions, thinkingBySession, replayTs]); const items = useMemo( () => mergeThinking(bundleEvents(scopedEvents), thinking).slice(0, props.limit ?? 40), [scopedEvents, thinking, props.limit], ); // The thinking feed is polled for at most 6 sessions (fan-out cap in wiring). // Surface that at fleet scale so a reader knows thinking is partial. const THINKING_SESSION_CAP = 6; const thinkingCapped = scopedSessions.length > THINKING_SESSION_CAP; if (!items.length) return
No activity yet.
; return (
{thinkingCapped && (
Showing agent thinking for the {THINKING_SESSION_CAP} most-recent sessions in scope.
)} {items.map((item) => { const agentId = itemAgent(item) || "—"; const agent = roster.get(agentId); const kind = kindOf(item); const kc = KIND_COLOR[kind]; // Status dot: replay-derived status when rewound, else the live roster. const status = replayStatus ? replayStatus.get(agentId)?.status : agent?.status; const sk = statusKey(status); const dotColor = statusColorVar(status); const msg = messageOf(item); const meta = metaOf(item); const truncated = isTruncated(item); // Identity color drives the row's left border (real agents only; else neutral). const agentColor = agent?.color || "var(--ink-dimmer)"; return (
{hhmmss(new Date(item.ts).getTime())} {/* one dot: live agent status. Identity color is carried by the row's left border. */} {sk === "running" && } {agentId} {kind}
{msg} {truncated && … truncated} {meta && {meta}}
); })}
); }