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>
);
}