diff options
| author | soryu <soryu@soryu.co> | 2026-02-11 00:37:57 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-02-11 00:37:57 +0000 |
| commit | 08f1211a1384006895799f4b22dbf0d6b8a22a36 (patch) | |
| tree | 8a347ad0421113ca48b17d6d20352ce456c82351 | |
| parent | 15b6e5fba161a194fe5427d7d29b0c4286423260 (diff) | |
| download | soryu-makima/makima--add-an-optional-memory-system-for-directiv-f96a848d.tar.gz soryu-makima/makima--add-an-optional-memory-system-for-directiv-f96a848d.zip | |
feat: makima: Add an optional memory system for directives: Create DirectiveLogStream component for stern-like multi-task output viewingmakima/makima--add-an-optional-memory-system-for-directiv-f96a848d
| -rw-r--r-- | makima/frontend/package-lock.json | 15 | ||||
| -rw-r--r-- | makima/frontend/src/components/directives/DirectiveLogStream.tsx | 512 |
2 files changed, 513 insertions, 14 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/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<string, TaskInfo>; + connected: boolean; + visibleTaskIds: Set<string> | 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<HTMLDivElement>(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 ( + <div className="flex flex-col border border-dashed border-[rgba(117,170,252,0.2)] bg-[#0a1628]"> + {/* ── Header ─────────────────────────────────────────────────── */} + <div className="flex items-center justify-between px-3 py-2 border-b border-dashed border-[rgba(117,170,252,0.2)] shrink-0"> + <div className="flex items-center gap-2"> + <button + onClick={onToggleCollapse} + className="font-mono text-[10px] text-[#556677] hover:text-[#9bc3ff] transition-colors" + title={isCollapsed ? "Expand" : "Collapse"} + > + {isCollapsed ? "[+]" : "[-]"} + </button> + + <span className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase select-none"> + Log Stream + </span> + + {/* Connection status dot */} + <span + className={`inline-block w-1.5 h-1.5 rounded-full ${ + connected ? "bg-green-400" : "bg-yellow-500" + }`} + title={connected ? "Connected" : "Reconnecting..."} + /> + + {/* Live badge */} + {isAnyTaskRunning && ( + <span className="flex items-center gap-1.5 px-2 py-0.5 bg-green-400/10 border border-green-400/30 text-green-400 font-mono text-[10px] uppercase"> + <span className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse" /> + Live + </span> + )} + </div> + + <div className="flex items-center gap-2"> + {!autoScroll && !isCollapsed && ( + <button + onClick={handleResumeScroll} + className="px-2 py-1 font-mono text-[10px] text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase" + > + Resume Scroll + </button> + )} + {entries.length > 0 && ( + <button + onClick={onClear} + className="px-2 py-1 font-mono text-[10px] text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase" + > + Clear + </button> + )} + </div> + </div> + + {!isCollapsed && ( + <> + {/* ── Filter Bar ───────────────────────────────────────────── */} + <FilterBar + tasks={tasks} + visibleTaskIds={visibleTaskIds} + onToggleTask={onToggleTask} + onShowAll={onShowAll} + onShowNone={onShowNone} + searchQuery={searchQuery} + onSearchChange={onSearchChange} + /> + + {/* ── Log Output Area ──────────────────────────────────────── */} + <div + ref={containerRef} + onScroll={handleScroll} + className="flex-1 overflow-auto bg-[#0a0f18] p-3 font-mono text-xs min-h-[120px] max-h-[500px]" + > + {entries.length === 0 ? ( + <div className="text-[#555] italic text-[10px]"> + {isAnyTaskRunning + ? "Waiting for output..." + : "No log entries yet"} + </div> + ) : filteredEntries.length === 0 ? ( + <div className="text-[#555] italic text-[10px]"> + All entries filtered out + </div> + ) : ( + <div className="space-y-0"> + {/* Load more indicator */} + {hiddenCount > 0 && ( + <button + onClick={() => setShowAllEntries(true)} + className="block w-full text-center py-1.5 mb-2 text-[10px] font-mono text-[#556677] hover:text-[#9bc3ff] border border-dashed border-[rgba(117,170,252,0.15)] hover:border-[rgba(117,170,252,0.3)] transition-colors" + > + {hiddenCount.toLocaleString()} older entries hidden — click + to load all + </button> + )} + + {visibleEntries.map((entry) => ( + <LogLine key={entry.id} entry={entry} /> + ))} + + {isAnyTaskRunning && ( + <span className="inline-block w-2 h-4 bg-[#9bc3ff] animate-pulse mt-1" /> + )} + </div> + )} + </div> + </> + )} + </div> + ); +} + +// ─── Filter Bar ───────────────────────────────────────────────────────────── + +interface FilterBarProps { + tasks: Map<string, TaskInfo>; + visibleTaskIds: Set<string> | 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 ( + <div className="flex items-center gap-2 px-3 py-1.5 border-b border-[rgba(117,170,252,0.1)] bg-[#0d1525] overflow-x-auto shrink-0"> + {/* All / None toggles */} + <button + onClick={onShowAll} + className="px-1.5 py-0.5 font-mono text-[9px] text-[#7788aa] hover:text-[#9bc3ff] border border-[rgba(117,170,252,0.15)] hover:border-[rgba(117,170,252,0.3)] transition-colors uppercase shrink-0" + > + All + </button> + <button + onClick={onShowNone} + className="px-1.5 py-0.5 font-mono text-[9px] text-[#7788aa] hover:text-[#9bc3ff] border border-[rgba(117,170,252,0.15)] hover:border-[rgba(117,170,252,0.3)] transition-colors uppercase shrink-0" + > + None + </button> + + <span className="w-px h-4 bg-[rgba(117,170,252,0.15)] shrink-0" /> + + {/* Task chips */} + <div className="flex items-center gap-1.5 overflow-x-auto"> + {taskEntries.map(([taskId, task]) => { + const isVisible = + visibleTaskIds === null || visibleTaskIds.has(taskId); + return ( + <TaskChip + key={taskId} + taskId={taskId} + task={task} + isVisible={isVisible} + onToggle={onToggleTask} + /> + ); + })} + </div> + + <span className="w-px h-4 bg-[rgba(117,170,252,0.15)] shrink-0 ml-auto" /> + + {/* Search field */} + <div className="flex items-center gap-1 shrink-0"> + <span className="text-[#556677] text-[10px] font-mono">/</span> + <input + type="text" + value={searchQuery} + onChange={(e) => 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 && ( + <button + onClick={() => onSearchChange("")} + className="text-[#556677] hover:text-[#9bc3ff] text-[10px] font-mono transition-colors" + > + x + </button> + )} + </div> + </div> + ); +} + +// ─── Task Chip ────────────────────────────────────────────────────────────── + +const STATUS_INDICATORS: Record<string, { symbol: string; className: string }> = + { + 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 ( + <button + onClick={() => onToggle(taskId)} + className={`flex items-center gap-1 px-1.5 py-0.5 font-mono text-[9px] border rounded transition-all shrink-0 ${ + isVisible + ? "border-[rgba(117,170,252,0.25)] hover:border-[rgba(117,170,252,0.4)]" + : "border-[rgba(117,170,252,0.1)] opacity-40 hover:opacity-60" + }`} + title={`${task.name} (${task.status}) — click to ${isVisible ? "hide" : "show"}`} + > + <span className={`text-[8px] leading-none ${indicator.className}`}> + {indicator.symbol} + </span> + <span + className={`${isVisible ? "" : "line-through"}`} + style={{ color: task.color }} + > + {task.name} + </span> + </button> + ); +} + +// ─── 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 ( + <div + className="flex items-start gap-0 py-[1px] hover:bg-[rgba(117,170,252,0.03)] group leading-tight" + onMouseEnter={() => setShowTimestamp(true)} + onMouseLeave={() => setShowTimestamp(false)} + > + {/* Timestamp (shown on hover) */} + <span + className={`shrink-0 font-mono text-[9px] text-[#3a4a5a] w-[58px] text-right pr-2 select-none transition-opacity ${ + showTimestamp ? "opacity-100" : "opacity-0" + }`} + > + {formattedTime} + </span> + + {/* Task name prefix */} + <span + className="shrink-0 font-mono text-[10px] font-medium pr-1.5 select-none min-w-[80px] max-w-[140px] truncate text-right" + style={{ color: entry.taskColor }} + title={entry.taskName} + > + [{entry.taskName}] + </span> + + {/* Content */} + <span className="flex-1 min-w-0"> + <LogContent entry={entry} /> + </span> + </div> + ); +} + +// ─── Log Content Renderer ─────────────────────────────────────────────────── + +function LogContent({ entry }: { entry: MultiTaskOutputEntry }) { + switch (entry.messageType) { + case "assistant": + return ( + <span className="text-[#9bc3ff] text-[10px]"> + <SimpleMarkdown content={entry.content} className="inline" /> + </span> + ); + + case "tool_use": + return ( + <span className="text-[10px]"> + <span className="text-yellow-500">*</span>{" "} + <span className="text-[#75aafc]"> + {entry.toolName || entry.content} + </span> + </span> + ); + + 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 ( + <span className="text-[10px]"> + <span className={entry.isError ? "text-red-400" : "text-green-500"}> + {entry.isError ? "x" : "+"} + </span>{" "} + <span className="text-[#555]"> + {truncated} + {hasMore && "…"} + </span> + </span> + ); + } + + case "result": + return ( + <span className="text-[10px]"> + <span className="text-green-500 font-medium">Result:</span>{" "} + <span className="text-[#9bc3ff]"> + {entry.content.split("\n")[0]} + </span> + {entry.costUsd !== undefined && ( + <span className="text-[#3a4a5a] ml-2"> + ${entry.costUsd.toFixed(4)} + </span> + )} + {entry.durationMs !== undefined && ( + <span className="text-[#3a4a5a] ml-1"> + {(entry.durationMs / 1000).toFixed(1)}s + </span> + )} + </span> + ); + + case "error": + return ( + <span className="text-red-400 text-[10px]">{entry.content}</span> + ); + + case "system": + return ( + <span className="text-[#555] text-[9px] uppercase tracking-wide opacity-60"> + {entry.content} + </span> + ); + + default: + return ( + <span className="text-[#7788aa] text-[10px]">{entry.content}</span> + ); + } +} |
