From 61522f43aa02b550c1db4149fc9e85efe84b9919 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 25 Feb 2026 22:00:27 -0600 Subject: [PATCH] feat(web): add agent output terminal tabs for orchestrator sessions (CT-ORCH-002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements read-only agent output viewing in the TerminalPanel as a separate tab group alongside interactive PTY terminal sessions. - useAgentStream hook: SSE connection to /api/orchestrator/events with exponential backoff reconnect, per-agent output accumulation, and full lifecycle tracking (spawning→running→completed/error) - AgentTerminal component: read-only
-based output view with ANSI
  stripping, status indicator (pulse/dot), status badge, elapsed duration,
  copy-to-clipboard, and error message overlay
- TerminalPanel integration: agent tabs appear automatically when agents
  are active, show colored status dots, dismissable when completed/error,
  and section divider separates terminal vs agent tabs
- 79 new unit tests across useAgentStream, AgentTerminal, and TerminalPanel

Co-Authored-By: Claude Opus 4.6 
---
 .../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, + }; +} -- 2.49.1