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 = { 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 ( ); } /** 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 ( ); } /** PR-bracket icon for the completion task. */ function CompletionIcon() { return ( ); } function PinIcon() { return ( ); } /** Tiny chip used for the inline directive-folder hover actions. */ function FolderActionButton({ children, title, onClick, }: { children: React.ReactNode; title: string; onClick: () => void; }) { 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; } /** * 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; /** 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([]); 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(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]); // 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 (
onDirectiveContextMenu(e, directive)} > {/* Hover/open-only action chips — discoverable replacement for the right-click menu. Right-click still works as a power-user fallback. */}
{showStart && ( onQuickAction(directive, "start")} > ▶ )} {showPause && ( onQuickAction(directive, "pause")} > ❚❚ )} {directive.prUrl && ( window.open(directive.prUrl ?? "", "_blank", "noreferrer") } > ↗ )} onCreateTask(directive)} > +
{/* Status dot — RIGHT side only. Glows when this directive has a pending user question, or pulses when the orchestrator is live. */}
{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 : t.kind === "ephemeral" ? EphemeralTaskIcon : 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" | "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([]); 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(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(); 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 */}
{/* 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. */}
{tmpOpen && (
    {orphanTasks.length === 0 ? (
  • No orphan tasks
  • ) : ( orphanTasks.map((t) => { const tdot = STEP_STATUS_DOT[t.status] ?? STEP_STATUS_DOT.pending; const live = t.status === "running"; return (
  • ); }) )}
)}
{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} onCreateTask={onCreateTask} onQuickAction={onQuickAction} /> )) )}
); } // ============================================================================= // 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(); 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 ( ); } 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; // "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 (
{/* Contract header — breadcrumb-like, mirrors a code editor's tab bar */}
contracts / {directive.id.slice(0, 8)} {selectedTaskId && ( <> / {taskLabel} )}
{/* Now-executing strip — only when viewing the contract doc itself. Click to jump into the live task transcript. */} {liveTask && ( )} {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), []); // 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(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 (

Loading...

); } const selection: SidebarSelection | null = selectedId ? { directiveId: selectedId, taskId: selectedTaskId } : null; return ( // h-screen + overflow-hidden so the page itself never scrolls; the // sidebar and editor pane each manage their own scroll via flex-1 // children with overflow-y-auto. Previously we set // height: calc(100vh - 80px) on
, which assumed an 80px masthead // and quietly clipped content when the masthead was taller (or pushed // the page below the viewport on shorter screens, which made the // whole page scroll instead of the sidebar/editor independently).
{/* Left: file-tree sidebar — independent scroll. */}
{/* 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}`); }} /> )} {newTaskFor && ( setNewTaskFor(null)} onSubmit={handleSubmitNewTask} /> )}
); } /** * 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; }) { const [name, setName] = useState(""); const [plan, setPlan] = useState(""); const [submitting, setSubmitting] = useState(false); const nameRef = useRef(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 (
e.stopPropagation()} className="w-[480px] max-w-[90vw] bg-[#0a1628] border border-[rgba(117,170,252,0.4)] shadow-xl flex flex-col" >

New task in

{directive.title}

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]" />