feat(KB-028): add multi-agent terminal view
- Create useMultiAgentLogs hook for real-time agent log streaming - Build TerminalModal component with tabbed task interface - Add terminal styles with mobile responsive design - Integrate terminal toggle into Header with in-progress badge - Wire TerminalModal into App.tsx with in-progress task filtering
This commit is contained in:
420
packages/dashboard/app/hooks/__tests__/useMultiAgentLogs.test.ts
Normal file
420
packages/dashboard/app/hooks/__tests__/useMultiAgentLogs.test.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useMultiAgentLogs } from "../useMultiAgentLogs";
|
||||
import { fetchAgentLogs } from "../../api";
|
||||
|
||||
// Mock the api module
|
||||
vi.mock("../../api", () => ({
|
||||
fetchAgentLogs: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
const mockFetchAgentLogs = vi.mocked(fetchAgentLogs);
|
||||
|
||||
// Mock EventSource - track instances per hook render, not globally
|
||||
class MockEventSource {
|
||||
url: string;
|
||||
listeners: Record<string, ((e: { data: string }) => void)[]> = {};
|
||||
readyState = 0;
|
||||
close = vi.fn();
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
this.readyState = 1;
|
||||
}
|
||||
|
||||
addEventListener(event: string, fn: (e: { data: string }) => void) {
|
||||
if (!this.listeners[event]) this.listeners[event] = [];
|
||||
this.listeners[event].push(fn);
|
||||
}
|
||||
|
||||
// Helper to simulate a server event
|
||||
_emit(event: string, data: unknown) {
|
||||
for (const fn of this.listeners[event] || []) {
|
||||
fn({ data: JSON.stringify(data) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const originalEventSource = globalThis.EventSource;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as unknown as Record<string, unknown>).EventSource = MockEventSource;
|
||||
mockFetchAgentLogs.mockReset().mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as unknown as Record<string, unknown>).EventSource = originalEventSource;
|
||||
});
|
||||
|
||||
function getActiveConnections(): MockEventSource[] {
|
||||
// Get all MockEventSource instances that haven't been closed
|
||||
// We need to track this ourselves since the mock is recreated each time
|
||||
const allSources: MockEventSource[] = [];
|
||||
|
||||
// Hook into the constructor to track instances
|
||||
const OriginalMock = MockEventSource;
|
||||
const instances: MockEventSource[] = [];
|
||||
|
||||
// Override to capture instances
|
||||
(globalThis as unknown as Record<string, unknown>).EventSource = class extends MockEventSource {
|
||||
constructor(url: string) {
|
||||
super(url);
|
||||
instances.push(this);
|
||||
}
|
||||
};
|
||||
|
||||
return instances;
|
||||
}
|
||||
|
||||
describe("useMultiAgentLogs", () => {
|
||||
it("initializes with empty entries for all provided task IDs", () => {
|
||||
const { result } = renderHook(() => useMultiAgentLogs(["KB-001", "KB-002"]));
|
||||
|
||||
expect(result.current["KB-001"]).toBeDefined();
|
||||
expect(result.current["KB-001"].entries).toEqual([]);
|
||||
expect(result.current["KB-001"].loading).toBe(true);
|
||||
|
||||
expect(result.current["KB-002"]).toBeDefined();
|
||||
expect(result.current["KB-002"].entries).toEqual([]);
|
||||
expect(result.current["KB-002"].loading).toBe(true);
|
||||
});
|
||||
|
||||
it("returns empty object when no task IDs provided", () => {
|
||||
const { result } = renderHook(() => useMultiAgentLogs([]));
|
||||
|
||||
expect(Object.keys(result.current)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("fetches historical logs for each task on mount", async () => {
|
||||
const logs1 = [
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "KB-001", text: "log1", type: "text" as const },
|
||||
];
|
||||
const logs2 = [
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "KB-002", text: "log2", type: "text" as const },
|
||||
];
|
||||
|
||||
mockFetchAgentLogs.mockImplementation((taskId) => {
|
||||
if (taskId === "KB-001") return Promise.resolve(logs1);
|
||||
if (taskId === "KB-002") return Promise.resolve(logs2);
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMultiAgentLogs(["KB-001", "KB-002"]));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current["KB-001"].entries).toEqual(logs1);
|
||||
expect(result.current["KB-002"].entries).toEqual(logs2);
|
||||
});
|
||||
|
||||
expect(mockFetchAgentLogs).toHaveBeenCalledWith("KB-001");
|
||||
expect(mockFetchAgentLogs).toHaveBeenCalledWith("KB-002");
|
||||
});
|
||||
|
||||
it("opens SSE EventSource for each task ID", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
|
||||
const instances: MockEventSource[] = [];
|
||||
|
||||
// Override to capture instances
|
||||
(globalThis as unknown as Record<string, unknown>).EventSource = class extends MockEventSource {
|
||||
constructor(url: string) {
|
||||
super(url);
|
||||
instances.push(this);
|
||||
}
|
||||
};
|
||||
|
||||
renderHook(() => useMultiAgentLogs(["KB-001", "KB-002"]));
|
||||
|
||||
await waitFor(() => {
|
||||
// Filter to unique URLs (Strict Mode may create duplicates)
|
||||
const urls = [...new Set(instances.map((es) => es.url))];
|
||||
expect(urls).toContain("/api/tasks/KB-001/logs/stream");
|
||||
expect(urls).toContain("/api/tasks/KB-002/logs/stream");
|
||||
});
|
||||
});
|
||||
|
||||
it("merges live SSE events with historical entries", async () => {
|
||||
const historical = [
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "KB-001", text: "old", type: "text" as const },
|
||||
];
|
||||
// Use mockResolvedValue (not Once) to handle Strict Mode double-run
|
||||
mockFetchAgentLogs.mockResolvedValue(historical);
|
||||
|
||||
const instances: MockEventSource[] = [];
|
||||
(globalThis as unknown as Record<string, unknown>).EventSource = class extends MockEventSource {
|
||||
constructor(url: string) {
|
||||
super(url);
|
||||
instances.push(this);
|
||||
}
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useMultiAgentLogs(["KB-001"]));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current["KB-001"].entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
const es = instances.find((e) => e.url.includes("KB-001"));
|
||||
expect(es).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
es!._emit("agent:log", {
|
||||
timestamp: "2026-01-01T00:01:00Z",
|
||||
taskId: "KB-001",
|
||||
text: "new",
|
||||
type: "text",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current["KB-001"].entries).toHaveLength(2);
|
||||
});
|
||||
|
||||
expect(result.current["KB-001"].entries[1].text).toBe("new");
|
||||
});
|
||||
|
||||
it("closes all SSE connections on unmount (memory leak prevention)", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
|
||||
const instances: MockEventSource[] = [];
|
||||
(globalThis as unknown as Record<string, unknown>).EventSource = class extends MockEventSource {
|
||||
constructor(url: string) {
|
||||
super(url);
|
||||
instances.push(this);
|
||||
}
|
||||
};
|
||||
|
||||
const { unmount } = renderHook(() => useMultiAgentLogs(["KB-001", "KB-002"]));
|
||||
|
||||
// Wait for connections to be established
|
||||
await waitFor(() => {
|
||||
expect(instances.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
// Get unique instances by URL (handling Strict Mode duplicates)
|
||||
const uniqueByUrl = new Map<string, MockEventSource>();
|
||||
for (const es of instances) {
|
||||
if (!uniqueByUrl.has(es.url) || !es.close.mock?.calls?.length) {
|
||||
uniqueByUrl.set(es.url, es);
|
||||
}
|
||||
}
|
||||
const finalInstances = Array.from(uniqueByUrl.values());
|
||||
|
||||
unmount();
|
||||
|
||||
// Verify all final connections are closed
|
||||
for (const es of finalInstances) {
|
||||
expect(es.close).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("closes specific connection when task ID removed from array", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
|
||||
const instances: MockEventSource[] = [];
|
||||
(globalThis as unknown as Record<string, unknown>).EventSource = class extends MockEventSource {
|
||||
constructor(url: string) {
|
||||
super(url);
|
||||
instances.push(this);
|
||||
}
|
||||
};
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ taskIds }: { taskIds: string[] }) => useMultiAgentLogs(taskIds),
|
||||
{ initialProps: { taskIds: ["KB-001", "KB-002"] } },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(instances.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
// Get the last connection for each URL
|
||||
const getConnection = (taskId: string) => {
|
||||
const url = `/api/tasks/${taskId}/logs/stream`;
|
||||
const matching = instances.filter((e) => e.url === url);
|
||||
return matching[matching.length - 1];
|
||||
};
|
||||
|
||||
const es1 = getConnection("KB-001");
|
||||
const es2 = getConnection("KB-002");
|
||||
|
||||
rerender({ taskIds: ["KB-001"] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(es2.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(es1.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens new connection when task ID added to array", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
|
||||
const instances: MockEventSource[] = [];
|
||||
(globalThis as unknown as Record<string, unknown>).EventSource = class extends MockEventSource {
|
||||
constructor(url: string) {
|
||||
super(url);
|
||||
instances.push(this);
|
||||
}
|
||||
};
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ taskIds }: { taskIds: string[] }) => useMultiAgentLogs(taskIds),
|
||||
{ initialProps: { taskIds: ["KB-001"] } },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(instances.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
rerender({ taskIds: ["KB-001", "KB-002"] });
|
||||
|
||||
await waitFor(() => {
|
||||
const urls = [...new Set(instances.map((es) => es.url))];
|
||||
expect(urls).toContain("/api/tasks/KB-002/logs/stream");
|
||||
});
|
||||
});
|
||||
|
||||
it("provides per-task clear function that resets entries", async () => {
|
||||
const logs = [
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "KB-001", text: "log1", type: "text" as const },
|
||||
{ timestamp: "2026-01-01T00:01:00Z", taskId: "KB-001", text: "log2", type: "text" as const },
|
||||
];
|
||||
// Use mockResolvedValue (not Once) to handle Strict Mode double-run
|
||||
mockFetchAgentLogs.mockResolvedValue(logs);
|
||||
|
||||
const { result } = renderHook(() => useMultiAgentLogs(["KB-001", "KB-002"]));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current["KB-001"].entries).toHaveLength(2);
|
||||
});
|
||||
|
||||
// Clear only KB-001
|
||||
act(() => {
|
||||
result.current["KB-001"].clear();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current["KB-001"].entries).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("handles errors gracefully when fetching historical logs", async () => {
|
||||
mockFetchAgentLogs.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const { result } = renderHook(() => useMultiAgentLogs(["KB-001"]));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current["KB-001"].loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current["KB-001"].entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("only opens one connection per task ID (no duplicates)", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
|
||||
const instances: MockEventSource[] = [];
|
||||
(globalThis as unknown as Record<string, unknown>).EventSource = class extends MockEventSource {
|
||||
constructor(url: string) {
|
||||
super(url);
|
||||
instances.push(this);
|
||||
}
|
||||
};
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ taskIds }: { taskIds: string[] }) => useMultiAgentLogs(taskIds),
|
||||
{ initialProps: { taskIds: ["KB-001"] } },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(instances.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
const initialCount = instances.length;
|
||||
|
||||
// Re-render with same task ID (should not create new connection)
|
||||
rerender({ taskIds: ["KB-001"] });
|
||||
|
||||
// Wait a bit
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// In strict mode, we may have more instances due to double-run,
|
||||
// but the active (non-closed) connections should remain stable
|
||||
const activeConnections = instances.filter((es) => !es.close.mock?.calls?.length);
|
||||
expect(activeConnections.length).toBeLessThanOrEqual(initialCount);
|
||||
});
|
||||
|
||||
it("handles SSE events for multiple tasks independently", async () => {
|
||||
const logs1 = [
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "KB-001", text: "task1-old", type: "text" as const },
|
||||
];
|
||||
const logs2 = [
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "KB-002", text: "task2-old", type: "text" as const },
|
||||
];
|
||||
|
||||
mockFetchAgentLogs.mockImplementation((taskId) => {
|
||||
if (taskId === "KB-001") return Promise.resolve(logs1);
|
||||
if (taskId === "KB-002") return Promise.resolve(logs2);
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
|
||||
const instances: MockEventSource[] = [];
|
||||
(globalThis as unknown as Record<string, unknown>).EventSource = class extends MockEventSource {
|
||||
constructor(url: string) {
|
||||
super(url);
|
||||
instances.push(this);
|
||||
}
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useMultiAgentLogs(["KB-001", "KB-002"]));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current["KB-001"].entries).toHaveLength(1);
|
||||
expect(result.current["KB-002"].entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Get the last connection for each URL
|
||||
const getConnection = (taskId: string) => {
|
||||
const url = `/api/tasks/${taskId}/logs/stream`;
|
||||
const matching = instances.filter((e) => e.url === url);
|
||||
return matching[matching.length - 1];
|
||||
};
|
||||
|
||||
const es1 = getConnection("KB-001");
|
||||
const es2 = getConnection("KB-002");
|
||||
expect(es1).toBeDefined();
|
||||
expect(es2).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
es1._emit("agent:log", {
|
||||
timestamp: "2026-01-01T00:01:00Z",
|
||||
taskId: "KB-001",
|
||||
text: "task1-new",
|
||||
type: "text",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current["KB-001"].entries).toHaveLength(2);
|
||||
expect(result.current["KB-002"].entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
es2._emit("agent:log", {
|
||||
timestamp: "2026-01-01T00:01:00Z",
|
||||
taskId: "KB-002",
|
||||
text: "task2-new",
|
||||
type: "text",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current["KB-001"].entries).toHaveLength(2);
|
||||
expect(result.current["KB-002"].entries).toHaveLength(2);
|
||||
});
|
||||
|
||||
expect(result.current["KB-001"].entries[1].text).toBe("task1-new");
|
||||
expect(result.current["KB-002"].entries[1].text).toBe("task2-new");
|
||||
});
|
||||
});
|
||||
154
packages/dashboard/app/hooks/useMultiAgentLogs.ts
Normal file
154
packages/dashboard/app/hooks/useMultiAgentLogs.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { AgentLogEntry } from "@kb/core";
|
||||
import { fetchAgentLogs } from "../api";
|
||||
|
||||
export interface TaskLogState {
|
||||
entries: AgentLogEntry[];
|
||||
loading: boolean;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export type LogStateMap = Record<string, TaskLogState>;
|
||||
|
||||
interface InitState {
|
||||
entries: AgentLogEntry[];
|
||||
loading: boolean;
|
||||
es?: EventSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that manages agent log fetching and live SSE streaming for multiple tasks.
|
||||
*
|
||||
* For each task ID in the provided array:
|
||||
* 1. Fetches historical logs via GET /api/tasks/:id/logs
|
||||
* 2. Opens an EventSource to /api/tasks/:id/logs/stream for live updates
|
||||
* 3. Merges historical + live entries in order
|
||||
*
|
||||
* When task IDs are added or removed, connections are opened/closed accordingly.
|
||||
* When the component unmounts, all EventSources are closed to prevent memory leaks.
|
||||
*/
|
||||
export function useMultiAgentLogs(taskIds: string[]): LogStateMap {
|
||||
// Store state per task
|
||||
const [stateMap, setStateMap] = useState<Record<string, InitState>>({});
|
||||
|
||||
// Ref to track active EventSources
|
||||
const sourcesRef = useRef<Record<string, EventSource>>({});
|
||||
|
||||
// Create clear function for a specific task
|
||||
const createClearFn = useCallback((taskId: string) => {
|
||||
return () => {
|
||||
setStateMap((prev) => {
|
||||
const current = prev[taskId];
|
||||
if (!current) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[taskId]: { ...current, entries: [] },
|
||||
};
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Main effect to manage connections
|
||||
useEffect(() => {
|
||||
const currentIds = new Set(taskIds);
|
||||
const sources = sourcesRef.current;
|
||||
|
||||
// Close connections for tasks no longer in the list
|
||||
for (const [taskId, es] of Object.entries(sources)) {
|
||||
if (!currentIds.has(taskId)) {
|
||||
es.close();
|
||||
delete sources[taskId];
|
||||
// Remove state for disconnected task
|
||||
setStateMap((prev) => {
|
||||
const { [taskId]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize state and connections for current tasks
|
||||
for (const taskId of taskIds) {
|
||||
// Initialize state if not present
|
||||
setStateMap((prev) => {
|
||||
if (prev[taskId]) return prev;
|
||||
return { ...prev, [taskId]: { entries: [], loading: true } };
|
||||
});
|
||||
|
||||
// Skip if already connected
|
||||
if (sources[taskId]) continue;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
// Fetch historical logs and open SSE
|
||||
const init = async () => {
|
||||
try {
|
||||
const historical = await fetchAgentLogs(taskId);
|
||||
if (cancelled) return;
|
||||
|
||||
setStateMap((prev) => ({
|
||||
...prev,
|
||||
[taskId]: { ...prev[taskId], entries: historical, loading: false },
|
||||
}));
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setStateMap((prev) => ({
|
||||
...prev,
|
||||
[taskId]: { ...prev[taskId], entries: [], loading: false },
|
||||
}));
|
||||
}
|
||||
|
||||
// Open SSE connection
|
||||
const es = new EventSource(`/api/tasks/${taskId}/logs/stream`);
|
||||
sources[taskId] = es;
|
||||
|
||||
es.addEventListener("agent:log", (e) => {
|
||||
try {
|
||||
const entry: AgentLogEntry = JSON.parse(e.data);
|
||||
setStateMap((prev) => {
|
||||
const current = prev[taskId];
|
||||
if (!current) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[taskId]: { ...current, entries: [...current.entries, entry] },
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
// skip malformed events
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
init();
|
||||
}
|
||||
|
||||
// Cleanup on effect re-run or unmount
|
||||
return () => {
|
||||
// In Strict Mode, React runs effects twice.
|
||||
// We only want to close connections on actual unmount, not on every cleanup.
|
||||
// The actual closing of connections for removed tasks is handled above.
|
||||
};
|
||||
}, [taskIds]);
|
||||
|
||||
// Close all connections on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const es of Object.values(sourcesRef.current)) {
|
||||
es.close();
|
||||
}
|
||||
sourcesRef.current = {};
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Build result map
|
||||
const result: LogStateMap = {};
|
||||
for (const taskId of taskIds) {
|
||||
const state = stateMap[taskId];
|
||||
result[taskId] = {
|
||||
entries: state?.entries ?? [],
|
||||
loading: state?.loading ?? true,
|
||||
clear: createClearFn(taskId),
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user