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, } from "../lib/api"; import type { DirectiveStatus, DirectiveSummary, DirectiveWithSteps, DirectiveRevision, } 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", 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 = { 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; /** 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(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 && ( )} {/* Direct task-page actions: send-message and open-in-task-page mirror what the standalone /exec/:taskId page exposes. */} {(onSendMessage || onOpenInTaskPage) &&
} {onSendMessage && ( )} {onOpenInTaskPage && ( )}
); } 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); // Revisions subfolder — collapsed by default since most contracts have // no merged history yet. const [revisionsOpen, setRevisionsOpen] = useState(false); const [revisions, setRevisions] = useState([]); // 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]); 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 (
    • ); }) )}
    )}
  • {/* 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 && (
  • {revisionsOpen && (
      {revisions.map((r) => { const isSelected = selection?.directiveId === directive.id && selection?.taskId === `revision:${r.id}`; return (
    • ); })}
    )}
  • )}
)}
); } /** * 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(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(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 (

Loading revision…

); } if (error || !revision) { return (

{error ?? "Revision not found"}

); } return (

v{revision.version}

Frozen {new Date(revision.frozenAt).toLocaleString()}

{revision.prUrl}

{/* 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. */}
            {revision.content}
          
); } /** 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 ( {prState} ); } /** * 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 drafts, then inactive // (shipped contracts are quieter), then archived. const sorted = useMemo(() => { const order: Record = { 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>(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

); } // The "task" param can encode either a real task id, or a revision via the // `revision:` 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; 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 )}
{revisionId ? ( ) : realTaskId ? ( ) : ( { 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"); } }} 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" && ( { 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}`); }} /> )}
); }