summaryrefslogblamecommitdiff
path: root/makima/frontend/src/components/mesh/TaskOutput.tsx
blob: 10de22502155fb949fd0f8272a2d1b4f36fa6b9a (plain) (tree)
























































































































































































































































































                                                                                                                                                                                                                           
import { useRef, useEffect, useState, useCallback } from "react";
import { SimpleMarkdown } from "../SimpleMarkdown";
import type { TaskOutputEvent } from "../../hooks/useTaskSubscription";
import { sendTaskMessage } from "../../lib/api";

interface TaskOutputProps {
  /** Array of parsed output events from the backend */
  entries: TaskOutputEvent[];
  isStreaming: boolean;
  /** Name of subtask whose output is being viewed (null = parent task) */
  viewingSubtaskName?: string | null;
  /** Callback to return to parent task output */
  onClearSubtaskView?: () => void;
  onClear?: () => void;
  /** Task ID for sending input (if provided, shows input bar when streaming) */
  taskId?: string | null;
  /** Callback when user sends input (to show it immediately in output) */
  onUserInput?: (message: string) => void;
}

export function TaskOutput({ entries, isStreaming, viewingSubtaskName, onClearSubtaskView, onClear, taskId, onUserInput }: TaskOutputProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [autoScroll, setAutoScroll] = useState(true);
  const [inputValue, setInputValue] = useState("");
  const [sendingInput, setSendingInput] = useState(false);
  const [inputError, setInputError] = useState<string | null>(null);
  const inputRef = useRef<HTMLInputElement>(null);

  // Handle scroll to check if user has scrolled 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 entries change
  useEffect(() => {
    if (autoScroll && containerRef.current) {
      containerRef.current.scrollTop = containerRef.current.scrollHeight;
    }
  }, [entries, autoScroll]);

  // Handle sending input to the task
  const handleSendInput = useCallback(async (e: React.FormEvent) => {
    e.preventDefault();
    if (!taskId || !inputValue.trim() || sendingInput) return;

    const message = inputValue.trim();
    setSendingInput(true);
    setInputError(null);

    // Show user input immediately in the output window
    onUserInput?.(message);

    try {
      await sendTaskMessage(taskId, message);
      setInputValue("");
      inputRef.current?.focus();
    } catch (err) {
      setInputError(err instanceof Error ? err.message : "Failed to send input");
    } finally {
      setSendingInput(false);
    }
  }, [taskId, inputValue, sendingInput, onUserInput]);

  // Show input bar when task is running and has a valid taskId
  const showInputBar = isStreaming && taskId;

  return (
    <div className="flex flex-col h-full">
      {/* Header */}
      <div className="flex items-center justify-between px-3 py-2 border-b border-dashed border-[rgba(117,170,252,0.35)] shrink-0">
        <div className="flex items-center gap-2">
          {viewingSubtaskName ? (
            <>
              <button
                onClick={onClearSubtaskView}
                className="font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff] transition-colors"
              >
                &lt;
              </button>
              <span className="font-mono text-xs text-green-400 tracking-wide uppercase">
                Subtask: {viewingSubtaskName}
              </span>
            </>
          ) : (
            <span className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
              Output
            </span>
          )}
          {isStreaming && (
            <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 && (
            <button
              onClick={() => {
                setAutoScroll(true);
                if (containerRef.current) {
                  containerRef.current.scrollTop =
                    containerRef.current.scrollHeight;
                }
              }}
              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>
          )}
          {onClear && 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>

      {/* Output area */}
      <div
        ref={containerRef}
        onScroll={handleScroll}
        className="flex-1 overflow-auto bg-[#0a0f18] p-3 font-mono text-xs min-h-0"
      >
        {entries.length === 0 ? (
          <div className="text-[#555] italic">
            {isStreaming ? "Waiting for output..." : "No output yet"}
          </div>
        ) : (
          <div className="space-y-3">
            {entries.map((entry, idx) => (
              <OutputEntryRenderer key={idx} entry={entry} />
            ))}
            {isStreaming && (
              <span className="inline-block w-2 h-4 bg-[#9bc3ff] animate-pulse" />
            )}
          </div>
        )}
      </div>

      {/* Input bar for sending messages to running tasks */}
      {showInputBar && (
        <div className="shrink-0 border-t border-[rgba(117,170,252,0.35)] bg-[#0d1b2d]">
          {inputError && (
            <div className="px-3 py-1 bg-red-900/20 text-red-400 text-xs font-mono">
              {inputError}
            </div>
          )}
          <form onSubmit={handleSendInput} className="flex items-center gap-2 px-3 py-2">
            <span className="text-green-400 font-mono text-sm">&gt;</span>
            <input
              ref={inputRef}
              type="text"
              value={inputValue}
              onChange={(e) => setInputValue(e.target.value)}
              placeholder={sendingInput ? "Sending..." : "Send input to Claude..."}
              disabled={sendingInput}
              className="flex-1 bg-transparent border-none outline-none font-mono text-sm text-white placeholder-[#555]"
            />
            <button
              type="submit"
              disabled={sendingInput || !inputValue.trim()}
              className="px-3 py-1 font-mono text-xs text-green-400 border border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
            >
              {sendingInput ? "..." : "Send"}
            </button>
          </form>
        </div>
      )}
    </div>
  );
}

function OutputEntryRenderer({ entry }: { entry: TaskOutputEvent }) {
  const [expanded, setExpanded] = useState(false);

  switch (entry.messageType) {
    case "user_input":
      return (
        <div className="pl-2 border-l-2 border-cyan-400/50">
          <div className="flex items-center gap-2">
            <span className="text-cyan-400 text-[10px] uppercase tracking-wide">You:</span>
          </div>
          <div className="text-cyan-300 mt-1">{entry.content}</div>
        </div>
      );

    case "system":
      return (
        <div className="text-[#555] text-[10px] uppercase tracking-wide">
          {entry.content}
        </div>
      );

    case "assistant":
      return (
        <div className="pl-2 border-l-2 border-[#3f6fb3]">
          <SimpleMarkdown content={entry.content} className="text-[#9bc3ff]" />
        </div>
      );

    case "tool_use":
      return (
        <div className="space-y-1">
          <div className="flex items-center gap-2">
            <span className="text-yellow-500">*</span>
            <span className="text-[#75aafc]">{entry.toolName || "unknown"}</span>
            {entry.toolInput && Object.keys(entry.toolInput).length > 0 && (
              <button
                onClick={() => setExpanded(!expanded)}
                className="text-[#555] hover:text-[#9bc3ff] text-[10px]"
              >
                {expanded ? "[-]" : "[+]"}
              </button>
            )}
          </div>
          {expanded && entry.toolInput && (
            <pre className="ml-4 text-[10px] text-[#555] bg-[#0a1525] p-2 overflow-x-auto">
              {JSON.stringify(entry.toolInput, null, 2)}
            </pre>
          )}
        </div>
      );

    case "tool_result":
      if (!entry.content) return null;
      return (
        <div className="ml-4 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>
      );

    case "result":
      return (
        <div className="border-t border-[rgba(117,170,252,0.2)] pt-2 mt-2">
          <div className="text-green-500 font-semibold mb-1">Result:</div>
          <SimpleMarkdown content={entry.content} className="text-[#9bc3ff]" />
          {(entry.costUsd !== undefined || entry.durationMs !== undefined) && (
            <div className="text-[10px] text-[#555] mt-2">
              {entry.durationMs !== undefined && (
                <span>Duration: {(entry.durationMs / 1000).toFixed(1)}s</span>
              )}
              {entry.costUsd !== undefined && entry.durationMs !== undefined && " | "}
              {entry.costUsd !== undefined && (
                <span>Cost: ${entry.costUsd.toFixed(4)}</span>
              )}
            </div>
          )}
        </div>
      );

    case "error":
      return (
        <div className="text-red-400 pl-2 border-l-2 border-red-400/50">
          {entry.content}
        </div>
      );

    case "raw":
      return (
        <div className="text-[#555] text-[10px]">
          {entry.content}
        </div>
      );

    default:
      return null;
  }
}