From 4b1d608b839769052634b4facc345b891d468926 Mon Sep 17 00:00:00 2001 From: soryu Date: Wed, 29 Apr 2026 01:10:11 +0100 Subject: feat: document-mode directive UI proof of concept (Lexical) (#101) * WIP: heartbeat checkpoint * feat: soryu-co/soryu - makima: Backend: feature flag + goal-edit interrupt messaging * WIP: heartbeat checkpoint * WIP: heartbeat checkpoint * feat: soryu-co/soryu - makima: Frontend: Lexical document editor with step blocks, context menu, countdown --- makima/frontend/src/routes/document-directives.tsx | 394 +++++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100644 makima/frontend/src/routes/document-directives.tsx (limited to 'makima/frontend/src/routes/document-directives.tsx') diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx new file mode 100644 index 0000000..42e6a69 --- /dev/null +++ b/makima/frontend/src/routes/document-directives.tsx @@ -0,0 +1,394 @@ +import { useEffect, useMemo, useState } from "react"; +import { useNavigate, useParams } 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, DirectiveStatus } 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]", +}; + +// ============================================================================= +// 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. +// ============================================================================= + +type SidebarGroup = "active" | "idle" | "archived"; + +const GROUP_LABEL: Record = { + active: "active", + idle: "idle", + archived: "archived", +}; + +function bucketOf(status: DirectiveStatus): SidebarGroup { + if (status === "active" || status === "paused") return "active"; + if (status === "archived") return "archived"; + // draft + idle land in the idle bucket (i.e. "not currently running"). + return "idle"; +} + +// ============================================================================= +// Sidebar icons (inline SVG, no new deps) +// ============================================================================= + +function FolderIcon({ open = false }: { open?: boolean }) { + return ( + + {open ? ( + + ) : ( + + )} + + ); +} + +function FileIcon() { + return ( + + + + + ); +} + +function Caret({ open }: { open: boolean }) { + return ( + + + + ); +} + +// ============================================================================= +// Sidebar +// ============================================================================= + +interface SidebarProps { + directives: DirectiveSummary[]; + loading: boolean; + selectedId: string | null; + onSelect: (id: string) => void; +} + +function DocumentSidebar({ directives, loading, selectedId, onSelect }: SidebarProps) { + const groups: Record = useMemo(() => { + const out: Record = { + active: [], + idle: [], + archived: [], + }; + for (const d of directives) { + out[bucketOf(d.status)].push(d); + } + // Sort each group alphabetically so it feels like a stable file tree. + (Object.keys(out) as SidebarGroup[]).forEach((k) => { + out[k].sort((a, b) => + a.title.localeCompare(b.title, undefined, { sensitivity: "base" }), + ); + }); + return out; + }, [directives]); + + // Default-collapsed state per folder. Archived is collapsed by default + // (it's history); the other two are open so users see their work. + const [openGroups, setOpenGroups] = useState>({ + active: true, + idle: true, + archived: false, + }); + + const toggleGroup = (g: SidebarGroup) => + setOpenGroups((prev) => ({ ...prev, [g]: !prev[g] })); + + return ( +
+ {/* Sidebar header */} +
+ + Documents + + + {directives.length} + +
+ + {/* Top-level "directives/" folder */} +
+ + directives/ +
+ + {/* Body */} +
+ {loading && directives.length === 0 ? ( +
+ Loading... +
+ ) : directives.length === 0 ? ( +
+ No directives yet +
+ ) : ( + (Object.keys(groups) as SidebarGroup[]).map((group) => { + const list = groups[group]; + if (list.length === 0) return null; + const open = openGroups[group]; + return ( +
+ {/* Group header (sub-folder) */} + + + {/* Files inside the group */} + {open && ( +
    + {list.map((d) => { + const isSelected = d.id === selectedId; + const dot = STATUS_DOT[d.status] ?? STATUS_DOT.draft; + const slug = d.title + .trim() + .replace(/\s+/g, "-") + .replace(/[^a-zA-Z0-9._-]/g, "") + .toLowerCase(); + const fileName = + slug.length > 0 ? `${slug}.md` : `${d.id.slice(0, 8)}.md`; + const orchestratorRunning = !!d.orchestratorTaskId; + return ( +
  • + +
  • + ); + })} +
+ )} +
+ ); + }) + )} +
+
+ ); +} + +// ============================================================================= +// Editor shell — wraps DocumentEditor and handles the "no document selected" +// and loading states. +// ============================================================================= + +interface EditorShellProps { + selectedId: string | undefined; + hasDirectives: boolean; + listLoading: boolean; +} + +function EditorShell({ selectedId, hasDirectives, listLoading }: 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

+
+ ); + } + + return ( +
+ {/* Document header — breadcrumb-like, mirrors a code editor's tab bar */} +
+
+ + directives / + {directive.id.slice(0, 8)} + {!!directive.orchestratorTaskId && ( + + + orchestrator running + + )} +
+
+ + {/* Lexical editor body */} + { + 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 { directives, loading: listLoading } = useDirectives(); + + useEffect(() => { + if (!authLoading && isAuthConfigured && !isAuthenticated) { + navigate("/login"); + } + }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); + + if (authLoading) { + return ( +
+ +
+

Loading...

+
+
+ ); + } + + return ( +
+ +
+ {/* Left: file-tree sidebar */} +
+ navigate(`/directives/${id}`)} + /> +
+ + {/* Right: Lexical editor */} + 0} + listLoading={listLoading} + /> +
+
+ ); +} -- cgit v1.2.3