From 9b2520ce1f99020a455f76cc0b2fae44869b3cd0 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 26 Feb 2026 04:04:26 +0000 Subject: [PATCH] feat(web): add agent output terminal tabs for orchestrator sessions (#522) Co-authored-by: Jason Woltje Co-committed-by: Jason Woltje --- .../terminal/AgentTerminal.test.tsx | 368 ++++++++++++ .../src/components/terminal/AgentTerminal.tsx | 381 ++++++++++++ .../terminal/TerminalPanel.test.tsx | 153 +++++ .../src/components/terminal/TerminalPanel.tsx | 236 +++++++- apps/web/src/components/terminal/index.ts | 2 + .../hooks/__tests__/useAgentStream.test.ts | 542 ++++++++++++++++++ apps/web/src/hooks/useAgentStream.ts | 319 +++++++++++ 7 files changed, 1991 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/components/terminal/AgentTerminal.test.tsx create mode 100644 apps/web/src/components/terminal/AgentTerminal.tsx create mode 100644 apps/web/src/hooks/__tests__/useAgentStream.test.ts create mode 100644 apps/web/src/hooks/useAgentStream.ts diff --git a/apps/web/src/components/terminal/AgentTerminal.test.tsx b/apps/web/src/components/terminal/AgentTerminal.test.tsx new file mode 100644 index 0000000..cf13b67 --- /dev/null +++ b/apps/web/src/components/terminal/AgentTerminal.test.tsx @@ -0,0 +1,368 @@ +/** + * @file AgentTerminal.test.tsx + * @description Unit tests for the AgentTerminal component + * + * Tests cover: + * - Output rendering + * - Status display (status indicator + badge) + * - ANSI stripping + * - Agent header information (type, duration, jobId) + * - Auto-scroll behavior + * - Copy-to-clipboard + * - Error message display + * - Empty state rendering + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import type { ReactElement } from "react"; +import { AgentTerminal } from "./AgentTerminal"; +import type { AgentSession } from "@/hooks/useAgentStream"; + +// ========================================== +// Mock navigator.clipboard +// ========================================== + +const mockWriteText = vi.fn(() => Promise.resolve()); +Object.defineProperty(navigator, "clipboard", { + value: { writeText: mockWriteText }, + writable: true, + configurable: true, +}); + +// ========================================== +// Factory helpers +// ========================================== + +function makeAgent(overrides: Partial = {}): AgentSession { + return { + agentId: "test-agent-1", + agentType: "worker", + status: "running", + outputLines: [], + startedAt: Date.now() - 5000, // 5s ago + ...overrides, + }; +} + +// ========================================== +// Tests +// ========================================== + +describe("AgentTerminal", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers({ shouldAdvanceTime: false }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // ========================================== + // Rendering + // ========================================== + + describe("rendering", () => { + it("renders the agent terminal container", () => { + render(() as ReactElement); + expect(screen.getByTestId("agent-terminal")).toBeInTheDocument(); + }); + + it("has region role with agent type label", () => { + render(() as ReactElement); + expect(screen.getByRole("region", { name: "Agent output: planner" })).toBeInTheDocument(); + }); + + it("sets data-agent-id attribute", () => { + render(() as ReactElement); + const container = screen.getByTestId("agent-terminal"); + expect(container).toHaveAttribute("data-agent-id", "my-agent-123"); + }); + + it("applies className prop to the outer container", () => { + render(() as ReactElement); + expect(screen.getByTestId("agent-terminal")).toHaveClass("custom-cls"); + }); + }); + + // ========================================== + // Header + // ========================================== + + describe("header", () => { + it("renders the agent type label", () => { + render(() as ReactElement); + expect(screen.getByTestId("agent-type-label")).toHaveTextContent("coordinator"); + }); + + it("includes jobId in the label when provided", () => { + render( + ( + + ) as ReactElement + ); + expect(screen.getByTestId("agent-type-label")).toHaveTextContent("worker · job-42"); + }); + + it("does not show jobId separator when jobId is absent", () => { + render(() as ReactElement); + expect(screen.getByTestId("agent-type-label")).not.toHaveTextContent("·"); + }); + + it("renders the duration element", () => { + render(() as ReactElement); + expect(screen.getByTestId("agent-duration")).toBeInTheDocument(); + }); + + it("shows seconds for short-running agents", () => { + const agent = makeAgent({ startedAt: Date.now() - 8000 }); + render(() as ReactElement); + expect(screen.getByTestId("agent-duration")).toHaveTextContent("s"); + }); + + it("shows minutes for long-running agents", () => { + const agent = makeAgent({ startedAt: Date.now() - 125000 }); // 2m 5s + render(() as ReactElement); + expect(screen.getByTestId("agent-duration")).toHaveTextContent("m"); + }); + }); + + // ========================================== + // Status indicator + // ========================================== + + describe("status indicator", () => { + it("shows a running indicator for running status", () => { + render(() as ReactElement); + const indicator = screen.getByTestId("status-indicator"); + expect(indicator).toHaveAttribute("data-status", "running"); + }); + + it("shows a spawning indicator for spawning status", () => { + render(() as ReactElement); + const indicator = screen.getByTestId("status-indicator"); + expect(indicator).toHaveAttribute("data-status", "spawning"); + }); + + it("shows completed indicator for completed status", () => { + render( + ( + + ) as ReactElement + ); + const indicator = screen.getByTestId("status-indicator"); + expect(indicator).toHaveAttribute("data-status", "completed"); + }); + + it("shows error indicator for error status", () => { + render( + ( + + ) as ReactElement + ); + const indicator = screen.getByTestId("status-indicator"); + expect(indicator).toHaveAttribute("data-status", "error"); + }); + }); + + // ========================================== + // Status badge + // ========================================== + + describe("status badge", () => { + it("renders the status badge", () => { + render(() as ReactElement); + expect(screen.getByTestId("status-badge")).toHaveTextContent("running"); + }); + + it("shows 'spawning' badge for spawning status", () => { + render(() as ReactElement); + expect(screen.getByTestId("status-badge")).toHaveTextContent("spawning"); + }); + + it("shows 'completed' badge for completed status", () => { + render( + ( + + ) as ReactElement + ); + expect(screen.getByTestId("status-badge")).toHaveTextContent("completed"); + }); + + it("shows 'error' badge for error status", () => { + render( + ( + + ) as ReactElement + ); + expect(screen.getByTestId("status-badge")).toHaveTextContent("error"); + }); + }); + + // ========================================== + // Output rendering + // ========================================== + + describe("output area", () => { + it("renders the output pre element", () => { + render(() as ReactElement); + expect(screen.getByTestId("agent-output")).toBeInTheDocument(); + }); + + it("shows 'Waiting for output...' when outputLines is empty and status is running", () => { + render( + ( + + ) as ReactElement + ); + expect(screen.getByTestId("agent-output")).toHaveTextContent("Waiting for output..."); + }); + + it("shows 'Spawning agent...' when status is spawning and no output", () => { + render( + ( + + ) as ReactElement + ); + expect(screen.getByTestId("agent-output")).toHaveTextContent("Spawning agent..."); + }); + + it("renders output lines as text content", () => { + const agent = makeAgent({ + outputLines: ["Hello world\n", "Second line\n"], + }); + render(() as ReactElement); + const output = screen.getByTestId("agent-output"); + expect(output).toHaveTextContent("Hello world"); + expect(output).toHaveTextContent("Second line"); + }); + + it("strips ANSI escape codes from output", () => { + const agent = makeAgent({ + outputLines: ["\x1b[32mGreen text\x1b[0m\n"], + }); + render(() as ReactElement); + const output = screen.getByTestId("agent-output"); + expect(output).toHaveTextContent("Green text"); + expect(output.textContent).not.toContain("\x1b"); + }); + + it("has aria-live=polite for screen reader announcements", () => { + render(() as ReactElement); + expect(screen.getByTestId("agent-output")).toHaveAttribute("aria-live", "polite"); + }); + }); + + // ========================================== + // Error message + // ========================================== + + describe("error message", () => { + it("shows error message when status is error and errorMessage is set", () => { + const agent = makeAgent({ + status: "error", + endedAt: Date.now(), + errorMessage: "Process crashed", + }); + render(() as ReactElement); + expect(screen.getByTestId("agent-error-message")).toHaveTextContent("Process crashed"); + }); + + it("renders alert role for error message", () => { + const agent = makeAgent({ + status: "error", + endedAt: Date.now(), + errorMessage: "OOM killed", + }); + render(() as ReactElement); + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); + + it("does not show error message when status is running", () => { + render(() as ReactElement); + expect(screen.queryByTestId("agent-error-message")).not.toBeInTheDocument(); + }); + + it("does not show error message when status is error but errorMessage is absent", () => { + const agent = makeAgent({ status: "error", endedAt: Date.now() }); + render(() as ReactElement); + expect(screen.queryByTestId("agent-error-message")).not.toBeInTheDocument(); + }); + }); + + // ========================================== + // Copy to clipboard + // ========================================== + + describe("copy to clipboard", () => { + it("renders the copy button", () => { + render(() as ReactElement); + expect(screen.getByTestId("copy-button")).toBeInTheDocument(); + }); + + it("copy button has aria-label='Copy agent output'", () => { + render(() as ReactElement); + expect(screen.getByRole("button", { name: "Copy agent output" })).toBeInTheDocument(); + }); + + it("calls clipboard.writeText with stripped output on click", async () => { + const agent = makeAgent({ + outputLines: ["\x1b[32mLine 1\x1b[0m\n", "Line 2\n"], + }); + render(() as ReactElement); + + await act(async () => { + fireEvent.click(screen.getByTestId("copy-button")); + await Promise.resolve(); + }); + + expect(mockWriteText).toHaveBeenCalledWith("Line 1\nLine 2\n"); + }); + + it("shows 'copied' text briefly after clicking copy", async () => { + render(() as ReactElement); + + await act(async () => { + fireEvent.click(screen.getByTestId("copy-button")); + await Promise.resolve(); + }); + + expect(screen.getByTestId("copy-button")).toHaveTextContent("copied"); + }); + + it("reverts copy button text after timeout", async () => { + render(() as ReactElement); + + await act(async () => { + fireEvent.click(screen.getByTestId("copy-button")); + await Promise.resolve(); + }); + + act(() => { + vi.advanceTimersByTime(2500); + }); + + expect(screen.getByTestId("copy-button")).toHaveTextContent("copy"); + }); + }); + + // ========================================== + // Auto-scroll + // ========================================== + + describe("auto-scroll", () => { + it("does not throw when outputLines changes", () => { + const agent = makeAgent({ outputLines: ["Line 1\n"] }); + const { rerender } = render(() as ReactElement); + + expect(() => { + rerender( + ( + + ) as ReactElement + ); + }).not.toThrow(); + }); + }); +}); diff --git a/apps/web/src/components/terminal/AgentTerminal.tsx b/apps/web/src/components/terminal/AgentTerminal.tsx new file mode 100644 index 0000000..a647d5a --- /dev/null +++ b/apps/web/src/components/terminal/AgentTerminal.tsx @@ -0,0 +1,381 @@ +"use client"; + +/** + * AgentTerminal component + * + * Read-only terminal view for displaying orchestrator agent output. + * Uses a
 element with monospace font rather than xterm.js because
+ * this is read-only agent stdout/stderr, not an interactive PTY.
+ *
+ * Features:
+ * - Displays accumulated output lines with basic ANSI color rendering
+ * - Status badge (spinning/checkmark/X) indicating agent lifecycle
+ * - Header bar with agent type, status, and elapsed duration
+ * - Auto-scrolls to bottom as new output arrives
+ * - Copy-to-clipboard button for full output
+ */
+
+import { useEffect, useRef, useState, useCallback } from "react";
+import type { ReactElement, CSSProperties } from "react";
+import type { AgentSession, AgentStatus } from "@/hooks/useAgentStream";
+
+// ==========================================
+// Types
+// ==========================================
+
+export interface AgentTerminalProps {
+  /** The agent session to display */
+  agent: AgentSession;
+  /** Optional CSS class name for the outer container */
+  className?: string;
+  /** Optional inline style for the outer container */
+  style?: CSSProperties;
+}
+
+// ==========================================
+// ANSI color strip helper
+// ==========================================
+
+// Simple ANSI escape sequence stripper — produces readable plain text for 
.
+// We strip rather than parse for security and simplicity in read-only display.
+// eslint-disable-next-line no-control-regex
+const ANSI_PATTERN = /\x1b\[[0-9;]*[mGKHF]/g;
+
+function stripAnsi(text: string): string {
+  return text.replace(ANSI_PATTERN, "");
+}
+
+// ==========================================
+// Duration helper
+// ==========================================
+
+function formatDuration(startedAt: number, endedAt?: number): string {
+  const elapsed = Math.floor(((endedAt ?? Date.now()) - startedAt) / 1000);
+  if (elapsed < 60) return `${elapsed.toString()}s`;
+  const minutes = Math.floor(elapsed / 60);
+  const seconds = elapsed % 60;
+  return `${minutes.toString()}m ${seconds.toString()}s`;
+}
+
+// ==========================================
+// Status indicator
+// ==========================================
+
+interface StatusIndicatorProps {
+  status: AgentStatus;
+}
+
+function StatusIndicator({ status }: StatusIndicatorProps): ReactElement {
+  const baseStyle: CSSProperties = {
+    display: "inline-block",
+    width: 8,
+    height: 8,
+    borderRadius: "50%",
+    flexShrink: 0,
+  };
+
+  if (status === "running" || status === "spawning") {
+    return (
+      
+    );
+  }
+
+  if (status === "completed") {
+    return (
+      
+    );
+  }
+
+  // error
+  return (
+    
+  );
+}
+
+// ==========================================
+// Status badge
+// ==========================================
+
+interface StatusBadgeProps {
+  status: AgentStatus;
+}
+
+function StatusBadge({ status }: StatusBadgeProps): ReactElement {
+  const colorMap: Record = {
+    spawning: "var(--warn)",
+    running: "var(--success)",
+    completed: "var(--muted)",
+    error: "var(--danger)",
+  };
+
+  const labelMap: Record = {
+    spawning: "spawning",
+    running: "running",
+    completed: "completed",
+    error: "error",
+  };
+
+  return (
+    
+      {labelMap[status]}
+    
+  );
+}
+
+// ==========================================
+// Component
+// ==========================================
+
+/**
+ * AgentTerminal renders accumulated agent output in a scrollable pre block.
+ * It is intentionally read-only — no keyboard input is accepted.
+ */
+export function AgentTerminal({ agent, className = "", style }: AgentTerminalProps): ReactElement {
+  const outputRef = useRef(null);
+  const [copied, setCopied] = useState(false);
+  const [tick, setTick] = useState(0);
+
+  // ==========================================
+  // Duration ticker — only runs while active
+  // ==========================================
+
+  useEffect(() => {
+    if (agent.status === "running" || agent.status === "spawning") {
+      const id = setInterval(() => {
+        setTick((t) => t + 1);
+      }, 1000);
+      return (): void => {
+        clearInterval(id);
+      };
+    }
+    return undefined;
+  }, [agent.status]);
+
+  // Consume tick to avoid unused-var lint
+  void tick;
+
+  // ==========================================
+  // Auto-scroll to bottom on new output
+  // ==========================================
+
+  useEffect(() => {
+    const el = outputRef.current;
+    if (el) {
+      el.scrollTop = el.scrollHeight;
+    }
+  }, [agent.outputLines]);
+
+  // ==========================================
+  // Copy to clipboard
+  // ==========================================
+
+  const handleCopy = useCallback((): void => {
+    const text = agent.outputLines.map(stripAnsi).join("");
+    void navigator.clipboard.writeText(text).then(() => {
+      setCopied(true);
+      setTimeout(() => {
+        setCopied(false);
+      }, 2000);
+    });
+  }, [agent.outputLines]);
+
+  // ==========================================
+  // Styles
+  // ==========================================
+
+  const containerStyle: CSSProperties = {
+    display: "flex",
+    flexDirection: "column",
+    height: "100%",
+    background: "var(--bg-deep)",
+    overflow: "hidden",
+    ...style,
+  };
+
+  const headerStyle: CSSProperties = {
+    display: "flex",
+    alignItems: "center",
+    gap: 8,
+    padding: "6px 12px",
+    borderBottom: "1px solid var(--border)",
+    flexShrink: 0,
+    background: "var(--bg-deep)",
+  };
+
+  const titleStyle: CSSProperties = {
+    fontSize: "0.75rem",
+    fontFamily: "var(--mono)",
+    color: "var(--text)",
+    flex: 1,
+    overflow: "hidden",
+    textOverflow: "ellipsis",
+    whiteSpace: "nowrap",
+  };
+
+  const durationStyle: CSSProperties = {
+    fontSize: "0.65rem",
+    fontFamily: "var(--mono)",
+    color: "var(--muted)",
+    flexShrink: 0,
+  };
+
+  const outputStyle: CSSProperties = {
+    flex: 1,
+    overflow: "auto",
+    margin: 0,
+    padding: "8px 12px",
+    fontFamily: "var(--mono)",
+    fontSize: "0.75rem",
+    lineHeight: 1.5,
+    color: "var(--text)",
+    background: "var(--bg-deep)",
+    whiteSpace: "pre-wrap",
+    wordBreak: "break-all",
+  };
+
+  const copyButtonStyle: CSSProperties = {
+    background: "transparent",
+    border: "1px solid var(--border)",
+    borderRadius: 3,
+    color: copied ? "var(--success)" : "var(--muted)",
+    cursor: "pointer",
+    fontSize: "0.65rem",
+    fontFamily: "var(--mono)",
+    padding: "2px 6px",
+    flexShrink: 0,
+  };
+
+  const duration = formatDuration(agent.startedAt, agent.endedAt);
+
+  return (
+    
+ {/* Header */} +
+ + + + {agent.agentType} + {agent.jobId !== undefined ? ` · ${agent.jobId}` : ""} + + + + + + {duration} + + + {/* Copy button */} + +
+ + {/* Output area */} +
+        {agent.outputLines.length === 0 ? (
+          
+            {agent.status === "spawning" ? "Spawning agent..." : "Waiting for output..."}
+          
+        ) : (
+          agent.outputLines.map(stripAnsi).join("")
+        )}
+      
+ + {/* Error message overlay */} + {agent.status === "error" && agent.errorMessage !== undefined && ( +
+ Error: {agent.errorMessage} +
+ )} + + {/* Pulse animation keyframes — injected inline via style tag for zero deps */} + +
+ ); +} diff --git a/apps/web/src/components/terminal/TerminalPanel.test.tsx b/apps/web/src/components/terminal/TerminalPanel.test.tsx index 7fc5902..88d611c 100644 --- a/apps/web/src/components/terminal/TerminalPanel.test.tsx +++ b/apps/web/src/components/terminal/TerminalPanel.test.tsx @@ -33,6 +33,20 @@ vi.mock("./XTerminal", () => ({ ), })); +// Mock AgentTerminal to avoid complexity in panel tests +vi.mock("./AgentTerminal", () => ({ + AgentTerminal: vi.fn( + ({ agent }: { agent: { agentId: string; agentType: string; status: string } }) => ( +
+ ) + ), +})); + // Mock useTerminalSessions const mockCreateSession = vi.fn(); const mockCloseSession = vi.fn(); @@ -72,6 +86,29 @@ vi.mock("@/hooks/useTerminalSessions", () => ({ })), })); +// Mock useAgentStream +const mockDismissAgent = vi.fn(); +let mockAgents = new Map< + string, + { + agentId: string; + agentType: string; + status: "spawning" | "running" | "completed" | "error"; + outputLines: string[]; + startedAt: number; + } +>(); +let mockAgentStreamConnected = false; + +vi.mock("@/hooks/useAgentStream", () => ({ + useAgentStream: vi.fn(() => ({ + agents: mockAgents, + isConnected: mockAgentStreamConnected, + connectionError: null, + dismissAgent: mockDismissAgent, + })), +})); + import { TerminalPanel } from "./TerminalPanel"; // ========================================== @@ -100,6 +137,8 @@ describe("TerminalPanel", () => { mockIsConnected = false; mockConnectionError = null; mockRegisterOutputCallback.mockReturnValue(vi.fn()); + mockAgents = new Map(); + mockAgentStreamConnected = false; }); // ========================================== @@ -425,4 +464,118 @@ describe("TerminalPanel", () => { expect(mockCreateSession).not.toHaveBeenCalled(); }); }); + + // ========================================== + // Agent tab integration + // ========================================== + + describe("agent tab integration", () => { + function setOneAgent(status: "spawning" | "running" | "completed" | "error" = "running"): void { + mockAgents = new Map([ + [ + "agent-1", + { + agentId: "agent-1", + agentType: "worker", + status, + outputLines: ["Hello from agent\n"], + startedAt: Date.now() - 3000, + }, + ], + ]); + } + + it("renders an agent tab when an agent is active", () => { + setOneAgent("running"); + render(() as ReactElement); + expect(screen.getAllByTestId("agent-tab")).toHaveLength(1); + }); + + it("renders no agent tabs when agents map is empty", () => { + mockAgents = new Map(); + render(() as ReactElement); + expect(screen.queryByTestId("agent-tab")).not.toBeInTheDocument(); + }); + + it("agent tab button has the agent type as label", () => { + setOneAgent("running"); + render(() as ReactElement); + expect(screen.getByRole("tab", { name: "Agent: worker" })).toBeInTheDocument(); + }); + + it("agent tab has role=tab", () => { + setOneAgent("running"); + render(() as ReactElement); + expect(screen.getByRole("tab", { name: "Agent: worker" })).toBeInTheDocument(); + }); + + it("shows dismiss button for completed agents", () => { + setOneAgent("completed"); + render(() as ReactElement); + expect(screen.getByRole("button", { name: "Dismiss worker agent" })).toBeInTheDocument(); + }); + + it("shows dismiss button for error agents", () => { + setOneAgent("error"); + render(() as ReactElement); + expect(screen.getByRole("button", { name: "Dismiss worker agent" })).toBeInTheDocument(); + }); + + it("does not show dismiss button for running agents", () => { + setOneAgent("running"); + render(() as ReactElement); + expect( + screen.queryByRole("button", { name: "Dismiss worker agent" }) + ).not.toBeInTheDocument(); + }); + + it("does not show dismiss button for spawning agents", () => { + setOneAgent("spawning"); + render(() as ReactElement); + expect( + screen.queryByRole("button", { name: "Dismiss worker agent" }) + ).not.toBeInTheDocument(); + }); + + it("calls dismissAgent when dismiss button is clicked", () => { + setOneAgent("completed"); + render(() as ReactElement); + fireEvent.click(screen.getByRole("button", { name: "Dismiss worker agent" })); + expect(mockDismissAgent).toHaveBeenCalledWith("agent-1"); + }); + + it("renders AgentTerminal when agent tab is active", () => { + setOneAgent("running"); + render(() as ReactElement); + // Click the agent tab to make it active + fireEvent.click(screen.getByRole("tab", { name: "Agent: worker" })); + // AgentTerminal should be rendered (mock shows mock-agent-terminal) + expect(screen.getByTestId("mock-agent-terminal")).toBeInTheDocument(); + }); + + it("shows a divider between terminal and agent tabs", () => { + setTwoSessions(); + setOneAgent("running"); + render(() as ReactElement); + // The divider div is aria-hidden; check it's present in the DOM + const tablist = screen.getByRole("tablist"); + const divider = tablist.querySelector('[aria-hidden="true"][style*="width: 1"]'); + expect(divider).toBeInTheDocument(); + }); + + it("agent tabs show correct data-agent-status", () => { + setOneAgent("running"); + render(() as ReactElement); + const tab = screen.getByTestId("agent-tab"); + expect(tab).toHaveAttribute("data-agent-status", "running"); + }); + + it("empty state not shown when agents exist but no terminal sessions", () => { + mockSessions = new Map(); + setOneAgent("running"); + mockIsConnected = false; + render(() as ReactElement); + expect(screen.queryByText("Connecting...")).not.toBeInTheDocument(); + }); + }); }); diff --git a/apps/web/src/components/terminal/TerminalPanel.tsx b/apps/web/src/components/terminal/TerminalPanel.tsx index 1309db7..6ba4e3a 100644 --- a/apps/web/src/components/terminal/TerminalPanel.tsx +++ b/apps/web/src/components/terminal/TerminalPanel.tsx @@ -7,18 +7,25 @@ * rendering one XTerminal per session and keeping all instances mounted (for * scrollback preservation) while switching visibility with display:none. * + * Also renders read-only agent output tabs from the orchestrator SSE stream + * via useAgentStream. Agent tabs are automatically added when agents are active + * and can be dismissed when completed or errored. + * * Features: - * - "+" button to open a new tab - * - Per-tab close button - * - Double-click tab label for inline rename - * - Auto-creates the first session on connect + * - "+" button to open a new terminal tab + * - Per-tab close button (terminal) / dismiss button (agent) + * - Double-click tab label for inline rename (terminal tabs only) + * - Auto-creates the first terminal session on connect * - Connection error state + * - Agent tabs: read-only, auto-appear, dismissable */ import { useState, useEffect, useRef, useCallback } from "react"; import type { ReactElement, CSSProperties, KeyboardEvent } from "react"; import { XTerminal } from "./XTerminal"; +import { AgentTerminal } from "./AgentTerminal"; import { useTerminalSessions } from "@/hooks/useTerminalSessions"; +import { useAgentStream } from "@/hooks/useAgentStream"; // ========================================== // Types @@ -59,6 +66,42 @@ export function TerminalPanel({ registerOutputCallback, } = useTerminalSessions({ token }); + // ========================================== + // Agent stream + // ========================================== + + const { agents, dismissAgent } = useAgentStream(); + + // ========================================== + // Active tab state (terminal session OR agent) + // "terminal:" or "agent:" + // ========================================== + + type TabId = string; // prefix-qualified: "terminal:" or "agent:" + + const [activeTabId, setActiveTabId] = useState(null); + + // Sync activeTabId with the terminal session activeSessionId when no agent tab is selected + useEffect(() => { + setActiveTabId((prev) => { + // If an agent tab is active, don't clobber it + if (prev?.startsWith("agent:")) return prev; + // Reflect active terminal session + if (activeSessionId !== null) return `terminal:${activeSessionId}`; + return prev; + }); + }, [activeSessionId]); + + // If the active agent tab is dismissed, fall back to the terminal session + useEffect(() => { + if (activeTabId?.startsWith("agent:")) { + const agentId = activeTabId.slice("agent:".length); + if (!agents.has(agentId)) { + setActiveTabId(activeSessionId !== null ? `terminal:${activeSessionId}` : null); + } + } + }, [agents, activeTabId, activeSessionId]); + // ========================================== // Inline rename state // ========================================== @@ -187,6 +230,16 @@ export function TerminalPanel({ position: "relative", }; + // ========================================== + // Agent status dot color + // ========================================== + + const agentDotColor = (status: string): string => { + if (status === "running" || status === "spawning") return "var(--success)"; + if (status === "error") return "var(--danger)"; + return "var(--muted)"; + }; + // ========================================== // Render // ========================================== @@ -203,8 +256,10 @@ export function TerminalPanel({
{/* Tab bar */}
+ {/* ---- Terminal session tabs ---- */} {[...sessions.entries()].map(([sessionId, sessionInfo]) => { - const isActive = sessionId === activeSessionId; + const tabKey = `terminal:${sessionId}`; + const isActive = tabKey === activeTabId; const isEditing = sessionId === editingTabId; const tabStyle: CSSProperties = { @@ -223,7 +278,7 @@ export function TerminalPanel({ }; return ( -
+
{isEditing ? ( { + setActiveTabId(tabKey); setActiveSession(sessionId); }} onDoubleClick={(): void => { @@ -364,6 +420,146 @@ export function TerminalPanel({ /> + + {/* ---- Agent section divider (only when agents exist) ---- */} + {agents.size > 0 && ( + {/* Action buttons */} @@ -428,8 +624,10 @@ export function TerminalPanel({ {/* Terminal body — keep all XTerminal instances mounted for scrollback */}
+ {/* ---- Terminal session panels ---- */} {[...sessions.entries()].map(([sessionId, sessionInfo]) => { - const isActive = sessionId === activeSessionId; + const tabKey = `terminal:${sessionId}`; + const isActive = tabKey === activeTabId; const termStyle: CSSProperties = { display: isActive ? "flex" : "none", flex: 1, @@ -438,7 +636,7 @@ export function TerminalPanel({ }; return ( -
+
{ + const tabKey = `agent:${agentId}`; + const isActive = tabKey === activeTabId; + const agentPanelStyle: CSSProperties = { + display: isActive ? "flex" : "none", + flex: 1, + flexDirection: "column", + minHeight: 0, + }; + + return ( +
+ +
+ ); + })} + + {/* Empty state — show only when no terminal sessions AND no agent sessions */} + {sessions.size === 0 && agents.size === 0 && (
void) | null; + onerror: ((event: Event) => void) | null; + onmessage: ((event: MessageEvent) => void) | null; + close: ReturnType; + addEventListener: ReturnType; + dispatchEvent: (type: string, data: string) => void; + _listeners: Map) => void)[]>; + readyState: number; +} + +let mockEventSourceInstances: MockEventSourceInstance[] = []; + +const MockEventSource = vi.fn(function (this: MockEventSourceInstance, url: string): void { + this.url = url; + this.onopen = null; + this.onerror = null; + this.onmessage = null; + this.close = vi.fn(); + this.readyState = 0; + this._listeners = new Map(); + + this.addEventListener = vi.fn( + (type: string, handler: (event: MessageEvent) => void): void => { + if (!this._listeners.has(type)) { + this._listeners.set(type, []); + } + const list = this._listeners.get(type); + if (list) list.push(handler); + } + ); + + this.dispatchEvent = (type: string, data: string): void => { + const handlers = this._listeners.get(type) ?? []; + const event = new MessageEvent(type, { data }); + for (const handler of handlers) { + handler(event); + } + }; + + mockEventSourceInstances.push(this); +}); + +// Add static constants +Object.assign(MockEventSource, { + CONNECTING: 0, + OPEN: 1, + CLOSED: 2, +}); + +vi.stubGlobal("EventSource", MockEventSource); + +// ========================================== +// Helpers +// ========================================== + +function getLatestES(): MockEventSourceInstance { + const es = mockEventSourceInstances[mockEventSourceInstances.length - 1]; + if (!es) throw new Error("No EventSource instance created"); + return es; +} + +function triggerOpen(): void { + const es = getLatestES(); + if (es.onopen) es.onopen(); +} + +function triggerError(): void { + const es = getLatestES(); + if (es.onerror) es.onerror(new Event("error")); +} + +function emitEvent(type: string, data: unknown): void { + const es = getLatestES(); + es.dispatchEvent(type, JSON.stringify(data)); +} + +// ========================================== +// Tests +// ========================================== + +describe("useAgentStream", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + mockEventSourceInstances = []; + }); + + afterEach(() => { + vi.runAllTimers(); + vi.useRealTimers(); + }); + + // ========================================== + // Initialization + // ========================================== + + describe("initialization", () => { + it("creates an EventSource connecting to /api/orchestrator/events", () => { + renderHook(() => useAgentStream()); + expect(MockEventSource).toHaveBeenCalledWith("/api/orchestrator/events"); + }); + + it("starts with isConnected=false before onopen fires", () => { + const { result } = renderHook(() => useAgentStream()); + expect(result.current.isConnected).toBe(false); + }); + + it("starts with an empty agents map", () => { + const { result } = renderHook(() => useAgentStream()); + expect(result.current.agents.size).toBe(0); + }); + + it("sets isConnected=true when EventSource opens", () => { + const { result } = renderHook(() => useAgentStream()); + act(() => { + triggerOpen(); + }); + expect(result.current.isConnected).toBe(true); + }); + + it("clears connectionError when EventSource opens", () => { + const { result } = renderHook(() => useAgentStream()); + + // Trigger an error first to set connectionError + act(() => { + triggerError(); + }); + + // Start a fresh reconnect and open it + act(() => { + vi.advanceTimersByTime(2000); + }); + + act(() => { + triggerOpen(); + }); + + expect(result.current.connectionError).toBeNull(); + }); + }); + + // ========================================== + // SSE event: agent:spawned + // ========================================== + + describe("agent:spawned event", () => { + it("adds an agent with status=spawning", () => { + const { result } = renderHook(() => useAgentStream()); + + act(() => { + triggerOpen(); + emitEvent("agent:spawned", { agentId: "agent-1", type: "worker", jobId: "job-abc" }); + }); + + expect(result.current.agents.has("agent-1")).toBe(true); + expect(result.current.agents.get("agent-1")?.status).toBe("spawning"); + }); + + it("sets agentType from the type field", () => { + const { result } = renderHook(() => useAgentStream()); + + act(() => { + triggerOpen(); + emitEvent("agent:spawned", { agentId: "agent-1", type: "planner" }); + }); + + expect(result.current.agents.get("agent-1")?.agentType).toBe("planner"); + }); + + it("defaults agentType to 'agent' when type is missing", () => { + const { result } = renderHook(() => useAgentStream()); + + act(() => { + triggerOpen(); + emitEvent("agent:spawned", { agentId: "agent-2" }); + }); + + expect(result.current.agents.get("agent-2")?.agentType).toBe("agent"); + }); + + it("stores jobId when present", () => { + const { result } = renderHook(() => useAgentStream()); + + act(() => { + triggerOpen(); + emitEvent("agent:spawned", { agentId: "agent-3", type: "worker", jobId: "job-xyz" }); + }); + + expect(result.current.agents.get("agent-3")?.jobId).toBe("job-xyz"); + }); + + it("starts with empty outputLines", () => { + const { result } = renderHook(() => useAgentStream()); + + act(() => { + triggerOpen(); + emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" }); + }); + + expect(result.current.agents.get("agent-1")?.outputLines).toEqual([]); + }); + }); + + // ========================================== + // SSE event: agent:output + // ========================================== + + describe("agent:output event", () => { + it("appends output to the agent's outputLines", () => { + const { result } = renderHook(() => useAgentStream()); + + act(() => { + triggerOpen(); + emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" }); + emitEvent("agent:output", { agentId: "agent-1", data: "Hello world\n" }); + }); + + expect(result.current.agents.get("agent-1")?.outputLines).toContain("Hello world\n"); + }); + + it("transitions status from spawning to running on first output", () => { + const { result } = renderHook(() => useAgentStream()); + + act(() => { + triggerOpen(); + emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" }); + emitEvent("agent:output", { agentId: "agent-1", data: "Starting...\n" }); + }); + + expect(result.current.agents.get("agent-1")?.status).toBe("running"); + }); + + it("accumulates multiple output lines", () => { + const { result } = renderHook(() => useAgentStream()); + + act(() => { + triggerOpen(); + emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" }); + emitEvent("agent:output", { agentId: "agent-1", data: "Line 1\n" }); + emitEvent("agent:output", { agentId: "agent-1", data: "Line 2\n" }); + emitEvent("agent:output", { agentId: "agent-1", data: "Line 3\n" }); + }); + + const lines = result.current.agents.get("agent-1")?.outputLines ?? []; + expect(lines).toHaveLength(3); + expect(lines[0]).toBe("Line 1\n"); + expect(lines[1]).toBe("Line 2\n"); + expect(lines[2]).toBe("Line 3\n"); + }); + + it("creates a new agent entry if output arrives before spawned event", () => { + const { result } = renderHook(() => useAgentStream()); + + act(() => { + triggerOpen(); + emitEvent("agent:output", { agentId: "unknown-agent", data: "Surprise output\n" }); + }); + + expect(result.current.agents.has("unknown-agent")).toBe(true); + expect(result.current.agents.get("unknown-agent")?.status).toBe("running"); + }); + }); + + // ========================================== + // SSE event: agent:completed + // ========================================== + + describe("agent:completed event", () => { + it("sets status to completed", () => { + const { result } = renderHook(() => useAgentStream()); + + act(() => { + triggerOpen(); + emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" }); + emitEvent("agent:output", { agentId: "agent-1", data: "Working...\n" }); + emitEvent("agent:completed", { agentId: "agent-1", exitCode: 0 }); + }); + + expect(result.current.agents.get("agent-1")?.status).toBe("completed"); + }); + + it("stores the exitCode", () => { + const { result } = renderHook(() => useAgentStream()); + + act(() => { + triggerOpen(); + emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" }); + emitEvent("agent:completed", { agentId: "agent-1", exitCode: 42 }); + }); + + expect(result.current.agents.get("agent-1")?.exitCode).toBe(42); + }); + + it("sets endedAt timestamp", () => { + const { result } = renderHook(() => useAgentStream()); + + act(() => { + triggerOpen(); + emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" }); + emitEvent("agent:completed", { agentId: "agent-1", exitCode: 0 }); + }); + + expect(result.current.agents.get("agent-1")?.endedAt).toBeDefined(); + }); + + it("ignores completed event for unknown agent", () => { + const { result } = renderHook(() => useAgentStream()); + + act(() => { + triggerOpen(); + emitEvent("agent:completed", { agentId: "ghost-agent", exitCode: 0 }); + }); + + expect(result.current.agents.has("ghost-agent")).toBe(false); + }); + }); + + // ========================================== + // SSE event: agent:error + // ========================================== + + describe("agent:error event", () => { + it("sets status to error", () => { + const { result } = renderHook(() => useAgentStream()); + + act(() => { + triggerOpen(); + emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" }); + emitEvent("agent:error", { agentId: "agent-1", error: "Out of memory" }); + }); + + expect(result.current.agents.get("agent-1")?.status).toBe("error"); + }); + + it("stores the error message", () => { + const { result } = renderHook(() => useAgentStream()); + + act(() => { + triggerOpen(); + emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" }); + emitEvent("agent:error", { agentId: "agent-1", error: "Segfault" }); + }); + + expect(result.current.agents.get("agent-1")?.errorMessage).toBe("Segfault"); + }); + + it("sets endedAt on error", () => { + const { result } = renderHook(() => useAgentStream()); + + act(() => { + triggerOpen(); + emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" }); + emitEvent("agent:error", { agentId: "agent-1", error: "Crash" }); + }); + + expect(result.current.agents.get("agent-1")?.endedAt).toBeDefined(); + }); + + it("ignores error event for unknown agent", () => { + const { result } = renderHook(() => useAgentStream()); + + act(() => { + triggerOpen(); + emitEvent("agent:error", { agentId: "ghost-agent", error: "Crash" }); + }); + + expect(result.current.agents.has("ghost-agent")).toBe(false); + }); + }); + + // ========================================== + // Reconnect behavior + // ========================================== + + describe("auto-reconnect", () => { + it("sets isConnected=false on error", () => { + const { result } = renderHook(() => useAgentStream()); + + act(() => { + triggerOpen(); + }); + act(() => { + triggerError(); + }); + + expect(result.current.isConnected).toBe(false); + }); + + it("sets connectionError on error", () => { + const { result } = renderHook(() => useAgentStream()); + + act(() => { + triggerOpen(); + triggerError(); + }); + + expect(result.current.connectionError).not.toBeNull(); + }); + + it("creates a new EventSource after reconnect delay", () => { + renderHook(() => useAgentStream()); + const initialCount = mockEventSourceInstances.length; + + act(() => { + triggerOpen(); + triggerError(); + }); + + act(() => { + vi.advanceTimersByTime(1500); // initial backoff = 1000ms + }); + + expect(mockEventSourceInstances.length).toBeGreaterThan(initialCount); + }); + + it("closes the old EventSource before reconnecting", () => { + renderHook(() => useAgentStream()); + + act(() => { + triggerOpen(); + triggerError(); + }); + + const closedInstance = mockEventSourceInstances[0]; + expect(closedInstance?.close).toHaveBeenCalled(); + }); + }); + + // ========================================== + // Cleanup on unmount + // ========================================== + + describe("cleanup on unmount", () => { + it("closes EventSource when the hook is unmounted", () => { + const { unmount } = renderHook(() => useAgentStream()); + + const es = getLatestES(); + unmount(); + + expect(es.close).toHaveBeenCalled(); + }); + + it("does not attempt to reconnect after unmount", () => { + const { unmount } = renderHook(() => useAgentStream()); + + act(() => { + triggerOpen(); + triggerError(); + }); + + const countBeforeUnmount = mockEventSourceInstances.length; + + unmount(); + + act(() => { + vi.advanceTimersByTime(5000); + }); + + // No new instances created after unmount + expect(mockEventSourceInstances.length).toBe(countBeforeUnmount); + }); + }); + + // ========================================== + // Dismiss agent + // ========================================== + + describe("dismissAgent", () => { + it("removes the agent from the map", () => { + const { result } = renderHook(() => useAgentStream()); + + act(() => { + triggerOpen(); + emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" }); + emitEvent("agent:completed", { agentId: "agent-1", exitCode: 0 }); + }); + + act(() => { + result.current.dismissAgent("agent-1"); + }); + + expect(result.current.agents.has("agent-1")).toBe(false); + }); + + it("is a no-op for unknown agentId", () => { + const { result } = renderHook(() => useAgentStream()); + + act(() => { + triggerOpen(); + emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" }); + }); + + act(() => { + result.current.dismissAgent("nonexistent-agent"); + }); + + expect(result.current.agents.has("agent-1")).toBe(true); + }); + }); + + // ========================================== + // Malformed event handling + // ========================================== + + describe("malformed events", () => { + it("ignores malformed JSON without throwing", () => { + const { result } = renderHook(() => useAgentStream()); + + act(() => { + triggerOpen(); + // Dispatch raw bad JSON + const es = getLatestES(); + es.dispatchEvent("agent:spawned", "NOT JSON {{{"); + }); + + // Should not crash, agents map stays empty + expect(result.current.agents.size).toBe(0); + }); + }); +}); diff --git a/apps/web/src/hooks/useAgentStream.ts b/apps/web/src/hooks/useAgentStream.ts new file mode 100644 index 0000000..45840db --- /dev/null +++ b/apps/web/src/hooks/useAgentStream.ts @@ -0,0 +1,319 @@ +"use client"; + +/** + * useAgentStream hook + * + * Connects to the orchestrator SSE event stream at /api/orchestrator/events + * and maintains a Map of agentId → AgentSession with accumulated output, + * status, and lifecycle metadata. + * + * SSE event types consumed: + * - agent:spawned — { agentId, type, jobId } + * - agent:output — { agentId, data } (stdout/stderr lines) + * - agent:completed — { agentId, exitCode, result } + * - agent:error — { agentId, error } + * + * Features: + * - Auto-reconnect with exponential backoff on connection loss + * - Cleans up EventSource on unmount + * - Accumulates output lines per agent + */ + +import { useEffect, useRef, useState, useCallback } from "react"; + +// ========================================== +// Types +// ========================================== + +export type AgentStatus = "spawning" | "running" | "completed" | "error"; + +export interface AgentSession { + /** Agent identifier from the orchestrator */ + agentId: string; + /** Agent type or name (e.g., "worker", "planner") */ + agentType: string; + /** Optional job ID this agent is associated with */ + jobId?: string; + /** Current lifecycle status */ + status: AgentStatus; + /** Accumulated output lines (stdout/stderr) */ + outputLines: string[]; + /** Timestamp when the agent was spawned */ + startedAt: number; + /** Timestamp when the agent completed or errored */ + endedAt?: number; + /** Exit code from agent:completed event */ + exitCode?: number; + /** Error message from agent:error event */ + errorMessage?: string; +} + +export interface UseAgentStreamReturn { + /** Map of agentId → AgentSession */ + agents: Map; + /** Whether the SSE stream is currently connected */ + isConnected: boolean; + /** Connection error message, if any */ + connectionError: string | null; + /** Dismiss (remove) an agent tab by agentId */ + dismissAgent: (agentId: string) => void; +} + +// ========================================== +// SSE payload shapes +// ========================================== + +interface SpawnedPayload { + agentId: string; + type?: string; + jobId?: string; +} + +interface OutputPayload { + agentId: string; + data: string; +} + +interface CompletedPayload { + agentId: string; + exitCode?: number; + result?: unknown; +} + +interface ErrorPayload { + agentId: string; + error?: string; +} + +// ========================================== +// Backoff config +// ========================================== + +const RECONNECT_BASE_MS = 1_000; +const RECONNECT_MAX_MS = 30_000; +const RECONNECT_MULTIPLIER = 2; + +// ========================================== +// Hook +// ========================================== + +/** + * Connects to the orchestrator SSE stream and tracks all agent sessions. + * + * @returns Agent sessions map, connection status, and dismiss callback + */ +export function useAgentStream(): UseAgentStreamReturn { + const [agents, setAgents] = useState>(new Map()); + const [isConnected, setIsConnected] = useState(false); + const [connectionError, setConnectionError] = useState(null); + + const eventSourceRef = useRef(null); + const reconnectTimerRef = useRef | null>(null); + const reconnectDelayRef = useRef(RECONNECT_BASE_MS); + const isMountedRef = useRef(true); + + // ========================================== + // Agent state update helpers + // ========================================== + + const handleAgentSpawned = useCallback((payload: SpawnedPayload): void => { + setAgents((prev) => { + const next = new Map(prev); + next.set(payload.agentId, { + agentId: payload.agentId, + agentType: payload.type ?? "agent", + ...(payload.jobId !== undefined ? { jobId: payload.jobId } : {}), + status: "spawning", + outputLines: [], + startedAt: Date.now(), + }); + return next; + }); + }, []); + + const handleAgentOutput = useCallback((payload: OutputPayload): void => { + setAgents((prev) => { + const existing = prev.get(payload.agentId); + if (!existing) { + // First output for an agent we haven't seen spawned — create it + const next = new Map(prev); + next.set(payload.agentId, { + agentId: payload.agentId, + agentType: "agent", + status: "running", + outputLines: [payload.data], + startedAt: Date.now(), + }); + return next; + } + + const next = new Map(prev); + next.set(payload.agentId, { + ...existing, + status: existing.status === "spawning" ? "running" : existing.status, + outputLines: [...existing.outputLines, payload.data], + }); + return next; + }); + }, []); + + const handleAgentCompleted = useCallback((payload: CompletedPayload): void => { + setAgents((prev) => { + const existing = prev.get(payload.agentId); + if (!existing) return prev; + + const next = new Map(prev); + next.set(payload.agentId, { + ...existing, + status: "completed", + endedAt: Date.now(), + ...(payload.exitCode !== undefined ? { exitCode: payload.exitCode } : {}), + }); + return next; + }); + }, []); + + const handleAgentError = useCallback((payload: ErrorPayload): void => { + setAgents((prev) => { + const existing = prev.get(payload.agentId); + if (!existing) return prev; + + const next = new Map(prev); + next.set(payload.agentId, { + ...existing, + status: "error", + endedAt: Date.now(), + ...(payload.error !== undefined ? { errorMessage: payload.error } : {}), + }); + return next; + }); + }, []); + + // ========================================== + // SSE connection + // ========================================== + + const connect = useCallback((): void => { + if (!isMountedRef.current) return; + if (typeof EventSource === "undefined") return; + + // Clean up any existing connection + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + + const es = new EventSource("/api/orchestrator/events"); + eventSourceRef.current = es; + + es.onopen = (): void => { + if (!isMountedRef.current) return; + setIsConnected(true); + setConnectionError(null); + reconnectDelayRef.current = RECONNECT_BASE_MS; + }; + + es.onerror = (): void => { + if (!isMountedRef.current) return; + setIsConnected(false); + + es.close(); + eventSourceRef.current = null; + + // Schedule reconnect with backoff + const delay = reconnectDelayRef.current; + reconnectDelayRef.current = Math.min(delay * RECONNECT_MULTIPLIER, RECONNECT_MAX_MS); + + const delaySecs = Math.round(delay / 1000).toString(); + setConnectionError(`SSE connection lost. Reconnecting in ${delaySecs}s...`); + + reconnectTimerRef.current = setTimeout(() => { + if (isMountedRef.current) { + connect(); + } + }, delay); + }; + + es.addEventListener("agent:spawned", (event: MessageEvent) => { + if (!isMountedRef.current) return; + try { + const payload = JSON.parse(event.data) as SpawnedPayload; + handleAgentSpawned(payload); + } catch { + // Ignore malformed events + } + }); + + es.addEventListener("agent:output", (event: MessageEvent) => { + if (!isMountedRef.current) return; + try { + const payload = JSON.parse(event.data) as OutputPayload; + handleAgentOutput(payload); + } catch { + // Ignore malformed events + } + }); + + es.addEventListener("agent:completed", (event: MessageEvent) => { + if (!isMountedRef.current) return; + try { + const payload = JSON.parse(event.data) as CompletedPayload; + handleAgentCompleted(payload); + } catch { + // Ignore malformed events + } + }); + + es.addEventListener("agent:error", (event: MessageEvent) => { + if (!isMountedRef.current) return; + try { + const payload = JSON.parse(event.data) as ErrorPayload; + handleAgentError(payload); + } catch { + // Ignore malformed events + } + }); + }, [handleAgentSpawned, handleAgentOutput, handleAgentCompleted, handleAgentError]); + + // ========================================== + // Mount / unmount + // ========================================== + + useEffect(() => { + isMountedRef.current = true; + connect(); + + return (): void => { + isMountedRef.current = false; + + if (reconnectTimerRef.current !== null) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + }; + }, [connect]); + + // ========================================== + // Dismiss agent + // ========================================== + + const dismissAgent = useCallback((agentId: string): void => { + setAgents((prev) => { + const next = new Map(prev); + next.delete(agentId); + return next; + }); + }, []); + + return { + agents, + isConnected, + connectionError, + dismissAgent, + }; +}