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, } from "../lib/api"; import type { DirectiveStatus, DirectiveSummary, DirectiveWithSteps, } 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 = { draft: "bg-[#556677]", active: "bg-green-400", idle: "bg-yellow-400", paused: "bg-orange-400", archived: "bg-[#3a4a6a]", }; // Per-task dot color for the sidebar entries inside a directive folder. // Matches the StepsBlockNode palette. const STEP_STATUS_DOT: Record = { 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 ( {open ? ( ) : ( )} ); } function FileIcon() { return ( ); } /** Terminal/prompt icon for orchestrator and step tasks. */ function TaskIcon() { return ( ); } /** PR-bracket icon for the completion task. */ function CompletionIcon() { return ( ); } function PinIcon() { return ( ); } function Caret({ open }: { open: boolean }) { return ( ); } // ============================================================================= // 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; } function TaskContextMenu({ x, y, task, onClose, onInterrupt, onComplete, onFail, onSkip, }: TaskContextMenuProps) { const ref = useRef(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 (
{task.kind === "orchestrator-active" ? "Orchestrator" : task.kind === "completion" ? "Completion" : task.label}
{showInterrupt && ( )} {(showComplete || showFail || showSkip) &&
} {showComplete && ( )} {showFail && ( )} {showSkip && ( )}
); } 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; } interface SidebarProps { directives: DirectiveSummary[]; loading: boolean; selection: SidebarSelection | null; onSelect: (sel: SidebarSelection) => void; } /** * 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, }: { 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; /** 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; }) { 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; // Collect the tasks to surface in the folder body. const tasks = useMemo(() => collectTasks(detailed, directive), [detailed, directive]); const orchestratorRunning = !!directive.orchestratorTaskId; // Tasks subfolder open state — independent of the directive folder. const [tasksOpen, setTasksOpen] = useState(true); return (
{open && (
    {/* Pinned document entry — always at the top of the folder. */}
  • {/* tasks/ subfolder — collapsible, contains orchestrator/completion/steps. */}
  • {tasksOpen && (
      {tasks.length === 0 ? (
    • No tasks yet
    • ) : ( 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 : TaskIcon; return (
    • ); }) )}
    )}
)}
); } /** * 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 ( ); } 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"; } function collectTasks( detailed: DirectiveWithSteps | null, summary: DirectiveSummary, ): 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", }); } } 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; } function DocumentSidebar({ directives, loading, selection, onSelect, onDirectiveContextMenu, onTaskContextMenu, }: SidebarProps) { // 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(); const tasks = new Set(); 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 archived. const sorted = useMemo(() => { const order: Record = { active: 0, paused: 1, idle: 2, draft: 3, archived: 4, }; 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>(new Set()); const lastSelectedRef = useRef(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 (
{/* Sidebar header */}
Contracts {directives.length}
{/* Top-level "contracts/" folder header (informational, non-interactive). */}
contracts/
{/* Body */}
{loading && directives.length === 0 ? (
Loading...
) : directives.length === 0 ? (
No contracts yet
) : ( sorted.map((d) => ( toggleOpen(d.id)} selection={selection} onSelect={onSelect} pendingTaskIds={tasksWithPending} hasPendingForDirective={directivesWithPending.has(d.id)} onDirectiveContextMenu={onDirectiveContextMenu} onTaskContextMenu={onTaskContextMenu} /> )) )}
); } // ============================================================================= // Editor shell — wraps DocumentEditor and handles the "no document selected" // and loading states. // ============================================================================= 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 (

{listLoading ? "Loading contracts..." : hasDirectives ? "Select a contract from the sidebar" : "No contracts yet — create one from the legacy UI"}

); } if (loading && !directive) { return (

Loading contract...

); } if (!directive) { return (

Contract not found

); } // Resolve the label for the breadcrumb when a task is selected. const taskLabel = selectedTaskId ? selectedTaskId === directive.orchestratorTaskId ? "orchestrator" : selectedTaskId === directive.completionTaskId ? "completion" : directive.steps.find((s) => s.taskId === selectedTaskId)?.name ?? selectedTaskId.slice(0, 8) : null; return (
{/* Contract header — breadcrumb-like, mirrors a code editor's tab bar */}
contracts / {directive.id.slice(0, 8)} {selectedTaskId && ( <> / {taskLabel} )} {!selectedTaskId && !!directive.orchestratorTaskId && ( orchestrator running )}
{selectedTaskId ? ( ) : ( { await updateGoal(goal); }} onCleanup={async () => { await cleanup(); }} onCreatePR={async () => { await createPR(); }} onPickUpOrders={async () => { await pickUpOrders(); }} /> )}
); } // ============================================================================= // 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(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), []); if (authLoading) { return (

Loading...

); } const selection: SidebarSelection | null = selectedId ? { directiveId: selectedId, taskId: selectedTaskId } : null; return (
{/* Left: file-tree sidebar */}
{/* Right: Lexical editor / task stream */} 0} listLoading={listLoading} onClearTask={onClearTask} />
{/* Context menus — rendered at page level so they overlay everything. */} {contextMenu?.kind === "directive" && ( { 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"); } }} /> )} {contextMenu?.kind === "task" && ( { 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(); }} /> )}
); }