summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--makima/frontend/package-lock.json15
-rw-r--r--makima/frontend/src/components/directives/DirectiveDetail.tsx57
-rw-r--r--makima/frontend/src/components/directives/DirectiveLogStream.tsx367
-rw-r--r--makima/frontend/src/hooks/useMultiTaskSubscription.ts191
4 files changed, 615 insertions, 15 deletions
diff --git a/makima/frontend/package-lock.json b/makima/frontend/package-lock.json
index 38adfc4..f1d54d6 100644
--- a/makima/frontend/package-lock.json
+++ b/makima/frontend/package-lock.json
@@ -55,7 +55,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -962,7 +961,6 @@
"resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.11.0.tgz",
"integrity": "sha512-g1ou5Zw3r4mCU0L+EXH4vRtAiyt8qz1JOvL1k+PW4rZ4+71h5nBy/fLgD7cg5BnzQZmjRO1PzCgpF5BIrlKYxQ==",
"dev": true,
- "peer": true,
"dependencies": {
"@babel/core": "^7.27.7",
"@babel/generator": "^7.27.5",
@@ -1894,7 +1892,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true,
- "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2043,7 +2040,6 @@
"url": "https://github.com/sponsors/ai"
}
],
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -2242,7 +2238,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
- "peer": true,
"engines": {
"node": ">=12"
}
@@ -2937,7 +2932,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -3009,7 +3003,6 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -3018,7 +3011,6 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
- "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -3036,7 +3028,6 @@
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
- "peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -3068,7 +3059,6 @@
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz",
"integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==",
- "peer": true,
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
@@ -3128,8 +3118,7 @@
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
- "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
- "peer": true
+ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@@ -3266,7 +3255,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -3358,7 +3346,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
- "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
diff --git a/makima/frontend/src/components/directives/DirectiveDetail.tsx b/makima/frontend/src/components/directives/DirectiveDetail.tsx
index 616c5d2..39eaa3f 100644
--- a/makima/frontend/src/components/directives/DirectiveDetail.tsx
+++ b/makima/frontend/src/components/directives/DirectiveDetail.tsx
@@ -1,6 +1,8 @@
-import { useState } from "react";
+import { useState, useMemo, useEffect, useRef } from "react";
import type { DirectiveWithSteps, DirectiveStatus } from "../../lib/api";
import { DirectiveDAG } from "./DirectiveDAG";
+import { DirectiveLogStream } from "./DirectiveLogStream";
+import { useMultiTaskSubscription } from "../../hooks/useMultiTaskSubscription";
const STATUS_BADGE: Record<DirectiveStatus, { color: string; label: string }> = {
draft: { color: "text-[#7788aa] border-[#2a3a5a]", label: "DRAFT" },
@@ -37,12 +39,47 @@ export function DirectiveDetail({
}: DirectiveDetailProps) {
const [editingGoal, setEditingGoal] = useState(false);
const [goalText, setGoalText] = useState(directive.goal);
+ const [visibleTaskIds, setVisibleTaskIds] = useState<Set<string> | null>(null);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [isLogCollapsed, setIsLogCollapsed] = useState(true);
+ const prevHadRunningRef = useRef(false);
const badge = STATUS_BADGE[directive.status] || STATUS_BADGE.draft;
const completedSteps = directive.steps.filter((s) => s.status === "completed").length;
const totalSteps = directive.steps.length;
const progress = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0;
+ // Build task map from directive steps and orchestrator
+ const taskMap = useMemo(() => {
+ const map = new Map<string, string>();
+ if (directive.orchestratorTaskId) {
+ map.set(directive.orchestratorTaskId, "Orchestrator");
+ }
+ for (const step of directive.steps) {
+ if (step.taskId) {
+ map.set(step.taskId, step.name);
+ }
+ }
+ return map;
+ }, [directive.orchestratorTaskId, directive.steps]);
+
+ // Subscribe to all task outputs
+ const { connected, entries, clearEntries } = useMultiTaskSubscription({
+ taskMap,
+ enabled: taskMap.size > 0,
+ });
+
+ // Auto-expand log panel when tasks start running
+ const hasRunningTasks = directive.steps.some((s) => s.status === "running") ||
+ !!directive.orchestratorTaskId;
+
+ useEffect(() => {
+ if (hasRunningTasks && !prevHadRunningRef.current) {
+ setIsLogCollapsed(false);
+ }
+ prevHadRunningRef.current = hasRunningTasks;
+ }, [hasRunningTasks]);
+
const handleGoalSave = () => {
if (goalText.trim() && goalText !== directive.goal) {
onUpdateGoal(goalText.trim());
@@ -261,6 +298,24 @@ export function DirectiveDetail({
onSkip={onSkipStep}
/>
</div>
+
+ {/* Log Stream */}
+ {taskMap.size > 0 && (
+ <div className="px-4 py-3 border-t border-[rgba(117,170,252,0.1)]">
+ <DirectiveLogStream
+ entries={entries}
+ taskMap={taskMap}
+ connected={connected}
+ visibleTaskIds={visibleTaskIds}
+ searchQuery={searchQuery}
+ isCollapsed={isLogCollapsed}
+ onToggleCollapse={() => setIsLogCollapsed((prev) => !prev)}
+ onSetVisibleTaskIds={setVisibleTaskIds}
+ onSetSearchQuery={setSearchQuery}
+ onClear={clearEntries}
+ />
+ </div>
+ )}
</div>
);
}
diff --git a/makima/frontend/src/components/directives/DirectiveLogStream.tsx b/makima/frontend/src/components/directives/DirectiveLogStream.tsx
new file mode 100644
index 0000000..d457fe3
--- /dev/null
+++ b/makima/frontend/src/components/directives/DirectiveLogStream.tsx
@@ -0,0 +1,367 @@
+import { useRef, useEffect, useState, useCallback } from "react";
+import { SimpleMarkdown } from "../SimpleMarkdown";
+import type { MultiTaskOutputEntry } from "../../hooks/useMultiTaskSubscription";
+
+interface DirectiveLogStreamProps {
+ entries: MultiTaskOutputEntry[];
+ /** Map of taskId -> label for display */
+ taskMap: Map<string, string>;
+ /** Whether the WebSocket is connected */
+ connected: boolean;
+ /** Filter: set of visible task IDs (null = show all) */
+ visibleTaskIds: Set<string> | null;
+ /** Current search query */
+ searchQuery: string;
+ /** Whether the panel is collapsed */
+ isCollapsed: boolean;
+ /** Toggle collapse state */
+ onToggleCollapse: () => void;
+ /** Update visible task filter */
+ onSetVisibleTaskIds: (ids: Set<string> | null) => void;
+ /** Update search query */
+ onSetSearchQuery: (query: string) => void;
+ /** Clear all entries */
+ onClear: () => void;
+}
+
+// Assign stable colors to tasks
+const TASK_COLORS = [
+ "#75aafc", // blue
+ "#4ade80", // green
+ "#f59e0b", // amber
+ "#a78bfa", // violet
+ "#f472b6", // pink
+ "#22d3ee", // cyan
+ "#fb923c", // orange
+ "#34d399", // emerald
+];
+
+function getTaskColor(index: number): string {
+ return TASK_COLORS[index % TASK_COLORS.length];
+}
+
+export function DirectiveLogStream({
+ entries,
+ taskMap,
+ connected,
+ visibleTaskIds,
+ searchQuery,
+ isCollapsed,
+ onToggleCollapse,
+ onSetVisibleTaskIds,
+ onSetSearchQuery,
+ onClear,
+}: DirectiveLogStreamProps) {
+ const containerRef = useRef<HTMLDivElement>(null);
+ const [autoScroll, setAutoScroll] = useState(true);
+ const [showFilters, setShowFilters] = useState(false);
+
+ // Build task color map
+ const taskColorMap = new Map<string, string>();
+ let colorIdx = 0;
+ for (const [taskId] of taskMap) {
+ taskColorMap.set(taskId, getTaskColor(colorIdx++));
+ }
+
+ // Filter entries
+ const filteredEntries = entries.filter((entry) => {
+ // Filter by visible task IDs
+ if (visibleTaskIds && !visibleTaskIds.has(entry.taskId)) return false;
+ // Filter by search query
+ if (searchQuery) {
+ const q = searchQuery.toLowerCase();
+ const matchesContent = entry.content?.toLowerCase().includes(q);
+ const matchesLabel = entry.taskLabel?.toLowerCase().includes(q);
+ const matchesTool = entry.toolName?.toLowerCase().includes(q);
+ if (!matchesContent && !matchesLabel && !matchesTool) return false;
+ }
+ return true;
+ });
+
+ // Handle scroll
+ const handleScroll = useCallback(() => {
+ if (!containerRef.current) return;
+ const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
+ const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
+ setAutoScroll(isAtBottom);
+ }, []);
+
+ // Auto-scroll when entries change
+ useEffect(() => {
+ if (autoScroll && containerRef.current) {
+ containerRef.current.scrollTop = containerRef.current.scrollHeight;
+ }
+ }, [filteredEntries.length, autoScroll]);
+
+ // Count active (running) tasks
+ const activeTaskCount = Array.from(taskMap.keys()).length;
+
+ if (isCollapsed) {
+ return (
+ <button
+ type="button"
+ onClick={onToggleCollapse}
+ className="flex items-center gap-2 w-full text-left"
+ >
+ <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide">
+ Log Stream
+ </span>
+ <span className="text-[10px] font-mono text-[#556677]">
+ [{activeTaskCount} task{activeTaskCount !== 1 ? "s" : ""}]
+ </span>
+ {connected && entries.length > 0 && (
+ <span className="flex items-center gap-1 px-1.5 py-0.5 bg-green-400/10 border border-green-400/20 rounded">
+ <span className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse" />
+ <span className="text-[9px] font-mono text-green-400">{entries.length}</span>
+ </span>
+ )}
+ <span className="text-[10px] font-mono text-[#556677] ml-auto">[expand]</span>
+ </button>
+ );
+ }
+
+ return (
+ <div className="flex flex-col" style={{ maxHeight: "400px" }}>
+ {/* Header */}
+ <div className="flex items-center justify-between mb-2">
+ <div className="flex items-center gap-2">
+ <button
+ type="button"
+ onClick={onToggleCollapse}
+ className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide hover:text-white"
+ >
+ Log Stream
+ </button>
+ {connected && (
+ <span className="flex items-center gap-1 px-1.5 py-0.5 bg-green-400/10 border border-green-400/20 rounded">
+ <span className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse" />
+ <span className="text-[9px] font-mono text-green-400">Live</span>
+ </span>
+ )}
+ <span className="text-[10px] font-mono text-[#556677]">
+ {filteredEntries.length} entries
+ </span>
+ </div>
+ <div className="flex items-center gap-2">
+ <button
+ type="button"
+ onClick={() => setShowFilters(!showFilters)}
+ className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc]"
+ >
+ [filter]
+ </button>
+ {entries.length > 0 && (
+ <button
+ type="button"
+ onClick={onClear}
+ className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc]"
+ >
+ [clear]
+ </button>
+ )}
+ {!autoScroll && (
+ <button
+ type="button"
+ onClick={() => {
+ setAutoScroll(true);
+ if (containerRef.current) {
+ containerRef.current.scrollTop = containerRef.current.scrollHeight;
+ }
+ }}
+ className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc]"
+ >
+ [scroll to bottom]
+ </button>
+ )}
+ <button
+ type="button"
+ onClick={onToggleCollapse}
+ className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc]"
+ >
+ [collapse]
+ </button>
+ </div>
+ </div>
+
+ {/* Filters */}
+ {showFilters && (
+ <div className="flex flex-wrap items-center gap-2 mb-2 pb-2 border-b border-[rgba(117,170,252,0.1)]">
+ {/* Search */}
+ <input
+ type="text"
+ value={searchQuery}
+ onChange={(e) => onSetSearchQuery(e.target.value)}
+ placeholder="Search logs..."
+ className="bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-0.5 text-[10px] font-mono text-white w-[160px] placeholder-[#556677]"
+ />
+ {/* Task filter buttons */}
+ <button
+ type="button"
+ onClick={() => onSetVisibleTaskIds(null)}
+ className={`text-[9px] font-mono px-1.5 py-0.5 rounded border ${
+ visibleTaskIds === null
+ ? "text-white border-[#75aafc] bg-[rgba(117,170,252,0.15)]"
+ : "text-[#556677] border-[#2a3a5a] hover:text-white"
+ }`}
+ >
+ All
+ </button>
+ {Array.from(taskMap.entries()).map(([taskId, label]) => {
+ const isVisible = visibleTaskIds === null || visibleTaskIds.has(taskId);
+ const color = taskColorMap.get(taskId) || "#75aafc";
+ return (
+ <button
+ key={taskId}
+ type="button"
+ onClick={() => {
+ if (visibleTaskIds === null) {
+ // Switch from "all" to just this task
+ onSetVisibleTaskIds(new Set([taskId]));
+ } else if (visibleTaskIds.has(taskId)) {
+ const next = new Set(visibleTaskIds);
+ next.delete(taskId);
+ if (next.size === 0) {
+ onSetVisibleTaskIds(null); // back to all
+ } else {
+ onSetVisibleTaskIds(next);
+ }
+ } else {
+ const next = new Set(visibleTaskIds);
+ next.add(taskId);
+ if (next.size === taskMap.size) {
+ onSetVisibleTaskIds(null); // all selected = show all
+ } else {
+ onSetVisibleTaskIds(next);
+ }
+ }
+ }}
+ className={`text-[9px] font-mono px-1.5 py-0.5 rounded border transition-colors ${
+ isVisible
+ ? "border-current bg-[rgba(117,170,252,0.1)]"
+ : "border-[#2a3a5a] opacity-50 hover:opacity-75"
+ }`}
+ style={{ color: isVisible ? color : "#556677" }}
+ >
+ {label}
+ </button>
+ );
+ })}
+ </div>
+ )}
+
+ {/* Log output */}
+ <div
+ ref={containerRef}
+ onScroll={handleScroll}
+ className="flex-1 overflow-auto bg-[#0a0f18] rounded p-2 font-mono text-xs min-h-0"
+ style={{ minHeight: "120px" }}
+ >
+ {filteredEntries.length === 0 ? (
+ <div className="text-[#555] italic text-[10px]">
+ {entries.length === 0
+ ? connected
+ ? "Waiting for output..."
+ : "No tasks subscribed"
+ : "No entries match filter"}
+ </div>
+ ) : (
+ <div className="space-y-1">
+ {filteredEntries.map((entry, idx) => (
+ <LogEntry
+ key={idx}
+ entry={entry}
+ color={taskColorMap.get(entry.taskId) || "#75aafc"}
+ />
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
+
+function LogEntry({
+ entry,
+ color,
+}: {
+ entry: MultiTaskOutputEntry;
+ color: string;
+}) {
+ const [expanded, setExpanded] = useState(false);
+
+ // Skip empty content for tool results
+ if (entry.messageType === "tool_result" && !entry.content) return null;
+
+ return (
+ <div className="flex gap-2 items-start py-0.5">
+ {/* Task label */}
+ <span
+ className="text-[9px] shrink-0 font-semibold uppercase tracking-wide w-[80px] truncate text-right"
+ style={{ color }}
+ title={entry.taskLabel}
+ >
+ {entry.taskLabel}
+ </span>
+ <span className="text-[#2a3a5a] shrink-0">|</span>
+
+ {/* Content */}
+ <div className="flex-1 min-w-0">
+ {entry.messageType === "assistant" && (
+ <SimpleMarkdown content={entry.content} className="text-[#9bc3ff] text-[10px]" />
+ )}
+ {entry.messageType === "tool_use" && (
+ <div className="flex items-center gap-1">
+ <span className="text-yellow-500 text-[10px]">*</span>
+ <span className="text-[#75aafc] text-[10px]">{entry.toolName || "unknown"}</span>
+ {entry.toolInput && Object.keys(entry.toolInput).length > 0 && (
+ <button
+ onClick={() => setExpanded(!expanded)}
+ className="text-[#555] hover:text-[#9bc3ff] text-[9px]"
+ >
+ {expanded ? "[-]" : "[+]"}
+ </button>
+ )}
+ {expanded && entry.toolInput && (
+ <pre className="text-[9px] text-[#555] bg-[#0a1525] p-1 overflow-x-auto mt-0.5 block w-full">
+ {JSON.stringify(entry.toolInput, null, 2)}
+ </pre>
+ )}
+ </div>
+ )}
+ {entry.messageType === "tool_result" && (
+ <div className="text-[10px]">
+ <span className={entry.isError ? "text-red-400" : "text-green-500"}>
+ {entry.isError ? "x" : "+"}
+ </span>{" "}
+ <span className="text-[#555]">
+ {entry.content.split("\n")[0]}
+ {entry.content.includes("\n") && "..."}
+ </span>
+ </div>
+ )}
+ {entry.messageType === "result" && (
+ <div className="text-[10px]">
+ <span className="text-green-500 font-semibold">Done</span>
+ {entry.costUsd !== undefined && (
+ <span className="text-[#555] ml-2">${entry.costUsd.toFixed(4)}</span>
+ )}
+ {entry.durationMs !== undefined && (
+ <span className="text-[#555] ml-2">{(entry.durationMs / 1000).toFixed(1)}s</span>
+ )}
+ </div>
+ )}
+ {entry.messageType === "error" && (
+ <span className="text-red-400 text-[10px]">{entry.content}</span>
+ )}
+ {entry.messageType === "system" && (
+ <span className="text-[#555] text-[9px] uppercase">{entry.content}</span>
+ )}
+ {!["assistant", "tool_use", "tool_result", "result", "error", "system"].includes(
+ entry.messageType
+ ) && (
+ <span className="text-[#555] text-[10px]">{entry.content}</span>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/hooks/useMultiTaskSubscription.ts b/makima/frontend/src/hooks/useMultiTaskSubscription.ts
new file mode 100644
index 0000000..19d6dea
--- /dev/null
+++ b/makima/frontend/src/hooks/useMultiTaskSubscription.ts
@@ -0,0 +1,191 @@
+import { useState, useCallback, useRef, useEffect, useMemo } from "react";
+import { TASK_SUBSCRIBE_ENDPOINT } from "../lib/api";
+import type { TaskOutputEvent } from "./useTaskSubscription";
+
+export interface MultiTaskOutputEntry extends TaskOutputEvent {
+ /** Label for the task (e.g. step name or "Orchestrator") */
+ taskLabel: string;
+ /** Timestamp when the entry was received */
+ receivedAt: number;
+}
+
+interface UseMultiTaskSubscriptionOptions {
+ /** Map of taskId -> label */
+ taskMap: Map<string, string>;
+ /** Whether to actively subscribe */
+ enabled?: boolean;
+ /** Max entries to keep in buffer */
+ maxEntries?: number;
+}
+
+export function useMultiTaskSubscription(options: UseMultiTaskSubscriptionOptions) {
+ const { taskMap, enabled = true, maxEntries = 2000 } = options;
+
+ const [connected, setConnected] = useState(false);
+ const [entries, setEntries] = useState<MultiTaskOutputEntry[]>([]);
+ const wsRef = useRef<WebSocket | null>(null);
+ const reconnectTimeoutRef = useRef<number | null>(null);
+ const subscribedTasksRef = useRef<Set<string>>(new Set());
+ const taskMapRef = useRef(taskMap);
+ const enabledRef = useRef(enabled);
+
+ // Keep refs in sync
+ useEffect(() => {
+ taskMapRef.current = taskMap;
+ }, [taskMap]);
+
+ useEffect(() => {
+ enabledRef.current = enabled;
+ }, [enabled]);
+
+ // Derive task IDs from the map
+ const taskIds = useMemo(() => Array.from(taskMap.keys()), [taskMap]);
+
+ const subscribeToTask = useCallback((ws: WebSocket, taskId: string) => {
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: "subscribeOutput", taskId }));
+ subscribedTasksRef.current.add(taskId);
+ }
+ }, []);
+
+ const unsubscribeFromTask = useCallback((ws: WebSocket, taskId: string) => {
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: "unsubscribeOutput", taskId }));
+ subscribedTasksRef.current.delete(taskId);
+ }
+ }, []);
+
+ const connect = useCallback(() => {
+ const currentState = wsRef.current?.readyState;
+ if (currentState === WebSocket.OPEN || currentState === WebSocket.CONNECTING) {
+ return;
+ }
+
+ if (wsRef.current && currentState === WebSocket.CLOSING) {
+ wsRef.current = null;
+ }
+
+ try {
+ const ws = new WebSocket(TASK_SUBSCRIBE_ENDPOINT);
+ wsRef.current = ws;
+
+ ws.onopen = () => {
+ setConnected(true);
+ // Re-subscribe to all tasks
+ for (const taskId of subscribedTasksRef.current) {
+ ws.send(JSON.stringify({ type: "subscribeOutput", taskId }));
+ }
+ };
+
+ ws.onmessage = (event) => {
+ try {
+ const message = JSON.parse(event.data);
+
+ if (message.type === "taskOutput") {
+ const label = taskMapRef.current.get(message.taskId) || message.taskId;
+ const entry: MultiTaskOutputEntry = {
+ taskId: message.taskId,
+ messageType: message.messageType,
+ content: message.content,
+ toolName: message.toolName,
+ toolInput: message.toolInput,
+ isError: message.isError,
+ costUsd: message.costUsd,
+ durationMs: message.durationMs,
+ isPartial: message.isPartial,
+ taskLabel: label,
+ receivedAt: Date.now(),
+ };
+
+ setEntries((prev) => {
+ const next = [...prev, entry];
+ if (next.length > maxEntries) {
+ return next.slice(next.length - maxEntries);
+ }
+ return next;
+ });
+ }
+ } catch (e) {
+ console.error("Failed to parse multi-task subscription message:", e);
+ }
+ };
+
+ ws.onerror = () => {
+ console.error("Multi-task WebSocket connection error");
+ };
+
+ ws.onclose = () => {
+ setConnected(false);
+ wsRef.current = null;
+
+ // Reconnect if we still have subscriptions
+ if (subscribedTasksRef.current.size > 0 && enabledRef.current) {
+ reconnectTimeoutRef.current = window.setTimeout(() => {
+ connect();
+ }, 3000);
+ }
+ };
+ } catch (e) {
+ console.error("Failed to connect multi-task subscription:", e);
+ }
+ }, [maxEntries]);
+
+ // Manage subscriptions when task IDs change
+ useEffect(() => {
+ if (!enabled || taskIds.length === 0) {
+ // Close connection if no tasks
+ if (wsRef.current) {
+ subscribedTasksRef.current.clear();
+ wsRef.current.close();
+ wsRef.current = null;
+ }
+ return;
+ }
+
+ const newTaskIds = new Set(taskIds);
+ const ws = wsRef.current;
+
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
+ // Set desired subscriptions and connect
+ subscribedTasksRef.current = newTaskIds;
+ connect();
+ return;
+ }
+
+ // Unsubscribe from removed tasks
+ for (const existingId of subscribedTasksRef.current) {
+ if (!newTaskIds.has(existingId)) {
+ unsubscribeFromTask(ws, existingId);
+ }
+ }
+
+ // Subscribe to new tasks
+ for (const newId of newTaskIds) {
+ if (!subscribedTasksRef.current.has(newId)) {
+ subscribeToTask(ws, newId);
+ }
+ }
+ }, [taskIds, enabled, connect, subscribeToTask, unsubscribeFromTask]);
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ if (reconnectTimeoutRef.current) {
+ clearTimeout(reconnectTimeoutRef.current);
+ }
+ if (wsRef.current) {
+ wsRef.current.close();
+ }
+ };
+ }, []);
+
+ const clearEntries = useCallback(() => {
+ setEntries([]);
+ }, []);
+
+ return {
+ connected,
+ entries,
+ clearEntries,
+ };
+}