From 3ffcaf5bb55a6a5f20e8aff3dffd4e99e3a9f077 Mon Sep 17 00:00:00 2001 From: soryu Date: Wed, 11 Feb 2026 00:42:15 +0000 Subject: feat: makima: Add an optional memory system for directives: Integrate log stream panel into directive detail page --- makima/frontend/package-lock.json | 15 +- .../src/components/directives/DirectiveDetail.tsx | 57 +++- .../components/directives/DirectiveLogStream.tsx | 367 +++++++++++++++++++++ .../frontend/src/hooks/useMultiTaskSubscription.ts | 191 +++++++++++ 4 files changed, 615 insertions(+), 15 deletions(-) create mode 100644 makima/frontend/src/components/directives/DirectiveLogStream.tsx create mode 100644 makima/frontend/src/hooks/useMultiTaskSubscription.ts 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 = { 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 | 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(); + 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} /> + + {/* Log Stream */} + {taskMap.size > 0 && ( +
+ setIsLogCollapsed((prev) => !prev)} + onSetVisibleTaskIds={setVisibleTaskIds} + onSetSearchQuery={setSearchQuery} + onClear={clearEntries} + /> +
+ )} ); } 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; + /** Whether the WebSocket is connected */ + connected: boolean; + /** Filter: set of visible task IDs (null = show all) */ + visibleTaskIds: Set | 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 | 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(null); + const [autoScroll, setAutoScroll] = useState(true); + const [showFilters, setShowFilters] = useState(false); + + // Build task color map + const taskColorMap = new Map(); + 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 ( + + ); + } + + return ( +
+ {/* Header */} +
+
+ + {connected && ( + + + Live + + )} + + {filteredEntries.length} entries + +
+
+ + {entries.length > 0 && ( + + )} + {!autoScroll && ( + + )} + +
+
+ + {/* Filters */} + {showFilters && ( +
+ {/* Search */} + 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 */} + + {Array.from(taskMap.entries()).map(([taskId, label]) => { + const isVisible = visibleTaskIds === null || visibleTaskIds.has(taskId); + const color = taskColorMap.get(taskId) || "#75aafc"; + return ( + + ); + })} +
+ )} + + {/* Log output */} +
+ {filteredEntries.length === 0 ? ( +
+ {entries.length === 0 + ? connected + ? "Waiting for output..." + : "No tasks subscribed" + : "No entries match filter"} +
+ ) : ( +
+ {filteredEntries.map((entry, idx) => ( + + ))} +
+ )} +
+
+ ); +} + +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 ( +
+ {/* Task label */} + + {entry.taskLabel} + + | + + {/* Content */} +
+ {entry.messageType === "assistant" && ( + + )} + {entry.messageType === "tool_use" && ( +
+ * + {entry.toolName || "unknown"} + {entry.toolInput && Object.keys(entry.toolInput).length > 0 && ( + + )} + {expanded && entry.toolInput && ( +
+                {JSON.stringify(entry.toolInput, null, 2)}
+              
+ )} +
+ )} + {entry.messageType === "tool_result" && ( +
+ + {entry.isError ? "x" : "+"} + {" "} + + {entry.content.split("\n")[0]} + {entry.content.includes("\n") && "..."} + +
+ )} + {entry.messageType === "result" && ( +
+ Done + {entry.costUsd !== undefined && ( + ${entry.costUsd.toFixed(4)} + )} + {entry.durationMs !== undefined && ( + {(entry.durationMs / 1000).toFixed(1)}s + )} +
+ )} + {entry.messageType === "error" && ( + {entry.content} + )} + {entry.messageType === "system" && ( + {entry.content} + )} + {!["assistant", "tool_use", "tool_result", "result", "error", "system"].includes( + entry.messageType + ) && ( + {entry.content} + )} +
+
+ ); +} 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; + /** 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([]); + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + const subscribedTasksRef = useRef>(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, + }; +} -- cgit v1.2.3