summaryrefslogblamecommitdiff
path: root/makima/frontend/src/components/directives/DirectiveDetail.tsx
blob: c8da7a0940dd49b4e844755b9e30a8874aa88891 (plain) (tree)
1
2
3
4
5
6
                                                             
                                                                                                 
                                              
                                                          
                                                                                
                                                                                   

















                                                                                 
                                                  

                        
                             
                                                                                                       
                                  










                                 
           

            
                 
                 
             


                                                           
                                                                                 

                                                                        
                                                      





                                                                 


                                                             




                                                                                        

                                                                                                   
 

















                                                                            
                                                         









                                                                                           










                                                            
                                                                      

















                                                                                
















                                                                                   






















































                                                                                           



















                                                                                             















                                                                                                                               


















                                                                                                                                
                                        


                                                                                                                  
                                                                     









                                                                                                     













                                                                             



































                                                                                                                                         
                                                                                      





                                                                                                                                         








                                                                                                                                 












                                                                                                                                                       

                         







                                                                                                                                                             
                              
                                                                                                                                                            



                   





                                                                                                       
















































                                                                                                                                                              
 











                                                                                                  

















                                                                           


          









































































                                                                                                                                                                         
import { useState, useMemo, useEffect, useRef } from "react";
import type { DirectiveWithSteps, DirectiveStatus, UpdateDirectiveRequest } from "../../lib/api";
import { DirectiveDAG } from "./DirectiveDAG";
import { DirectiveLogStream } from "./DirectiveLogStream";
import { useMultiTaskSubscription } from "../../hooks/useMultiTaskSubscription";
import { useSupervisorQuestions } from "../../contexts/SupervisorQuestionsContext";

const STATUS_BADGE: Record<DirectiveStatus, { color: string; label: string }> = {
  draft: { color: "text-[#7788aa] border-[#2a3a5a]", label: "DRAFT" },
  active: { color: "text-green-400 border-green-800", label: "ACTIVE" },
  idle: { color: "text-yellow-400 border-yellow-800", label: "IDLE" },
  paused: { color: "text-orange-400 border-orange-800", label: "PAUSED" },
  archived: { color: "text-[#556677] border-[#2a3a5a]", label: "ARCHIVED" },
};

interface DirectiveDetailProps {
  directive: DirectiveWithSteps;
  onStart: () => void;
  onPause: () => void;
  onAdvance: () => void;
  onCompleteStep: (stepId: string) => void;
  onFailStep: (stepId: string) => void;
  onSkipStep: (stepId: string) => void;
  onUpdateGoal: (goal: string) => void;
  onUpdate: (req: UpdateDirectiveRequest) => void;
  onDelete: () => void;
  onRefresh: () => void;
  onCleanupTasks: () => void;
  onPickUpOrders: () => Promise<{ message: string; orderCount: number; taskId: string | null } | null>;
  onCreatePR: () => Promise<void>;
}

export function DirectiveDetail({
  directive,
  onStart,
  onPause,
  onAdvance,
  onCompleteStep,
  onFailStep,
  onSkipStep,
  onUpdateGoal,
  onUpdate,
  onDelete,
  onRefresh,
  onCleanupTasks,
  onPickUpOrders,
  onCreatePR,
}: DirectiveDetailProps) {
  const [editingGoal, setEditingGoal] = useState(false);
  const [goalText, setGoalText] = useState(directive.goal);
  const [visibleTaskIds, setVisibleTaskIds] = useState<Set<string> | null>(null);
  const [pickingUpOrders, setPickingUpOrders] = useState(false);
  const [pickUpResult, setPickUpResult] = useState<string | null>(null);
  const [creatingPR, setCreatingPR] = useState(false);

  // Sync goalText and reset editing state when directive changes
  useEffect(() => {
    setGoalText(directive.goal);
    setEditingGoal(false);
  }, [directive.id, directive.goal]);
  const [searchQuery, setSearchQuery] = useState("");
  const [isLogCollapsed, setIsLogCollapsed] = useState(true);
  const prevHadRunningRef = useRef(false);
  const badge = STATUS_BADGE[directive.status] || STATUS_BADGE.draft;

  const completedSteps = directive.steps.filter((s) => s.status === "completed").length;
  const totalSteps = directive.steps.length;
  const progress = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0;
  const terminalStatuses = new Set(["completed", "failed", "skipped"]);
  const hasTerminalTasks = directive.steps.some((s) => s.taskId && terminalStatuses.has(s.status));

  // Get pending questions for this directive's tasks
  const { pendingQuestions, submitAnswer } = useSupervisorQuestions();
  const directiveTaskIds = useMemo(() => {
    const ids = new Set<string>();
    if (directive.orchestratorTaskId) ids.add(directive.orchestratorTaskId);
    for (const step of directive.steps) {
      if (step.taskId) ids.add(step.taskId);
    }
    return ids;
  }, [directive.orchestratorTaskId, directive.steps]);

  const directiveQuestions = useMemo(
    () => pendingQuestions.filter((q) =>
      q.directiveId === directive.id || directiveTaskIds.has(q.taskId)
    ),
    [pendingQuestions, directive.id, directiveTaskIds]
  );

  // Build task map from directive steps and orchestrator
  // Derive a stable key from the actual task IDs to avoid recreating the map on every poll
  const taskMapKey = useMemo(() => {
    const parts: string[] = [];
    if (directive.orchestratorTaskId) parts.push(`o:${directive.orchestratorTaskId}`);
    for (const step of directive.steps) {
      if (step.taskId) parts.push(`${step.id}:${step.taskId}`);
    }
    return parts.join(",");
  }, [directive.orchestratorTaskId, directive.steps]);

  const taskMap = useMemo(() => {
    const map = new Map<string, string>();
    if (directive.orchestratorTaskId) {
      map.set(directive.orchestratorTaskId, "Orchestrator");
    }
    for (const step of directive.steps) {
      if (step.taskId) {
        map.set(step.taskId, step.name);
      }
    }
    return map;
  }, [taskMapKey]); // eslint-disable-line react-hooks/exhaustive-deps

  // Subscribe to all task outputs
  const { connected, entries, clearEntries } = useMultiTaskSubscription({
    taskMap,
    enabled: taskMap.size > 0,
  });

  // Auto-expand log panel when tasks start running
  const hasRunningTasks = directive.steps.some((s) => s.status === "running") ||
    !!directive.orchestratorTaskId;

  useEffect(() => {
    if (hasRunningTasks && !prevHadRunningRef.current) {
      setIsLogCollapsed(false);
    }
    prevHadRunningRef.current = hasRunningTasks;
  }, [hasRunningTasks]);

  const handlePickUpOrders = async () => {
    setPickingUpOrders(true);
    setPickUpResult(null);
    try {
      const result = await onPickUpOrders();
      if (result) {
        setPickUpResult(result.message);
        setTimeout(() => setPickUpResult(null), 5000);
      }
    } catch (e) {
      setPickUpResult(e instanceof Error ? e.message : "Failed to pick up orders");
      setTimeout(() => setPickUpResult(null), 5000);
    } finally {
      setPickingUpOrders(false);
    }
  };

  const handleGoalSave = () => {
    if (goalText.trim() && goalText !== directive.goal) {
      onUpdateGoal(goalText.trim());
    }
    setEditingGoal(false);
  };

  return (
    <div className="flex flex-col h-full overflow-y-auto">
      {/* Header */}
      <div className="px-4 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)]">
        <div className="flex items-center justify-between mb-2">
          <h2 className="text-[14px] font-mono text-white font-medium truncate pr-2">
            {directive.title}
          </h2>
          <div className="flex items-center gap-2 shrink-0">
            <span
              className={`text-[10px] font-mono ${badge.color} border rounded px-2 py-0.5`}
            >
              {badge.label}
            </span>
            <button
              type="button"
              onClick={onRefresh}
              className="text-[10px] font-mono text-[#7788aa] hover:text-white"
              title="Refresh"
            >
              [refresh]
            </button>
          </div>
        </div>

        {/* Progress bar */}
        {totalSteps > 0 && (
          <div className="flex items-center gap-2 mb-2">
            <div className="flex-1 h-1.5 bg-[#1a2540] rounded overflow-hidden">
              <div
                className="h-full bg-emerald-600 rounded transition-all"
                style={{ width: `${progress}%` }}
              />
            </div>
            <span className="text-[10px] font-mono text-[#7788aa] shrink-0">
              {completedSteps}/{totalSteps} steps
            </span>
          </div>
        )}

        {/* Repo info */}
        {(directive.repositoryUrl || directive.localPath) && (
          <div className="text-[10px] font-mono text-[#556677] mb-2 truncate">
            {directive.repositoryUrl || directive.localPath}
            {directive.baseBranch && ` @ ${directive.baseBranch}`}
          </div>
        )}

        {/* Reconcile mode toggle */}
        <div className="flex items-center gap-2 mb-2">
          <button
            type="button"
            onClick={() => onUpdate({ reconcileMode: !directive.reconcileMode })}
            className={`text-[10px] font-mono border rounded px-2 py-0.5 transition-colors ${
              directive.reconcileMode
                ? "text-amber-400 border-amber-800 bg-amber-900/20"
                : "text-[#556677] border-[#2a3a5a] hover:text-[#7788aa]"
            }`}
          >
            {directive.reconcileMode ? "Reconcile: ON" : "Reconcile: OFF"}
          </button>
          <span className="text-[9px] font-mono text-[#445566]">
            {directive.reconcileMode
              ? "Questions pause execution"
              : "Questions timeout after 30s"}
          </span>
        </div>

        {/* Orchestrator planning indicator */}
        {directive.orchestratorTaskId && (
          <div className="flex items-center gap-2 mb-2 px-2 py-1.5 bg-[#1a1a30] border border-[rgba(117,170,252,0.2)] rounded">
            <span className="inline-block w-2 h-2 rounded-full bg-[#75aafc] animate-pulse" />
            <span className="text-[10px] font-mono text-[#75aafc]">
              Planning in progress...
            </span>
            <a
              href={`/mesh/${directive.orchestratorTaskId}`}
              className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc] underline ml-auto"
            >
              View task
            </a>
          </div>
        )}

        {/* PR link */}
        {directive.prUrl && (
          <div className="flex items-center gap-2 mb-2 px-2 py-1.5 bg-[#0a1a10] border border-emerald-900 rounded">
            <span className="inline-block w-2 h-2 rounded-full bg-emerald-400" />
            <span className="text-[10px] font-mono text-emerald-400">
              PR created
            </span>
            <a
              href={directive.prUrl}
              target="_blank"
              rel="noopener noreferrer"
              className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300 underline ml-auto truncate max-w-[200px]"
            >
              {directive.prUrl}
            </a>
          </div>
        )}

        {/* Completion task indicator */}
        {directive.completionTaskId && (
          <div className="flex items-center gap-2 mb-2 px-2 py-1.5 bg-[#1a1a10] border border-yellow-900 rounded">
            <span className="inline-block w-2 h-2 rounded-full bg-yellow-400 animate-pulse" />
            <span className="text-[10px] font-mono text-yellow-400">
              {directive.prUrl ? "Updating PR..." : "Creating PR..."}
            </span>
            <a
              href={`/mesh/${directive.completionTaskId}`}
              className="text-[9px] font-mono text-[#556677] hover:text-yellow-400 underline ml-auto"
            >
              View task
            </a>
          </div>
        )}

        {/* Pending Questions */}
        {directiveQuestions.length > 0 && (
          <div className="mb-2 space-y-2">
            {directiveQuestions.map((q) => (
              <DirectiveQuestionCard
                key={q.questionId}
                question={q}
                taskName={taskMap.get(q.taskId) || "Task"}
                onAnswer={(response) => submitAnswer(q.questionId, response)}
              />
            ))}
          </div>
        )}

        {/* Controls */}
        <div className="flex flex-wrap gap-2">
          {(directive.status === "draft" || directive.status === "paused") && (
            <button
              type="button"
              onClick={onStart}
              className="text-[10px] font-mono text-green-400 hover:text-green-300 border border-green-800 rounded px-2 py-1"
            >
              Start
            </button>
          )}
          {directive.status === "active" && (
            <>
              <button
                type="button"
                onClick={onPause}
                className="text-[10px] font-mono text-orange-400 hover:text-orange-300 border border-orange-800 rounded px-2 py-1"
              >
                Pause
              </button>
              <button
                type="button"
                onClick={onAdvance}
                className="text-[10px] font-mono text-[#75aafc] hover:text-white border border-[rgba(117,170,252,0.3)] rounded px-2 py-1"
              >
                Advance
              </button>
            </>
          )}
          {directive.status === "idle" && (
            <div className="flex items-center gap-2">
              <span className="text-[10px] font-mono text-yellow-400">
                All steps done. Update goal to add new work.
              </span>
              <button
                type="button"
                onClick={() => { setGoalText(directive.goal); setEditingGoal(true); }}
                className="text-[10px] font-mono text-[#75aafc] hover:text-white border border-[rgba(117,170,252,0.3)] rounded px-2 py-1"
              >
                Update Goal
              </button>
            </div>
          )}
          {hasTerminalTasks && (
            <button
              type="button"
              onClick={onCleanupTasks}
              className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-1 ml-auto"
            >
              Clean up tasks
            </button>
          )}
          {completedSteps > 0 && !directive.completionTaskId && (
            <button
              type="button"
              onClick={async () => {
                setCreatingPR(true);
                try { await onCreatePR(); } catch (e) { console.error("Failed to create PR:", e); } finally { setCreatingPR(false); }
              }}
              disabled={creatingPR}
              className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-2 py-1 disabled:opacity-50"
            >
              {creatingPR ? "Creating..." : directive.prUrl ? "Update PR" : "Create PR"}
            </button>
          )}
          <button
            type="button"
            onClick={handlePickUpOrders}
            disabled={pickingUpOrders}
            className="text-[10px] font-mono text-[#c084fc] hover:text-[#d8b4fe] border border-[rgba(192,132,252,0.3)] rounded px-2 py-1 disabled:opacity-50"
          >
            {pickingUpOrders ? "Picking up..." : "Pick Up Orders"}
          </button>
          <button
            type="button"
            onClick={onDelete}
            className={`text-[10px] font-mono text-red-400 hover:text-red-300 border border-red-800 rounded px-2 py-1 ${hasTerminalTasks ? "" : "ml-auto"}`}
          >
            Delete
          </button>
        </div>

        {pickUpResult && (
          <div className="mt-2 px-2 py-1.5 bg-[#1a1030] border border-[rgba(192,132,252,0.2)] rounded">
            <span className="text-[10px] font-mono text-[#c084fc]">{pickUpResult}</span>
          </div>
        )}
      </div>

      {/* Goal */}
      <div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)]">
        <div className="flex items-center justify-between mb-1">
          <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide">
            Goal
          </span>
          {!editingGoal && (
            <button
              type="button"
              onClick={() => { setGoalText(directive.goal); setEditingGoal(true); }}
              className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc]"
            >
              [edit]
            </button>
          )}
        </div>
        {editingGoal ? (
          <div className="flex flex-col gap-1.5">
            <textarea
              value={goalText}
              onChange={(e) => setGoalText(e.target.value)}
              className="w-full bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[11px] font-mono text-white resize-y min-h-[60px]"
              rows={3}
            />
            <div className="flex gap-1.5">
              <button
                type="button"
                onClick={handleGoalSave}
                className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-2 py-0.5"
              >
                Save
              </button>
              <button
                type="button"
                onClick={() => setEditingGoal(false)}
                className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-0.5"
              >
                Cancel
              </button>
            </div>
          </div>
        ) : (
          <p className="text-[11px] font-mono text-[#c0d0e0] whitespace-pre-wrap">
            {directive.goal}
          </p>
        )}
      </div>

      {/* DAG */}
      <div className="px-4 py-3 flex-1">
        <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-2">
          Steps ({totalSteps})
        </span>
        <DirectiveDAG
          steps={directive.steps}
          onComplete={onCompleteStep}
          onFail={onFailStep}
          onSkip={onSkipStep}
        />
      </div>

      {/* Log Stream */}
      {taskMap.size > 0 && (
        <div className="px-4 py-3 border-t border-[rgba(117,170,252,0.1)]">
          <DirectiveLogStream
            entries={entries}
            taskMap={taskMap}
            connected={connected}
            visibleTaskIds={visibleTaskIds}
            searchQuery={searchQuery}
            isCollapsed={isLogCollapsed}
            onToggleCollapse={() => setIsLogCollapsed((prev) => !prev)}
            onSetVisibleTaskIds={setVisibleTaskIds}
            onSetSearchQuery={setSearchQuery}
            onClear={clearEntries}
          />
        </div>
      )}
    </div>
  );
}

/** Inline question card for directive pending questions */
function DirectiveQuestionCard({
  question,
  taskName,
  onAnswer,
}: {
  question: { questionId: string; question: string; choices: string[]; context: string | null };
  taskName: string;
  onAnswer: (response: string) => void;
}) {
  const [customResponse, setCustomResponse] = useState("");
  const [submitting, setSubmitting] = useState(false);

  const handleSubmit = async (response: string) => {
    setSubmitting(true);
    await onAnswer(response);
    setSubmitting(false);
  };

  return (
    <div className="px-2 py-2 bg-[#1a1020] border border-purple-900/50 rounded">
      <div className="flex items-center gap-1.5 mb-1">
        <span className="inline-block w-2 h-2 rounded-full bg-purple-400 animate-pulse" />
        <span className="text-[9px] font-mono text-purple-400 uppercase">
          Question from {taskName}
        </span>
      </div>
      <p className="text-[11px] font-mono text-white mb-1.5">{question.question}</p>
      {question.context && (
        <p className="text-[9px] font-mono text-[#556677] mb-1.5">{question.context}</p>
      )}
      {question.choices.length > 0 ? (
        <div className="flex flex-wrap gap-1">
          {question.choices.map((choice) => (
            <button
              key={choice}
              type="button"
              disabled={submitting}
              onClick={() => handleSubmit(choice)}
              className="text-[10px] font-mono text-purple-300 hover:text-white border border-purple-800 hover:border-purple-600 rounded px-2 py-0.5 disabled:opacity-50"
            >
              {choice}
            </button>
          ))}
        </div>
      ) : (
        <div className="flex gap-1">
          <input
            type="text"
            value={customResponse}
            onChange={(e) => setCustomResponse(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === "Enter" && customResponse.trim()) {
                handleSubmit(customResponse.trim());
              }
            }}
            placeholder="Type your answer..."
            className="flex-1 bg-[#0a0618] border border-purple-900/50 rounded px-2 py-0.5 text-[10px] font-mono text-white placeholder:text-[#445566]"
            disabled={submitting}
          />
          <button
            type="button"
            disabled={submitting || !customResponse.trim()}
            onClick={() => handleSubmit(customResponse.trim())}
            className="text-[10px] font-mono text-purple-300 hover:text-white border border-purple-800 rounded px-2 py-0.5 disabled:opacity-50"
          >
            Send
          </button>
        </div>
      )}
    </div>
  );
}