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



















                                                                                 
                             












                                 
                 


                                                           



                                                                                 




                                                                                        

                                                                                                   
 






























                                                                                






















































                                                                                           















                                                                                                                               


















                                                                                                                                
                                        


                                                                                                                  
                                                                     









                                                                                                     










































                                                                                                                                         








                                                                                                                                 


                              
                                                                                                                                                            




















































                                                                                                                                                              
 











                                                                                                  

















                                                                           


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

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;
  onDelete: () => void;
  onRefresh: () => void;
  onCleanupTasks: () => void;
}

export function DirectiveDetail({
  directive,
  onStart,
  onPause,
  onAdvance,
  onCompleteStep,
  onFailStep,
  onSkipStep,
  onUpdateGoal,
  onDelete,
  onRefresh,
  onCleanupTasks,
}: DirectiveDetailProps) {
  const [editingGoal, setEditingGoal] = useState(false);
  const [goalText, setGoalText] = useState(directive.goal);
  const [visibleTaskIds, setVisibleTaskIds] = useState<Set<string> | null>(null);
  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));

  // Build task map from directive steps and orchestrator
  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;
  }, [directive.orchestratorTaskId, directive.steps]);

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

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

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