diff options
Diffstat (limited to 'makima/frontend/src/components/directives/DirectiveLogStream.tsx')
| -rw-r--r-- | makima/frontend/src/components/directives/DirectiveLogStream.tsx | 367 |
1 files changed, 367 insertions, 0 deletions
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> + ); +} |
