From 2b695485753d55f956746b73c31c2deba0ed0a29 Mon Sep 17 00:00:00 2001
From: soryu <soryu@soryu.co>
Date: Thu, 30 Apr 2026 09:57:07 +0100
Subject: feat(document-mode): longer goal save countdown, per-directive
 folders, live task stream (#103)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Three connected UX changes for the document-mode directive UI.

## Goal save UX (DocumentEditor.tsx)
- Replace the old 3-second countdown with 60s when no orchestrator is running
  and 10s when one is, so editing a goal mid-flight does not feel rushed.
- The countdown bar is hidden until the last 10 seconds; users see "Saved" /
  "Unsaved changes" indicators rather than a constantly-ticking clock.
- Continuously persist work-in-progress to localStorage on every keystroke
  (debounced 250ms). On mount, if a draft for the directive exists in
  localStorage and differs from the persisted goal, restore it and put the
  editor in dirty/pending state — leaving the page no longer loses work.
- localStorage-backed "Live start" toggle in the bar. When off, the editor
  stays in "dirty" instead of auto-firing; user clicks "Save now" to commit.
- Discard button reverts the editor to the persisted goal and clears the draft.

## Sidebar restructure (document-directives.tsx)
- Drop the active/idle/archived top-level grouping; show one folder per
  directive instead. Folders sort by lifecycle (active, paused, idle, draft,
  archived) then alphabetically.
- Each folder header shows a colored status dot on BOTH sides (left as the
  primary status icon, right as a mirror plus a pulse when the orchestrator
  is live), replacing the previous "/active", "/idle" text labels.
- Inside each open folder: the directive's document is pinned at the top
  (with a small star icon), then the orchestrator task (if running), then
  the completion task, then any step tasks that have started.
- The currently-selected directive's folder is auto-opened so deep links
  always land somewhere visible.

## Live document task stream (DocumentTaskStream.tsx, new)
- Selecting a task in a folder navigates to ?task=<id> and replaces the
  Lexical editor with a document-styled live transcript: assistant prose as
  flowing paragraphs, tool calls as marginalia, results as a closing block.
  No log/code box.
- Comment textarea at the bottom calls sendTaskMessage on submit, the same
  wire the existing TaskOutput input bar uses for interrupts. ⌘/Ctrl-Enter
  submits.
- Header breadcrumb gains a "back to document" affordance to return to the
  pinned doc view without reopening the sidebar.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 .../src/components/directives/DocumentEditor.tsx   | 266 +++++++++++++++++----
 1 file changed, 221 insertions(+), 45 deletions(-)

(limited to 'makima/frontend/src/components/directives/DocumentEditor.tsx')

diff --git a/makima/frontend/src/components/directives/DocumentEditor.tsx b/makima/frontend/src/components/directives/DocumentEditor.tsx
index 40fccf1..d953d45 100644
--- a/makima/frontend/src/components/directives/DocumentEditor.tsx
+++ b/makima/frontend/src/components/directives/DocumentEditor.tsx
@@ -49,8 +49,34 @@ import {
 // Constants
 // =============================================================================
 
-const SAVE_COUNTDOWN_MS = 3000;
+/**
+ * Time between the user's last keystroke and the goal being persisted (which
+ * triggers the orchestrator to (re)plan). Longer when the directive is fresh
+ * so the user can think; shorter when the orchestrator is already running and
+ * we want changes to flow through quickly.
+ */
+const COUNTDOWN_FRESH_MS = 60_000;
+const COUNTDOWN_RUNNING_MS = 10_000;
+/** The countdown bar only appears once we're inside this many ms from firing. */
+const BAR_VISIBLE_MS = 10_000;
 const SAVED_TOAST_MS = 1200;
+/** Debounce for writing the in-progress draft to localStorage (no backend hit). */
+const DRAFT_PERSIST_DEBOUNCE_MS = 250;
+const DRAFT_KEY = (directiveId: string) => `makima:directive-goal-draft:${directiveId}`;
+const LIVE_START_KEY = "makima:liveStartEnabled";
+
+function isLiveStartEnabled(): boolean {
+  if (typeof window === "undefined") return true;
+  const raw = window.localStorage.getItem(LIVE_START_KEY);
+  // Default: live start ON — preserves existing behaviour for users who never
+  // touch the toggle.
+  return raw === null ? true : raw === "true";
+}
+
+function setLiveStartEnabled(value: boolean) {
+  if (typeof window === "undefined") return;
+  window.localStorage.setItem(LIVE_START_KEY, value ? "true" : "false");
+}
 
 // =============================================================================
 // Editor theme — minimal, just enough so the rich-text plugin has something to
@@ -85,8 +111,10 @@ const editorTheme = {
  */
 function SeedContentPlugin({
   directive,
+  onDraftRestored,
 }: {
   directive: DirectiveWithSteps;
+  onDraftRestored: (draft: string) => void;
 }) {
   const [editor] = useLexicalComposerContext();
   const seededIdRef = useRef<string | null>(null);
@@ -95,6 +123,22 @@ function SeedContentPlugin({
     if (seededIdRef.current === directive.id) return;
     seededIdRef.current = directive.id;
 
+    // If a localStorage draft exists for this directive, prefer it over the
+    // persisted goal so the user does not lose unsaved work after navigating
+    // away. The parent is told about the restored draft so its state machine
+    // can transition to "dirty" or "pending".
+    let initialGoal = directive.goal;
+    let restoredDraft: string | null = null;
+    try {
+      const stored = window.localStorage.getItem(DRAFT_KEY(directive.id));
+      if (stored !== null && stored !== directive.goal) {
+        initialGoal = stored;
+        restoredDraft = stored;
+      }
+    } catch {
+      /* localStorage may be unavailable; fall back to persisted goal */
+    }
+
     editor.update(
       () => {
         const root = $getRoot();
@@ -107,8 +151,8 @@ function SeedContentPlugin({
 
         // Paragraph: goal (editable).
         const goalPara = $createParagraphNode();
-        if (directive.goal.length > 0) {
-          goalPara.append($createTextNode(directive.goal));
+        if (initialGoal.length > 0) {
+          goalPara.append($createTextNode(initialGoal));
         }
         root.append(goalPara);
 
@@ -121,7 +165,13 @@ function SeedContentPlugin({
       },
       { tag: "history-merge" },
     );
-  }, [editor, directive.id, directive.title, directive.goal]);
+
+    if (restoredDraft !== null) {
+      // Defer so the parent's state update lands AFTER the editor's seeded
+      // content (avoids the GoalChangePlugin firing first and double-tracking).
+      queueMicrotask(() => onDraftRestored(restoredDraft!));
+    }
+  }, [editor, directive.id, directive.title, directive.goal, onDraftRestored]);
 
   return null;
 }
@@ -340,19 +390,36 @@ function EditorContextMenu({
 // =============================================================================
 
 interface SaveCountdownBarProps {
-  state: "idle" | "pending" | "saving" | "saved" | "error";
+  state: "idle" | "dirty" | "pending" | "saving" | "saved" | "error";
   remainingMs: number;
-  totalMs: number;
+  liveStart: boolean;
+  orchestratorRunning: boolean;
+  onSaveNow: () => void;
   onCancel: () => void;
+  onToggleLiveStart: (next: boolean) => void;
 }
 
 function SaveCountdownBar({
   state,
   remainingMs,
-  totalMs,
+  liveStart,
+  orchestratorRunning,
+  onSaveNow,
   onCancel,
+  onToggleLiveStart,
 }: SaveCountdownBarProps) {
-  if (state === "idle") return null;
+  // Visibility rules:
+  //   - Always show when actually saving / saved / error (transient feedback).
+  //   - Show when "dirty" if live-start is OFF (user must trigger save).
+  //   - Show when "pending" only inside the last BAR_VISIBLE_MS so the user
+  //     does not feel rushed during the long fresh countdown.
+  const visible =
+    state === "saving" ||
+    state === "saved" ||
+    state === "error" ||
+    (state === "dirty" && !liveStart) ||
+    (state === "pending" && remainingMs <= BAR_VISIBLE_MS);
+  if (!visible) return null;
 
   let label: string;
   let progressPct = 0;
@@ -360,8 +427,18 @@ function SaveCountdownBar({
 
   if (state === "pending") {
     const seconds = Math.max(0, Math.ceil(remainingMs / 1000));
-    label = `Saving goal in ${seconds}s — press Esc or Undo to cancel.`;
-    progressPct = Math.max(0, Math.min(100, (1 - remainingMs / totalMs) * 100));
+    label = orchestratorRunning
+      ? `Replanning in ${seconds}s — Esc/Undo cancels.`
+      : `Saving goal in ${seconds}s — Esc/Undo cancels.`;
+    progressPct = Math.max(
+      0,
+      Math.min(100, (1 - remainingMs / BAR_VISIBLE_MS) * 100),
+    );
+  } else if (state === "dirty") {
+    label = orchestratorRunning
+      ? "Unsaved changes — saving will replan the directive."
+      : "Unsaved changes.";
+    progressPct = 0;
   } else if (state === "saving") {
     label = "Saving…";
     progressPct = 100;
@@ -387,15 +464,36 @@ function SaveCountdownBar({
           style={{ width: `${progressPct}%` }}
         />
       </div>
-      <div className="flex items-center justify-between px-4 py-1.5">
-        <span className="text-[10px] font-mono">{label}</span>
-        {state === "pending" && (
+      <div className="flex items-center gap-3 px-4 py-1.5">
+        <span className="text-[10px] font-mono flex-1 truncate">{label}</span>
+
+        {/* Live-start toggle is always shown so users can flip it from the bar. */}
+        <label className="flex items-center gap-1.5 text-[10px] font-mono text-[#7788aa] cursor-pointer select-none shrink-0">
+          <input
+            type="checkbox"
+            checked={liveStart}
+            onChange={(e) => onToggleLiveStart(e.target.checked)}
+            className="accent-[#75aafc]"
+          />
+          <span>Live start</span>
+        </label>
+
+        {(state === "dirty" || state === "pending") && (
+          <button
+            type="button"
+            onClick={onSaveNow}
+            className="text-[10px] font-mono text-emerald-300 hover:text-white border border-emerald-700/60 rounded px-2 py-0.5 shrink-0"
+          >
+            Save now
+          </button>
+        )}
+        {(state === "dirty" || state === "pending") && (
           <button
             type="button"
             onClick={onCancel}
-            className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-0.5"
+            className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-0.5 shrink-0"
           >
-            Cancel
+            Discard
           </button>
         )}
       </div>
@@ -415,7 +513,7 @@ export interface DocumentEditorProps {
   onPickUpOrders: () => Promise<unknown> | unknown;
 }
 
-type SaveState = "idle" | "pending" | "saving" | "saved" | "error";
+type SaveState = "idle" | "dirty" | "pending" | "saving" | "saved" | "error";
 
 export function DocumentEditor({
   directive,
@@ -442,45 +540,66 @@ export function DocumentEditor({
     [directive.id],
   );
 
+  // ---- Live-start setting (localStorage-backed) -------------------------
+  const [liveStart, setLiveStartState] = useState<boolean>(isLiveStartEnabled);
+  const toggleLiveStart = useCallback((next: boolean) => {
+    setLiveStartEnabled(next);
+    setLiveStartState(next);
+  }, []);
+
   // ---- Goal auto-save state machine --------------------------------------
+  const orchestratorRunning =
+    !!directive.orchestratorTaskId || !!directive.completionTaskId;
+  // Pick the right countdown based on whether we'd be restarting work.
+  const countdownMs = orchestratorRunning ? COUNTDOWN_RUNNING_MS : COUNTDOWN_FRESH_MS;
+
   const [saveState, setSaveState] = useState<SaveState>("idle");
-  const [remainingMs, setRemainingMs] = useState(SAVE_COUNTDOWN_MS);
+  const [remainingMs, setRemainingMs] = useState(countdownMs);
   const pendingGoalRef = useRef<string>(directive.goal);
   const timerRef = useRef<number | null>(null);
   const tickRef = useRef<number | null>(null);
   const deadlineRef = useRef<number>(0);
+  const draftDebounceRef = useRef<number | null>(null);
   const editorRef = useRef<LexicalEditor | null>(null);
 
+  function cancelTimers() {
+    if (timerRef.current != null) {
+      window.clearTimeout(timerRef.current);
+      timerRef.current = null;
+    }
+    if (tickRef.current != null) {
+      window.clearInterval(tickRef.current);
+      tickRef.current = null;
+    }
+  }
+
   // Reset state when switching directives.
   useEffect(() => {
     pendingGoalRef.current = directive.goal;
     cancelTimers();
     setSaveState("idle");
-    setRemainingMs(SAVE_COUNTDOWN_MS);
+    setRemainingMs(countdownMs);
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [directive.id]);
 
   // If the persisted goal updated externally and matches the pending goal,
   // settle the bar.
   useEffect(() => {
-    if (saveState === "pending" && pendingGoalRef.current === directive.goal) {
+    if (
+      (saveState === "pending" || saveState === "dirty") &&
+      pendingGoalRef.current === directive.goal
+    ) {
       cancelTimers();
       setSaveState("idle");
+      try {
+        window.localStorage.removeItem(DRAFT_KEY(directive.id));
+      } catch {
+        /* localStorage may be unavailable; ignore */
+      }
     }
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [directive.goal]);
 
-  function cancelTimers() {
-    if (timerRef.current != null) {
-      window.clearTimeout(timerRef.current);
-      timerRef.current = null;
-    }
-    if (tickRef.current != null) {
-      window.clearInterval(tickRef.current);
-      tickRef.current = null;
-    }
-  }
-
   const fireSave = useCallback(async () => {
     const next = pendingGoalRef.current;
     cancelTimers();
@@ -488,6 +607,11 @@ export function DocumentEditor({
     try {
       await onUpdateGoal(next);
       setSaveState("saved");
+      try {
+        window.localStorage.removeItem(DRAFT_KEY(directive.id));
+      } catch {
+        /* ignore */
+      }
       window.setTimeout(() => {
         // Only fade if no new edit has reopened a pending state in the meantime.
         setSaveState((s) => (s === "saved" ? "idle" : s));
@@ -500,13 +624,13 @@ export function DocumentEditor({
         setSaveState((s) => (s === "error" ? "idle" : s));
       }, 2500);
     }
-  }, [onUpdateGoal]);
+  }, [onUpdateGoal, directive.id]);
 
   const startOrExtendCountdown = useCallback(() => {
     cancelTimers();
-    deadlineRef.current = Date.now() + SAVE_COUNTDOWN_MS;
+    deadlineRef.current = Date.now() + countdownMs;
     setSaveState("pending");
-    setRemainingMs(SAVE_COUNTDOWN_MS);
+    setRemainingMs(countdownMs);
     tickRef.current = window.setInterval(() => {
       const remaining = Math.max(0, deadlineRef.current - Date.now());
       setRemainingMs(remaining);
@@ -514,18 +638,23 @@ export function DocumentEditor({
         window.clearInterval(tickRef.current);
         tickRef.current = null;
       }
-    }, 100);
+    }, 200);
     timerRef.current = window.setTimeout(() => {
       void fireSave();
-    }, SAVE_COUNTDOWN_MS);
-  }, [fireSave]);
+    }, countdownMs);
+  }, [fireSave, countdownMs]);
 
   const cancelCountdown = useCallback(() => {
-    if (saveState !== "pending") return;
+    if (saveState !== "pending" && saveState !== "dirty") return;
     cancelTimers();
     pendingGoalRef.current = directive.goal; // reset pending edit
     setSaveState("idle");
-    setRemainingMs(SAVE_COUNTDOWN_MS);
+    setRemainingMs(countdownMs);
+    try {
+      window.localStorage.removeItem(DRAFT_KEY(directive.id));
+    } catch {
+      /* ignore */
+    }
     // Also revert the editor's goal paragraph back to the persisted value, so
     // the user sees the rollback.
     const editor = editorRef.current;
@@ -543,27 +672,61 @@ export function DocumentEditor({
         { tag: "history-merge" },
       );
     }
-  }, [directive.goal, saveState]);
+  }, [directive.goal, directive.id, saveState, countdownMs]);
 
   // Cleanup on unmount.
   useEffect(() => {
-    return cancelTimers;
+    return () => {
+      cancelTimers();
+      if (draftDebounceRef.current != null) {
+        window.clearTimeout(draftDebounceRef.current);
+        draftDebounceRef.current = null;
+      }
+    };
   }, []);
 
   const handleGoalChange = useCallback(
     (goal: string) => {
       pendingGoalRef.current = goal;
+
+      // 1. Always persist work-in-progress to localStorage (debounced) so
+      //    leaving the page does not lose typing. This is independent of
+      //    whether we will trigger a backend save.
+      if (draftDebounceRef.current != null) {
+        window.clearTimeout(draftDebounceRef.current);
+      }
+      draftDebounceRef.current = window.setTimeout(() => {
+        try {
+          if (goal === directive.goal) {
+            window.localStorage.removeItem(DRAFT_KEY(directive.id));
+          } else {
+            window.localStorage.setItem(DRAFT_KEY(directive.id), goal);
+          }
+        } catch {
+          /* localStorage may be unavailable / full; ignore */
+        }
+        draftDebounceRef.current = null;
+      }, DRAFT_PERSIST_DEBOUNCE_MS);
+
+      // 2. State-machine.
       if (goal === directive.goal) {
         // Edit reverted — cancel the countdown (if any).
-        if (saveState === "pending") {
+        if (saveState === "pending" || saveState === "dirty") {
           cancelTimers();
           setSaveState("idle");
         }
         return;
       }
-      startOrExtendCountdown();
+
+      if (liveStart) {
+        startOrExtendCountdown();
+      } else {
+        // Manual mode: stay "dirty" until the user clicks Save now.
+        cancelTimers();
+        setSaveState("dirty");
+      }
     },
-    [directive.goal, saveState, startOrExtendCountdown],
+    [directive.goal, directive.id, liveStart, saveState, startOrExtendCountdown],
   );
 
   // ---- Right-click context menu -----------------------------------------
@@ -581,7 +744,17 @@ export function DocumentEditor({
         <LexicalComposer key={directive.id} initialConfig={initialConfig}>
           {/* Capture the editor ref via a tiny inline plugin */}
           <EditorRefCapture editorRef={editorRef} />
-          <SeedContentPlugin directive={directive} />
+          <SeedContentPlugin
+            directive={directive}
+            onDraftRestored={(draft) => {
+              pendingGoalRef.current = draft;
+              if (liveStart) {
+                startOrExtendCountdown();
+              } else {
+                setSaveState("dirty");
+              }
+            }}
+          />
           <ReadOnlyTitlePlugin title={directive.title} />
           <HistoryPlugin />
           <GoalChangePlugin onGoalChange={handleGoalChange} />
@@ -614,8 +787,11 @@ export function DocumentEditor({
       <SaveCountdownBar
         state={saveState}
         remainingMs={remainingMs}
-        totalMs={SAVE_COUNTDOWN_MS}
+        liveStart={liveStart}
+        orchestratorRunning={orchestratorRunning}
+        onSaveNow={() => void fireSave()}
         onCancel={cancelCountdown}
+        onToggleLiveStart={toggleLiveStart}
       />
 
       {menu && (
-- 
cgit v1.2.3

