summaryrefslogblamecommitdiff
path: root/makima/frontend/src/components/directives/DirectiveLogStream.tsx
blob: d457fe353a9a3ab5a1bca303293b961da6c7159a (plain) (tree)














































































































































































































































































































































































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