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 { DocumentTaskStream } from "../components/directives/DocumentTaskStream"; import type { DirectiveStatus, DirectiveSummary, DirectiveWithSteps, } from "../lib/api"; // Status dot color, matching the existing tabular UI's badge palette so the // document mode feels like a sibling of the existing list, not a foreign UI. const STATUS_DOT: Record = { draft: "bg-[#556677]", active: "bg-green-400", idle: "bg-yellow-400", paused: "bg-orange-400", archived: "bg-[#3a4a6a]", }; // Per-task dot color for the sidebar entries inside a directive folder. // Matches the StepsBlockNode palette. const STEP_STATUS_DOT: Record = { pending: "bg-[#556677]", ready: "bg-[#9bc3ff]", running: "bg-yellow-400", done: "bg-green-400", failed: "bg-red-400", skipped: "bg-[#3a4a6a]", }; // ============================================================================= // Sidebar icons (inline SVG, no new deps) // ============================================================================= function FolderIcon({ open = false }: { open?: boolean }) { return ( {open ? ( ) : ( )} ); } function FileIcon() { return ( ); } function PinIcon() { return ( ); } function Caret({ open }: { open: boolean }) { return ( ); } // ============================================================================= // Sidebar // ============================================================================= function slugify(title: string, fallback: string): string { const slug = title .trim() .replace(/\s+/g, "-") .replace(/[^a-zA-Z0-9._-]/g, "") .toLowerCase(); return slug.length > 0 ? slug : fallback; } interface SidebarSelection { directiveId: string; /** null = the directive's document; otherwise a task id (orchestrator/step). */ taskId: string | null; } interface SidebarProps { directives: DirectiveSummary[]; loading: boolean; selection: SidebarSelection | null; onSelect: (sel: SidebarSelection) => void; } /** * Per-directive folder. Renders the directive as a collapsible folder whose * children are the pinned document entry (always first) and the live task list * — orchestrator, completion, and any step tasks. We fetch the directive's * full step list lazily, only when the folder is expanded, to avoid a thundering * herd of GETs at page load. */ function DirectiveFolder({ directive, open, onToggle, selection, onSelect, }: { directive: DirectiveSummary; open: boolean; onToggle: () => void; selection: SidebarSelection | null; onSelect: (sel: SidebarSelection) => void; }) { const dotColor = STATUS_DOT[directive.status] ?? STATUS_DOT.draft; const fileName = `${slugify(directive.title, directive.id.slice(0, 8))}.md`; // Lazy fetch full directive (with steps) only when folder is open. const { directive: detailed } = useDirective(open ? directive.id : undefined); const docSelected = selection?.directiveId === directive.id && selection.taskId === null; // Collect the tasks to surface in the folder body. const tasks = useMemo(() => collectTasks(detailed, directive), [detailed, directive]); return (
{open && (
    {/* Pinned document entry — always at the top of the folder. */}
  • {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"; return (
  • ); }) )}
)}
); } interface FolderTaskRow { taskId: string; label: string; status: string; kind: "orchestrator-active" | "completion" | "step"; } function collectTasks( detailed: DirectiveWithSteps | null, summary: DirectiveSummary, ): FolderTaskRow[] { const rows: FolderTaskRow[] = []; // Orchestrator (planner) — surfaces only while it's actively running so // the folder is not flooded with stale orchestrator entries. const orchestratorId = detailed?.orchestratorTaskId ?? summary.orchestratorTaskId ?? null; if (orchestratorId) { rows.push({ taskId: orchestratorId, label: "orchestrator", status: "running", kind: "orchestrator-active", }); } // Completion (PR creation) task. const completionId = detailed?.completionTaskId ?? summary.completionTaskId ?? null; if (completionId) { rows.push({ taskId: completionId, 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, label: step.name, status: step.status, kind: "step", }); } } return rows; } interface SidebarProps { directives: DirectiveSummary[]; loading: boolean; selection: SidebarSelection | null; onSelect: (sel: SidebarSelection) => void; } function DocumentSidebar({ directives, loading, selection, onSelect }: SidebarProps) { // Sort active first, then idle, then paused, then archived. const sorted = useMemo(() => { const order: Record = { active: 0, paused: 1, idle: 2, draft: 3, archived: 4, }; return [...directives].sort((a, b) => { const oa = order[a.status] ?? 99; const ob = order[b.status] ?? 99; if (oa !== ob) return oa - ob; return a.title.localeCompare(b.title, undefined, { sensitivity: "base" }); }); }, [directives]); // Track which directive folders are open. The currently selected directive // is forced open so deep links land on something visible. const [openIds, setOpenIds] = useState>(new Set()); const lastSelectedRef = useRef(null); useEffect(() => { if (selection && selection.directiveId !== lastSelectedRef.current) { lastSelectedRef.current = selection.directiveId; setOpenIds((prev) => { if (prev.has(selection.directiveId)) return prev; const next = new Set(prev); next.add(selection.directiveId); return next; }); } }, [selection]); const toggleOpen = useCallback((id: string) => { setOpenIds((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }, []); return (
{/* Sidebar header */}
Documents {directives.length}
{/* Top-level "directives/" folder header (informational, non-interactive). */}
directives/
{/* Body */}
{loading && directives.length === 0 ? (
Loading...
) : directives.length === 0 ? (
No directives yet
) : ( sorted.map((d) => ( toggleOpen(d.id)} selection={selection} onSelect={onSelect} /> )) )}
); } // ============================================================================= // Editor shell — wraps DocumentEditor and handles the "no document selected" // and loading states. // ============================================================================= interface EditorShellProps { selectedId: string | undefined; selectedTaskId: string | null; hasDirectives: boolean; listLoading: boolean; onClearTask: () => void; } function EditorShell({ selectedId, selectedTaskId, hasDirectives, listLoading, onClearTask, }: EditorShellProps) { const { directive, loading, updateGoal, cleanup, createPR, pickUpOrders, } = useDirective(selectedId); if (!selectedId) { return (

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

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

Loading document...

); } if (!directive) { return (

Document not found

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

Loading...

); } const selection: SidebarSelection | null = selectedId ? { directiveId: selectedId, taskId: selectedTaskId } : null; return (
{/* Left: file-tree sidebar */}
{/* Right: Lexical editor / task stream */} 0} listLoading={listLoading} onClearTask={onClearTask} />
); }