summaryrefslogblamecommitdiff
path: root/makima/frontend/src/routes/document-directives.tsx
blob: 7b0a89bc5fb55f0cdf8319edbd7744f9cad33591 (plain) (tree)
1
2
3
4
5
6
7
8

                                                                          


                                                                     
                                                                                
                                                                         
                                                                                 









                                                                                     
                         
                    




                    


                              
                    



                     
                    
              
                    







                                                                             
                           


                           








                                                                        

  






















































                                                                                                                                                                                                                                            















                                                                                                            



















                                                                                                            

















                                                                                                

















                                                                                        
























                                                                                                                                                                  

















                                                                                














                                                                                



                                                                                           










                          

                   

































































































                                                                                                                                                 



























                                                                             



          














                                                                                  

                                                                            




                                                                            






                          

                         

                         

                





                                            



                                                                        

                                                                                          



                                                                                 









                                                                                



















                                                                               
                                                     



                                                            
 


                                                                        




















                                                                           
 









                                                                              
          

                                           
                                                                                                                                          
                                                                   
       





















































                                                                                   






                                                                               
          
            





















                                                                                                                              






























                                                                                                                                                




                                                









                                                        


                                                                 






















                                                                                                                                           






















































                                                                                                                                                  





             
   












































































































                                                                                                                


































                                                                                                

                         

                                                                                 

                 
                                                                    




                                      
                                









                                                                            
                   











                                                                   
                   











                                                                             
                        



                            
     

   












                                                                            







                                            

                                                                                          





                                                                                 

 






                          


                 
                  






















                                                                           














                                                                               

                                                                          





                                                    

                  





                                                                                
       

                   























                                                                             





                                                                                                                         
                   





                                                               
                                                                                    

                                                                                                  
                               



                                                   



















































                                                                                                                                                                                                                    





                                                                                      
                            

                







                                               

                                                                      

                                                             

                                           

              










                                                                                
























































                                                                               

                                 
                                

                         
                          

 






                      













                                                                      
                                    
                           

                                                                   







                                                                      
                                                                                   






                                                                      
                                                                                  



            








                                                                             
                                                                  

                                                 
                      
                                                 
                    



                                                                     

           
















                                                                                  

                                                                 
                                                                                  


                                                                                                              
                                  
                                                                            








                                                                                                                          
                                


                       


              




















                                                                                                                                                                                            


                                                                             
                                 

                                                     
                               

















                                         







                                                                                










                                                                            



                                                                                  

                                                            

                                                                                     






                                                                 















                                                    



















                                                                                      























































                                                                              










                                                                             



                                                         







                                                                           
                                                                                                                                             


                                   

                                 

                                                           


                                           


              
                                                   

                                 
                                         

                                               
                                   

             









































                                                                                  






                                                                          















                                                              








































                                                                      

















                                                                             

          






















































































































                                                                                                                                                                                                        


          
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router";
import { Masthead } from "../components/Masthead";
import { useDirective, useDirectives } from "../hooks/useDirectives";
import { useAuth } from "../contexts/AuthContext";
import { useSupervisorQuestions } from "../contexts/SupervisorQuestionsContext";
import { DocumentEditor } from "../components/directives/DocumentEditor";
import { DocumentTaskStream } from "../components/directives/DocumentTaskStream";
import { DirectiveContextMenu } from "../components/directives/DirectiveContextMenu";
import {
  startDirective,
  pauseDirective,
  updateDirective,
  deleteDirective,
  completeDirectiveStep,
  failDirectiveStep,
  skipDirectiveStep,
  stopTask,
  listDirectiveRevisions,
  newDirectiveDraft,
  createDirectivePR,
  advanceDirective,
  cleanupDirective,
  pickUpOrders,
  sendTaskMessage,
  listDirectiveEphemeralTasks,
  createDirectiveTask,
  listOrphanTasks,
} from "../lib/api";
import type {
  DirectiveStatus,
  DirectiveSummary,
  DirectiveWithSteps,
  DirectiveRevision,
  TaskSummary,
} from "../lib/api";

// Status dot color, matching the existing tabular UI's badge palette so the
// document mode feels like a sibling of the existing list, not a foreign UI.
const STATUS_DOT: Record<DirectiveStatus, string> = {
  draft: "bg-[#556677]",
  active: "bg-green-400",
  idle: "bg-yellow-400",
  paused: "bg-orange-400",
  inactive: "bg-[#75aafc]",
  archived: "bg-[#3a4a6a]",
};

// Per-task dot color for the sidebar entries inside a directive folder.
// Matches the StepsBlockNode palette.
const STEP_STATUS_DOT: Record<string, string> = {
  pending: "bg-[#556677]",
  ready: "bg-[#9bc3ff]",
  running: "bg-yellow-400",
  done: "bg-green-400",
  failed: "bg-red-400",
  skipped: "bg-[#3a4a6a]",
};

// =============================================================================
// Sidebar icons (inline SVG, no new deps)
// =============================================================================

function FolderIcon({ open = false }: { open?: boolean }) {
  return (
    <svg
      viewBox="0 0 16 16"
      width={12}
      height={12}
      className="shrink-0"
      aria-hidden
    >
      {open ? (
        <path
          d="M1.5 3.5a1 1 0 0 1 1-1h3.382a1 1 0 0 1 .894.553l.448.894a1 1 0 0 0 .894.553H13.5a1 1 0 0 1 1 1V6H1.5V3.5z M1 6.5h13.382a.5.5 0 0 1 .49.598l-.9 5A.5.5 0 0 1 13.482 12.5H2.518a.5.5 0 0 1-.49-.402l-.9-5A.5.5 0 0 1 1.62 6.5H1z"
          fill="#75aafc"
          opacity="0.85"
        />
      ) : (
        <path
          d="M1.5 4a1 1 0 0 1 1-1h3.382a1 1 0 0 1 .894.553l.448.894a1 1 0 0 0 .894.553H13.5a1 1 0 0 1 1 1V12a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1V4z"
          fill="#75aafc"
          opacity="0.65"
        />
      )}
    </svg>
  );
}

function FileIcon() {
  return (
    <svg
      viewBox="0 0 16 16"
      width={12}
      height={12}
      className="shrink-0"
      aria-hidden
    >
      <path
        d="M3 1.5h6.293a1 1 0 0 1 .707.293l3 3A1 1 0 0 1 13.293 5.5H13V14a.5.5 0 0 1-.5.5h-9A.5.5 0 0 1 3 14V1.5z"
        fill="none"
        stroke="#9bc3ff"
        strokeWidth="1"
      />
      <path
        d="M9.5 1.5v3h3"
        fill="none"
        stroke="#9bc3ff"
        strokeWidth="1"
      />
    </svg>
  );
}

/** Terminal/prompt icon for orchestrator and step tasks. */
function TaskIcon() {
  return (
    <svg
      viewBox="0 0 16 16"
      width={12}
      height={12}
      className="shrink-0"
      aria-hidden
    >
      <rect x="1.5" y="3" width="13" height="10" rx="1" fill="none" stroke="#9bc3ff" strokeWidth="1" />
      <path d="M3.5 6l2 2-2 2 M7 10h4" stroke="#9bc3ff" strokeWidth="1" fill="none" strokeLinecap="round" />
    </svg>
  );
}

/** Asterisk-on-terminal icon for ephemeral spinoff tasks — visually
    distinct from the plain TaskIcon used for step-spawned execution tasks
    so users can tell at a glance which tasks are part of the DAG vs which
    are user-spun side quests. */
function EphemeralTaskIcon() {
  return (
    <svg
      viewBox="0 0 16 16"
      width={12}
      height={12}
      className="shrink-0"
      aria-hidden
    >
      <rect x="1.5" y="3" width="13" height="10" rx="1" fill="none" stroke="#c084fc" strokeWidth="1" />
      <path d="M3.5 6l2 2-2 2 M7 10h4" stroke="#c084fc" strokeWidth="1" fill="none" strokeLinecap="round" />
      <path d="M11 4l1 1m-1 0l1-1" stroke="#c084fc" strokeWidth="1" fill="none" />
    </svg>
  );
}

/** PR-bracket icon for the completion task. */
function CompletionIcon() {
  return (
    <svg
      viewBox="0 0 16 16"
      width={12}
      height={12}
      className="shrink-0"
      aria-hidden
    >
      <circle cx="4" cy="4" r="1.4" fill="none" stroke="#9bc3ff" strokeWidth="1" />
      <circle cx="4" cy="12" r="1.4" fill="none" stroke="#9bc3ff" strokeWidth="1" />
      <circle cx="12" cy="12" r="1.4" fill="none" stroke="#9bc3ff" strokeWidth="1" />
      <path d="M4 5.4v5.2 M4 12h6.6 M12 4l0 6.6" stroke="#9bc3ff" strokeWidth="1" fill="none" />
    </svg>
  );
}

function PinIcon() {
  return (
    <svg
      viewBox="0 0 16 16"
      width={10}
      height={10}
      className="shrink-0"
      aria-hidden
    >
      <path
        d="M8 1.5l1.6 3.6 3.9.4-2.95 2.7.85 3.9L8 10.2 4.6 12.1l.85-3.9L2.5 5.5l3.9-.4z"
        fill="#75aafc"
        opacity="0.7"
      />
    </svg>
  );
}

/** Tiny chip used for the inline directive-folder hover actions. */
function FolderActionButton({
  children,
  title,
  onClick,
}: {
  children: React.ReactNode;
  title: string;
  onClick: () => void;
}) {
  return (
    <button
      type="button"
      title={title}
      onClick={(e) => {
        e.stopPropagation();
        onClick();
      }}
      className="w-5 h-5 flex items-center justify-center text-[10px] text-[#7788aa] hover:text-white hover:bg-[rgba(117,170,252,0.15)] rounded transition-colors"
    >
      {children}
    </button>
  );
}

function Caret({ open }: { open: boolean }) {
  return (
    <svg
      viewBox="0 0 8 8"
      width={8}
      height={8}
      className={`shrink-0 transition-transform ${open ? "rotate-90" : ""}`}
      aria-hidden
    >
      <path d="M2 1l4 3-4 3z" fill="#7788aa" />
    </svg>
  );
}

// =============================================================================
// Sidebar
// =============================================================================

// =============================================================================
// Task row context menu — sits next to DirectiveContextMenu and offers the
// task-level controls (interrupt for orchestrator/completion, complete/fail/
// skip for step tasks).
// =============================================================================

interface TaskContextMenuProps {
  x: number;
  y: number;
  task: FolderTaskRow;
  onClose: () => void;
  onInterrupt: () => void;
  onComplete?: () => void;
  onFail?: () => void;
  onSkip?: () => void;
  /** Send a freeform message to the running task (same wire as the inline comment box). */
  onSendMessage?: () => void;
  /** Navigate to the standalone task page for full-screen control. */
  onOpenInTaskPage?: () => void;
}

function TaskContextMenu({
  x,
  y,
  task,
  onClose,
  onInterrupt,
  onComplete,
  onFail,
  onSkip,
  onSendMessage,
  onOpenInTaskPage,
}: TaskContextMenuProps) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const click = (e: MouseEvent) => {
      if (ref.current && !ref.current.contains(e.target as Node)) onClose();
    };
    const key = (e: KeyboardEvent) => {
      if (e.key === "Escape") onClose();
    };
    document.addEventListener("mousedown", click);
    document.addEventListener("keydown", key);
    return () => {
      document.removeEventListener("mousedown", click);
      document.removeEventListener("keydown", key);
    };
  }, [onClose]);

  useEffect(() => {
    if (!ref.current) return;
    const rect = ref.current.getBoundingClientRect();
    if (rect.right > window.innerWidth) ref.current.style.left = `${x - rect.width}px`;
    if (rect.bottom > window.innerHeight) ref.current.style.top = `${y - rect.height}px`;
  }, [x, y]);

  const item =
    "w-full px-3 py-1.5 text-left text-xs font-mono text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] flex items-center gap-2";
  const divider = "border-t border-[rgba(117,170,252,0.2)] my-1";

  // Interrupt is meaningful for live tasks (orchestrator-active or running steps).
  const showInterrupt =
    task.kind === "orchestrator-active" ||
    task.kind === "completion" ||
    task.status === "running";
  // Step lifecycle controls only apply to step tasks.
  const isStep = task.kind === "step";
  const showComplete = isStep && task.status !== "done";
  const showFail = isStep && task.status !== "failed";
  const showSkip = isStep && task.status !== "skipped";

  return (
    <div
      ref={ref}
      className="fixed z-50 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] shadow-lg min-w-[180px]"
      style={{ left: x, top: y }}
    >
      <div className="px-3 py-1.5 text-[10px] font-mono text-[#556677] uppercase border-b border-[rgba(117,170,252,0.2)] truncate max-w-[220px]">
        {task.kind === "orchestrator-active" ? "Orchestrator" : task.kind === "completion" ? "Completion" : task.label}
      </div>
      {showInterrupt && (
        <button
          className={item}
          onClick={() => {
            onInterrupt();
            onClose();
          }}
        >
          <span className="text-amber-300">⏹</span>
          Interrupt
        </button>
      )}
      {(showComplete || showFail || showSkip) && <div className={divider} />}
      {showComplete && (
        <button
          className={item}
          onClick={() => {
            onComplete?.();
            onClose();
          }}
        >
          <span className="text-emerald-400">✓</span>
          Mark complete
        </button>
      )}
      {showFail && (
        <button
          className={item}
          onClick={() => {
            onFail?.();
            onClose();
          }}
        >
          <span className="text-red-400">✗</span>
          Mark failed
        </button>
      )}
      {showSkip && (
        <button
          className={item}
          onClick={() => {
            onSkip?.();
            onClose();
          }}
        >
          <span className="text-[#7788aa]">⤳</span>
          Skip
        </button>
      )}

      {/* Direct task-page actions: send-message and open-in-task-page mirror
          what the standalone /exec/:taskId page exposes. */}
      {(onSendMessage || onOpenInTaskPage) && <div className={divider} />}
      {onSendMessage && (
        <button
          className={item}
          onClick={() => {
            onSendMessage();
            onClose();
          }}
        >
          <span className="text-cyan-300">⌨</span>
          Send message
        </button>
      )}
      {onOpenInTaskPage && (
        <button
          className={item}
          onClick={() => {
            onOpenInTaskPage();
            onClose();
          }}
        >
          <span className="text-[#75aafc]">↗</span>
          Open in task page
        </button>
      )}
    </div>
  );
}

function slugify(title: string, fallback: string): string {
  const slug = title
    .trim()
    .replace(/\s+/g, "-")
    .replace(/[^a-zA-Z0-9._-]/g, "")
    .toLowerCase();
  return slug.length > 0 ? slug : fallback;
}

interface SidebarSelection {
  directiveId: string;
  /** null = the directive's document; otherwise a task id (orchestrator/step). */
  taskId: string | null;
}

/**
 * Per-directive folder. Renders the directive as a collapsible folder whose
 * body is the pinned document entry (always first) followed by a `tasks/`
 * subfolder containing the orchestrator, completion, and step tasks.
 *
 * Status dot lives on the right side only (single-side, per the v2 design).
 * If a directive or task has a pending user question, its icon glows.
 */
function DirectiveFolder({
  directive,
  open,
  onToggle,
  selection,
  onSelect,
  pendingTaskIds,
  hasPendingForDirective,
  onDirectiveContextMenu,
  onTaskContextMenu,
  onCreateTask,
  onQuickAction,
}: {
  directive: DirectiveSummary;
  open: boolean;
  onToggle: () => void;
  selection: SidebarSelection | null;
  onSelect: (sel: SidebarSelection) => void;
  /** Set of task ids that currently have pending user questions. */
  pendingTaskIds: Set<string>;
  /** Whether any pending question is associated with this directive. */
  hasPendingForDirective: boolean;
  onDirectiveContextMenu: (e: React.MouseEvent, d: DirectiveSummary) => void;
  onTaskContextMenu: (e: React.MouseEvent, t: FolderTaskRow, directiveId: string) => void;
  /** Open the inline "+ New task" form for this directive. */
  onCreateTask: (d: DirectiveSummary) => void;
  /** Trigger a quick action (start/pause/PR) on the directive. */
  onQuickAction: (d: DirectiveSummary, action: "start" | "pause" | "pr") => void;
}) {
  const dotColor = STATUS_DOT[directive.status] ?? STATUS_DOT.draft;
  const fileName = `${slugify(directive.title, directive.id.slice(0, 8))}.md`;

  // Lazy fetch full directive (with steps) only when folder is open.
  const { directive: detailed } = useDirective(open ? directive.id : undefined);

  const docSelected =
    selection?.directiveId === directive.id && selection.taskId === null;

  // Ephemeral tasks attached to this directive (no directive_step_id). Fetched
  // lazily when the folder opens; refetched whenever a poll lands on the
  // directive's detail (poll-driven freshness).
  const [ephemeralTasks, setEphemeralTasks] = useState<TaskSummary[]>([]);
  useEffect(() => {
    if (!open) return;
    let cancelled = false;
    listDirectiveEphemeralTasks(directive.id)
      .then((res) => {
        if (!cancelled) setEphemeralTasks(res.tasks);
      })
      .catch((err) => {
        // eslint-disable-next-line no-console
        console.warn("[makima] failed to load ephemeral tasks", err);
      });
    return () => {
      cancelled = true;
    };
  }, [open, directive.id, directive.updatedAt]);

  // Collect the tasks to surface in the folder body.
  const tasks = useMemo(
    () => collectTasks(detailed, directive, ephemeralTasks),
    [detailed, directive, ephemeralTasks],
  );

  const orchestratorRunning = !!directive.orchestratorTaskId;
  // Tasks subfolder open state — independent of the directive folder.
  const [tasksOpen, setTasksOpen] = useState<boolean>(true);
  // Revisions subfolder — collapsed by default since most contracts have
  // no merged history yet.
  const [revisionsOpen, setRevisionsOpen] = useState<boolean>(false);
  const [revisions, setRevisions] = useState<DirectiveRevision[]>([]);
  // Fetch revisions only when the parent folder is open. Re-fetch whenever
  // the directive's pr_url changes so a freshly-raised PR appears.
  useEffect(() => {
    if (!open) return;
    let cancelled = false;
    listDirectiveRevisions(directive.id)
      .then((res) => {
        if (!cancelled) setRevisions(res.revisions);
      })
      .catch((err) => {
        // eslint-disable-next-line no-console
        console.warn("[makima] failed to load revisions", err);
      });
    return () => {
      cancelled = true;
    };
  }, [open, directive.id, directive.prUrl]);

  // Inline action buttons on the folder header — visible on hover (and when
  // the folder is open) so users don't have to right-click to discover the
  // primary directive controls. Mirrors a code-editor sidebar's affordance.
  const showStart =
    directive.status === "draft" ||
    directive.status === "paused" ||
    directive.status === "idle" ||
    directive.status === "inactive";
  const showPause = directive.status === "active";

  return (
    <div className="select-none group/dir">
      <div
        className="w-full flex items-center gap-1.5 pl-3 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]"
        onContextMenu={(e) => onDirectiveContextMenu(e, directive)}
      >
        <button
          type="button"
          onClick={onToggle}
          title={directive.title}
          className="flex items-center gap-1.5 flex-1 min-w-0 text-left"
        >
          <Caret open={open} />
          <FolderIcon open={open} />
          <span className="truncate flex-1">{directive.title}</span>
        </button>

        {/* Hover/open-only action chips — discoverable replacement for the
            right-click menu. Right-click still works as a power-user fallback. */}
        <div
          className={`flex items-center gap-0.5 transition-opacity ${
            open
              ? "opacity-100"
              : "opacity-0 group-hover/dir:opacity-100"
          }`}
        >
          {showStart && (
            <FolderActionButton
              title="Start"
              onClick={() => onQuickAction(directive, "start")}
            >
              ▶
            </FolderActionButton>
          )}
          {showPause && (
            <FolderActionButton
              title="Pause"
              onClick={() => onQuickAction(directive, "pause")}
            >
              ❚❚
            </FolderActionButton>
          )}
          {directive.prUrl && (
            <FolderActionButton
              title="Open PR"
              onClick={() =>
                window.open(directive.prUrl ?? "", "_blank", "noreferrer")
              }
            >
              ↗
            </FolderActionButton>
          )}
          <FolderActionButton
            title="New task"
            onClick={() => onCreateTask(directive)}
          >
            +
          </FolderActionButton>
        </div>

        {/* Status dot — RIGHT side only. Glows when this directive has a
            pending user question, or pulses when the orchestrator is live. */}
        <StatusDot
          color={dotColor}
          live={orchestratorRunning}
          glow={hasPendingForDirective}
          status={directive.status}
        />
      </div>

      {open && (
        <ul className="py-0.5">
          {/* Pinned document entry — always at the top of the folder. */}
          <li>
            <button
              type="button"
              onClick={() =>
                onSelect({ directiveId: directive.id, taskId: null })
              }
              className={`w-full text-left flex items-center gap-1.5 pl-8 pr-3 py-1 font-mono text-[11px] transition-colors ${
                docSelected
                  ? "bg-[rgba(117,170,252,0.12)] text-white border-l-2 border-[#75aafc]"
                  : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent"
              }`}
            >
              <PinIcon />
              <FileIcon />
              <span className="truncate flex-1">{fileName}</span>
            </button>
          </li>

          {/* tasks/ subfolder — collapsible, contains orchestrator/completion/steps. */}
          <li>
            <button
              type="button"
              onClick={() => setTasksOpen((p) => !p)}
              className="w-full flex items-center gap-1.5 pl-8 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]"
            >
              <Caret open={tasksOpen} />
              <FolderIcon open={tasksOpen} />
              <span className="truncate flex-1 text-left">tasks/</span>
              {tasks.length > 0 && (
                <span className="text-[10px] text-[#556677]">{tasks.length}</span>
              )}
            </button>

            {tasksOpen && (
              <ul className="py-0.5">
                {tasks.length === 0 ? (
                  <li className="pl-14 pr-3 py-1 font-mono text-[10px] text-[#556677]">
                    No tasks yet
                  </li>
                ) : (
                  tasks.map((t) => {
                    const isSelected =
                      selection?.directiveId === directive.id &&
                      selection?.taskId === t.taskId;
                    const tdot = STEP_STATUS_DOT[t.status] ?? STEP_STATUS_DOT.pending;
                    const live =
                      t.status === "running" || t.kind === "orchestrator-active";
                    const glow = pendingTaskIds.has(t.taskId);
                    const Icon =
                      t.kind === "completion"
                        ? CompletionIcon
                        : t.kind === "ephemeral"
                        ? EphemeralTaskIcon
                        : TaskIcon;
                    return (
                      <li key={t.taskId}>
                        <button
                          type="button"
                          onClick={() =>
                            onSelect({
                              directiveId: directive.id,
                              taskId: t.taskId,
                            })
                          }
                          onContextMenu={(e) =>
                            onTaskContextMenu(e, t, directive.id)
                          }
                          title={t.label}
                          className={`w-full text-left flex items-center gap-1.5 pl-14 pr-3 py-1 font-mono text-[11px] transition-colors ${
                            isSelected
                              ? "bg-[rgba(117,170,252,0.12)] text-white border-l-2 border-[#75aafc]"
                              : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent"
                          }`}
                        >
                          <Icon />
                          <span className="truncate flex-1">{t.label}</span>
                          <StatusDot
                            color={tdot}
                            live={live}
                            glow={glow}
                            status={t.status}
                          />
                        </button>
                      </li>
                    );
                  })
                )}
              </ul>
            )}
          </li>

          {/* revisions/ subfolder — per-PR frozen snapshots of this contract.
              Only rendered when there's at least one revision; otherwise the
              folder body would be a confusing empty placeholder. */}
          {revisions.length > 0 && (
            <li>
              <button
                type="button"
                onClick={() => setRevisionsOpen((p) => !p)}
                className="w-full flex items-center gap-1.5 pl-8 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]"
              >
                <Caret open={revisionsOpen} />
                <FolderIcon open={revisionsOpen} />
                <span className="truncate flex-1 text-left">revisions/</span>
                <span className="text-[10px] text-[#556677]">
                  {revisions.length}
                </span>
              </button>

              {revisionsOpen && (
                <ul className="py-0.5">
                  {revisions.map((r) => {
                    const isSelected =
                      selection?.directiveId === directive.id &&
                      selection?.taskId === `revision:${r.id}`;
                    return (
                      <li key={r.id}>
                        <button
                          type="button"
                          onClick={() =>
                            onSelect({
                              directiveId: directive.id,
                              taskId: `revision:${r.id}`,
                            })
                          }
                          title={`v${r.version} · ${r.prState} · ${r.prUrl}`}
                          className={`w-full text-left flex items-center gap-1.5 pl-14 pr-3 py-1 font-mono text-[11px] transition-colors ${
                            isSelected
                              ? "bg-[rgba(117,170,252,0.12)] text-white border-l-2 border-[#75aafc]"
                              : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent"
                          }`}
                        >
                          <FileIcon />
                          <span className="truncate flex-1">
                            v{r.version}.md
                          </span>
                          <RevisionStateBadge prState={r.prState} />
                        </button>
                      </li>
                    );
                  })}
                </ul>
              )}
            </li>
          )}
        </ul>
      )}
    </div>
  );
}

/**
 * Read-only viewer for a frozen contract revision. We render the markdown as
 * plain pre-formatted text — these are immutable historical records, not
 * places to edit. A header strip shows the PR state and a deep link.
 */
function RevisionViewer({
  directiveId,
  revisionId,
}: {
  directiveId: string;
  revisionId: string;
}) {
  const [revision, setRevision] = useState<DirectiveRevision | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    setError(null);
    listDirectiveRevisions(directiveId)
      .then((res) => {
        if (cancelled) return;
        const found = res.revisions.find((r) => r.id === revisionId) ?? null;
        if (!found) setError("Revision not found");
        setRevision(found);
      })
      .catch((err) => {
        if (cancelled) return;
        setError(err instanceof Error ? err.message : String(err));
      })
      .finally(() => {
        if (!cancelled) setLoading(false);
      });
    return () => {
      cancelled = true;
    };
  }, [directiveId, revisionId]);

  if (loading) {
    return (
      <div className="flex-1 flex items-center justify-center">
        <p className="text-[#556677] font-mono text-[12px]">Loading revision…</p>
      </div>
    );
  }
  if (error || !revision) {
    return (
      <div className="flex-1 flex items-center justify-center">
        <p className="text-red-400 font-mono text-[12px]">
          {error ?? "Revision not found"}
        </p>
      </div>
    );
  }

  return (
    <div className="flex-1 flex flex-col h-full overflow-hidden bg-[#0a1628]">
      <div className="flex-1 overflow-y-auto">
        <div className="max-w-3xl mx-auto px-8 py-10 text-[#dbe7ff]">
          <div className="flex items-center gap-3 mb-1">
            <h1 className="text-[24px] font-medium text-white tracking-tight">
              v{revision.version}
            </h1>
            <RevisionStateBadge prState={revision.prState} />
          </div>
          <p className="text-[10px] font-mono text-[#556677] uppercase tracking-wide mb-1">
            Frozen {new Date(revision.frozenAt).toLocaleString()}
          </p>
          <p className="text-[11px] font-mono text-[#7788aa] mb-8">
            <a
              href={revision.prUrl}
              target="_blank"
              rel="noreferrer"
              className="text-[#75aafc] hover:text-[#9bc3ff] underline"
            >
              {revision.prUrl}
            </a>
          </p>

          {/* Render the frozen markdown as plain pre-formatted text. We
              deliberately do not parse it into rich nodes — the goal is to
              show the historical record exactly as it was at PR time. */}
          <pre className="whitespace-pre-wrap break-words font-mono text-[13px] leading-relaxed text-[#e0eaf8]">
            {revision.content}
          </pre>
        </div>
      </div>
    </div>
  );
}

/** Tiny pill showing the PR state of a revision (open / merged / closed). */
function RevisionStateBadge({ prState }: { prState: string }) {
  const tone =
    prState === "merged"
      ? "text-emerald-300 border-emerald-700/60"
      : prState === "closed"
      ? "text-[#7788aa] border-[#2a3a5a]"
      : "text-amber-300 border-amber-600/40";
  return (
    <span
      className={`text-[9px] font-mono uppercase border rounded px-1 py-0 ${tone}`}
    >
      {prState}
    </span>
  );
}

/**
 * Right-side status indicator. Composes the colored status dot with optional
 * "live" pulse (orchestrator running) and "glow" attention ring (pending user
 * question waiting on a response).
 */
function StatusDot({
  color,
  live,
  glow,
  status,
}: {
  color: string;
  live: boolean;
  glow: boolean;
  status: string;
}) {
  // The glow is a soft amber ring pulsed via box-shadow. Keep it subtle so it
  // doesn't fight the live pulse for attention when both are present.
  const ring = glow
    ? "shadow-[0_0_0_2px_rgba(251,191,36,0.45),0_0_8px_2px_rgba(251,191,36,0.55)] animate-pulse"
    : "";
  const livePulse = live && !glow ? "animate-pulse" : "";
  const title = glow
    ? `${status} — needs response`
    : live
    ? `${status} — running`
    : `status: ${status}`;
  return (
    <span
      className={`inline-block w-2 h-2 rounded-full shrink-0 ${color} ${ring} ${livePulse}`}
      aria-label={title}
      title={title}
    />
  );
}

interface FolderTaskRow {
  taskId: string;
  /** Directive step id for step kinds — needed for complete/fail/skip APIs. */
  stepId: string | null;
  label: string;
  status: string;
  kind: "orchestrator-active" | "completion" | "step" | "ephemeral";
}

function collectTasks(
  detailed: DirectiveWithSteps | null,
  summary: DirectiveSummary,
  ephemeralTasks: TaskSummary[],
): FolderTaskRow[] {
  const rows: FolderTaskRow[] = [];

  // Orchestrator (planner) — surfaces only while it's actively running so
  // the folder is not flooded with stale orchestrator entries.
  const orchestratorId =
    detailed?.orchestratorTaskId ?? summary.orchestratorTaskId ?? null;
  if (orchestratorId) {
    rows.push({
      taskId: orchestratorId,
      stepId: null,
      label: "orchestrator",
      status: "running",
      kind: "orchestrator-active",
    });
  }

  // Completion (PR creation) task.
  const completionId =
    detailed?.completionTaskId ?? summary.completionTaskId ?? null;
  if (completionId) {
    rows.push({
      taskId: completionId,
      stepId: null,
      label: "completion",
      status: "running",
      kind: "completion",
    });
  }

  // Step tasks — only steps that have actually been started have a taskId.
  if (detailed) {
    for (const step of detailed.steps) {
      if (!step.taskId) continue;
      rows.push({
        taskId: step.taskId,
        stepId: step.id,
        label: step.name,
        status: step.status,
        kind: "step",
      });
    }
  }

  // Ephemeral tasks — user-spawned spinoffs not part of the DAG. Surfaced
  // alongside step tasks but with a different icon and the "ephemeral" kind
  // so context menus and the merge button behave correctly.
  for (const t of ephemeralTasks) {
    rows.push({
      taskId: t.id,
      stepId: null,
      label: t.name,
      status: t.status,
      kind: "ephemeral",
    });
  }

  return rows;
}

interface SidebarProps {
  directives: DirectiveSummary[];
  loading: boolean;
  selection: SidebarSelection | null;
  onSelect: (sel: SidebarSelection) => void;
  onDirectiveContextMenu: (e: React.MouseEvent, d: DirectiveSummary) => void;
  onTaskContextMenu: (e: React.MouseEvent, t: FolderTaskRow, directiveId: string) => void;
  /** Open the inline "+ New task" form for this directive. */
  onCreateTask: (d: DirectiveSummary) => void;
  /** Trigger a quick action (start/pause/PR) on the directive. */
  onQuickAction: (d: DirectiveSummary, action: "start" | "pause" | "pr") => void;
  /** Navigate to an orphan (no-directive) task's standalone view. */
  onSelectOrphan: (taskId: string) => void;
}

function DocumentSidebar({
  directives,
  loading,
  selection,
  onSelect,
  onDirectiveContextMenu,
  onTaskContextMenu,
  onCreateTask,
  onQuickAction,
  onSelectOrphan,
}: SidebarProps) {
  // Orphan tasks (no directive) — top-level "tmp/" pseudo-folder. Polled
  // every 5s so newly-spawned standalone tasks appear without a manual
  // refresh.
  const [orphanTasks, setOrphanTasks] = useState<TaskSummary[]>([]);
  useEffect(() => {
    let cancelled = false;
    const load = () => {
      listOrphanTasks()
        .then((res) => {
          if (!cancelled) setOrphanTasks(res.tasks);
        })
        .catch(() => {
          /* swallow — tmp/ is a nice-to-have, never blocking */
        });
    };
    load();
    const interval = setInterval(load, 5000);
    return () => {
      cancelled = true;
      clearInterval(interval);
    };
  }, []);
  const [tmpOpen, setTmpOpen] = useState<boolean>(true);
  // Pending user questions — drives the "glow" attention ring. We split into
  // two indices so the directive folder header glows whenever ANY of its
  // tasks has a pending question, while individual task rows glow only for
  // their own question.
  const { pendingQuestions } = useSupervisorQuestions();
  const { directivesWithPending, tasksWithPending } = useMemo(() => {
    const dirs = new Set<string>();
    const tasks = new Set<string>();
    for (const q of pendingQuestions) {
      if (q.directiveId) dirs.add(q.directiveId);
      if (q.taskId) tasks.add(q.taskId);
    }
    return { directivesWithPending: dirs, tasksWithPending: tasks };
  }, [pendingQuestions]);

  // Sort active first, then idle, then paused, then drafts, then inactive
  // (shipped contracts are quieter), then archived.
  const sorted = useMemo(() => {
    const order: Record<DirectiveStatus, number> = {
      active: 0,
      paused: 1,
      idle: 2,
      draft: 3,
      inactive: 4,
      archived: 5,
    };
    return [...directives].sort((a, b) => {
      const oa = order[a.status] ?? 99;
      const ob = order[b.status] ?? 99;
      if (oa !== ob) return oa - ob;
      return a.title.localeCompare(b.title, undefined, { sensitivity: "base" });
    });
  }, [directives]);

  // Track which directive folders are open. The currently selected directive
  // is forced open so deep links land on something visible.
  const [openIds, setOpenIds] = useState<Set<string>>(new Set());
  const lastSelectedRef = useRef<string | null>(null);
  useEffect(() => {
    if (selection && selection.directiveId !== lastSelectedRef.current) {
      lastSelectedRef.current = selection.directiveId;
      setOpenIds((prev) => {
        if (prev.has(selection.directiveId)) return prev;
        const next = new Set(prev);
        next.add(selection.directiveId);
        return next;
      });
    }
  }, [selection]);

  const toggleOpen = useCallback((id: string) => {
    setOpenIds((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  }, []);

  return (
    <div className="flex flex-col h-full">
      {/* Sidebar header */}
      <div className="flex items-center justify-between px-3 py-2 border-b border-dashed border-[rgba(117,170,252,0.2)]">
        <span className="text-[11px] font-mono text-[#9bc3ff] uppercase tracking-wide">
          Contracts
        </span>
        <span className="text-[10px] font-mono text-[#556677]">
          {directives.length}
        </span>
      </div>

      {/* Top-level "contracts/" folder header (informational, non-interactive). */}
      <div className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-mono text-[#9bc3ff]">
        <FolderIcon open />
        <span>contracts/</span>
      </div>

      {/* Body */}
      <div className="flex-1 overflow-y-auto pb-4">
        {/* tmp/ pseudo-folder — orphan tasks (directive_id NULL). Always
            rendered so users can create scratchpad tasks even when zero
            directives exist; collapses to a thin header when empty. */}
        <div className="select-none border-b border-dashed border-[rgba(117,170,252,0.1)] pb-1 mb-1">
          <button
            type="button"
            onClick={() => setTmpOpen((p) => !p)}
            className="w-full flex items-center gap-1.5 pl-3 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]"
          >
            <Caret open={tmpOpen} />
            <FolderIcon open={tmpOpen} />
            <span className="truncate flex-1 text-left text-[#7788aa]">tmp/</span>
            <span className="text-[10px] text-[#556677]">
              {orphanTasks.length}
            </span>
          </button>
          {tmpOpen && (
            <ul className="py-0.5">
              {orphanTasks.length === 0 ? (
                <li className="pl-8 pr-3 py-1 font-mono text-[10px] text-[#556677] italic">
                  No orphan tasks
                </li>
              ) : (
                orphanTasks.map((t) => {
                  const tdot =
                    STEP_STATUS_DOT[t.status] ?? STEP_STATUS_DOT.pending;
                  const live = t.status === "running";
                  return (
                    <li key={t.id}>
                      <button
                        type="button"
                        onClick={() => onSelectOrphan(t.id)}
                        title={t.name}
                        className="w-full text-left flex items-center gap-1.5 pl-8 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent transition-colors"
                      >
                        <TaskIcon />
                        <span className="truncate flex-1">{t.name}</span>
                        <StatusDot
                          color={tdot}
                          live={live}
                          glow={false}
                          status={t.status}
                        />
                      </button>
                    </li>
                  );
                })
              )}
            </ul>
          )}
        </div>

        {loading && directives.length === 0 ? (
          <div className="px-3 py-6 text-center text-[#556677] font-mono text-[11px]">
            Loading...
          </div>
        ) : directives.length === 0 ? (
          <div className="px-3 py-6 text-center text-[#556677] font-mono text-[11px]">
            No contracts yet
          </div>
        ) : (
          sorted.map((d) => (
            <DirectiveFolder
              key={d.id}
              directive={d}
              open={openIds.has(d.id)}
              onToggle={() => toggleOpen(d.id)}
              selection={selection}
              onSelect={onSelect}
              pendingTaskIds={tasksWithPending}
              hasPendingForDirective={directivesWithPending.has(d.id)}
              onDirectiveContextMenu={onDirectiveContextMenu}
              onTaskContextMenu={onTaskContextMenu}
              onCreateTask={onCreateTask}
              onQuickAction={onQuickAction}
            />
          ))
        )}
      </div>
    </div>
  );
}

// =============================================================================
// Editor shell — wraps DocumentEditor and handles the "no document selected"
// and loading states.
// =============================================================================

/**
 * Wraps DocumentTaskStream with ephemeral-aware metadata. Determines whether
 * the selected task is part of the directive's DAG (orchestrator/completion/
 * steps) or an ephemeral spinoff, and looks up its current status from the
 * ephemeral list — that decides whether the "Merge to base" affordance
 * should appear in the stream's action header.
 */
function EphemeralAwareTaskStream({
  taskId,
  label,
  directive,
}: {
  taskId: string;
  label: string;
  directive: DirectiveWithSteps;
}) {
  const isStepBound =
    taskId === directive.orchestratorTaskId ||
    taskId === directive.completionTaskId ||
    directive.steps.some((s) => s.taskId === taskId);

  // Status lookup for ephemeral tasks. We poll the ephemeral list lazily —
  // this is a lightweight call and only triggers when the user is viewing a
  // task in the editor pane.
  const [ephemeralStatus, setEphemeralStatus] = useState<string | undefined>();
  useEffect(() => {
    if (isStepBound) return;
    let cancelled = false;
    const load = () => {
      listDirectiveEphemeralTasks(directive.id)
        .then((res) => {
          if (cancelled) return;
          const match = res.tasks.find((t) => t.id === taskId);
          setEphemeralStatus(match?.status);
        })
        .catch(() => {
          /* non-blocking */
        });
    };
    load();
    const interval = setInterval(load, 5000);
    return () => {
      cancelled = true;
      clearInterval(interval);
    };
  }, [taskId, directive.id, isStepBound]);

  return (
    <DocumentTaskStream
      taskId={taskId}
      label={label}
      ephemeral={!isStepBound}
      status={ephemeralStatus}
    />
  );
}

interface EditorShellProps {
  selectedId: string | undefined;
  selectedTaskId: string | null;
  hasDirectives: boolean;
  listLoading: boolean;
  onClearTask: () => void;
}

function EditorShell({
  selectedId,
  selectedTaskId,
  hasDirectives,
  listLoading,
  onClearTask,
}: EditorShellProps) {
  const {
    directive,
    loading,
    updateGoal,
    cleanup,
    createPR,
    pickUpOrders,
  } = useDirective(selectedId);

  if (!selectedId) {
    return (
      <div className="flex-1 flex items-center justify-center h-full">
        <p className="text-[#556677] font-mono text-[12px]">
          {listLoading
            ? "Loading contracts..."
            : hasDirectives
            ? "Select a contract from the sidebar"
            : "No contracts yet — create one from the legacy UI"}
        </p>
      </div>
    );
  }

  if (loading && !directive) {
    return (
      <div className="flex-1 flex items-center justify-center h-full">
        <p className="text-[#556677] font-mono text-[12px]">Loading contract...</p>
      </div>
    );
  }

  if (!directive) {
    return (
      <div className="flex-1 flex items-center justify-center h-full">
        <p className="text-[#7788aa] font-mono text-[12px]">Contract not found</p>
      </div>
    );
  }

  // The "task" param can encode either a real task id, or a revision via the
  // `revision:<uuid>` prefix. Split that out so the right pane can switch
  // between the live task stream and the read-only revision viewer.
  const revisionId =
    selectedTaskId && selectedTaskId.startsWith("revision:")
      ? selectedTaskId.slice("revision:".length)
      : null;
  const realTaskId = revisionId ? null : selectedTaskId;

  // Resolve the label for the breadcrumb when a task is selected.
  const taskLabel = realTaskId
    ? realTaskId === directive.orchestratorTaskId
      ? "orchestrator"
      : realTaskId === directive.completionTaskId
      ? "completion"
      : directive.steps.find((s) => s.taskId === realTaskId)?.name ??
        realTaskId.slice(0, 8)
    : revisionId
    ? "revision"
    : null;

  // "Now executing" strip — surfaces what's live when looking at the
  // contract editor, so users don't have to scan the sidebar to find it.
  const liveTask = (() => {
    if (selectedTaskId) return null; // already viewing a task; strip is redundant
    if (directive.orchestratorTaskId) {
      return { id: directive.orchestratorTaskId, name: "orchestrator" };
    }
    if (directive.completionTaskId) {
      return { id: directive.completionTaskId, name: "completion" };
    }
    const runningStep = directive.steps.find((s) => s.status === "running");
    if (runningStep && runningStep.taskId) {
      return { id: runningStep.taskId, name: runningStep.name };
    }
    return null;
  })();

  return (
    <div className="flex-1 flex flex-col h-full overflow-hidden">
      {/* Contract header — breadcrumb-like, mirrors a code editor's tab bar */}
      <div className="px-6 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)]">
        <div className="flex items-center gap-2 text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">
          <FileIcon />
          <span>contracts /</span>
          <span className="text-[#9bc3ff]">{directive.id.slice(0, 8)}</span>
          {selectedTaskId && (
            <>
              <span>/</span>
              <span className="text-[#9bc3ff]">{taskLabel}</span>
              <button
                type="button"
                onClick={onClearTask}
                className="ml-2 px-1.5 py-0.5 text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded normal-case"
              >
                back to contract
              </button>
            </>
          )}
        </div>
      </div>

      {/* Now-executing strip — only when viewing the contract doc itself.
          Click to jump into the live task transcript. */}
      {liveTask && (
        <button
          type="button"
          onClick={() =>
            // Navigate via the search-param so EditorShell switches to the
            // task stream for this live task.
            (window.location.search = `?task=${liveTask.id}`)
          }
          className="shrink-0 flex items-center gap-2 px-6 py-1.5 bg-amber-900/15 border-b border-amber-700/40 text-amber-300 font-mono text-[11px] hover:bg-amber-900/30 transition-colors"
        >
          <span className="inline-block w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
          <span className="uppercase tracking-wide text-[10px]">Now executing</span>
          <span className="text-[#dbe7ff]">{liveTask.name}</span>
          <span className="ml-auto text-[10px] text-amber-200/70">
            click to view transcript ↗
          </span>
        </button>
      )}

      {revisionId ? (
        <RevisionViewer directiveId={directive.id} revisionId={revisionId} />
      ) : realTaskId ? (
        <EphemeralAwareTaskStream
          taskId={realTaskId}
          label={taskLabel ?? realTaskId.slice(0, 8)}
          directive={directive}
        />
      ) : (
        <DocumentEditor
          directive={directive}
          onUpdateGoal={async (goal) => {
            await updateGoal(goal);
          }}
          onCleanup={async () => {
            await cleanup();
          }}
          onCreatePR={async () => {
            await createPR();
          }}
          onPickUpOrders={async () => {
            await pickUpOrders();
          }}
        />
      )}
    </div>
  );
}

// =============================================================================
// Page
// =============================================================================

type ContextMenuState =
  | { kind: "directive"; x: number; y: number; directive: DirectiveSummary }
  | {
      kind: "task";
      x: number;
      y: number;
      task: FolderTaskRow;
      directiveId: string;
    }
  | null;

export default function DocumentDirectivesPage() {
  const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth();
  const navigate = useNavigate();
  const { id: selectedId } = useParams<{ id: string }>();
  const [searchParams, setSearchParams] = useSearchParams();
  const selectedTaskId = searchParams.get("task");
  const { directives, loading: listLoading, refresh: refreshList } = useDirectives();
  const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);

  useEffect(() => {
    if (!authLoading && isAuthConfigured && !isAuthenticated) {
      navigate("/login");
    }
  }, [authLoading, isAuthConfigured, isAuthenticated, navigate]);

  const onSelect = useCallback(
    (sel: SidebarSelection) => {
      const next = `/directives/${sel.directiveId}${
        sel.taskId ? `?task=${sel.taskId}` : ""
      }`;
      navigate(next);
    },
    [navigate],
  );

  const onClearTask = useCallback(() => {
    const next = new URLSearchParams(searchParams);
    next.delete("task");
    setSearchParams(next, { replace: true });
  }, [searchParams, setSearchParams]);

  const onDirectiveContextMenu = useCallback(
    (e: React.MouseEvent, d: DirectiveSummary) => {
      e.preventDefault();
      e.stopPropagation();
      setContextMenu({ kind: "directive", x: e.clientX, y: e.clientY, directive: d });
    },
    [],
  );

  const onTaskContextMenu = useCallback(
    (e: React.MouseEvent, task: FolderTaskRow, directiveId: string) => {
      e.preventDefault();
      e.stopPropagation();
      setContextMenu({ kind: "task", x: e.clientX, y: e.clientY, task, directiveId });
    },
    [],
  );

  const closeContextMenu = useCallback(() => setContextMenu(null), []);

  // Inline "+ New task" form state. When set, we render a small modal-ish
  // overlay anchored to the directive folder; submitting calls the
  // ephemeral-task endpoint.
  const [newTaskFor, setNewTaskFor] = useState<DirectiveSummary | null>(null);

  const onCreateTask = useCallback((d: DirectiveSummary) => {
    setNewTaskFor(d);
  }, []);

  const handleSubmitNewTask = useCallback(
    async (name: string, plan: string) => {
      if (!newTaskFor) return;
      try {
        const task = await createDirectiveTask(newTaskFor.id, { name, plan });
        // Navigate the user into the freshly-spawned task's transcript.
        navigate(`/directives/${newTaskFor.id}?task=${task.id}`);
        setNewTaskFor(null);
      } catch (err) {
        // eslint-disable-next-line no-console
        console.error("[makima] failed to create ephemeral task", err);
        alert(
          err instanceof Error
            ? `Failed to create task: ${err.message}`
            : "Failed to create task",
        );
      }
    },
    [newTaskFor, navigate],
  );

  const onQuickAction = useCallback(
    async (d: DirectiveSummary, action: "start" | "pause" | "pr") => {
      try {
        if (action === "start") {
          await startDirective(d.id);
        } else if (action === "pause") {
          await pauseDirective(d.id);
        } else if (action === "pr") {
          await createDirectivePR(d.id);
        }
        await refreshList();
      } catch (err) {
        // eslint-disable-next-line no-console
        console.error(`[makima] quick action ${action} failed`, err);
      }
    },
    [refreshList],
  );

  const onSelectOrphan = useCallback(
    (taskId: string) => {
      navigate(`/tmp/${taskId}`);
    },
    [navigate],
  );

  if (authLoading) {
    return (
      <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
        <Masthead showNav />
        <main className="flex-1 flex items-center justify-center">
          <p className="text-[#7788aa] font-mono text-sm">Loading...</p>
        </main>
      </div>
    );
  }

  const selection: SidebarSelection | null = selectedId
    ? { directiveId: selectedId, taskId: selectedTaskId }
    : null;

  return (
    <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
      <Masthead showNav />
      <main
        className="flex-1 flex overflow-hidden"
        style={{ height: "calc(100vh - 80px)" }}
      >
        {/* Left: file-tree sidebar */}
        <div className="w-[260px] shrink-0 border-r border-dashed border-[rgba(117,170,252,0.2)] overflow-hidden flex flex-col bg-[#091428]">
          <DocumentSidebar
            directives={directives}
            loading={listLoading}
            selection={selection}
            onSelect={onSelect}
            onDirectiveContextMenu={onDirectiveContextMenu}
            onTaskContextMenu={onTaskContextMenu}
            onCreateTask={onCreateTask}
            onQuickAction={onQuickAction}
            onSelectOrphan={onSelectOrphan}
          />
        </div>

        {/* Right: Lexical editor / task stream */}
        <EditorShell
          selectedId={selectedId}
          selectedTaskId={selectedTaskId}
          hasDirectives={directives.length > 0}
          listLoading={listLoading}
          onClearTask={onClearTask}
        />
      </main>

      {/* Context menus — rendered at page level so they overlay everything. */}
      {contextMenu?.kind === "directive" && (
        <DirectiveContextMenu
          x={contextMenu.x}
          y={contextMenu.y}
          directive={contextMenu.directive}
          onClose={closeContextMenu}
          onStart={async () => {
            await startDirective(contextMenu.directive.id);
            await refreshList();
          }}
          onPause={async () => {
            await pauseDirective(contextMenu.directive.id);
            await refreshList();
          }}
          onArchive={async () => {
            await updateDirective(contextMenu.directive.id, {
              status: "archived",
            });
            await refreshList();
          }}
          onDelete={async () => {
            if (
              !window.confirm(
                `Delete "${contextMenu.directive.title}"? This cannot be undone.`,
              )
            ) {
              return;
            }
            await deleteDirective(contextMenu.directive.id);
            await refreshList();
            // If the deleted one was selected, clear selection.
            if (selectedId === contextMenu.directive.id) {
              navigate("/directives");
            }
          }}
          onGoToPR={() => {
            if (contextMenu.directive.prUrl) {
              window.open(contextMenu.directive.prUrl, "_blank", "noreferrer");
            }
          }}
          onNewDraft={async () => {
            await newDirectiveDraft(contextMenu.directive.id);
            await refreshList();
            // Send the user into the freshly-cleared contract so they can
            // start typing the next iteration immediately.
            navigate(`/directives/${contextMenu.directive.id}`);
          }}
          onCreatePR={async () => {
            await createDirectivePR(contextMenu.directive.id);
            await refreshList();
          }}
          onAdvance={async () => {
            await advanceDirective(contextMenu.directive.id);
            await refreshList();
          }}
          onCleanup={async () => {
            await cleanupDirective(contextMenu.directive.id);
            await refreshList();
          }}
          onPickUpOrders={async () => {
            await pickUpOrders(contextMenu.directive.id);
            await refreshList();
          }}
        />
      )}
      {contextMenu?.kind === "task" && (
        <TaskContextMenu
          x={contextMenu.x}
          y={contextMenu.y}
          task={contextMenu.task}
          onClose={closeContextMenu}
          onInterrupt={async () => {
            try {
              await stopTask(contextMenu.task.taskId);
            } catch (err) {
              // eslint-disable-next-line no-console
              console.error("[makima] failed to interrupt task", err);
            }
            await refreshList();
          }}
          onComplete={async () => {
            if (!contextMenu.task.stepId) return;
            await completeDirectiveStep(
              contextMenu.directiveId,
              contextMenu.task.stepId,
            );
            await refreshList();
          }}
          onFail={async () => {
            if (!contextMenu.task.stepId) return;
            await failDirectiveStep(
              contextMenu.directiveId,
              contextMenu.task.stepId,
            );
            await refreshList();
          }}
          onSkip={async () => {
            if (!contextMenu.task.stepId) return;
            await skipDirectiveStep(
              contextMenu.directiveId,
              contextMenu.task.stepId,
            );
            await refreshList();
          }}
          onSendMessage={async () => {
            // Browser prompt is the lightest-weight surface that doesn't
            // require redesigning a modal. The same comment box is also
            // available below the live transcript when the task is selected.
            const message = window.prompt("Send message to task:");
            if (!message || !message.trim()) return;
            try {
              await sendTaskMessage(contextMenu.task.taskId, message.trim());
            } catch (err) {
              // eslint-disable-next-line no-console
              console.error("[makima] failed to send task message", err);
            }
          }}
          onOpenInTaskPage={() => {
            // The standalone /exec/:taskId page has the full task UI with
            // worktree diff viewer, checkpoint controls, etc.
            navigate(`/exec/${contextMenu.task.taskId}`);
          }}
        />
      )}

      {newTaskFor && (
        <NewTaskModal
          directive={newTaskFor}
          onClose={() => setNewTaskFor(null)}
          onSubmit={handleSubmitNewTask}
        />
      )}
    </div>
  );
}

/**
 * Inline "+ New task" form for spawning an ephemeral task under a
 * directive. Surfaced as a centered modal, dismissible with Esc / click-out.
 */
function NewTaskModal({
  directive,
  onClose,
  onSubmit,
}: {
  directive: DirectiveSummary;
  onClose: () => void;
  onSubmit: (name: string, plan: string) => Promise<void>;
}) {
  const [name, setName] = useState("");
  const [plan, setPlan] = useState("");
  const [submitting, setSubmitting] = useState(false);
  const nameRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    nameRef.current?.focus();
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") onClose();
    };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [onClose]);

  const submit = async (e: React.FormEvent) => {
    e.preventDefault();
    const trimmedName = name.trim();
    const trimmedPlan = plan.trim();
    if (!trimmedName || !trimmedPlan || submitting) return;
    setSubmitting(true);
    try {
      await onSubmit(trimmedName, trimmedPlan);
    } finally {
      setSubmitting(false);
    }
  };

  return (
    <div
      className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
      onClick={onClose}
    >
      <form
        onSubmit={submit}
        onClick={(e) => e.stopPropagation()}
        className="w-[480px] max-w-[90vw] bg-[#0a1628] border border-[rgba(117,170,252,0.4)] shadow-xl flex flex-col"
      >
        <div className="px-4 py-3 border-b border-dashed border-[rgba(117,170,252,0.25)]">
          <p className="text-[10px] font-mono text-[#556677] uppercase tracking-wide">
            New task in
          </p>
          <p className="text-[12px] font-mono text-white truncate">
            {directive.title}
          </p>
        </div>
        <div className="px-4 py-4 space-y-3">
          <div className="space-y-1">
            <label className="text-[10px] font-mono text-[#7788aa] uppercase tracking-wide">
              Name
            </label>
            <input
              ref={nameRef}
              type="text"
              value={name}
              onChange={(e) => setName(e.target.value)}
              placeholder="e.g. Investigate flaky test in auth.test.ts"
              className="w-full bg-transparent border border-[rgba(117,170,252,0.2)] focus:border-[#75aafc] outline-none px-3 py-2 text-[13px] text-[#dbe7ff] placeholder-[#445566]"
            />
          </div>
          <div className="space-y-1">
            <label className="text-[10px] font-mono text-[#7788aa] uppercase tracking-wide">
              Plan / instructions
            </label>
            <textarea
              value={plan}
              onChange={(e) => setPlan(e.target.value)}
              onKeyDown={(e) => {
                if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
                  void submit(e as unknown as React.FormEvent);
                }
              }}
              rows={5}
              placeholder="What should the task do?"
              className="w-full bg-transparent border border-[rgba(117,170,252,0.2)] focus:border-[#75aafc] outline-none px-3 py-2 text-[13px] text-[#dbe7ff] placeholder-[#445566] resize-none"
            />
          </div>
        </div>
        <div className="px-4 py-3 border-t border-dashed border-[rgba(117,170,252,0.25)] flex items-center justify-end gap-2">
          <button
            type="button"
            onClick={onClose}
            className="px-3 py-1.5 text-[11px] font-mono uppercase tracking-wide text-[#7788aa] border border-[#2a3a5a] hover:text-white"
          >
            Cancel
          </button>
          <button
            type="submit"
            disabled={!name.trim() || !plan.trim() || submitting}
            className="px-3 py-1.5 text-[11px] font-mono uppercase tracking-wide text-emerald-300 border border-emerald-700/60 hover:border-emerald-400 disabled:opacity-40 disabled:cursor-not-allowed"
          >
            {submitting ? "Creating…" : "Spawn task"}
          </button>
        </div>
      </form>
    </div>
  );
}