summaryrefslogtreecommitdiff
path: root/makima/frontend/src/routes/document-directives.tsx
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-05-16 19:55:34 +0100
committersoryu <soryu@soryu.co>2026-05-16 19:55:34 +0100
commit8cd7b40ace4e5e2b22ad89aafec74c7655def19b (patch)
tree73e9ba4b91086cf8043eb71a295f75589e9bbe90 /makima/frontend/src/routes/document-directives.tsx
parent8e2bbcab1a7b3b9005803d7ce3bfce7fa483a4d7 (diff)
downloadsoryu-8cd7b40ace4e5e2b22ad89aafec74c7655def19b.tar.gz
soryu-8cd7b40ace4e5e2b22ad89aafec74c7655def19b.zip
feat(directives): strict orchestration flow + sidebar overhaul + task page rewritestrict-orchestration
End-to-end rewrite addressing the issues from the user's UX review. The system now feels like a daemon-orchestration tool: lock a contract and the orchestrator just goes; PR raised → auto-ship → reopen for amendments. The sidebar tree shows real entities only (no duplicates, no inline action buttons polluting the file list), and every entity gets a right-click context menu. Task page matches the old /exec layout (diff on the left, feed + composer on the right). ## Backend — strict lifecycle (the orchestrator-never-spawned bug) Root cause: `phase_planning()` gates on `directive.status='active'`, but `start_contract()` only flipped the contract row — the parent directive stayed in whatever state it was. So locking a contract did nothing visible. Fix: contract lifecycle now drives directive status in the same transaction. start_contract → if contract becomes active, flip directive draft|paused|idle|inactive → active pause_contract → after promote, if no active contract left, directive → paused complete_contract→ after promote, if no active left, directive → inactive (also fires on auto-ship from PR detect) unlock_contract → if was active and no active left, directive → paused reopen_contract → NEW. shipped → active. Directive → active, orchestrator_task_id/pr_url/pr_branch cleared so the reconciler spawns a fresh planner. The planner reads get_latest_merged_revision and frames the new plan as an amendment. handlers::directive_documents lifts state.kick_directive_reconciler() into run_contract_transition so every successful transition wakes the reconciler immediately (no 15s wait). handlers::directives `update_directive` (PR-detection branch) calls `complete_contract(active_contract_id, pr_url, pr_branch)` instead of `set_directive_inactive`. The contract auto-ships; the directive follows via the sync above. No more manual "Mark complete" click. POST /api/v1/contracts/{id}/reopen added + wired through openapi. Spawn task names dropped the directive-title prefix that looked redundant in the sidebar: "Plan: <title>" → "orchestrator" "Re-plan: <title>" → "orchestrator (re-plan)" "PR: <title>" → "completion" "Update PR: <title>" → "completion (update)" ## Frontend — sidebar * De-dupe: DocumentTasksFolder filters tasks[] to exclude any task whose id already appears in steps[].taskId. Single row per task, single highlight on click. * Generic SidebarContextMenu (new) replaces the directive-only DirectiveContextMenu (deleted). Per-entity item arrays built at the page level — directive, contract, step, task each have their own contextual actions. * Right-click works on every sidebar entity now (was directive-only). * `+ New document` / `+ New ephemeral task` inline buttons removed. Reachable via the directive folder right-click OR the hover-only `+` button on the directive folder row. * ContractHeader: dropped "Mark complete" button (auto-fires on PR). Added "Reopen for amendment" button when contract is shipped. ## Frontend — task page rewrite TaskPage.tsx replaces DocumentTaskStream.tsx (deleted). Two-column layout matches the old /exec page that the user preferred: ┌────────────────────────┬──────────────────────────────────┐ │ Changed files (~30%) │ Transcript feed (scrollable) │ │ ────────────────── │ ────────────────────── │ │ src/foo.rs │ [user] do thing │ │ src/bar.rs │ [tool] Read foo.rs │ │ │ │ │ Diff (selected file) │ │ │ ├──────────────────────────────────┤ │ │ Composer (sticky bottom) │ └────────────────────────┴──────────────────────────────────┘ Diff comes from getTaskDiff(); parseDiff + DiffFileView exported from OverlayDiffViewer for reuse (no duplication). Diff auto-refreshes when the task transitions to a terminal state. Transcript styling + sticky composer keep the parts the user liked. "Open in task page" button removed — the right pane IS the task page. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'makima/frontend/src/routes/document-directives.tsx')
-rw-r--r--makima/frontend/src/routes/document-directives.tsx466
1 files changed, 298 insertions, 168 deletions
diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx
index 479dcd8..801e397 100644
--- a/makima/frontend/src/routes/document-directives.tsx
+++ b/makima/frontend/src/routes/document-directives.tsx
@@ -20,8 +20,8 @@ import {
listDirectiveContractTasks as listContractTasks,
startDirectiveContract,
pauseDirectiveContract,
- completeDirectiveContract,
unlockDirectiveContract,
+ reopenDirectiveContract,
reorderDirectiveContract,
createDirectiveTask,
startDirective,
@@ -32,9 +32,11 @@ import {
advanceDirective,
cleanupDirective,
pickUpOrders,
+ stopTask,
+ skipDirectiveStep,
} from "../lib/api";
-import { DirectiveContextMenu } from "../components/directives/DirectiveContextMenu";
-import { DocumentTaskStream } from "../components/directives/DocumentTaskStream";
+import { SidebarContextMenu, type ContextMenuItem } from "../components/SidebarContextMenu";
+import { TaskPage } from "../components/directives/TaskPage";
// 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.
@@ -182,11 +184,12 @@ interface DirectiveFolderProps {
onHeaderClick: () => void;
selection: SidebarSelection | null;
onSelectDocument: (directiveId: string, doc: Contract) => void;
- 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;
+ /** Right-click handler — opens the generic context menu with items
+ * built for the given entity type. */
+ onContextMenuDirective: (e: React.MouseEvent, directive: DirectiveSummary) => void;
+ onContextMenuContract: (e: React.MouseEvent, contract: Contract) => void;
+ onContextMenuStep: (e: React.MouseEvent, step: DirectiveStep, directiveId: string) => void;
+ onContextMenuTask: (e: React.MouseEvent, task: Task, directiveId: string) => void;
/** Click handler for task/step rows — navigates to the live transcript. */
onSelectTask: (directiveId: string, taskId: string) => void;
/**
@@ -204,9 +207,10 @@ function DirectiveFolder({
onHeaderClick,
selection,
onSelectDocument,
- onCreateDocument,
- onCreateEphemeralTask,
- onContextMenu,
+ onContextMenuDirective,
+ onContextMenuContract,
+ onContextMenuStep,
+ onContextMenuTask,
onSelectTask,
refreshNonce,
}: DirectiveFolderProps) {
@@ -226,9 +230,6 @@ function DirectiveFolder({
// shipped/ subfolder open state — independent of the directive folder.
const [shippedOpen, setShippedOpen] = useState(false);
- // Whether a "+ New document" call is in flight (disables the button).
- const [creating, setCreating] = useState(false);
-
const refresh = useCallback(async () => {
setDocsLoading(true);
setDocsError(null);
@@ -271,18 +272,6 @@ function DirectiveFolder({
// so it can rename itself to `tasks - <contract name>/` for clarity.
const multipleContracts = activeDocs.length + shippedDocs.length > 1;
- const handleCreate = useCallback(async () => {
- if (creating) return;
- setCreating(true);
- try {
- await onCreateDocument(directive);
- // Refresh after creating so the new doc appears in the list.
- await refresh();
- } finally {
- setCreating(false);
- }
- }, [creating, onCreateDocument, directive, refresh]);
-
// Selection helpers — used to highlight the currently-selected doc row.
const selectedDocumentId =
selection && selection.directiveId === directive.id
@@ -318,16 +307,29 @@ function DirectiveFolder({
{/* 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"
+ delete / create-PR / update-PR / etc.).
+
+ Row is a div with onClick (not a <button>) so we can nest a
+ real `+` button inside that surfaces on hover for quick
+ contract / ephemeral-task creation without leaving the file
+ tree. */}
+ <div
+ role="button"
+ tabIndex={0}
onClick={() => {
onToggle();
onHeaderClick();
}}
- onContextMenu={(e) => onContextMenu(e, directive)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onToggle();
+ onHeaderClick();
+ }
+ }}
+ onContextMenu={(e) => onContextMenuDirective(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)]"
+ className="group 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)] cursor-pointer"
>
<Caret open={open} />
<FolderIcon open={open} />
@@ -337,6 +339,23 @@ function DirectiveFolder({
: directive.id.slice(0, 8)}
/
</span>
+ {/* Hover-only quick-add affordance — replaces the old inline
+ "+ New document" / "+ New ephemeral task" buttons that used
+ to sit inside the folder body. Builds the same items array
+ the directive context menu would, so right-click and `+`
+ stay in sync. */}
+ <button
+ type="button"
+ onClick={(e) => {
+ e.stopPropagation();
+ onContextMenuDirective(e, directive);
+ }}
+ className="opacity-0 group-hover:opacity-100 text-[12px] leading-none text-emerald-300 hover:text-white px-1"
+ title="New contract / ephemeral task"
+ aria-label="New contract or ephemeral task"
+ >
+ +
+ </button>
{orchestratorRunning && (
<span
className="inline-block w-1.5 h-1.5 rounded-full shrink-0 bg-yellow-400 animate-pulse"
@@ -350,7 +369,7 @@ function DirectiveFolder({
aria-label={`status: ${directive.status}`}
title={`status: ${directive.status}`}
/>
- </button>
+ </div>
{/* Folder body — rendered only when open */}
{open && (
@@ -369,36 +388,16 @@ function DirectiveFolder({
{/* Active group */}
{docs && (
<>
- {/* + New document affordance — sits at the top of the active list
- so the user can always reach it without scrolling past
- existing docs. */}
- <button
- type="button"
- onClick={handleCreate}
- disabled={creating}
- className="w-full flex items-center gap-1.5 pl-14 pr-3 py-1 font-mono text-[11px] text-emerald-400 hover:bg-[rgba(74,222,128,0.06)] disabled:opacity-50"
- title="Create a new document under this directive"
- >
- <span className="text-[12px] leading-none">+</span>
- <span>New document</span>
- </button>
-
- {/* + New ephemeral task — sibling affordance for spawning a
- one-off task under this directive that's NOT part of the
- DAG. Useful for sidebar scratch work, debugging, etc. */}
- <button
- type="button"
- onClick={() => onCreateEphemeralTask(directive)}
- className="w-full flex items-center gap-1.5 pl-14 pr-3 py-1 font-mono text-[11px] text-[#c084fc] hover:bg-[rgba(192,132,252,0.06)]"
- title="Spawn a one-off ephemeral task under this directive"
- >
- <span className="text-[12px] leading-none">+</span>
- <span>New ephemeral task</span>
- </button>
+ {/* "New contract" / "New ephemeral task" used to be rendered
+ here as inline buttons. They're now reachable via the
+ directive folder's right-click context menu and the `+`
+ hover-button on the directive header (see DirectiveFolder
+ row above). Keeping the file tree free of action rows
+ makes the hierarchy easier to scan. */}
{activeDocs.length === 0 && !docsLoading && (
<div className="pl-14 pr-3 py-1 font-mono text-[10px] text-[#556677] italic">
- no active documents
+ no contracts yet — right-click to add
</div>
)}
@@ -413,6 +412,7 @@ function DirectiveFolder({
directive={directive}
selected={doc.id === selectedDocumentId}
onSelect={() => onSelectDocument(directive.id, doc)}
+ onContextMenu={onContextMenuContract}
draggable
onDragStart={() => setDragId(doc.id)}
onDragEnd={() => {
@@ -438,6 +438,8 @@ function DirectiveFolder({
refreshNonce={refreshNonce}
selectedTaskId={selectedTaskIdForFolder}
onSelectTask={onSelectTask}
+ onContextMenuStep={onContextMenuStep}
+ onContextMenuTask={onContextMenuTask}
contractLabel={fileLabel(doc, directive)}
multipleContracts={multipleContracts}
/>
@@ -474,6 +476,7 @@ function DirectiveFolder({
directive={directive}
selected={doc.id === selectedDocumentId}
onSelect={() => onSelectDocument(directive.id, doc)}
+ onContextMenu={onContextMenuContract}
indent="deep"
/>
<DocumentTasksFolder
@@ -484,6 +487,8 @@ function DirectiveFolder({
refreshNonce={refreshNonce}
selectedTaskId={selectedTaskIdForFolder}
onSelectTask={onSelectTask}
+ onContextMenuStep={onContextMenuStep}
+ onContextMenuTask={onContextMenuTask}
contractLabel={fileLabel(doc, directive)}
multipleContracts={multipleContracts}
/>
@@ -509,6 +514,7 @@ interface DocumentRowProps {
directive: DirectiveSummary;
selected: boolean;
onSelect: () => void;
+ onContextMenu: (e: React.MouseEvent, contract: Contract) => void;
indent?: "normal" | "deep";
// ----- Drag-to-reorder props (optional — only wired by the active list) ---
/** Whether this row participates in HTML5 drag (active docs only). */
@@ -527,6 +533,7 @@ function DocumentRow({
directive,
selected,
onSelect,
+ onContextMenu,
indent = "normal",
draggable = false,
onDragStart,
@@ -549,6 +556,7 @@ function DocumentRow({
<button
type="button"
onClick={onSelect}
+ onContextMenu={(e) => onContextMenu(e, doc)}
title={name}
draggable={draggable}
onDragStart={(e) => {
@@ -636,6 +644,8 @@ interface DocumentTasksFolderProps {
selectedTaskId: string | null;
/** Click handler for step/task rows — navigates to the live transcript. */
onSelectTask: (directiveId: string, taskId: string) => void;
+ onContextMenuStep: (e: React.MouseEvent, step: DirectiveStep, directiveId: string) => void;
+ onContextMenuTask: (e: React.MouseEvent, task: Task, directiveId: string) => void;
/** Human-readable contract label (already resolved via fileLabel). Used to
* disambiguate multiple tasks/ folders under the same directive. */
contractLabel: string;
@@ -653,6 +663,8 @@ function DocumentTasksFolder({
refreshNonce,
selectedTaskId,
onSelectTask,
+ onContextMenuStep,
+ onContextMenuTask,
contractLabel,
multipleContracts,
}: DocumentTasksFolderProps) {
@@ -688,7 +700,20 @@ function DocumentTasksFolder({
void refresh();
}, [open, refresh, refreshNonce]);
- const total = (data?.steps.length ?? 0) + (data?.tasks.length ?? 0);
+ // De-duplicate: a step that has spawned a task appears in `steps[]`
+ // with step.taskId === task.id, and the same task ALSO appears in
+ // `tasks[]`. Rendering both produces a double-row + double-highlight
+ // when the user clicks. Keep the step row (it carries the step name,
+ // which is the meaningful label) and drop the matching task row.
+ const stepTaskIds = useMemo(
+ () => new Set((data?.steps ?? []).map((s) => s.taskId).filter((id): id is string => !!id)),
+ [data],
+ );
+ const ephemeralTasks = useMemo(
+ () => (data?.tasks ?? []).filter((t) => !stepTaskIds.has(t.id)),
+ [data, stepTaskIds],
+ );
+ const total = (data?.steps.length ?? 0) + ephemeralTasks.length;
// Folder always renders (even when empty) so the user can click into a
// fresh contract's tasks/ folder and see it stay visible. The empty state
@@ -742,9 +767,10 @@ function DocumentTasksFolder({
selected={!!selectedTaskId && step.taskId === selectedTaskId}
padLeft={rowPadLeft}
onSelect={onSelectTask}
+ onContextMenu={onContextMenuStep}
/>
))}
- {data?.tasks.map((task) => (
+ {ephemeralTasks.map((task) => (
<TaskRow
key={`task-${task.id}`}
task={task}
@@ -752,6 +778,7 @@ function DocumentTasksFolder({
selected={task.id === selectedTaskId}
padLeft={rowPadLeft}
onSelect={onSelectTask}
+ onContextMenu={onContextMenuTask}
/>
))}
</div>
@@ -790,6 +817,7 @@ interface StepRowProps {
selected: boolean;
padLeft: string;
onSelect: (directiveId: string, taskId: string) => void;
+ onContextMenu: (e: React.MouseEvent, step: DirectiveStep, directiveId: string) => void;
}
function StepRow({
@@ -798,6 +826,7 @@ function StepRow({
selected,
padLeft,
onSelect,
+ onContextMenu,
}: StepRowProps) {
const dot = STEP_STATUS_DOT[step.status] ?? "bg-[#556677]";
// Steps without an underlying task can't be opened — the executor
@@ -811,6 +840,7 @@ function StepRow({
type="button"
disabled={!clickable}
onClick={() => clickable && onSelect(directiveId, taskId!)}
+ onContextMenu={(e) => onContextMenu(e, step, directiveId)}
title={
clickable
? `${step.name} (${step.status})`
@@ -844,6 +874,7 @@ interface TaskRowProps {
selected: boolean;
padLeft: string;
onSelect: (directiveId: string, taskId: string) => void;
+ onContextMenu: (e: React.MouseEvent, task: Task, directiveId: string) => void;
}
function TaskRow({
@@ -852,6 +883,7 @@ function TaskRow({
selected,
padLeft,
onSelect,
+ onContextMenu,
}: TaskRowProps) {
const dot = TASK_STATUS_DOT[task.status] ?? "bg-[#556677]";
// Supervisor tasks get a small "sup" tag so the user can spot
@@ -861,6 +893,7 @@ function TaskRow({
<button
type="button"
onClick={() => onSelect(directiveId, task.id)}
+ onContextMenu={(e) => onContextMenu(e, task, directiveId)}
title={`${task.name} (${task.status})`}
className={`w-full text-left flex items-center gap-1.5 ${padLeft} pr-3 py-1 font-mono text-[11px] transition-colors ${
selected
@@ -892,10 +925,14 @@ interface SidebarProps {
selection: SidebarSelection | null;
onSelectDocument: (directiveId: string, doc: Contract) => void;
onSelectDirective: (directiveId: string) => void;
- onCreateDocument: (directive: DirectiveSummary) => Promise<void>;
+ /** Top-level "Contracts" header `+ New` button — opens the
+ * NewContractModal to create a brand-new directive (along with its
+ * first contract). */
onCreateContract: () => void;
- onCreateEphemeralTask: (directive: DirectiveSummary) => void;
- onContextMenu: (e: React.MouseEvent, directive: DirectiveSummary) => void;
+ onContextMenuDirective: (e: React.MouseEvent, directive: DirectiveSummary) => void;
+ onContextMenuContract: (e: React.MouseEvent, contract: Contract) => void;
+ onContextMenuStep: (e: React.MouseEvent, step: DirectiveStep, directiveId: string) => void;
+ onContextMenuTask: (e: React.MouseEvent, task: Task, directiveId: string) => void;
onSelectTask: (directiveId: string, taskId: string) => void;
refreshNonce: number;
}
@@ -906,10 +943,11 @@ function DocumentSidebar({
selection,
onSelectDocument,
onSelectDirective,
- onCreateDocument,
onCreateContract,
- onCreateEphemeralTask,
- onContextMenu,
+ onContextMenuDirective,
+ onContextMenuContract,
+ onContextMenuStep,
+ onContextMenuTask,
onSelectTask,
refreshNonce,
}: SidebarProps) {
@@ -997,9 +1035,10 @@ function DocumentSidebar({
onHeaderClick={() => onSelectDirective(d.id)}
selection={selection}
onSelectDocument={onSelectDocument}
- onCreateDocument={onCreateDocument}
- onCreateEphemeralTask={onCreateEphemeralTask}
- onContextMenu={onContextMenu}
+ onContextMenuDirective={onContextMenuDirective}
+ onContextMenuContract={onContextMenuContract}
+ onContextMenuStep={onContextMenuStep}
+ onContextMenuTask={onContextMenuTask}
onSelectTask={onSelectTask}
refreshNonce={refreshNonce}
/>
@@ -1043,7 +1082,7 @@ function ContractHeader({
docTitle,
onContractChanged,
}: ContractHeaderProps) {
- const [busy, setBusy] = useState<null | "start" | "pause" | "complete" | "unlock" | "merge_mode">(
+ const [busy, setBusy] = useState<null | "start" | "pause" | "unlock" | "reopen" | "merge_mode">(
null,
);
const [error, setError] = useState<string | null>(null);
@@ -1072,14 +1111,14 @@ function ContractHeader({
() => wrap("pause", () => pauseDirectiveContract(doc.id)),
[doc.id, wrap],
);
- const onComplete = useCallback(
- () => wrap("complete", () => completeDirectiveContract(doc.id)),
- [doc.id, wrap],
- );
const onUnlock = useCallback(
() => wrap("unlock", () => unlockDirectiveContract(doc.id)),
[doc.id, wrap],
);
+ const onReopen = useCallback(
+ () => wrap("reopen", () => reopenDirectiveContract(doc.id)),
+ [doc.id, wrap],
+ );
const onMergeMode = useCallback(
(mode: ContractMergeMode) =>
wrap("merge_mode", () => updateContract(doc.id, { mergeMode: mode })),
@@ -1127,14 +1166,20 @@ function ContractHeader({
<ContractActionButton onClick={onPause} disabled={busy !== null}>
{busy === "pause" ? "Pausing…" : "Pause"}
</ContractActionButton>
- <ContractActionButton onClick={onComplete} disabled={busy !== null} variant="primary">
- {busy === "complete" ? "Completing…" : "Mark complete"}
- </ContractActionButton>
<ContractActionButton onClick={onUnlock} disabled={busy !== null}>
{busy === "unlock" ? "Unlocking…" : "Unlock"}
</ContractActionButton>
+ {/* No "Mark complete" — the contract auto-ships when the
+ orchestrator raises a PR (server-side; see
+ update_directive's PR-detection branch in handlers/
+ directives.rs). */}
</>
)}
+ {doc.status === "shipped" && (
+ <ContractActionButton onClick={onReopen} disabled={busy !== null} variant="primary">
+ {busy === "reopen" ? "Reopening…" : "Reopen for amendment"}
+ </ContractActionButton>
+ )}
{/* Merge mode radios — visible always, editable only in draft/queued */}
<div className="ml-auto flex items-center gap-2 text-[#7788aa]">
@@ -1352,7 +1397,7 @@ function EditorShell({
}
// --- Task path: task row clicked in the sidebar ------------------------
- // Renders the live transcript via DocumentTaskStream. Selection wins over
+ // Renders the live transcript via TaskPage. Selection wins over
// the document path when both are somehow present (defensive).
if (selection?.taskId) {
const taskId = selection.taskId;
@@ -1386,7 +1431,7 @@ function EditorShell({
<span className="text-white">{label}</span>
</div>
</div>
- <DocumentTaskStream
+ <TaskPage
taskId={taskId}
label={label}
ephemeral={!isStepBound}
@@ -1617,7 +1662,7 @@ export default function DocumentDirectivesPage() {
);
// Click on a task or step row → open the live transcript pane via
- // ?task=<id>. EditorShell switches to DocumentTaskStream when this is set.
+ // ?task=<id>. EditorShell switches to TaskPage when this is set.
const handleSelectTask = useCallback(
(directiveId: string, taskId: string) => {
navigate(`/directives/${directiveId}?task=${taskId}`);
@@ -1657,25 +1702,18 @@ 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.
+ // Right-click context menu — generic. The state holds whichever items
+ // array the entity's builder produced; the SidebarContextMenu just
+ // renders them. Each entity type has its own builder (directive,
+ // contract, step, task) hung off the page so all the action callbacks
+ // (start/pause/archive/delete/etc.) live in one place.
const { refresh: refreshDirectiveList } = useDirectives();
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
- directive: DirectiveSummary;
+ items: ContextMenuItem[];
} | 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(
@@ -1695,6 +1733,166 @@ export default function DocumentDirectivesPage() {
[refreshDirectiveList, bumpRefresh],
);
+ const openMenu = useCallback(
+ (e: React.MouseEvent, items: ContextMenuItem[]) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setContextMenu({ x: e.clientX, y: e.clientY, items });
+ },
+ [],
+ );
+
+ // ---- Per-entity menu builders. Each returns its items array; the
+ // handler wraps them with openMenu so each row can fire its own
+ // right-click with one line of wiring. ----
+ const handleContextMenuDirective = useCallback(
+ (e: React.MouseEvent, d: DirectiveSummary) => {
+ const items: ContextMenuItem[] = [
+ {
+ label: "+ New contract here",
+ onClick: () => {
+ void handleCreateDocument(d);
+ },
+ },
+ { label: "+ New ephemeral task", onClick: () => setNewEphemeralFor(d) },
+ { label: "", separator: true },
+ {
+ label: "Start",
+ onClick: () => runAction(() => startDirective(d.id), "Failed to start directive"),
+ disabled: d.status === "active" || d.status === "archived",
+ },
+ {
+ label: "Pause",
+ onClick: () => runAction(() => pauseDirective(d.id), "Failed to pause directive"),
+ disabled: d.status !== "active",
+ },
+ { label: "", separator: true },
+ {
+ label: d.prUrl ? "Update PR" : "Create PR",
+ onClick: () =>
+ runAction(
+ () => createDirectivePR(d.id),
+ d.prUrl ? "Failed to update PR" : "Failed to create PR",
+ ),
+ },
+ ...(d.prUrl
+ ? [{
+ label: "Go to PR",
+ onClick: () => window.open(d.prUrl!, "_blank", "noreferrer"),
+ } as ContextMenuItem]
+ : []),
+ {
+ label: "Advance DAG",
+ onClick: () => runAction(() => advanceDirective(d.id), "Failed to advance DAG"),
+ },
+ {
+ label: "Cleanup merged steps",
+ onClick: () => runAction(() => cleanupDirective(d.id), "Failed to clean up"),
+ },
+ {
+ label: "Pick up orders",
+ onClick: () => runAction(() => pickUpOrders(d.id), "Failed to pick up orders"),
+ },
+ { label: "", separator: true },
+ {
+ label: "Archive",
+ danger: true,
+ onClick: () =>
+ runAction(
+ () => updateDirective(d.id, { status: "archived" }),
+ "Failed to archive",
+ ),
+ disabled: d.status === "archived",
+ },
+ {
+ label: "Delete",
+ danger: true,
+ onClick: async () => {
+ if (!window.confirm(`Delete "${d.title}"? This cannot be undone.`)) return;
+ await runAction(() => deleteDirective(d.id), "Failed to delete");
+ if (selection?.directiveId === d.id) navigate("/directives");
+ },
+ },
+ ];
+ openMenu(e, items);
+ },
+ [openMenu, runAction, navigate, selection],
+ );
+
+ const handleContextMenuContract = useCallback(
+ (e: React.MouseEvent, c: Contract) => {
+ const items: ContextMenuItem[] = [
+ {
+ label: "Lock & Start",
+ onClick: () =>
+ runAction(() => startDirectiveContract(c.id), "Failed to start contract"),
+ disabled: c.status !== "draft",
+ },
+ {
+ label: "Pause",
+ onClick: () =>
+ runAction(() => pauseDirectiveContract(c.id), "Failed to pause contract"),
+ disabled: c.status !== "active",
+ },
+ {
+ label: "Unlock",
+ onClick: () =>
+ runAction(() => unlockDirectiveContract(c.id), "Failed to unlock contract"),
+ disabled: c.status !== "active" && c.status !== "queued",
+ },
+ {
+ label: "Reopen for amendment",
+ onClick: () =>
+ runAction(() => reopenDirectiveContract(c.id), "Failed to reopen contract"),
+ disabled: c.status !== "shipped",
+ },
+ ];
+ openMenu(e, items);
+ },
+ [openMenu, runAction],
+ );
+
+ const handleContextMenuStep = useCallback(
+ (e: React.MouseEvent, step: DirectiveStep, directiveId: string) => {
+ const items: ContextMenuItem[] = [
+ {
+ label: "Stop task",
+ onClick: () =>
+ step.taskId
+ ? runAction(() => stopTask(step.taskId!), "Failed to stop task")
+ : undefined,
+ disabled: !step.taskId || step.status !== "running",
+ },
+ {
+ label: "Skip step",
+ danger: true,
+ onClick: () =>
+ runAction(
+ () => skipDirectiveStep(directiveId, step.id),
+ "Failed to skip step",
+ ),
+ disabled: step.status === "completed" || step.status === "skipped",
+ },
+ ];
+ openMenu(e, items);
+ },
+ [openMenu, runAction],
+ );
+
+ const handleContextMenuTask = useCallback(
+ (e: React.MouseEvent, task: Task, _directiveId: string) => {
+ const items: ContextMenuItem[] = [
+ {
+ label: "Stop task",
+ onClick: () => runAction(() => stopTask(task.id), "Failed to stop task"),
+ disabled: task.status === "done" || task.status === "failed" || task.status === "merged",
+ },
+ ];
+ openMenu(e, items);
+ },
+ [openMenu, runAction],
+ );
+
if (authLoading) {
return (
<div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
@@ -1725,10 +1923,11 @@ export default function DocumentDirectivesPage() {
selection={selection}
onSelectDocument={handleSelectDocument}
onSelectDirective={handleSelectDirective}
- onCreateDocument={handleCreateDocument}
onCreateContract={() => setShowNewContract(true)}
- onCreateEphemeralTask={(d) => setNewEphemeralFor(d)}
- onContextMenu={handleContextMenu}
+ onContextMenuDirective={handleContextMenuDirective}
+ onContextMenuContract={handleContextMenuContract}
+ onContextMenuStep={handleContextMenuStep}
+ onContextMenuTask={handleContextMenuTask}
onSelectTask={handleSelectTask}
refreshNonce={refreshNonce}
/>
@@ -1758,80 +1957,11 @@ export default function DocumentDirectivesPage() {
)}
{contextMenu && (
- <DirectiveContextMenu
+ <SidebarContextMenu
x={contextMenu.x}
y={contextMenu.y}
- directive={contextMenu.directive}
+ items={contextMenu.items}
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>