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 { DocumentEditor } from "../components/directives/DocumentEditor"; import { type DirectiveSummary, type DirectiveStatus, type DirectiveContract as Contract, type DirectiveContractStatus as ContractStatus, type DirectiveStep, type Task, type DirectiveContractTasksResponse as ContractTasksResponse, type DirectiveContractMergeMode as ContractMergeMode, listDirectiveContracts as listContracts, createDirectiveContract as createContract, getDirectiveContract as getContract, updateDirectiveContract as updateContract, listDirectiveContractTasks as listContractTasks, startDirectiveContract, pauseDirectiveContract, unlockDirectiveContract, reopenDirectiveContract, reorderDirectiveContract, createDirectiveTask, startDirective, pauseDirective, updateDirective, deleteDirective, createDirectivePR, advanceDirective, cleanupDirective, pickUpOrders, stopTask, skipDirectiveStep, } from "../lib/api"; import { SidebarContextMenu, type ContextMenuItem } from "../components/SidebarContextMenu"; import { TaskPage } from "../components/directives/TaskPage"; // 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-contract status palette. Active = bright green (currently driving // daemons); queued = amber (locked, waiting for the active slot); draft // = grey (editable spec); shipped = muted blue (work done); archived = // faint navy. const DOC_STATUS_DOT: Record = { draft: "bg-[#556677]", queued: "bg-amber-400", active: "bg-green-400", shipped: "bg-[#75aafc]", archived: "bg-[#3a4a6a]", }; // ============================================================================= // Sidebar grouping — group directives by lifecycle stage so the file tree // reads like a folder per status. We collapse the noisy ones (Archived) by // default and keep Active / Idle expanded. // ============================================================================= // Sidebar is now a flat list ordered by status precedence — see // `sortedDirectives` in DocumentSidebar. Status is shown as a colored dot // on the right of each row, no per-status grouping. // Slugify a document title for the displayed `.md` filename, falling back to // the directive title and finally the document id slice when the title is // empty. Mirrors the file-naming fix from step 1 (use the user-readable label // rather than just an id slice). Accepts either a DirectiveSummary or a full // DirectiveWithSteps — only `title` is read. function fileLabel( doc: Contract, directive: { title: string }, ): string { const docTitle = doc.title.trim(); if (docTitle.length > 0) return docTitle; const dirTitle = directive.title.trim(); if (dirTitle.length > 0) return dirTitle; return doc.id.slice(0, 8); } // ============================================================================= // Sidebar icons (inline SVG, no new deps) // ============================================================================= function FolderIcon({ open = false }: { open?: boolean }) { return ( {open ? ( ) : ( )} ); } function FileIcon() { return ( ); } function Caret({ open }: { open: boolean }) { return ( ); } // ============================================================================= // SidebarSelection — exactly one of taskId/documentId is non-null. taskId is // reserved for a future "task selection" feature (we expose it in the URL // already so the param shape is stable). documentId picks one of the // directive's documents. // ============================================================================= interface SidebarSelection { directiveId: string; taskId: string | null; documentId: string | null; } // ============================================================================= // Per-directive folder — renders as a collapsible folder containing the // directive's documents. Loads documents lazily on first open (mirroring the // pattern from step 1's DirectiveFolder, which fetched the full directive // only when expanded). // ============================================================================= interface DirectiveFolderProps { directive: DirectiveSummary; open: boolean; onToggle: () => void; /** Called when the user clicks the folder header itself (after toggle). */ onHeaderClick: () => void; selection: SidebarSelection | null; onSelectDocument: (directiveId: string, doc: Contract) => void; /** Right-click handler — opens the generic context menu with items * built for the given entity type. */ onContextMenuDirective: (e: React.MouseEvent, directive: DirectiveSummary) => void; onContextMenuContract: (e: React.MouseEvent, contract: Contract) => void; onContextMenuStep: (e: React.MouseEvent, step: DirectiveStep, directiveId: string) => void; onContextMenuTask: (e: React.MouseEvent, task: Task, directiveId: string) => void; /** Click handler for task/step rows — navigates to the live transcript. */ onSelectTask: (directiveId: string, taskId: string) => void; /** * Document refresh trigger — bumped externally so the folder refetches its * document list after a create/update happens elsewhere. Primarily used so * a freshly-created document shows up immediately. */ refreshNonce: number; } function DirectiveFolder({ directive, open, onToggle, onHeaderClick, selection, onSelectDocument, onContextMenuDirective, onContextMenuContract, onContextMenuStep, onContextMenuTask, onSelectTask, refreshNonce, }: DirectiveFolderProps) { const selectedTaskIdForFolder = selection && selection.directiveId === directive.id ? selection.taskId : null; const dotColor = STATUS_DOT[directive.status] ?? STATUS_DOT.draft; const orchestratorRunning = !!directive.orchestratorTaskId; // Documents fetched lazily on open. We deliberately scope the fetch to the // open-state so closed folders don't pay the network cost on initial render. const [docs, setDocs] = useState(null); const [docsLoading, setDocsLoading] = useState(false); const [docsError, setDocsError] = useState(null); // shipped/ subfolder open state — independent of the directive folder. const [shippedOpen, setShippedOpen] = useState(false); const refresh = useCallback(async () => { setDocsLoading(true); setDocsError(null); try { const list = await listContracts(directive.id); setDocs(list); } catch (e) { setDocsError(e instanceof Error ? e.message : "Failed to load documents"); } finally { setDocsLoading(false); } }, [directive.id]); // Fetch on open; refetch when refreshNonce bumps and the folder is open. useEffect(() => { if (!open) return; void refresh(); }, [open, refresh, refreshNonce]); // Split the documents into the two visual groups. Memoised so we don't // recompute on every render. const { activeDocs, shippedDocs } = useMemo(() => { const active: Contract[] = []; const shipped: Contract[] = []; for (const d of docs ?? []) { if (d.status === "shipped" || d.status === "archived") { shipped.push(d); } else { active.push(d); } } // Stable order: by createdAt ascending so the first row is the oldest doc. active.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); shipped.sort((a, b) => (b.shippedAt ?? "").localeCompare(a.shippedAt ?? "")); return { activeDocs: active, shippedDocs: shipped }; }, [docs]); // When a directive owns more than one contract, the two `tasks/` folders // would otherwise be ambiguous. We pass this down to DocumentTasksFolder // so it can rename itself to `tasks - /` for clarity. const multipleContracts = activeDocs.length + shippedDocs.length > 1; // Selection helpers — used to highlight the currently-selected doc row. const selectedDocumentId = selection && selection.directiveId === directive.id ? selection.documentId : null; // Drag-to-reorder state — only active contracts (draft/queued/active) are // reorderable. The drag id lives in the folder so the drop target can read // it from props. `dragOverId` powers the visual indicator on hover. const [dragId, setDragId] = useState(null); const [dragOverId, setDragOverId] = useState(null); const handleReorder = useCallback( async (draggedId: string, targetDoc: Contract) => { setDragId(null); setDragOverId(null); // No-op if dropping on self. if (draggedId === targetDoc.id) return; try { await reorderDirectiveContract(draggedId, targetDoc.position); await refresh(); } catch (e) { // Fail open — sidebar will refresh on next refreshNonce bump. // eslint-disable-next-line no-console console.error("Reorder failed", e); } }, [refresh], ); return (
{/* Directive folder header. Status is shown as a colored dot on the RIGHT (per the user's spec — flat list, no per-status grouping). Right-click opens the context menu (start / pause / archive / delete / create-PR / update-PR / etc.). Row is a div with onClick (not a {orchestratorRunning && ( )} {/* Status indicator on the RIGHT side of the row. */}
{/* Folder body — rendered only when open */} {open && (
{docsLoading && !docs && (
Loading documents…
)} {docsError && (
{docsError}
)} {/* Active group */} {docs && ( <> {/* "New contract" / "New ephemeral task" used to be rendered here as inline buttons. They're now reachable via the directive folder's right-click context menu and the `+` hover-button on the directive header (see DirectiveFolder row above). Keeping the file tree free of action rows makes the hierarchy easier to scan. */} {activeDocs.length === 0 && !docsLoading && (
no contracts yet — right-click to add
)} {activeDocs.map((doc) => ( // Each active document gets its own tasks/ subfolder // immediately below it. Active docs default-open the // folder so the user sees their live work without an // extra click.
onSelectDocument(directive.id, doc)} onContextMenu={onContextMenuContract} draggable onDragStart={() => setDragId(doc.id)} onDragEnd={() => { setDragId(null); setDragOverId(null); }} onDragOver={() => { if (dragId && dragId !== doc.id) setDragOverId(doc.id); }} onDragLeave={() => { if (dragOverId === doc.id) setDragOverId(null); }} onDrop={() => { if (dragId) void handleReorder(dragId, doc); }} dragOver={dragOverId === doc.id} />
))} {/* shipped/ subfolder — only rendered when at least one shipped or archived doc exists. Hidden entirely otherwise so empty directives stay tidy. */} {shippedDocs.length > 0 && (
{shippedOpen && shippedDocs.map((doc) => ( // Shipped docs render the doc row + its frozen // tasks/ subfolder. The tasks/ folder defaults // closed (history) so it doesn't dominate the // sidebar; users can click to inspect what work // produced this shipped contract.
onSelectDocument(directive.id, doc)} onContextMenu={onContextMenuContract} indent="deep" />
))}
)} )}
)} ); } // ============================================================================= // DocumentRow — one row inside a directive folder. The indent depth differs // between active rows (one level deep) and shipped rows (two levels deep). // ============================================================================= interface DocumentRowProps { doc: Contract; directive: DirectiveSummary; selected: boolean; onSelect: () => void; onContextMenu: (e: React.MouseEvent, contract: Contract) => void; indent?: "normal" | "deep"; // ----- Drag-to-reorder props (optional — only wired by the active list) --- /** Whether this row participates in HTML5 drag (active docs only). */ draggable?: boolean; onDragStart?: () => void; onDragEnd?: () => void; onDragOver?: () => void; onDragLeave?: () => void; onDrop?: () => void; /** True while a drag is hovering over this row — drives the drop indicator. */ dragOver?: boolean; } function DocumentRow({ doc, directive, selected, onSelect, onContextMenu, indent = "normal", draggable = false, onDragStart, onDragEnd, onDragOver, onDragLeave, onDrop, dragOver = false, }: DocumentRowProps) { const dot = DOC_STATUS_DOT[doc.status] ?? DOC_STATUS_DOT.draft; const padLeft = indent === "deep" ? "pl-[88px]" : "pl-14"; const name = `${fileLabel(doc, directive)}.md`; // Drop-indicator: a top border accent on the hovered target row. const dropAccent = dragOver ? "border-t-2 border-t-emerald-400" : "border-t-2 border-t-transparent"; return ( ); } // ============================================================================= // Per-document tasks/ subfolder — fetches the steps + ephemeral tasks for a // single document and renders a collapsible `tasks/` row beneath the // document. Lazy: fetch only fires once the user opens the folder, and we // also keep the folder closed by default for shipped docs (where it's // historical) and open by default for active docs (where it's live work). // ============================================================================= interface DocumentTasksFolderProps { documentId: string; /** Parent directive id — needed so a clicked task row can navigate to * /directives/?task=. */ directiveId: string; /** Visual indent depth — mirrors the parent DocumentRow's indent so the * tasks/ row sits one level deeper than its parent doc. */ depth: "normal" | "deep"; /** Whether to fetch+open by default. Active docs default to open so the * user sees their live tasks immediately; shipped docs default to closed * (historical), and the user can click to expand. */ defaultOpen: boolean; /** Bumped externally so the folder refetches its task list after a save * or status change elsewhere. Same nonce used for the directive folder. */ refreshNonce: number; /** Currently-selected task id (drives row highlight). */ selectedTaskId: string | null; /** Click handler for step/task rows — navigates to the live transcript. */ onSelectTask: (directiveId: string, taskId: string) => void; onContextMenuStep: (e: React.MouseEvent, step: DirectiveStep, directiveId: string) => void; onContextMenuTask: (e: React.MouseEvent, task: Task, directiveId: string) => void; /** Human-readable contract label (already resolved via fileLabel). Used to * disambiguate multiple tasks/ folders under the same directive. */ contractLabel: string; /** True when the parent directive owns more than one contract — drives the * `tasks - /` rename so the two sibling tasks/ folders are * distinguishable. Single-contract directives keep the plain `tasks/`. */ multipleContracts: boolean; } function DocumentTasksFolder({ documentId, directiveId, depth, defaultOpen, refreshNonce, selectedTaskId, onSelectTask, onContextMenuStep, onContextMenuTask, contractLabel, multipleContracts, }: DocumentTasksFolderProps) { const [open, setOpen] = useState(defaultOpen); const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); // Inner row indent is one level deeper than the folder header. Folder // header uses pl-[88px] (deep) or pl-14 (normal); tasks rows go one // step beyond that. const headerPadLeft = depth === "deep" ? "pl-[88px]" : "pl-14"; const rowPadLeft = depth === "deep" ? "pl-[112px]" : "pl-[72px]"; const refresh = useCallback(async () => { setLoading(true); setError(null); try { const res = await listContractTasks(documentId); setData(res); } catch (e) { setError(e instanceof Error ? e.message : "Failed to load tasks"); } finally { setLoading(false); } }, [documentId]); // Fetch when the folder is open (initial open or refresh). We don't // pre-fetch on closed folders so we don't waste bandwidth on the long // tail of historical shipped docs the user never expands. useEffect(() => { if (!open) return; void refresh(); }, [open, refresh, refreshNonce]); // De-duplicate: a step that has spawned a task appears in `steps[]` // with step.taskId === task.id, and the same task ALSO appears in // `tasks[]`. Rendering both produces a double-row + double-highlight // when the user clicks. Keep the step row (it carries the step name, // which is the meaningful label) and drop the matching task row. const stepTaskIds = useMemo( () => new Set((data?.steps ?? []).map((s) => s.taskId).filter((id): id is string => !!id)), [data], ); const ephemeralTasks = useMemo( () => (data?.tasks ?? []).filter((t) => !stepTaskIds.has(t.id)), [data, stepTaskIds], ); const total = (data?.steps.length ?? 0) + ephemeralTasks.length; // Folder always renders (even when empty) so the user can click into a // fresh contract's tasks/ folder and see it stay visible. The empty state // shows a muted "no tasks yet" placeholder inside the open body — same // visual weight as the existing "Loading tasks…" / error placeholders. // // When the parent directive owns multiple contracts, both tasks/ folders // are disambiguated as `tasks - /` so the user can tell // them apart. Single-contract directives keep the plain `tasks/` label. const headerLabel = multipleContracts ? `tasks - ${contractLabel}/` : "tasks/"; return (
{open && (
{loading && !data && (
Loading tasks…
)} {error && (
{error}
)} {data && total === 0 && !loading && !error && (
no tasks yet
)} {data?.steps.map((step) => ( ))} {ephemeralTasks.map((task) => ( ))}
)}
); } // Step status → coloured dot, mirroring directive status palette so the // sidebar reads consistently. const STEP_STATUS_DOT: Record = { pending: "bg-[#556677]", ready: "bg-[#9bc3ff]", running: "bg-yellow-400", completed: "bg-green-400", failed: "bg-red-400", skipped: "bg-[#3a4a6a]", }; // Task status → coloured dot. Statuses come from the Task model; the small // set we expect in directive context is enough — anything else falls back // to the muted "draft" colour. const TASK_STATUS_DOT: Record = { pending: "bg-[#556677]", starting: "bg-yellow-400", running: "bg-yellow-400", completed: "bg-green-400", failed: "bg-red-400", cancelled: "bg-[#3a4a6a]", interrupted: "bg-orange-400", }; interface StepRowProps { step: DirectiveStep; directiveId: string; selected: boolean; padLeft: string; onSelect: (directiveId: string, taskId: string) => void; onContextMenu: (e: React.MouseEvent, step: DirectiveStep, directiveId: string) => void; } function StepRow({ step, directiveId, selected, padLeft, onSelect, onContextMenu, }: StepRowProps) { const dot = STEP_STATUS_DOT[step.status] ?? "bg-[#556677]"; // Steps without an underlying task can't be opened — the executor // hasn't started yet so there's no transcript to show. Render them // disabled so the user can see them in the list but knows they're // inert. Same for steps stuck in pending/skipped. const taskId = step.taskId; const clickable = !!taskId; return ( ); } interface TaskRowProps { task: Task; directiveId: string; selected: boolean; padLeft: string; onSelect: (directiveId: string, taskId: string) => void; onContextMenu: (e: React.MouseEvent, task: Task, directiveId: string) => void; } function TaskRow({ task, directiveId, selected, padLeft, onSelect, onContextMenu, }: TaskRowProps) { const dot = TASK_STATUS_DOT[task.status] ?? "bg-[#556677]"; // Supervisor tasks get a small "sup" tag so the user can spot // contract orchestrators in the list. const isSup = task.isSupervisor; return ( ); } // ============================================================================= // Sidebar // ============================================================================= interface SidebarProps { directives: DirectiveSummary[]; loading: boolean; selection: SidebarSelection | null; onSelectDocument: (directiveId: string, doc: Contract) => void; onSelectDirective: (directiveId: string) => void; /** Top-level "Contracts" header `+ New` button — opens the * NewContractModal to create a brand-new directive (along with its * first contract). */ onCreateContract: () => void; onContextMenuDirective: (e: React.MouseEvent, directive: DirectiveSummary) => void; onContextMenuContract: (e: React.MouseEvent, contract: Contract) => void; onContextMenuStep: (e: React.MouseEvent, step: DirectiveStep, directiveId: string) => void; onContextMenuTask: (e: React.MouseEvent, task: Task, directiveId: string) => void; onSelectTask: (directiveId: string, taskId: string) => void; refreshNonce: number; } function DocumentSidebar({ directives, loading, selection, onSelectDocument, onSelectDirective, onCreateContract, onContextMenuDirective, onContextMenuContract, onContextMenuStep, onContextMenuTask, onSelectTask, refreshNonce, }: SidebarProps) { // Flat sort: active first, then idle, paused, draft, inactive, archived. // Status is surfaced as a colored dot on the RIGHT of each contract row // (see DirectiveFolder header) — the user explicitly asked NOT to nest // contracts inside per-status folders. const sortedDirectives: DirectiveSummary[] = 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]); // Per-directive open state. We auto-open the directive containing the // current selection so the user can see what they're editing. const [openDirectives, setOpenDirectives] = useState>({}); useEffect(() => { if (!selection) return; setOpenDirectives((prev) => prev[selection.directiveId] ? prev : { ...prev, [selection.directiveId]: true }, ); }, [selection?.directiveId]); const toggleDirective = (id: string) => setOpenDirectives((prev) => ({ ...prev, [id]: !prev[id] })); return (
{/* Sidebar header */}
Contracts
{directives.length}
{/* Top-level "directives/" folder */}
directives/
{/* Body — flat list, status is a colored dot on the right of each row. */}
{loading && directives.length === 0 ? (
Loading...
) : directives.length === 0 ? (
No contracts yet
) : (
{sortedDirectives.map((d) => ( toggleDirective(d.id)} onHeaderClick={() => onSelectDirective(d.id)} selection={selection} onSelectDocument={onSelectDocument} onContextMenuDirective={onContextMenuDirective} onContextMenuContract={onContextMenuContract} onContextMenuStep={onContextMenuStep} onContextMenuTask={onContextMenuTask} onSelectTask={onSelectTask} refreshNonce={refreshNonce} /> ))}
)}
); } // ============================================================================= // Contract header — breadcrumb + status badge + lifecycle action buttons + // merge mode radio. Renders above the spec editor in the document path. // // Action visibility is status-driven: // * draft → Lock & Start // * queued → Unlock (back to draft); shows "queued" pill // * active → Pause, Complete, Unlock; shows "active" + pulsing dot // * shipped → reopen via spec edit (no buttons here; backend reactivates) // * archived → no buttons // // Merge mode (shared / own_pr) is editable while the contract is in // `draft` or `queued` — once active, the queue scheduler has already // claimed the slot, so flipping the toggle would silently change a // running flow's branch target. Locked rows show the value as readonly. // ============================================================================= interface ContractHeaderProps { directive: { id: string; title: string; orchestratorTaskId: string | null }; doc: Contract; docTitle: string; /** Called with the server's response after any status / merge-mode * transition so the parent can refresh the editor + sidebar. */ onContractChanged: (updated: Contract) => void; } function ContractHeader({ directive, doc, docTitle, onContractChanged, }: ContractHeaderProps) { const [busy, setBusy] = useState( null, ); const [error, setError] = useState(null); const wrap = useCallback( async (tag: typeof busy, op: () => Promise) => { try { setBusy(tag); setError(null); const updated = await op(); onContractChanged(updated); } catch (e) { setError(e instanceof Error ? e.message : "Unknown error"); } finally { setBusy(null); } }, [onContractChanged], ); const onStart = useCallback( () => wrap("start", () => startDirectiveContract(doc.id)), [doc.id, wrap], ); const onPause = useCallback( () => wrap("pause", () => pauseDirectiveContract(doc.id)), [doc.id, wrap], ); const onUnlock = useCallback( () => wrap("unlock", () => unlockDirectiveContract(doc.id)), [doc.id, wrap], ); const onReopen = useCallback( () => wrap("reopen", () => reopenDirectiveContract(doc.id)), [doc.id, wrap], ); const onMergeMode = useCallback( (mode: ContractMergeMode) => wrap("merge_mode", () => updateContract(doc.id, { mergeMode: mode })), [doc.id, wrap], ); const editableMergeMode = doc.status === "draft" || doc.status === "queued"; return (
{/* Row 1: breadcrumb + status pill + orchestrator indicator */}
directives / {directive.title.trim().length > 0 ? directive.title : directive.id.slice(0, 8)} / {docTitle} {!!directive.orchestratorTaskId && ( orchestrator running )}
{/* Row 2: action buttons (status-driven) + merge mode + error */}
{doc.status === "draft" && ( {busy === "start" ? "Starting…" : "Lock & Start"} )} {doc.status === "queued" && ( {busy === "unlock" ? "Unlocking…" : "Unlock"} )} {doc.status === "active" && ( <> {busy === "pause" ? "Pausing…" : "Pause"} {busy === "unlock" ? "Unlocking…" : "Unlock"} {/* No "Mark complete" — the contract auto-ships when the orchestrator raises a PR (server-side; see update_directive's PR-detection branch in handlers/ directives.rs). */} )} {doc.status === "shipped" && ( {busy === "reopen" ? "Reopening…" : "Reopen for amendment"} )} {/* Merge mode radios — visible always, editable only in draft/queued */}
merge:
{error && (
{error}
)}
); } function ContractStatusPill({ status }: { status: ContractStatus }) { const styles: Record = { draft: { label: "draft", cls: "text-[#556677]" }, queued: { label: "queued", cls: "text-amber-400" }, active: { label: "active", cls: "text-green-400" }, shipped: { label: "shipped", cls: "text-[#75aafc]" }, archived: { label: "archived", cls: "text-[#7788aa]" }, }; const s = styles[status]; return {s.label}; } function ContractActionButton({ children, onClick, disabled, variant, }: { children: React.ReactNode; onClick: () => void; disabled?: boolean; variant?: "primary"; }) { const base = "px-2 py-1 border border-[rgba(117,170,252,0.3)] rounded text-[10px] uppercase tracking-wide transition-colors"; const colors = variant === "primary" ? "text-green-300 hover:bg-[rgba(120,200,140,0.1)]" : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)]"; const dim = disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"; return ( ); } function MergeModeRadio({ value, onChange, disabled, }: { value: ContractMergeMode; onChange: (mode: ContractMergeMode) => void; disabled?: boolean; }) { const opt = (mode: ContractMergeMode, label: string) => { const selected = value === mode; const cls = selected ? "text-white border-[rgba(117,170,252,0.6)] bg-[rgba(117,170,252,0.1)]" : "text-[#7788aa] border-transparent hover:text-[#9bc3ff]"; const dim = disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"; return ( ); }; return (
{opt("shared", "shared")} {opt("own_pr", "own pr")}
); } // ============================================================================= // Editor shell — wraps DocumentEditor and handles the "no document selected" // and loading states. Two modes: // 1) documentId selected → fetch the Contract and edit doc.body via // updateContract (the call that auto-reactivates a shipped doc). // 2) no documentId (legacy fallback, kept for the "select a directive but // not a document" transitional case) → edit directive.goal as before. // ============================================================================= interface EditorShellProps { selection: SidebarSelection | null; hasDirectives: boolean; listLoading: boolean; /** Bumped after a successful document save so the sidebar refetches. */ onDocumentChanged: () => void; } function EditorShell({ selection, hasDirectives, listLoading, onDocumentChanged, }: EditorShellProps) { const directiveId = selection?.directiveId; const documentId = selection?.documentId ?? null; // We deliberately don't pull `updateGoal` here — in the multi-document // world, edits flow through updateContract (which auto-reactivates // a shipped doc when its body changes). The legacy directive.goal is // unused on this surface. const { directive, loading: directiveLoading, cleanup, createPR, pickUpOrders, } = useDirective(directiveId); // Document fetch — only when documentId is selected. Refetched whenever the // id changes; not polled (the document stream is too low-traffic to warrant // background refresh in this iteration). const [doc, setDoc] = useState(null); const [docLoading, setDocLoading] = useState(false); const [docError, setDocError] = useState(null); useEffect(() => { if (!documentId) { setDoc(null); setDocLoading(false); setDocError(null); return; } let cancelled = false; setDocLoading(true); setDocError(null); getContract(documentId) .then((d) => { if (cancelled) return; setDoc(d); }) .catch((e) => { if (cancelled) return; setDocError(e instanceof Error ? e.message : "Failed to load document"); }) .finally(() => { if (cancelled) return; setDocLoading(false); }); return () => { cancelled = true; }; }, [documentId]); // Save callback for the document path. The backend re-stamps a shipped doc // back to active when its body changes, so we just optimistically update // local state with the server's response. const onUpdateDocumentBody = useCallback( async (body: string) => { if (!documentId) return; const updated = await updateContract(documentId, { body }); setDoc(updated); // Tell the sidebar to refetch the directive's document list so the // status chip flips from `shipped` back to `active` (and any title // changes propagate). Cheap — folders only refetch when open. onDocumentChanged(); }, [documentId, onDocumentChanged], ); // ---- Empty / error / loading states ------------------------------------ if (!directiveId) { return (

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

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

Loading directive...

); } if (!directive) { return (

Directive not found

); } // --- Task path: task row clicked in the sidebar ------------------------ // Renders the live transcript via TaskPage. Selection wins over // the document path when both are somehow present (defensive). if (selection?.taskId) { const taskId = selection.taskId; // Resolve a human label for the task: orchestrator/completion are // labelled by role; step tasks borrow the step name; everything else // is an ephemeral and just shows the task id slice. Look-up uses the // already-fetched directive (with steps). const stepWithTask = directive.steps.find((s) => s.taskId === taskId); const label = taskId === directive.orchestratorTaskId ? "orchestrator" : taskId === directive.completionTaskId ? "completion" : stepWithTask?.name ?? taskId.slice(0, 8); const isStepBound = taskId === directive.orchestratorTaskId || taskId === directive.completionTaskId || !!stepWithTask; return (
directives / {directive.title.trim().length > 0 ? directive.title : directive.id.slice(0, 8)} / {label}
); } // --- Document path: documentId selected -------------------------------- if (documentId) { if (docLoading && !doc) { return (

Loading document...

); } if (docError) { return (

{docError}

); } if (!doc) { return (

Document not found

); } // The contract title is the filename label; the contract body is the // editor body. DocumentEditor takes these directly (no more synthesis // hack) — `directive` is still passed for orchestrator state and the // embedded steps panel via StepsBlockContextProvider. const docTitle = `${fileLabel(doc, directive)}.md`; return (
{ setDoc(updated); onDocumentChanged(); }} /> { await cleanup(); }} onCreatePR={async () => { await createPR(); }} onPickUpOrders={async () => { await pickUpOrders(); }} // Locked-and-started (`active`), `queued`, `shipped`, and // `archived` contracts must be unlocked before edits are // accepted. Only `draft` is freely editable; everything else // shows the in-editor Unlock affordance. editable={doc.status === "draft"} onRequestUnlock={async () => { const updated = await unlockDirectiveContract(doc.id); setDoc(updated); onDocumentChanged(); }} />
); } // --- Legacy fallback: directive selected but no document chosen -------- // We only ever land here transiently while the page resolves the default // document selection, so we render a thin "loading" placeholder rather // than the full goal editor (which would be confusing alongside the new // multi-document model). return (
directives / {directive.title.trim().length > 0 ? directive.title : directive.id.slice(0, 8)}

Select a document, or click "+ New document" to create one.

); } // ============================================================================= // Page // ============================================================================= export default function DocumentDirectivesPage() { const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); const navigate = useNavigate(); const { id: routeDirectiveId } = useParams<{ id: string }>(); const [searchParams, setSearchParams] = useSearchParams(); const { directives, loading: listLoading } = useDirectives(); // refreshNonce — bumped to tell open directive folders to refetch their // document lists (after a create or save). const [refreshNonce, setRefreshNonce] = useState(0); const bumpRefresh = useCallback(() => setRefreshNonce((n) => n + 1), []); // Derive the SidebarSelection from the URL. The route param is the // directive id; ?document=:id and ?task=:id pick a specific child. Exactly // one of taskId/documentId can be set; if both happen to be present in the // URL (which shouldn't happen via our nav code) we prefer ?task= since // task selection is the more disruptive action. const selection: SidebarSelection | null = useMemo(() => { if (!routeDirectiveId) return null; const taskId = searchParams.get("task"); const documentId = searchParams.get("document"); if (taskId) return { directiveId: routeDirectiveId, taskId, documentId: null }; if (documentId) return { directiveId: routeDirectiveId, taskId: null, documentId }; return { directiveId: routeDirectiveId, taskId: null, documentId: null }; }, [routeDirectiveId, searchParams]); useEffect(() => { if (!authLoading && isAuthConfigured && !isAuthenticated) { navigate("/login"); } }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); // ------------------------------------------------------------------ // Default-selection: when the user clicks a directive's folder header (or // lands on /directives/:id without ?document=) we pick the first active or // draft document and update the URL to point at it. This avoids the // "directive selected, but nothing in the editor" intermediate state. // ------------------------------------------------------------------ const lastResolvedRef = useRef(null); useEffect(() => { if (!routeDirectiveId) { lastResolvedRef.current = null; return; } // Only auto-resolve when no document/task has been picked yet, AND we // haven't already resolved this directive in a prior tick (otherwise // navigating away from the doc would instantly re-pick the same one). if (selection?.documentId || selection?.taskId) { lastResolvedRef.current = routeDirectiveId; return; } if (lastResolvedRef.current === routeDirectiveId) return; lastResolvedRef.current = routeDirectiveId; let cancelled = false; listContracts(routeDirectiveId) .then((list) => { if (cancelled) return; // Prefer the first 'active' doc; fall back to the first 'draft'. const firstActive = list.find((d) => d.status === "active"); const firstDraft = list.find((d) => d.status === "draft"); const pick = firstActive ?? firstDraft; if (pick) { setSearchParams( (prev) => { const next = new URLSearchParams(prev); next.set("document", pick.id); next.delete("task"); return next; }, { replace: true }, ); } }) .catch(() => { // Swallow — the editor pane will show "Document not found" and the // user can click "+ New document" to recover. }); return () => { cancelled = true; }; }, [routeDirectiveId, selection?.documentId, selection?.taskId, setSearchParams]); const handleSelectDocument = useCallback( (directiveId: string, doc: Contract) => { navigate(`/directives/${directiveId}?document=${doc.id}`); }, [navigate], ); // When the user clicks a directive folder header (not a document row), we // jump to /directives/:id without ?document= — the default-selection // effect above will then pick the first active doc. const handleSelectDirective = useCallback( (directiveId: string) => { if (routeDirectiveId === directiveId) return; navigate(`/directives/${directiveId}`); }, [navigate, routeDirectiveId], ); const handleCreateDocument = useCallback( async (directive: DirectiveSummary) => { const created = await createContract(directive.id, { title: "", body: "", }); bumpRefresh(); // Navigate to the new doc so it's selected immediately. navigate(`/directives/${directive.id}?document=${created.id}`); }, [bumpRefresh, navigate], ); // Click on a task or step row → open the live transcript pane via // ?task=. EditorShell switches to TaskPage when this is set. const handleSelectTask = useCallback( (directiveId: string, taskId: string) => { navigate(`/directives/${directiveId}?task=${taskId}`); }, [navigate], ); // Modal state for the two new creation surfaces in the sidebar: // * + New contract → opens NewContractModal, calls useDirectives.create // * + New ephemeral task (per directive) → opens NewEphemeralTaskModal const { create: createDirective } = useDirectives(); const [showNewContract, setShowNewContract] = useState(false); const [newEphemeralFor, setNewEphemeralFor] = useState(null); const handleSubmitNewContract = useCallback( async (title: string, body: string, repositoryUrl: string) => { const d = await createDirective({ title, contractBody: body, repositoryUrl: repositoryUrl.length > 0 ? repositoryUrl : undefined, }); setShowNewContract(false); navigate(`/directives/${d.id}`); }, [createDirective, navigate], ); const handleSubmitNewEphemeral = useCallback( async (name: string, plan: string) => { if (!newEphemeralFor) return; const task = await createDirectiveTask(newEphemeralFor.id, { name, plan }); const target = newEphemeralFor.id; setNewEphemeralFor(null); bumpRefresh(); navigate(`/directives/${target}?task=${task.id}`); }, [newEphemeralFor, bumpRefresh, navigate], ); // Right-click context menu — generic. The state holds whichever items // array the entity's builder produced; the SidebarContextMenu just // renders them. Each entity type has its own builder (directive, // contract, step, task) hung off the page so all the action callbacks // (start/pause/archive/delete/etc.) live in one place. const { refresh: refreshDirectiveList } = useDirectives(); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; items: ContextMenuItem[]; } | null>(null); const closeContextMenu = useCallback(() => setContextMenu(null), []); const runAction = useCallback( async (action: () => Promise, errMsg: string) => { try { await action(); await refreshDirectiveList(); bumpRefresh(); } catch (err) { // eslint-disable-next-line no-console console.error(`[makima] ${errMsg}`, err); alert( err instanceof Error ? `${errMsg}: ${err.message}` : errMsg, ); } }, [refreshDirectiveList, bumpRefresh], ); const openMenu = useCallback( (e: React.MouseEvent, items: ContextMenuItem[]) => { e.preventDefault(); e.stopPropagation(); setContextMenu({ x: e.clientX, y: e.clientY, items }); }, [], ); // ---- Per-entity menu builders. Each returns its items array; the // handler wraps them with openMenu so each row can fire its own // right-click with one line of wiring. ---- const handleContextMenuDirective = useCallback( (e: React.MouseEvent, d: DirectiveSummary) => { const items: ContextMenuItem[] = [ { label: "+ New contract here", onClick: () => { void handleCreateDocument(d); }, }, { label: "+ New ephemeral task", onClick: () => setNewEphemeralFor(d) }, { label: "", separator: true }, { label: "Start", onClick: () => runAction(() => startDirective(d.id), "Failed to start directive"), disabled: d.status === "active" || d.status === "archived", }, { label: "Pause", onClick: () => runAction(() => pauseDirective(d.id), "Failed to pause directive"), disabled: d.status !== "active", }, { label: "", separator: true }, { label: d.prUrl ? "Update PR" : "Create PR", onClick: () => runAction( () => createDirectivePR(d.id), d.prUrl ? "Failed to update PR" : "Failed to create PR", ), }, ...(d.prUrl ? [{ label: "Go to PR", onClick: () => window.open(d.prUrl!, "_blank", "noreferrer"), } as ContextMenuItem] : []), { label: "Advance DAG", onClick: () => runAction(() => advanceDirective(d.id), "Failed to advance DAG"), }, { label: "Cleanup merged steps", onClick: () => runAction(() => cleanupDirective(d.id), "Failed to clean up"), }, { label: "Pick up orders", onClick: () => runAction(() => pickUpOrders(d.id), "Failed to pick up orders"), }, { label: "", separator: true }, { label: "Archive", danger: true, onClick: () => runAction( () => updateDirective(d.id, { status: "archived" }), "Failed to archive", ), disabled: d.status === "archived", }, { label: "Delete", danger: true, onClick: async () => { if (!window.confirm(`Delete "${d.title}"? This cannot be undone.`)) return; await runAction(() => deleteDirective(d.id), "Failed to delete"); if (selection?.directiveId === d.id) navigate("/directives"); }, }, ]; openMenu(e, items); }, [openMenu, runAction, navigate, selection], ); const handleContextMenuContract = useCallback( (e: React.MouseEvent, c: Contract) => { const items: ContextMenuItem[] = [ { label: "Lock & Start", onClick: () => runAction(() => startDirectiveContract(c.id), "Failed to start contract"), disabled: c.status !== "draft", }, { label: "Pause", onClick: () => runAction(() => pauseDirectiveContract(c.id), "Failed to pause contract"), disabled: c.status !== "active", }, { label: "Unlock", onClick: () => runAction(() => unlockDirectiveContract(c.id), "Failed to unlock contract"), disabled: c.status !== "active" && c.status !== "queued", }, { label: "Reopen for amendment", onClick: () => runAction(() => reopenDirectiveContract(c.id), "Failed to reopen contract"), disabled: c.status !== "shipped", }, ]; openMenu(e, items); }, [openMenu, runAction], ); const handleContextMenuStep = useCallback( (e: React.MouseEvent, step: DirectiveStep, directiveId: string) => { const items: ContextMenuItem[] = [ { label: "Stop task", onClick: () => step.taskId ? runAction(() => stopTask(step.taskId!), "Failed to stop task") : undefined, disabled: !step.taskId || step.status !== "running", }, { label: "Skip step", danger: true, onClick: () => runAction( () => skipDirectiveStep(directiveId, step.id), "Failed to skip step", ), disabled: step.status === "completed" || step.status === "skipped", }, ]; openMenu(e, items); }, [openMenu, runAction], ); const handleContextMenuTask = useCallback( (e: React.MouseEvent, task: Task, _directiveId: string) => { const items: ContextMenuItem[] = [ { label: "Stop task", onClick: () => runAction(() => stopTask(task.id), "Failed to stop task"), disabled: task.status === "done" || task.status === "failed" || task.status === "merged", }, ]; openMenu(e, items); }, [openMenu, runAction], ); if (authLoading) { return (

Loading...

); } 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. */}
setShowNewContract(true)} onContextMenuDirective={handleContextMenuDirective} onContextMenuContract={handleContextMenuContract} onContextMenuStep={handleContextMenuStep} onContextMenuTask={handleContextMenuTask} onSelectTask={handleSelectTask} refreshNonce={refreshNonce} />
{/* Right: Lexical editor */} 0} listLoading={listLoading} onDocumentChanged={bumpRefresh} />
{showNewContract && ( setShowNewContract(false)} onSubmit={handleSubmitNewContract} /> )} {newEphemeralFor && ( setNewEphemeralFor(null)} onSubmit={handleSubmitNewEphemeral} /> )} {contextMenu && ( )}
); } /** * Modal for creating a new directive (= "contract" in the doc-mode UI). * Title + goal are required; repository_url is optional. On submit calls * useDirectives.create and navigates the user into the new directive. */ function NewContractModal({ onClose, onSubmit, }: { onClose: () => void; onSubmit: (title: string, goal: string, repositoryUrl: string) => Promise; }) { const [title, setTitle] = useState(""); const [goal, setGoal] = useState(""); const [repo, setRepo] = useState(""); const [submitting, setSubmitting] = useState(false); const [err, setErr] = useState(null); const titleRef = useRef(null); useEffect(() => { titleRef.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 t = title.trim(); const g = goal.trim(); if (!t || !g || submitting) return; setSubmitting(true); setErr(null); try { await onSubmit(t, g, repo.trim()); } catch (caught) { setErr(caught instanceof Error ? caught.message : String(caught)); } finally { setSubmitting(false); } }; return (
e.stopPropagation()} className="w-[520px] max-w-[90vw] bg-[#0a1628] border border-[rgba(117,170,252,0.4)] shadow-xl flex flex-col" >

New contract

setTitle(e.target.value)} placeholder="e.g. Migrate auth to Supabase" 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]" />