summaryrefslogblamecommitdiff
path: root/makima/frontend/src/components/mesh/TaskOutput.tsx
blob: f49c36614286bb087bc57cde46d06d6c154fb024 (plain) (tree)
1
2
3
4
5
6
7
8
9



                                                                       




                                                   













                                                                                



                                                                             

 










                            



















































































































                                                                                                                                                                  





                                                       








































                                                                                                                                                                                                                           






                                                                                                         
































































































                                                                                           


                                                 








                                                 








                                                 



                  
 











                                                                             
                                                                          



                                                      
                                                                                 




                                                                 
























                                                                              

                        



                                                              




















                                                                                 


                                                                                       








                                                                                


                                                  




                                                  



















                                                                                                             


                  










                                                                                                                                                                              





































                                                                                                                                                                              



























































































                                                                                                                                                                          



















































                                                                             
import { useRef, useEffect, useState, useCallback } from "react";
import { SimpleMarkdown } from "../SimpleMarkdown";
import type { TaskOutputEvent } from "../../hooks/useTaskSubscription";
import { sendTaskMessage } from "../../lib/api";
import {
  PhaseConfirmationInline,
  type PhaseConfirmationData,
} from "../contracts/PhaseConfirmationModal";
import type { ContractPhase } 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;
  /** Set of pending question IDs (for supervisor questions) */
  pendingQuestionIds?: Set<string>;
  /** Callback to answer a supervisor question */
  onAnswerQuestion?: (questionId: string, response: string) => Promise<void>;
}

export function TaskOutput({
  entries,
  isStreaming,
  viewingSubtaskName,
  onClearSubtaskView,
  onClear,
  taskId,
  onUserInput,
  pendingQuestionIds,
  onAnswerQuestion,
}: 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}
                pendingQuestionIds={pendingQuestionIds}
                onAnswerQuestion={onAnswerQuestion}
              />
            ))}
            {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>
  );
}

interface OutputEntryRendererProps {
  entry: TaskOutputEvent;
  pendingQuestionIds?: Set<string>;
  onAnswerQuestion?: (questionId: string, response: string) => Promise<void>;
}

function OutputEntryRenderer({ entry, pendingQuestionIds, onAnswerQuestion }: OutputEntryRendererProps) {
  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>
      );

    case "auth_required":
      return <AuthRequiredEntry entry={entry} />;

    case "supervisor_question":
      return (
        <SupervisorQuestionEntry
          entry={entry}
          pendingQuestionIds={pendingQuestionIds}
          onAnswerQuestion={onAnswerQuestion}
        />
      );

    case "phase_confirmation":
      return (
        <PhaseConfirmationEntry
          entry={entry}
          pendingQuestionIds={pendingQuestionIds}
          onAnswerQuestion={onAnswerQuestion}
        />
      );

    default:
      return null;
  }
}

function SupervisorQuestionEntry({
  entry,
  pendingQuestionIds,
  onAnswerQuestion,
}: {
  entry: TaskOutputEvent;
  pendingQuestionIds?: Set<string>;
  onAnswerQuestion?: (questionId: string, response: string) => Promise<void>;
}) {
  const questionId = entry.toolInput?.question_id as string;
  const choices = (entry.toolInput?.choices as string[]) || [];
  const context = entry.toolInput?.context as string | null;
  const multiSelect = (entry.toolInput?.multi_select as boolean) ?? false;

  const [customInput, setCustomInput] = useState("");
  const [showOther, setShowOther] = useState(false);
  const [submitting, setSubmitting] = useState(false);
  const [selectedChoices, setSelectedChoices] = useState<Set<string>>(new Set());

  const isPending = pendingQuestionIds?.has(questionId) ?? false;

  const handleChoiceSelect = async (choice: string) => {
    if (!onAnswerQuestion || submitting) return;

    if (multiSelect) {
      // Toggle selection for multi-select mode
      setSelectedChoices(prev => {
        const newSet = new Set(prev);
        if (newSet.has(choice)) {
          newSet.delete(choice);
        } else {
          newSet.add(choice);
        }
        return newSet;
      });
    } else {
      // Single select - submit immediately
      setSubmitting(true);
      try {
        await onAnswerQuestion(questionId, choice);
      } finally {
        setSubmitting(false);
      }
    }
  };

  const handleMultiSelectSubmit = async () => {
    if (!onAnswerQuestion || submitting || selectedChoices.size === 0) return;
    setSubmitting(true);
    try {
      // Join selected choices with comma
      const response = Array.from(selectedChoices).join(", ");
      await onAnswerQuestion(questionId, response);
      setSelectedChoices(new Set());
    } finally {
      setSubmitting(false);
    }
  };

  const handleOtherSubmit = async () => {
    if (!onAnswerQuestion || !customInput.trim() || submitting) return;
    setSubmitting(true);
    try {
      await onAnswerQuestion(questionId, customInput.trim());
      setCustomInput("");
    } finally {
      setSubmitting(false);
    }
  };

  return (
    <div className="bg-amber-900/20 border border-amber-500/50 rounded p-3 my-2">
      <div className="flex items-center gap-2 text-amber-400 font-semibold mb-2">
        <span>?</span>
        <span>Question</span>
        {multiSelect && isPending && (
          <span className="text-amber-300 text-xs font-normal">(select multiple)</span>
        )}
        {!isPending && (
          <span className="text-green-400 text-xs font-normal">(Answered)</span>
        )}
      </div>

      {context && (
        <p className="text-amber-200/60 text-xs mb-2 uppercase">{context}</p>
      )}

      <div className="text-amber-100 mb-3">
        <SimpleMarkdown content={entry.content} />
      </div>

      {isPending && (
        <div className="space-y-2">
          {choices.length > 0 && (
            <div className="flex flex-wrap gap-2">
              {choices.map((choice, idx) => {
                const isSelected = selectedChoices.has(choice);
                return (
                  <button
                    key={idx}
                    onClick={() => handleChoiceSelect(choice)}
                    disabled={submitting}
                    className={`px-3 py-1.5 text-sm font-mono border transition-colors disabled:opacity-50 ${
                      multiSelect && isSelected
                        ? "bg-amber-500/50 border-amber-400 text-amber-50"
                        : "bg-amber-500/20 border-amber-500/50 hover:bg-amber-500/30 text-amber-100"
                    }`}
                  >
                    {multiSelect && (
                      <span className="mr-1.5">{isSelected ? "✓" : "○"}</span>
                    )}
                    {choice}
                  </button>
                );
              })}
            </div>
          )}

          {/* Submit button for multi-select mode */}
          {multiSelect && selectedChoices.size > 0 && (
            <button
              onClick={handleMultiSelectSubmit}
              disabled={submitting}
              className="px-4 py-1.5 bg-amber-500 text-black text-sm font-medium rounded disabled:opacity-50 disabled:cursor-not-allowed transition-colors hover:bg-amber-400"
            >
              {submitting ? "Submitting..." : `Submit (${selectedChoices.size} selected)`}
            </button>
          )}

          {/* Other option */}
          {!showOther ? (
            <button
              onClick={() => setShowOther(true)}
              className="text-xs text-amber-400 hover:text-amber-300 transition-colors"
            >
              + Other (custom response)
            </button>
          ) : (
            <div className="flex gap-2">
              <input
                type="text"
                value={customInput}
                onChange={(e) => setCustomInput(e.target.value)}
                placeholder="Type custom response..."
                disabled={submitting}
                className="flex-1 px-2 py-1 bg-[#0a1525] border border-amber-500/30 text-amber-100 text-sm rounded focus:outline-none focus:border-amber-400"
                onKeyDown={(e) => {
                  if (e.key === "Enter" && customInput.trim()) {
                    handleOtherSubmit();
                  }
                }}
              />
              <button
                onClick={handleOtherSubmit}
                disabled={submitting || !customInput.trim()}
                className="px-3 py-1 bg-amber-500 text-black text-sm font-medium rounded disabled:opacity-50 disabled:cursor-not-allowed transition-colors hover:bg-amber-400"
              >
                {submitting ? "..." : "Submit"}
              </button>
            </div>
          )}
        </div>
      )}
    </div>
  );
}

function AuthRequiredEntry({ entry }: { entry: TaskOutputEvent }) {
  const [authCode, setAuthCode] = useState("");
  const [submitting, setSubmitting] = useState(false);
  const [submitted, setSubmitted] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const loginUrl = entry.toolInput?.loginUrl as string | undefined;
  const hostname = entry.toolInput?.hostname as string | undefined;
  // Get taskId from entry or fallback to toolInput (for robustness)
  const taskId = entry.taskId || (entry.toolInput?.taskId as string | undefined);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!authCode.trim() || !taskId) return;

    setSubmitting(true);
    setError(null);

    try {
      // Send the auth code to the task via the message endpoint
      await sendTaskMessage(taskId, `AUTH_CODE:${authCode.trim()}`);
      setSubmitted(true);
    } catch (err) {
      setError(err instanceof Error ? err.message : "Failed to submit code");
    } finally {
      setSubmitting(false);
    }
  };

  if (submitted) {
    return (
      <div className="bg-green-900/30 border border-green-500/50 rounded p-3 my-2">
        <div className="flex items-center gap-2 text-green-400 font-semibold">
          <span>✓</span>
          <span>Authentication code submitted</span>
        </div>
        <p className="text-green-200/80 text-sm mt-1">
          Waiting for authentication to complete...
        </p>
      </div>
    );
  }

  return (
    <div className="bg-amber-900/30 border border-amber-500/50 rounded p-3 my-2">
      <div className="flex items-center gap-2 text-amber-400 font-semibold mb-2">
        <span>🔐</span>
        <span>Authentication Required{hostname ? ` (${hostname})` : ""}</span>
      </div>
      <p className="text-amber-200/80 text-sm mb-3">
        The daemon's OAuth token has expired. Click the button to login, then paste the code below:
      </p>

      <div className="flex flex-col gap-3">
        {loginUrl ? (
          <a
            href={loginUrl}
            target="_blank"
            rel="noopener noreferrer"
            className="inline-block bg-amber-500 hover:bg-amber-400 text-black font-medium px-4 py-2 rounded transition-colors text-center"
          >
            1. Login to Claude
          </a>
        ) : (
          <p className="text-red-400 text-sm">Login URL not available</p>
        )}

        <form onSubmit={handleSubmit} className="flex gap-2">
          <input
            type="text"
            value={authCode}
            onChange={(e) => setAuthCode(e.target.value)}
            placeholder="2. Paste authentication code here"
            className="flex-1 bg-[#0a1525] border border-amber-500/30 rounded px-3 py-2 text-amber-100 placeholder-amber-500/50 focus:outline-none focus:border-amber-400"
            disabled={submitting}
          />
          <button
            type="submit"
            disabled={submitting || !authCode.trim()}
            className="bg-amber-500 hover:bg-amber-400 disabled:bg-amber-700 disabled:cursor-not-allowed text-black font-medium px-4 py-2 rounded transition-colors"
          >
            {submitting ? "..." : "Submit"}
          </button>
        </form>

        {error && (
          <p className="text-red-400 text-sm">{error}</p>
        )}
      </div>
    </div>
  );
}

/** Entry for phase transition confirmations */
function PhaseConfirmationEntry({
  entry,
  pendingQuestionIds,
  onAnswerQuestion,
}: {
  entry: TaskOutputEvent;
  pendingQuestionIds?: Set<string>;
  onAnswerQuestion?: (questionId: string, response: string) => Promise<void>;
}) {
  const questionId = entry.toolInput?.question_id as string;
  const currentPhase = entry.toolInput?.current_phase as ContractPhase;
  const nextPhase = entry.toolInput?.next_phase as ContractPhase;
  const contractId = entry.toolInput?.contract_id as string;
  const contractName = entry.toolInput?.contract_name as string | undefined;
  const summary = entry.toolInput?.summary as string | undefined;
  const deliverables = entry.toolInput?.deliverables as
    | Array<{ name: string; completed: boolean }>
    | undefined;

  const isPending = pendingQuestionIds?.has(questionId) ?? false;

  const data: PhaseConfirmationData = {
    questionId,
    contractId,
    contractName,
    currentPhase,
    nextPhase,
    summary,
    deliverables,
  };

  const handleApprove = async (qId: string) => {
    if (!onAnswerQuestion) return;
    await onAnswerQuestion(qId, "APPROVE");
  };

  const handleRequestChanges = async (qId: string, feedback: string) => {
    if (!onAnswerQuestion) return;
    await onAnswerQuestion(qId, `CHANGES_REQUESTED: ${feedback}`);
  };

  return (
    <PhaseConfirmationInline
      data={data}
      isPending={isPending}
      onApprove={handleApprove}
      onRequestChanges={handleRequestChanges}
    />
  );
}