diff options
Diffstat (limited to 'makima/frontend')
| -rw-r--r-- | makima/frontend/src/routes/document-directives.tsx | 272 |
1 files changed, 183 insertions, 89 deletions
diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx index 427122c..c5cf151 100644 --- a/makima/frontend/src/routes/document-directives.tsx +++ b/makima/frontend/src/routes/document-directives.tsx @@ -18,7 +18,16 @@ import { updateDirectiveDocument, listDirectiveDocumentTasks, createDirectiveTask, + startDirective, + pauseDirective, + updateDirective, + deleteDirective, + createDirectivePR, + advanceDirective, + cleanupDirective, + pickUpOrders, } from "../lib/api"; +import { DirectiveContextMenu } from "../components/directives/DirectiveContextMenu"; // 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. @@ -46,20 +55,9 @@ const DOC_STATUS_DOT: Record<DirectiveDocumentStatus, string> = { // default and keep Active / Idle expanded. // ============================================================================= -type SidebarGroup = "active" | "idle" | "archived"; - -const GROUP_LABEL: Record<SidebarGroup, string> = { - 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 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 @@ -177,6 +175,8 @@ interface DirectiveFolderProps { onCreateDocument: (directive: DirectiveSummary) => Promise<void>; /** 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; /** * Document refresh trigger — bumped externally so the folder refetches its * document list after a create/update happens elsewhere. Primarily used so @@ -194,6 +194,7 @@ function DirectiveFolder({ onSelectDocument, onCreateDocument, onCreateEphemeralTask, + onContextMenu, refreshNonce, }: DirectiveFolderProps) { const dotColor = STATUS_DOT[directive.status] ?? STATUS_DOT.draft; @@ -268,22 +269,22 @@ function DirectiveFolder({ return ( <div className="select-none"> - {/* Directive folder header */} + {/* 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.). */} <button type="button" onClick={() => { onToggle(); onHeaderClick(); }} - title={directive.title} - className="w-full flex items-center gap-1.5 pl-9 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)]" + onContextMenu={(e) => onContextMenu(e, directive)} + title={`${directive.title} — ${directive.status}`} + className="w-full flex items-center gap-1.5 pl-3 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)]" > <Caret open={open} /> <FolderIcon open={open} /> - <span - className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${dotColor}`} - aria-hidden - /> <span className="truncate flex-1 text-left"> {directive.title.trim().length > 0 ? directive.title @@ -297,6 +298,12 @@ function DirectiveFolder({ aria-label="Orchestrator running" /> )} + {/* Status indicator on the RIGHT side of the row. */} + <span + className={`inline-block w-2 h-2 rounded-full shrink-0 ${dotColor}`} + aria-label={`status: ${directive.status}`} + title={`status: ${directive.status}`} + /> </button> {/* Folder body — rendered only when open */} @@ -687,6 +694,7 @@ interface SidebarProps { onCreateDocument: (directive: DirectiveSummary) => Promise<void>; onCreateContract: () => void; onCreateEphemeralTask: (directive: DirectiveSummary) => void; + onContextMenu: (e: React.MouseEvent, directive: DirectiveSummary) => void; refreshNonce: number; } @@ -699,33 +707,30 @@ function DocumentSidebar({ onCreateDocument, onCreateContract, onCreateEphemeralTask, + onContextMenu, refreshNonce, }: SidebarProps) { - const groups: Record<SidebarGroup, DirectiveSummary[]> = useMemo(() => { - const out: Record<SidebarGroup, DirectiveSummary[]> = { - active: [], - idle: [], - archived: [], + // 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<DirectiveStatus, number> = { + active: 0, + paused: 1, + idle: 2, + draft: 3, + inactive: 4, + archived: 5, }; - 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 [...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" }); }); - return out; }, [directives]); - // Default-collapsed state per group folder. - const [openGroups, setOpenGroups] = useState<Record<SidebarGroup, boolean>>({ - active: true, - idle: true, - archived: false, - }); - // 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<Record<string, boolean>>({}); @@ -737,9 +742,6 @@ function DocumentSidebar({ ); }, [selection?.directiveId]); - const toggleGroup = (g: SidebarGroup) => - setOpenGroups((prev) => ({ ...prev, [g]: !prev[g] })); - const toggleDirective = (id: string) => setOpenDirectives((prev) => ({ ...prev, [id]: !prev[id] })); @@ -771,7 +773,7 @@ function DocumentSidebar({ <span>directives/</span> </div> - {/* Body */} + {/* Body — flat list, status is a colored dot on the right of each row. */} <div className="flex-1 overflow-y-auto pb-4"> {loading && directives.length === 0 ? ( <div className="px-3 py-6 text-center text-[#556677] font-mono text-[11px]"> @@ -779,51 +781,26 @@ function DocumentSidebar({ </div> ) : directives.length === 0 ? ( <div className="px-3 py-6 text-center text-[#556677] font-mono text-[11px]"> - No directives yet + No contracts yet </div> ) : ( - (Object.keys(groups) as SidebarGroup[]).map((group) => { - const list = groups[group]; - if (list.length === 0) return null; - const open = openGroups[group]; - return ( - <div key={group} className="select-none"> - {/* Group header (sub-folder) */} - <button - type="button" - onClick={() => toggleGroup(group)} - className="w-full flex items-center gap-1.5 pl-4 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]" - > - <Caret open={open} /> - <FolderIcon open={open} /> - <span>{GROUP_LABEL[group]}/</span> - <span className="ml-auto text-[10px] text-[#556677]"> - {list.length} - </span> - </button> - - {/* Each directive is a folder containing N documents. */} - {open && ( - <div className="py-0.5"> - {list.map((d) => ( - <DirectiveFolder - key={d.id} - directive={d} - open={!!openDirectives[d.id]} - onToggle={() => toggleDirective(d.id)} - onHeaderClick={() => onSelectDirective(d.id)} - selection={selection} - onSelectDocument={onSelectDocument} - onCreateDocument={onCreateDocument} - onCreateEphemeralTask={onCreateEphemeralTask} - refreshNonce={refreshNonce} - /> - ))} - </div> - )} - </div> - ); - }) + <div className="py-0.5"> + {sortedDirectives.map((d) => ( + <DirectiveFolder + key={d.id} + directive={d} + open={!!openDirectives[d.id]} + onToggle={() => toggleDirective(d.id)} + onHeaderClick={() => onSelectDirective(d.id)} + selection={selection} + onSelectDocument={onSelectDocument} + onCreateDocument={onCreateDocument} + onCreateEphemeralTask={onCreateEphemeralTask} + onContextMenu={onContextMenu} + refreshNonce={refreshNonce} + /> + ))} + </div> )} </div> </div> @@ -1216,6 +1193,44 @@ export default function DocumentDirectivesPage() { [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<unknown>, 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 ( <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> @@ -1249,6 +1264,7 @@ export default function DocumentDirectivesPage() { onCreateDocument={handleCreateDocument} onCreateContract={() => setShowNewContract(true)} onCreateEphemeralTask={(d) => setNewEphemeralFor(d)} + onContextMenu={handleContextMenu} refreshNonce={refreshNonce} /> </div> @@ -1275,6 +1291,84 @@ export default function DocumentDirectivesPage() { onSubmit={handleSubmitNewEphemeral} /> )} + + {contextMenu && ( + <DirectiveContextMenu + x={contextMenu.x} + y={contextMenu.y} + directive={contextMenu.directive} + onClose={closeContextMenu} + onStart={() => + 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", + ) + } + /> + )} </div> ); } |
