summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--makima/frontend/src/routes/document-directives.tsx272
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>
);
}