agent cards: disk-LED glow on live topology step
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:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user