From cd09103183139788ba4297eaaa6f75d51a154c8a Mon Sep 17 00:00:00 2001 From: soryu Date: Sat, 2 May 2026 17:47:24 +0100 Subject: fix(doc-mode): flatten sidebar list + restore right-click context menu (#124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions reported by the user: 1. The sidebar was grouping contracts under per-status sub-folders (active/idle/archived). The user explicitly does not want that — they want a flat list with status indicated by a colored dot on the right of each row. 2. The right-click context menu on contract rows was missing — start/pause/archive/delete/create-PR/update-PR are no longer reachable through the UI. Fixes: * Drop the SidebarGroup/bucketOf/GROUP_LABEL helpers; replace the grouped render with a flat sort by status precedence (active → paused → idle → draft → inactive → archived) then alpha by title within the same status. The existing dot-color palette is unchanged; the dot just moved from the LEFT of the contract title to the RIGHT (after the orchestrator-running pulse, when present). * Wire `onContextMenu` through SidebarProps → DirectiveFolderProps → the folder header ` {/* Folder body — rendered only when open */} @@ -687,6 +694,7 @@ interface SidebarProps { onCreateDocument: (directive: DirectiveSummary) => Promise; 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 = useMemo(() => { - const out: Record = { - 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 = { + 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>({ - 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>({}); @@ -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({ directives/ - {/* Body */} + {/* Body — flat list, status is a colored dot on the right of each row. */}
{loading && directives.length === 0 ? (
@@ -779,51 +781,26 @@ function DocumentSidebar({
) : directives.length === 0 ? (
- No directives yet + No contracts 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) */} - - - {/* Each directive is a folder containing N documents. */} - {open && ( -
- {list.map((d) => ( - toggleDirective(d.id)} - onHeaderClick={() => onSelectDirective(d.id)} - selection={selection} - onSelectDocument={onSelectDocument} - onCreateDocument={onCreateDocument} - onCreateEphemeralTask={onCreateEphemeralTask} - refreshNonce={refreshNonce} - /> - ))} -
- )} -
- ); - }) +
+ {sortedDirectives.map((d) => ( + toggleDirective(d.id)} + onHeaderClick={() => onSelectDirective(d.id)} + selection={selection} + onSelectDocument={onSelectDocument} + onCreateDocument={onCreateDocument} + onCreateEphemeralTask={onCreateEphemeralTask} + onContextMenu={onContextMenu} + refreshNonce={refreshNonce} + /> + ))} +
)}
@@ -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, 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 (
@@ -1249,6 +1264,7 @@ export default function DocumentDirectivesPage() { onCreateDocument={handleCreateDocument} onCreateContract={() => setShowNewContract(true)} onCreateEphemeralTask={(d) => setNewEphemeralFor(d)} + onContextMenu={handleContextMenu} refreshNonce={refreshNonce} />
@@ -1275,6 +1291,84 @@ export default function DocumentDirectivesPage() { 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", + ) + } + /> + )} ); } -- cgit v1.2.3