From 08f1211a1384006895799f4b22dbf0d6b8a22a36 Mon Sep 17 00:00:00 2001 From: soryu Date: Wed, 11 Feb 2026 00:37:57 +0000 Subject: feat: makima: Add an optional memory system for directives: Create DirectiveLogStream component for stern-like multi-task output viewing --- makima/frontend/package-lock.json | 15 +- .../components/directives/DirectiveLogStream.tsx | 512 +++++++++++++++++++++ 2 files changed, 513 insertions(+), 14 deletions(-) create mode 100644 makima/frontend/src/components/directives/DirectiveLogStream.tsx 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/DirectiveLogStream.tsx b/makima/frontend/src/components/directives/DirectiveLogStream.tsx new file mode 100644 index 0000000..11c42ca --- /dev/null +++ b/makima/frontend/src/components/directives/DirectiveLogStream.tsx @@ -0,0 +1,512 @@ +import { useRef, useEffect, useState, useCallback, useMemo } from "react"; +import { SimpleMarkdown } from "../SimpleMarkdown"; + +// ─── Types ────────────────────────────────────────────────────────────────── + +/** + * A single log entry from a directive task stream. + * Extends TaskOutputEvent with task-level metadata for multi-task interleaving. + */ +export interface MultiTaskOutputEntry { + /** Unique ID for this entry (for keying) */ + id: string; + /** The task ID this entry belongs to */ + taskId: string; + /** Human-readable task/step name */ + taskName: string; + /** Color assigned to this task */ + taskColor: string; + /** Message type matching TaskOutputEvent conventions */ + messageType: string; + /** Main text content */ + content: string; + /** Tool name if tool_use message */ + toolName?: string; + /** Whether tool result was an error */ + isError?: boolean; + /** Cost in USD if result message */ + costUsd?: number; + /** Duration in ms if result message */ + durationMs?: number; + /** Timestamp of the entry */ + timestamp: number; +} + +export interface TaskInfo { + name: string; + color: string; + status: string; +} + +export interface DirectiveLogStreamProps { + entries: MultiTaskOutputEntry[]; + filteredEntries: MultiTaskOutputEntry[]; + tasks: Map; + connected: boolean; + visibleTaskIds: Set | null; + onToggleTask: (taskId: string) => void; + onShowAll: () => void; + onShowNone: () => void; + onClear: () => void; + searchQuery: string; + onSearchChange: (query: string) => void; + isCollapsed: boolean; + onToggleCollapse: () => void; +} + +// ─── Constants ────────────────────────────────────────────────────────────── + +/** Max entries to render for performance; older entries are hidden behind "load more" */ +const MAX_RENDERED_ENTRIES = 1000; + +// ─── Component ────────────────────────────────────────────────────────────── + +export function DirectiveLogStream({ + entries, + filteredEntries, + tasks, + connected, + visibleTaskIds, + onToggleTask, + onShowAll, + onShowNone, + onClear, + searchQuery, + onSearchChange, + isCollapsed, + onToggleCollapse, +}: DirectiveLogStreamProps) { + const containerRef = useRef(null); + const [autoScroll, setAutoScroll] = useState(true); + const [showAllEntries, setShowAllEntries] = useState(false); + + const isAnyTaskRunning = useMemo(() => { + for (const task of tasks.values()) { + if (task.status === "running") return true; + } + return false; + }, [tasks]); + + // Determine which entries to render (capped for performance) + const { visibleEntries, hiddenCount } = useMemo(() => { + if (showAllEntries || filteredEntries.length <= MAX_RENDERED_ENTRIES) { + return { visibleEntries: filteredEntries, hiddenCount: 0 }; + } + const hidden = filteredEntries.length - MAX_RENDERED_ENTRIES; + return { + visibleEntries: filteredEntries.slice(hidden), + hiddenCount: hidden, + }; + }, [filteredEntries, showAllEntries]); + + // Handle scroll to detect user scrolling up + const handleScroll = useCallback(() => { + if (!containerRef.current) return; + const { scrollTop, scrollHeight, clientHeight } = containerRef.current; + const isAtBottom = scrollHeight - scrollTop - clientHeight < 50; + setAutoScroll(isAtBottom); + }, []); + + // Auto-scroll when new entries arrive + useEffect(() => { + if (autoScroll && containerRef.current) { + containerRef.current.scrollTop = containerRef.current.scrollHeight; + } + }, [filteredEntries.length, autoScroll]); + + const handleResumeScroll = useCallback(() => { + setAutoScroll(true); + if (containerRef.current) { + containerRef.current.scrollTop = containerRef.current.scrollHeight; + } + }, []); + + return ( +
+ {/* ── Header ─────────────────────────────────────────────────── */} +
+
+ + + + Log Stream + + + {/* Connection status dot */} + + + {/* Live badge */} + {isAnyTaskRunning && ( + + + Live + + )} +
+ +
+ {!autoScroll && !isCollapsed && ( + + )} + {entries.length > 0 && ( + + )} +
+
+ + {!isCollapsed && ( + <> + {/* ── Filter Bar ───────────────────────────────────────────── */} + + + {/* ── Log Output Area ──────────────────────────────────────── */} +
+ {entries.length === 0 ? ( +
+ {isAnyTaskRunning + ? "Waiting for output..." + : "No log entries yet"} +
+ ) : filteredEntries.length === 0 ? ( +
+ All entries filtered out +
+ ) : ( +
+ {/* Load more indicator */} + {hiddenCount > 0 && ( + + )} + + {visibleEntries.map((entry) => ( + + ))} + + {isAnyTaskRunning && ( + + )} +
+ )} +
+ + )} +
+ ); +} + +// ─── Filter Bar ───────────────────────────────────────────────────────────── + +interface FilterBarProps { + tasks: Map; + visibleTaskIds: Set | null; + onToggleTask: (taskId: string) => void; + onShowAll: () => void; + onShowNone: () => void; + searchQuery: string; + onSearchChange: (query: string) => void; +} + +function FilterBar({ + tasks, + visibleTaskIds, + onToggleTask, + onShowAll, + onShowNone, + searchQuery, + onSearchChange, +}: FilterBarProps) { + const taskEntries = useMemo(() => Array.from(tasks.entries()), [tasks]); + + if (taskEntries.length === 0) return null; + + return ( +
+ {/* All / None toggles */} + + + + + + {/* Task chips */} +
+ {taskEntries.map(([taskId, task]) => { + const isVisible = + visibleTaskIds === null || visibleTaskIds.has(taskId); + return ( + + ); + })} +
+ + + + {/* Search field */} +
+ / + onSearchChange(e.target.value)} + placeholder="search..." + className="w-[120px] bg-transparent border-b border-[rgba(117,170,252,0.15)] focus:border-[#75aafc] outline-none font-mono text-[10px] text-[#9bc3ff] placeholder-[#3a4a5a] py-0.5 transition-colors" + /> + {searchQuery && ( + + )} +
+
+ ); +} + +// ─── Task Chip ────────────────────────────────────────────────────────────── + +const STATUS_INDICATORS: Record = + { + running: { + symbol: "●", + className: "text-green-400 animate-pulse", + }, + completed: { + symbol: "✓", + className: "text-emerald-400", + }, + failed: { + symbol: "✕", + className: "text-red-400", + }, + ready: { + symbol: "○", + className: "text-yellow-400", + }, + pending: { + symbol: "·", + className: "text-[#556677]", + }, + skipped: { + symbol: "—", + className: "text-[#556677]", + }, + }; + +interface TaskChipProps { + taskId: string; + task: TaskInfo; + isVisible: boolean; + onToggle: (taskId: string) => void; +} + +function TaskChip({ taskId, task, isVisible, onToggle }: TaskChipProps) { + const indicator = STATUS_INDICATORS[task.status] || STATUS_INDICATORS.pending; + + return ( + + ); +} + +// ─── Log Line ─────────────────────────────────────────────────────────────── + +interface LogLineProps { + entry: MultiTaskOutputEntry; +} + +function LogLine({ entry }: LogLineProps) { + const [showTimestamp, setShowTimestamp] = useState(false); + + const formattedTime = useMemo(() => { + const d = new Date(entry.timestamp); + return d.toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + }, [entry.timestamp]); + + return ( +
setShowTimestamp(true)} + onMouseLeave={() => setShowTimestamp(false)} + > + {/* Timestamp (shown on hover) */} + + {formattedTime} + + + {/* Task name prefix */} + + [{entry.taskName}] + + + {/* Content */} + + + +
+ ); +} + +// ─── Log Content Renderer ─────────────────────────────────────────────────── + +function LogContent({ entry }: { entry: MultiTaskOutputEntry }) { + switch (entry.messageType) { + case "assistant": + return ( + + + + ); + + case "tool_use": + return ( + + *{" "} + + {entry.toolName || entry.content} + + + ); + + case "tool_result": { + if (!entry.content) return null; + const firstLine = entry.content.split("\n")[0]; + const truncated = + firstLine.length > 120 ? firstLine.slice(0, 120) + "..." : firstLine; + const hasMore = entry.content.includes("\n") || firstLine.length > 120; + return ( + + + {entry.isError ? "x" : "+"} + {" "} + + {truncated} + {hasMore && "…"} + + + ); + } + + case "result": + return ( + + Result:{" "} + + {entry.content.split("\n")[0]} + + {entry.costUsd !== undefined && ( + + ${entry.costUsd.toFixed(4)} + + )} + {entry.durationMs !== undefined && ( + + {(entry.durationMs / 1000).toFixed(1)}s + + )} + + ); + + case "error": + return ( + {entry.content} + ); + + case "system": + return ( + + {entry.content} + + ); + + default: + return ( + {entry.content} + ); + } +} -- cgit v1.2.3