From 8cd7b40ace4e5e2b22ad89aafec74c7655def19b Mon Sep 17 00:00:00 2001
From: soryu <soryu@soryu.co>
Date: Sat, 16 May 2026 19:55:34 +0100
Subject: feat(directives): strict orchestration flow + sidebar overhaul + task
 page rewrite
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

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>
---
 makima/frontend/src/routes/document-directives.tsx | 466 +++++++++++++--------
 1 file changed, 298 insertions(+), 168 deletions(-)

(limited to 'makima/frontend/src/routes/document-directives.tsx')

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>
-- 
cgit v1.2.3

