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 DirectiveDocument, type DirectiveDocumentStatus, type DirectiveStep, type Task, type DocumentTasksResponse, listDirectiveDocuments, createDirectiveDocument, getDirectiveDocument, updateDirectiveDocument, listDirectiveDocumentTasks, createDirectiveTask, startDirective, pauseDirective, updateDirective, deleteDirective, createDirectivePR, advanceDirective, cleanupDirective, pickUpOrders, } from "../lib/api"; import { DirectiveContextMenu } from "../components/directives/DirectiveContextMenu"; import { DocumentTaskStream } from "../components/directives/DocumentTaskStream"; // 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-document status palette. Active/draft documents use the same bright // green-ish accent as a running directive; shipped/archived use a muted blue. const DOC_STATUS_DOT: Record = { draft: "bg-[#556677]", 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: DirectiveDocument, 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: DirectiveDocument) => void; onCreateDocument: (directive: DirectiveSummary) => Promise; /** Open the inline "+ New ephemeral task" form for this directive. */ onCreateEphemeralTask: (directive: DirectiveSummary) => void; /** Right-click handler — opens DirectiveContextMenu with start/pause/PR/etc. */ onContextMenu: (e: React.MouseEvent, directive: DirectiveSummary) => 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, onCreateDocument, onCreateEphemeralTask, onContextMenu, 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); // Whether a "+ New document" call is in flight (disables the button). const [creating, setCreating] = useState(false); const refresh = useCallback(async () => { setDocsLoading(true); setDocsError(null); try { const list = await listDirectiveDocuments(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: DirectiveDocument[] = []; const shipped: DirectiveDocument[] = []; 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]); const handleCreate = useCallback(async () => { if (creating) return; setCreating(true); try { await onCreateDocument(directive); // Refresh after creating so the new doc appears in the list. await refresh(); } finally { setCreating(false); } }, [creating, onCreateDocument, directive, refresh]); // Selection helpers — used to highlight the currently-selected doc row. const selectedDocumentId = selection && selection.directiveId === directive.id ? selection.documentId : null; 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.). */} {/* Folder body — rendered only when open */} {open && (
{docsLoading && !docs && (
Loading documents…
)} {docsError && (
{docsError}
)} {/* Active group */} {docs && ( <> {/* + New document affordance — sits at the top of the active list so the user can always reach it without scrolling past existing docs. */} {/* + New ephemeral task — sibling affordance for spawning a one-off task under this directive that's NOT part of the DAG. Useful for sidebar scratch work, debugging, etc. */} {activeDocs.length === 0 && !docsLoading && (
no active documents
)} {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)} />
))} {/* 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)} 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: DirectiveDocument; directive: DirectiveSummary; selected: boolean; onSelect: () => void; indent?: "normal" | "deep"; } function DocumentRow({ doc, directive, selected, onSelect, indent = "normal", }: 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`; 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; } function DocumentTasksFolder({ documentId, directiveId, depth, defaultOpen, refreshNonce, selectedTaskId, onSelectTask, }: 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 listDirectiveDocumentTasks(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]); const total = (data?.steps.length ?? 0) + (data?.tasks.length ?? 0); // Don't render the folder at all if we've fetched and the document has // no tasks. This is the cleanest visual: a draft document just shows up // as a single row with no children. The empty-folder check is gated on // a successful fetch so we don't flash "no tasks/" rows during loading. if (data && total === 0 && !loading && !error) { return null; } return (
{open && (
{loading && !data && (
Loading tasks…
)} {error && (
{error}
)} {data?.steps.map((step) => ( ))} {data?.tasks.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; } function StepRow({ step, directiveId, selected, padLeft, onSelect, }: 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; } function TaskRow({ task, directiveId, selected, padLeft, onSelect, }: 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: DirectiveDocument) => void; onSelectDirective: (directiveId: string) => void; onCreateDocument: (directive: DirectiveSummary) => Promise; onCreateContract: () => void; onCreateEphemeralTask: (directive: DirectiveSummary) => void; onContextMenu: (e: React.MouseEvent, directive: DirectiveSummary) => void; onSelectTask: (directiveId: string, taskId: string) => void; refreshNonce: number; } function DocumentSidebar({ directives, loading, selection, onSelectDocument, onSelectDirective, onCreateDocument, onCreateContract, onCreateEphemeralTask, onContextMenu, 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} onCreateDocument={onCreateDocument} onCreateEphemeralTask={onCreateEphemeralTask} onContextMenu={onContextMenu} onSelectTask={onSelectTask} refreshNonce={refreshNonce} /> ))}
)}
); } // ============================================================================= // Editor shell — wraps DocumentEditor and handles the "no document selected" // and loading states. Two modes: // 1) documentId selected → fetch the DirectiveDocument and edit doc.body via // updateDirectiveDocument (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 updateDirectiveDocument (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); getDirectiveDocument(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 updateDirectiveDocument(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 DocumentTaskStream. 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

); } // Synthesise a directive-shaped object whose `goal` is the document body. // DocumentEditor was originally written against DirectiveWithSteps, so we // can keep its shape by overriding `goal` with `doc.body` and `title` // with the document's filename label. The steps panel still draws from // the real directive (passed through StepsBlockContextProvider). const docTitle = `${fileLabel(doc, directive)}.md`; const directiveAsDocument = { ...directive, goal: doc.body, title: docTitle, }; return (
{/* Breadcrumb — directives / / .md */}
directives / {directive.title.trim().length > 0 ? directive.title : directive.id.slice(0, 8)} / {docTitle} {doc.status === "shipped" && ( shipped )} {doc.status === "archived" && ( archived )} {doc.status === "draft" && ( draft )} {!!directive.orchestratorTaskId && ( orchestrator running )}
{ await cleanup(); }} onCreatePR={async () => { await createPR(); }} onPickUpOrders={async () => { await pickUpOrders(); }} />
); } // --- 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; listDirectiveDocuments(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: DirectiveDocument) => { 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 createDirectiveDocument(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 DocumentTaskStream 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, goal: string, repositoryUrl: string) => { const d = await createDirective({ title, goal, 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 state. Right-clicking any directive header // opens the menu; menu actions (start/pause/archive/delete/PR/etc.) hit // the directives API and trigger a sidebar refresh on success. const { refresh: refreshDirectiveList } = useDirectives(); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; directive: DirectiveSummary; } | null>(null); const handleContextMenu = useCallback( (e: React.MouseEvent, directive: DirectiveSummary) => { e.preventDefault(); e.stopPropagation(); setContextMenu({ x: e.clientX, y: e.clientY, directive }); }, [], ); 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], ); 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)} onCreateEphemeralTask={(d) => setNewEphemeralFor(d)} onContextMenu={handleContextMenu} onSelectTask={handleSelectTask} refreshNonce={refreshNonce} />
{/* Right: Lexical editor */} 0} listLoading={listLoading} onDocumentChanged={bumpRefresh} />
{showNewContract && ( setShowNewContract(false)} onSubmit={handleSubmitNewContract} /> )} {newEphemeralFor && ( setNewEphemeralFor(null)} onSubmit={handleSubmitNewEphemeral} /> )} {contextMenu && ( runAction( () => startDirective(contextMenu.directive.id), "Failed to start contract", ) } onPause={() => runAction( () => pauseDirective(contextMenu.directive.id), "Failed to pause contract", ) } onArchive={() => runAction( () => updateDirective(contextMenu.directive.id, { status: "archived", }), "Failed to archive contract", ) } onDelete={async () => { if ( !window.confirm( `Delete "${contextMenu.directive.title}"? This cannot be undone.`, ) ) { return; } await runAction( () => deleteDirective(contextMenu.directive.id), "Failed to delete contract", ); // If the deleted contract was selected, clear the URL. if (selection?.directiveId === contextMenu.directive.id) { navigate("/directives"); } }} onGoToPR={() => { if (contextMenu.directive.prUrl) { window.open(contextMenu.directive.prUrl, "_blank", "noreferrer"); } }} onCreatePR={() => runAction( () => createDirectivePR(contextMenu.directive.id), contextMenu.directive.prUrl ? "Failed to update PR" : "Failed to create PR", ) } onAdvance={() => runAction( () => advanceDirective(contextMenu.directive.id), "Failed to advance DAG", ) } onCleanup={() => runAction( () => cleanupDirective(contextMenu.directive.id), "Failed to clean up contract", ) } onPickUpOrders={() => runAction( () => pickUpOrders(contextMenu.directive.id), "Failed to pick up orders", ) } /> )}
); } /** * 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]" />