diff options
| author | soryu <soryu@soryu.co> | 2026-05-02 17:47:24 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-02 17:47:24 +0100 |
| commit | cd09103183139788ba4297eaaa6f75d51a154c8a (patch) | |
| tree | d4874def26a46d94c7cd290bdb8f27e9715117e3 | |
| parent | 2c3b0e3926b8c535fb610092301f8621440b51ed (diff) | |
| download | soryu-cd09103183139788ba4297eaaa6f75d51a154c8a.tar.gz soryu-cd09103183139788ba4297eaaa6f75d51a154c8a.zip | |
fix(doc-mode): flatten sidebar list + restore right-click context menu (#124)
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 `<button>`. Page-level state captures the click
position and the targeted directive; the existing
DirectiveContextMenu component (start/pause/archive/delete/PR/
Advance/Cleanup/PickUpOrders) renders on top.
* runAction helper centralises error handling: dispatches the API
call, refreshes the sidebar list + bumps the document-folder
refresh nonce on success, surfaces an alert on failure.
* Delete confirms via window.confirm and clears the URL when the
deleted contract was the one selected.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| -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> ); } |
