agent cards: disk-LED glow on live topology step
ci / gates (push) Successful in 6s
ci / frontend (push) Successful in 40s
ci / rust (push) Successful in 3m13s
ci / e2e (push) Skipped
ci / publish (push) Successful in 58s

Wire SSE step events from LiveRunLogs up to ResearchCanvas via an
optional onStep callback (StepPulse: {role, phase, node_id, ts}).
ResearchCanvas keeps a role -> {phase, expiresAt} map; a 250ms tick
clears expired entries so the glow fades naturally.

Each agent card:
- 8px LED dot next to the name (green idle-off / colored when live)
- outer glow via two-layer box-shadow when the agent's role_slot
  matches the last step's role, within a 2.5s window
- inline 'reading' / 'writing' hint below the role

Color mapping (disk-LED metaphor):
- Plan phase   -> cyan  #5ec8d8 (reading / decomposing)
- Work/Synth/Aggregate -> green #5fd08a (writing / producing)

Overlapping steps reset the timer so back-to-back activity on the
same role holds the glow. When no run is active nothing pulses —
LiveRunLogs is silent, no callback fires.
This commit is contained in:
Omar Sobh
2026-07-15 14:42:57 -07:00
parent 45292bf5fb
commit 43f7880327
2 changed files with 142 additions and 9 deletions
@@ -23,7 +23,7 @@ type SseEvent =
| { kind: "step"; index: number; ts: number; raw: string } | { kind: "step"; index: number; ts: number; raw: string }
| { kind: "done"; ts: number; raw: string; error?: string | null }; | { kind: "done"; ts: number; raw: string; error?: string | null };
function useRunEvents(runId: string | null) { function useRunEvents(runId: string | null, onStep?: (p: StepPulse) => void) {
const [events, setEvents] = useState<SseEvent[]>([]); const [events, setEvents] = useState<SseEvent[]>([]);
const [status, setStatus] = useState<"connecting" | "streaming" | "done" | "error">( const [status, setStatus] = useState<"connecting" | "streaming" | "done" | "error">(
"connecting", "connecting",
@@ -44,10 +44,40 @@ function useRunEvents(runId: string | null) {
es.onopen = () => setStatus("streaming"); es.onopen = () => setStatus("streaming");
es.addEventListener("step", (e: MessageEvent) => { es.addEventListener("step", (e: MessageEvent) => {
const idx = Number((e as unknown as { lastEventId?: string }).lastEventId ?? -1); const idx = Number((e as unknown as { lastEventId?: string }).lastEventId ?? -1);
const ts = Date.now();
setEvents((prev) => [ setEvents((prev) => [
...prev, ...prev,
{ kind: "step", index: Number.isFinite(idx) ? idx : prev.length, ts: Date.now(), raw: e.data }, { kind: "step", index: Number.isFinite(idx) ? idx : prev.length, ts, raw: e.data },
]); ]);
// Fire a pulse so parent UI can flash the acting agent's card.
if (onStep) {
try {
const j = JSON.parse(e.data) as {
role?: string;
node_id?: string;
phase?: string | { kind?: string };
};
const phaseRaw =
typeof j.phase === "string"
? j.phase
: j.phase && typeof j.phase === "object"
? j.phase.kind
: undefined;
const phase: StepPulse["phase"] =
phaseRaw === "plan" || phaseRaw === "work" ||
phaseRaw === "synth" || phaseRaw === "aggregate"
? phaseRaw
: "unknown";
onStep({
role: j.role ?? "",
phase,
node_id: j.node_id ?? "",
ts,
});
} catch {
/* ignore malformed */
}
}
}); });
es.addEventListener("done", (e: MessageEvent) => { es.addEventListener("done", (e: MessageEvent) => {
let error: string | null = null; let error: string | null = null;
@@ -114,7 +144,24 @@ const STAGE_DOT: Record<string, string> = {
skip: "◯", skip: "◯",
}; };
export function LiveRunLogs({ topicId }: { topicId: string }) { /** Fires whenever a new topology-run step SSE event lands. Consumers use
* it to drive live UI signals — e.g. an agent-card glow keyed to the
* step's role/phase. Called once per step, right after the event
* arrives; safe to be a no-op. */
export type StepPulse = {
role: string;
phase: "plan" | "work" | "synth" | "aggregate" | "unknown";
node_id: string;
ts: number;
};
export function LiveRunLogs({
topicId,
onStep,
}: {
topicId: string;
onStep?: (pulse: StepPulse) => void;
}) {
const [runs, setRuns] = useState<string[]>([]); const [runs, setRuns] = useState<string[]>([]);
const [activeRun, setActiveRun] = useState<string | null>(null); const [activeRun, setActiveRun] = useState<string | null>(null);
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
@@ -172,7 +219,7 @@ export function LiveRunLogs({ topicId }: { topicId: string }) {
}; };
}, [topicId]); }, [topicId]);
const { events, status } = useRunEvents(activeRun); const { events, status } = useRunEvents(activeRun, onStep);
// Autoscroll to newest event when pinned to the bottom. // Autoscroll to newest event when pinned to the bottom.
useEffect(() => { useEffect(() => {
@@ -21,7 +21,7 @@ import {
type TopicDetail, type TopicDetail,
type TopicStatus, type TopicStatus,
} from "@/lib/api/research"; } from "@/lib/api/research";
import { LiveRunLogs } from "./LiveRunLogs"; import { LiveRunLogs, type StepPulse } from "./LiveRunLogs";
const mono = const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace"; "ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
@@ -104,6 +104,39 @@ export function ResearchCanvas({
const [pendingApproval, setPendingApproval] = useState<PublishApproval | null>(null); const [pendingApproval, setPendingApproval] = useState<PublishApproval | null>(null);
const [pipeline, setPipeline] = useState<PipelineStateResponse | null>(null); const [pipeline, setPipeline] = useState<PipelineStateResponse | null>(null);
const [diagOpen, setDiagOpen] = useState(false); const [diagOpen, setDiagOpen] = useState(false);
// "Disk-LED" for agent cards: role_slot → {phase, expiresAt}. Set on
// every SSE step event; a 250ms tick clears expired entries so the
// card glow fades naturally. Plan = reading (cyan), Work/Synth/
// Aggregate = writing (green).
const [activeAgents, setActiveAgents] = useState<
Map<string, { phase: StepPulse["phase"]; expiresAt: number }>
>(new Map());
useEffect(() => {
const tick = setInterval(() => {
const now = Date.now();
setActiveAgents((prev) => {
let changed = false;
const next = new Map(prev);
for (const [role, v] of next) {
if (v.expiresAt <= now) {
next.delete(role);
changed = true;
}
}
return changed ? next : prev;
});
}, 250);
return () => clearInterval(tick);
}, []);
function handleStepPulse(pulse: StepPulse) {
if (!pulse.role) return;
setActiveAgents((prev) => {
const next = new Map(prev);
// 2.5s glow window per step. Overlapping steps refresh the timer.
next.set(pulse.role, { phase: pulse.phase, expiresAt: Date.now() + 2500 });
return next;
});
}
const [decidingApproval, setDecidingApproval] = useState<"approve" | "reject" | null>(null); const [decidingApproval, setDecidingApproval] = useState<"approve" | "reject" | null>(null);
const [rejectFormOpen, setRejectFormOpen] = useState(false); const [rejectFormOpen, setRejectFormOpen] = useState(false);
const [rejectNotes, setRejectNotes] = useState(""); const [rejectNotes, setRejectNotes] = useState("");
@@ -520,7 +553,7 @@ export function ResearchCanvas({
) : null} ) : null}
{/* Live topology-run logs — renders only when a run is in flight. */} {/* Live topology-run logs — renders only when a run is in flight. */}
<LiveRunLogs topicId={topic.id} /> <LiveRunLogs topicId={topic.id} onStep={handleStepPulse} />
{/* Agents */} {/* Agents */}
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}> <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
@@ -537,6 +570,20 @@ export function ResearchCanvas({
> >
{topic.agents.map((slot) => { {topic.agents.map((slot) => {
const a = agentById.get(slot.agent_id); const a = agentById.get(slot.agent_id);
// Match role_slot against the last SSE step's role. When
// a step fires for this agent, glow for 2.5s. Cyan for
// Plan (reading/decomposing), green for Work/Synth/
// Aggregate (writing/producing).
const pulse = slot.role_slot
? activeAgents.get(slot.role_slot)
: undefined;
const activePhase = pulse?.phase;
const glow =
activePhase === "plan"
? "#5ec8d8"
: activePhase
? "#5fd08a"
: null;
return ( return (
<div <div
key={slot.agent_id} key={slot.agent_id}
@@ -544,17 +591,56 @@ export function ResearchCanvas({
padding: 12, padding: 12,
borderRadius: 10, borderRadius: 10,
background: "#101014", background: "#101014",
border: "1px solid rgba(255,255,255,.06)", border: `1px solid ${glow ? glow : "rgba(255,255,255,.06)"}`,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: 4, gap: 4,
// Disk-LED glow — only when acting. Uses two
// shadow layers so it reads clearly against the
// dark canvas without being cartoonish.
boxShadow: glow
? `0 0 14px ${glow}55, 0 0 3px ${glow}`
: "none",
transition:
"box-shadow 220ms ease-out, border-color 220ms ease-out",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div
style={{
width: 8,
height: 8,
borderRadius: "50%",
flex: "none",
background: glow ?? "#3a3a42",
boxShadow: glow ? `0 0 8px ${glow}` : "none",
transition:
"background 220ms ease-out, box-shadow 220ms ease-out",
}}
aria-hidden
/>
<div
style={{
fontSize: 14,
fontWeight: 600,
color: "#f3f3f5",
minWidth: 0,
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}} }}
> >
<div style={{ fontSize: 14, fontWeight: 600, color: "#f3f3f5" }}>
{a?.name ?? "(missing agent)"} {a?.name ?? "(missing agent)"}
</div> </div>
</div>
<div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92" }}> <div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92" }}>
{slot.role_slot ?? a?.job_title ?? "—"} {slot.role_slot ?? a?.job_title ?? "—"}
{activePhase ? (
<span style={{ color: glow ?? "#8a8a92", marginLeft: 6 }}>
· {activePhase === "plan" ? "reading" : "writing"}
</span>
) : null}
</div> </div>
</div> </div>
); );