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

















                                                                                 
                                                  

                        
                        
                                                                                                       
                                  










                                 
           

            
            
                 
             


                                                           
                                                                                 

                                                                        
                                                      




                                                                            





                                                                 


                                                             




                                                                                        

















                                                                            
                                                         









                                                                                           










                                                            
                                                                      
 


























                                                                                           
















                                                                                
















                                                                                   






                                                         
 






                                                                        
          
      














































                                                                                           

                                                      




















                                                                                                                                                 
                                                                


                                                                                      


                 

















                                                                                                                                













                                                                             



































                                                                                                                                         
                                                                                      



                                                                                                                                         






                                                                                                                           

                  












                                                                                                                                                       

                         



                                                                                                                                                             
                                                             


                         
                              
                                                                                                                             



                   





                                                                                                       
















































                                                                                                                                                              
 






                                                                                                  
                                             


                                     
                                     

            

















                                                                           
          







                                             

    









































































                                                                                                                                                                         
import { useState, useMemo, useEffect, useRef } from "react";
import type { DirectiveWithSteps, DirectiveStatus, UpdateDirectiveRequest } from "../../lib/api";
import { DirectiveDAG } from "./DirectiveDAG";
import type { SpecializedStep } from "./DirectiveDAG";
import { DirectiveLogStream } from "./DirectiveLogStream";
import { TaskSlideOutPanel } from "./TaskSlideOutPanel";
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;
  onCleanup: () => 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,
  onCleanup,
  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);
  const [slideOutTaskId, setSlideOutTaskId] = useState<string | null>(null);

  const handleViewTask = (taskId: string) => {
    setSlideOutTaskId(taskId);
  };

  // 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;
  // 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

  // Build specialized steps for DAG visualization
  const specializedSteps = useMemo(() => {
    const steps: SpecializedStep[] = [];

    if (directive.orchestratorTaskId) {
      steps.push({
        id: `orchestrator-${directive.orchestratorTaskId}`,
        name: taskMap.get(directive.orchestratorTaskId) || "Planning",
        type: "orchestrator",
        taskId: directive.orchestratorTaskId,
        status: "running",
      });
    }

    if (directive.completionTaskId) {
      steps.push({
        id: `completion-${directive.completionTaskId}`,
        name: directive.prUrl ? "Updating PR" : "Creating PR",
        type: "completion",
        taskId: directive.completionTaskId,
        status: "running",
      });
    }

    return steps;
  }, [directive.orchestratorTaskId, directive.completionTaskId, directive.prUrl, taskMap]);

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


  // Find the task name for the slide-out panel
  const slideOutTaskName = slideOutTaskId
    ? (directive.steps.find((s) => s.taskId === slideOutTaskId)?.name ??
       taskMap.get(slideOutTaskId) ??
       undefined)
    : undefined;

  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">
          <div className="flex items-center border border-[#2a3a5a] rounded overflow-hidden">
            {(["auto", "semi-auto", "manual"] as const).map((mode) => {
              const isActive = directive.reconcileMode === mode;
              const modeStyles: Record<string, string> = {
                auto: isActive ? "text-[#9bc3ff] bg-[#1a2540]" : "text-[#445566] hover:text-[#7788aa]",
                "semi-auto": isActive ? "text-amber-400 bg-amber-900/20" : "text-[#445566] hover:text-[#7788aa]",
                manual: isActive ? "text-orange-400 bg-orange-900/20" : "text-[#445566] hover:text-[#7788aa]",
              };
              const labels: Record<string, string> = { auto: "Auto", "semi-auto": "Semi", manual: "Manual" };
              return (
                <button
                  key={mode}
                  type="button"
                  onClick={() => onUpdate({ reconcileMode: mode })}
                  className={`text-[10px] font-mono px-2 py-0.5 transition-colors border-r border-[#2a3a5a] last:border-r-0 ${modeStyles[mode]}`}
                >
                  {labels[mode]}
                </button>
              );
            })}
          </div>
          <span className="text-[9px] font-mono text-[#445566]">
            {directive.reconcileMode === "auto" && "Questions timeout after 30s"}
            {directive.reconcileMode === "semi-auto" && "Questions pause execution"}
            {directive.reconcileMode === "manual" && "Tasks ask clarifying questions"}
          </span>
        </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>
        )}

        {/* 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>
              <button
                type="button"
                onClick={onCleanup}
                className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-1"
              >
                Clean up
              </button>
            </div>
          )}
          {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 ? "Planning..." : "Plan 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 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}
          specializedSteps={specializedSteps}
          onComplete={onCompleteStep}
          onFail={onFailStep}
          onSkip={onSkipStep}
          onViewTask={handleViewTask}
        />
      </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>

    <TaskSlideOutPanel
      taskId={slideOutTaskId || ""}
      taskName={slideOutTaskName}
      isOpen={!!slideOutTaskId}
      onClose={() => setSlideOutTaskId(null)}
    />
    </>
  );
}

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