From 6d922307223d12f436b229d4c4b29b8835b93b6c Mon Sep 17 00:00:00 2001
From: soryu <soryu@soryu.co>
Date: Thu, 30 Apr 2026 23:26:10 +0100
Subject: fix(doc-mode): root-walk goal serializer + roundtrip-confirmed draft
 drop, plus richer context menus (#114)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

## The data-loss bug

User reported "even after clicking Save now I have lost my doc". Two causes:

1. **GoalChangePlugin only read children[1]** — it captured edits to the
   single goal paragraph but silently dropped any typing that landed in
   the trailing paragraph below the StepsBlock (or in extra paragraphs the
   user had inserted). pendingGoalRef stayed at the persisted value, Save
   now fired empty/stale content, the doc was overwritten.

2. **fireSave dropped localStorage immediately on save success.** If the
   save persisted the wrong/empty content, the draft (which had the real
   content) was already gone — no recovery path.

## Fixes

### Capture all body content
New `serializeGoalFromRoot` walks the entire root, skips only the H1 title
and the StepsBlock decorator, and emits multi-paragraph markdown joined by
blank lines. `GoalChangePlugin` and `fireSave` both call it now. Seed code
splits an existing multi-paragraph goal back into ParagraphNodes on load.

### Read directly from the editor at save time
`fireSave` now reads pendingGoalRef AND consults the live editor state via
`editor.getEditorState().read()`. If anything went wrong with OnChangePlugin
(which is rare, but possible — and was eating typing for many users), the
save still picks up the actual document body.

### Defensive pre-save flush
Before talking to the backend, `fireSave` writes the value to localStorage.
If the network fails, the page closes mid-flight, or anything else goes
sideways, the draft survives.

### Roundtrip-confirmed draft cleanup
We no longer drop the localStorage draft inside `fireSave`. Instead a new
effect watches `directive.goal` and clears the draft only when:
  lastSavedValueRef === directive.goal === pendingGoalRef.current
i.e. only when the polled state confirms our save persisted AND the user
hasn't typed anything new in the meantime.

## Question notifications respect document mode

`SupervisorQuestionNotification` and `PhaseConfirmationToast` now route to
`/directives/<id>?task=<taskId>` (the doc-mode surface) when the user has
documentMode on AND the question carries a directiveId. Falls back to the
old `/exec/:taskId` route for non-doc-mode users.

## Richer directive folder context menu

In addition to start/pause/archive/delete/go-to-PR/new-draft we now expose:
  - Advance DAG
  - Plan orders
  - Clean up
  - Create / Update PR
All optional callbacks; the legacy tabular UI is unaffected.

## Richer task row context menu

In addition to interrupt/complete/fail/skip we now expose:
  - Send message — browser prompt → sendTaskMessage (same wire as the
    inline comment box)
  - Open in task page — navigates to /exec/:taskId for the full task UI
    (worktree diff viewer, checkpoint controls, etc.)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 makima/frontend/src/routes/document-directives.tsx | 73 ++++++++++++++++++++++
 1 file changed, 73 insertions(+)

(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 d442a41..ffd2a8b 100644
--- a/makima/frontend/src/routes/document-directives.tsx
+++ b/makima/frontend/src/routes/document-directives.tsx
@@ -18,6 +18,11 @@ import {
   stopTask,
   listDirectiveRevisions,
   newDirectiveDraft,
+  createDirectivePR,
+  advanceDirective,
+  cleanupDirective,
+  pickUpOrders,
+  sendTaskMessage,
 } from "../lib/api";
 import type {
   DirectiveStatus,
@@ -188,6 +193,10 @@ interface TaskContextMenuProps {
   onComplete?: () => void;
   onFail?: () => void;
   onSkip?: () => void;
+  /** Send a freeform message to the running task (same wire as the inline comment box). */
+  onSendMessage?: () => void;
+  /** Navigate to the standalone task page for full-screen control. */
+  onOpenInTaskPage?: () => void;
 }
 
 function TaskContextMenu({
@@ -199,6 +208,8 @@ function TaskContextMenu({
   onComplete,
   onFail,
   onSkip,
+  onSendMessage,
+  onOpenInTaskPage,
 }: TaskContextMenuProps) {
   const ref = useRef<HTMLDivElement>(null);
 
@@ -297,6 +308,34 @@ function TaskContextMenu({
           Skip
         </button>
       )}
+
+      {/* Direct task-page actions: send-message and open-in-task-page mirror
+          what the standalone /exec/:taskId page exposes. */}
+      {(onSendMessage || onOpenInTaskPage) && <div className={divider} />}
+      {onSendMessage && (
+        <button
+          className={item}
+          onClick={() => {
+            onSendMessage();
+            onClose();
+          }}
+        >
+          <span className="text-cyan-300">⌨</span>
+          Send message
+        </button>
+      )}
+      {onOpenInTaskPage && (
+        <button
+          className={item}
+          onClick={() => {
+            onOpenInTaskPage();
+            onClose();
+          }}
+        >
+          <span className="text-[#75aafc]">↗</span>
+          Open in task page
+        </button>
+      )}
     </div>
   );
 }
@@ -1188,6 +1227,22 @@ export default function DocumentDirectivesPage() {
             // start typing the next iteration immediately.
             navigate(`/directives/${contextMenu.directive.id}`);
           }}
+          onCreatePR={async () => {
+            await createDirectivePR(contextMenu.directive.id);
+            await refreshList();
+          }}
+          onAdvance={async () => {
+            await advanceDirective(contextMenu.directive.id);
+            await refreshList();
+          }}
+          onCleanup={async () => {
+            await cleanupDirective(contextMenu.directive.id);
+            await refreshList();
+          }}
+          onPickUpOrders={async () => {
+            await pickUpOrders(contextMenu.directive.id);
+            await refreshList();
+          }}
         />
       )}
       {contextMenu?.kind === "task" && (
@@ -1229,6 +1284,24 @@ export default function DocumentDirectivesPage() {
             );
             await refreshList();
           }}
+          onSendMessage={async () => {
+            // Browser prompt is the lightest-weight surface that doesn't
+            // require redesigning a modal. The same comment box is also
+            // available below the live transcript when the task is selected.
+            const message = window.prompt("Send message to task:");
+            if (!message || !message.trim()) return;
+            try {
+              await sendTaskMessage(contextMenu.task.taskId, message.trim());
+            } catch (err) {
+              // eslint-disable-next-line no-console
+              console.error("[makima] failed to send task message", err);
+            }
+          }}
+          onOpenInTaskPage={() => {
+            // The standalone /exec/:taskId page has the full task UI with
+            // worktree diff viewer, checkpoint controls, etc.
+            navigate(`/exec/${contextMenu.task.taskId}`);
+          }}
         />
       )}
     </div>
-- 
cgit v1.2.3

