fix(dashboard): unstick Active Agents panel cards from "Connecting..."

Agents in `active` state without a current taskId had no SSE to attach
to, but the card still rendered "Connecting..." — implying a network
state that never resolves. Branch on taskId first: idle agents show
"Idle — no task assigned"; running-without-task shows "Starting...".

Also fix a related SSE multiplexer race: subscribers joining a channel
that had already opened never got an onOpen callback (EventSource only
fires `open` once), so they sat at isConnected=false forever whenever
another component was already streaming the same URL. Fire onOpen on a
microtask for late-joiners.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-29 14:23:58 -07:00
parent 3460977be3
commit c31090efb5
4 changed files with 75 additions and 1 deletions

View File

@@ -98,7 +98,13 @@ function LiveAgentCard({ agent, projectId, onSelect, onOpenTaskLogs }: LiveAgent
<div className="live-agent-card-transcript">
{entries.length === 0 ? (
<div className="live-agent-card-empty">
{currentStep ? (
{!agent.taskId ? (
// "active" agents that aren't currently working a task have no
// SSE stream to attach to; useLiveTranscript bails out with
// isConnected=false. Showing "Connecting..." here is misleading
// — the agent is just idle.
<span>{agent.state === "running" ? "Starting..." : "Idle — no task assigned"}</span>
) : currentStep ? (
<>
<div className="live-agent-card-status">
Step {stepNumber}

View File

@@ -119,6 +119,48 @@ describe("ActiveAgentsPanel", () => {
expect(screen.getByText("Waiting for output...")).toBeInTheDocument();
});
it("shows idle copy (not 'Connecting...') when an active agent has no taskId", async () => {
mockUseLiveTranscript.mockReturnValue({
entries: [],
isConnected: false,
});
const mockAgent: Agent = {
id: "agent-001",
name: "Test Agent",
role: "executor",
state: "active",
// taskId intentionally omitted — agent is available but not running
lastHeartbeatAt: new Date().toISOString(),
} as Agent;
render(<ActiveAgentsPanel agents={[mockAgent]} />);
expect(screen.getByText("Idle — no task assigned")).toBeInTheDocument();
expect(screen.queryByText("Connecting...")).toBeNull();
});
it("shows 'Starting...' for running agents that haven't picked up a task yet", async () => {
mockUseLiveTranscript.mockReturnValue({
entries: [],
isConnected: false,
});
const mockAgent: Agent = {
id: "agent-001",
name: "Test Agent",
role: "executor",
state: "running",
// taskId intentionally omitted — race between state flip and task bind
lastHeartbeatAt: new Date().toISOString(),
} as Agent;
render(<ActiveAgentsPanel agents={[mockAgent]} />);
expect(screen.getByText("Starting...")).toBeInTheDocument();
expect(screen.queryByText("Connecting...")).toBeNull();
});
it("renders multiple agent cards with separate transcript streams", async () => {
mockUseLiveTranscript
.mockReturnValueOnce({

View File

@@ -383,9 +383,22 @@ export function subscribeSse(url: string, sub: SseSubscription = {}): () => void
}
channel.subscribers.add(subscriber);
const wasAlreadyOpen = !!channel.es && channel.hasOpenedOnce;
openChannel(channel);
reattachNativeListeners(channel);
// Subscribers joining a channel that already opened never see another
// `open` event from EventSource, so fire onOpen for them on a microtask
// (microtask, not sync, so the caller finishes wiring before state
// updates land).
if (wasAlreadyOpen) {
queueMicrotask(() => {
if (channel.subscribers.has(subscriber)) {
subscriber.onOpen?.();
}
});
}
let active = true;
return () => {
if (!active) return;