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

                                                                                         
                                              


                                                                                








                                                                                 









                                                                                                                        










                                           
                             












                                 
                 


                                                           



                                                                                 




                                                                                        

                                                                                                   
 





















































                                                                                         






                                                         
















                                            















































                                                                                           















                                                                                                                               


















                                                                                                                                
                                        


                                                                                                                  
                                                                     









                                                                                                     










































                                                                                                                                         








                                                                                                                                 


                              
                                                                                                                                                            





















































                                                                                                                                                              















































































































































































































                                                                                                                                                                                             











                                                                                                  

















                                                                           


          
import { useState, useMemo, useEffect, useRef } from "react";
import type { DirectiveWithSteps, DirectiveStatus, MemoryCategory } from "../../lib/api";
import { DirectiveDAG } from "./DirectiveDAG";
import { DirectiveLogStream } from "./DirectiveLogStream";
import { useDirectiveMemories } from "../../hooks/useDirectiveMemories";
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" },
};

const CATEGORY_COLORS: Record<MemoryCategory, { text: string; border: string; bg: string; label: string }> = {
  decision:   { text: "text-amber-400",   border: "border-amber-800",   bg: "bg-amber-900/20",   label: "Decision" },
  context:    { text: "text-cyan-400",     border: "border-cyan-800",    bg: "bg-cyan-900/20",    label: "Context" },
  preference: { text: "text-violet-400",   border: "border-violet-800",  bg: "bg-violet-900/20",  label: "Preference" },
  learning:   { text: "text-emerald-400",  border: "border-emerald-800", bg: "bg-emerald-900/20", label: "Learning" },
  other:      { text: "text-[#7788aa]",    border: "border-[#2a3a5a]",   bg: "bg-[#1a2540]",      label: "Other" },
};

const ALL_CATEGORIES: MemoryCategory[] = ["decision", "context", "preference", "learning", "other"];

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

  // Memory panel state
  const [memoryOpen, setMemoryOpen] = useState(false);
  const [addingMemory, setAddingMemory] = useState(false);
  const [newCategory, setNewCategory] = useState<MemoryCategory>("context");
  const [newContent, setNewContent] = useState("");
  const [newSource, setNewSource] = useState("");
  const [confirmClear, setConfirmClear] = useState(false);

  const {
    grouped,
    config: memoryConfig,
    loading: memoryLoading,
    error: memoryError,
    toggleEnabled,
    add: addMemory,
    remove: removeMemory,
    clearAll: clearMemories,
    refresh: refreshMemories,
  } = useDirectiveMemories(directive.id);

  const memoryEnabled = memoryConfig?.enabled ?? false;
  const totalMemories = Object.values(grouped).reduce((sum, arr) => sum + arr.length, 0);

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

  const handleAddMemory = async () => {
    if (!newContent.trim()) return;
    await addMemory({
      category: newCategory,
      content: newContent.trim(),
      source: newSource.trim() || undefined,
    });
    setNewContent("");
    setNewSource("");
    setAddingMemory(false);
  };

  const handleClearAll = async () => {
    await clearMemories();
    setConfirmClear(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>

      {/* Memory Panel */}
      <div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)]">
        {/* Memory header — always visible */}
        <div className="flex items-center justify-between">
          <button
            type="button"
            onClick={() => setMemoryOpen((v) => !v)}
            className="flex items-center gap-1.5 group"
          >
            <span className="text-[10px] font-mono text-[#556677] group-hover:text-[#9bc3ff] transition-colors">
              {memoryOpen ? "\u25BC" : "\u25B6"}
            </span>
            <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide">
              Memory
            </span>
            {totalMemories > 0 && (
              <span className="text-[9px] font-mono text-[#556677] ml-1">
                ({totalMemories})
              </span>
            )}
          </button>
          <div className="flex items-center gap-2">
            {/* Enable/disable toggle */}
            <button
              type="button"
              onClick={() => toggleEnabled(!memoryEnabled)}
              className={`text-[9px] font-mono border rounded px-1.5 py-0.5 transition-colors ${
                memoryEnabled
                  ? "text-emerald-400 border-emerald-800 hover:text-emerald-300"
                  : "text-[#556677] border-[#2a3a5a] hover:text-[#7788aa]"
              }`}
              title={memoryEnabled ? "Disable memory" : "Enable memory"}
            >
              {memoryEnabled ? "ON" : "OFF"}
            </button>
          </div>
        </div>

        {/* Collapsible content */}
        {memoryOpen && (
          <div className="mt-2">
            {memoryError && (
              <div className="text-[10px] font-mono text-red-400 mb-2 px-2 py-1 bg-red-900/10 border border-red-800/30 rounded">
                {memoryError}
              </div>
            )}

            {memoryLoading ? (
              <div className="text-[10px] font-mono text-[#556677] py-2">Loading...</div>
            ) : totalMemories === 0 ? (
              <div className="text-[10px] font-mono text-[#556677] py-2">
                No memory entries yet.
                {!memoryEnabled && " Enable memory to start capturing entries."}
              </div>
            ) : (
              /* Grouped entries */
              <div className="flex flex-col gap-2">
                {ALL_CATEGORIES.map((cat) => {
                  const entries = grouped[cat];
                  if (entries.length === 0) return null;
                  const style = CATEGORY_COLORS[cat];
                  return (
                    <div key={cat}>
                      <div className="flex items-center gap-1.5 mb-1">
                        <span className={`text-[9px] font-mono ${style.text} uppercase tracking-wider`}>
                          {style.label}
                        </span>
                        <span className="text-[9px] font-mono text-[#556677]">
                          ({entries.length})
                        </span>
                      </div>
                      <div className="flex flex-col gap-1">
                        {entries.map((entry) => (
                          <div
                            key={entry.id}
                            className={`flex items-start gap-2 px-2 py-1.5 rounded border ${style.border} ${style.bg}`}
                          >
                            <div className="flex-1 min-w-0">
                              <p className="text-[10px] font-mono text-[#c0d0e0] whitespace-pre-wrap break-words">
                                {entry.content}
                              </p>
                              {entry.source && (
                                <span className="text-[9px] font-mono text-[#556677] mt-0.5 block">
                                  src: {entry.source}
                                </span>
                              )}
                            </div>
                            <button
                              type="button"
                              onClick={() => removeMemory(entry.id)}
                              className="text-[9px] font-mono text-[#556677] hover:text-red-400 shrink-0 mt-0.5"
                              title="Delete entry"
                            >
                              x
                            </button>
                          </div>
                        ))}
                      </div>
                    </div>
                  );
                })}
              </div>
            )}

            {/* Action bar: Add + Clear */}
            <div className="flex items-center gap-2 mt-2 pt-2 border-t border-[rgba(117,170,252,0.1)]">
              <button
                type="button"
                onClick={() => setAddingMemory((v) => !v)}
                className="text-[10px] font-mono text-[#75aafc] hover:text-white border border-[rgba(117,170,252,0.2)] rounded px-2 py-0.5"
              >
                {addingMemory ? "Cancel" : "+ Add"}
              </button>
              {totalMemories > 0 && (
                <>
                  {confirmClear ? (
                    <div className="flex items-center gap-1.5 ml-auto">
                      <span className="text-[9px] font-mono text-red-400">Clear all?</span>
                      <button
                        type="button"
                        onClick={handleClearAll}
                        className="text-[9px] font-mono text-red-400 hover:text-red-300 border border-red-800 rounded px-1.5 py-0.5"
                      >
                        Yes
                      </button>
                      <button
                        type="button"
                        onClick={() => setConfirmClear(false)}
                        className="text-[9px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-1.5 py-0.5"
                      >
                        No
                      </button>
                    </div>
                  ) : (
                    <button
                      type="button"
                      onClick={() => setConfirmClear(true)}
                      className="text-[10px] font-mono text-[#556677] hover:text-red-400 ml-auto"
                    >
                      Clear all
                    </button>
                  )}
                </>
              )}
              <button
                type="button"
                onClick={refreshMemories}
                className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc]"
                title="Refresh memories"
              >
                [refresh]
              </button>
            </div>

            {/* Add form */}
            {addingMemory && (
              <div className="mt-2 p-2 bg-[#0a1628] border border-[rgba(117,170,252,0.15)] rounded flex flex-col gap-2">
                <div className="flex items-center gap-2">
                  <label className="text-[9px] font-mono text-[#7788aa] shrink-0">Category</label>
                  <select
                    value={newCategory}
                    onChange={(e) => setNewCategory(e.target.value as MemoryCategory)}
                    className="bg-[#1a2540] border border-[rgba(117,170,252,0.2)] rounded px-1.5 py-0.5 text-[10px] font-mono text-white flex-1"
                  >
                    {ALL_CATEGORIES.map((c) => (
                      <option key={c} value={c}>
                        {CATEGORY_COLORS[c].label}
                      </option>
                    ))}
                  </select>
                </div>
                <textarea
                  value={newContent}
                  onChange={(e) => setNewContent(e.target.value)}
                  placeholder="Memory content..."
                  className="w-full bg-[#1a2540] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[10px] font-mono text-white resize-y min-h-[40px] placeholder:text-[#556677]"
                  rows={2}
                />
                <input
                  type="text"
                  value={newSource}
                  onChange={(e) => setNewSource(e.target.value)}
                  placeholder="Source (optional)"
                  className="w-full bg-[#1a2540] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1 text-[10px] font-mono text-white placeholder:text-[#556677]"
                />
                <div className="flex gap-1.5">
                  <button
                    type="button"
                    onClick={handleAddMemory}
                    disabled={!newContent.trim()}
                    className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-2 py-0.5 disabled:opacity-40 disabled:cursor-not-allowed"
                  >
                    Save
                  </button>
                  <button
                    type="button"
                    onClick={() => { setAddingMemory(false); setNewContent(""); setNewSource(""); }}
                    className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-0.5"
                  >
                    Cancel
                  </button>
                </div>
              </div>
            )}
          </div>
        )}
      </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>
  );
}