summaryrefslogblamecommitdiff
path: root/makima/frontend/src/components/directives/DocumentEditor.tsx
blob: d953d45ad7402eebbd08bcaeaacbed373f5c3854 (plain) (tree)


















































                                                                                  









                                                                                 
                            
















                                                                                        

































                                                                                 
                  

                                
                                           







                                                     















                                                                             











                                                             

                                                        











                                                                             






                                                                                

























































































































































































































                                                                                                                             
                                                                     
                      


                               
                       
                                             




                           


                      
           
                    
                           











                                                                               






                                                               











                                                               
























                                                                                     























                                                                                                                                         


                              
                                                                                                                                  
           
                   


















                                                                                
                                                                             

























                                                                             






                                                                               
                                                                             




                                                                                      
                                                                
                                                              



                                                        
                                                       

                                                       










                                            




                                            
                                





                                                                           



                                                           

                           




                                                                



                                                           






                                            




                                                                











                                                                                 
                                   


                                                    
                                                   
                            
                                






                                                                      
            

                                                

                              

                                             
                                                                 


                                                                  





                                                              
















                                                                              
                                                             


                        






                                                      




                                       




















                                                                          

                                                           
                                                               




                               







                                                                    
      
                                                                                 
















                                                                             










                                             































                                                                                                                                                                                                                                                                                                                                                                              


                                                 
                                  
                                           













































                                                                                
/**
 * DocumentEditor — the Lexical-based document body for Document Mode.
 *
 * Layout (top to bottom):
 *   - Read-only H1 with the directive's title.
 *   - Editable paragraph with the directive's goal. Editing the goal triggers
 *     a 3-second countdown bar at the bottom of the editor; if the timer
 *     expires, we call updateGoal(). Esc / ⌘Z cancels; further typing extends.
 *   - A custom non-editable StepsBlock decorator node showing each step.
 *
 * Right-click anywhere in the editor opens a custom context menu offering
 * the three directive-level actions: Clean Up, Update PR, Plan Orders.
 *
 * Live updates:
 *   - The directive prop is updated by the parent's useDirective polling.
 *   - StepsBlockContextProvider wraps the LexicalComposer so that the steps
 *     block (a Lexical DecoratorNode) sees fresh data on every render
 *     without us having to mutate the editor state.
 */
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
  $createParagraphNode,
  $createTextNode,
  $getRoot,
  $isElementNode,
  COMMAND_PRIORITY_LOW,
  KEY_ESCAPE_COMMAND,
  UNDO_COMMAND,
  type LexicalEditor,
} from "lexical";
import { $createHeadingNode, HeadingNode } from "@lexical/rich-text";
import { ListNode, ListItemNode } from "@lexical/list";
import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import type { DirectiveWithSteps } from "../../lib/api";
import {
  $createStepsBlockNode,
  $isStepsBlockNode,
  StepsBlockNode,
  StepsBlockContextProvider,
} from "./StepsBlockNode";

// =============================================================================
// Constants
// =============================================================================

/**
 * 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
// hang class names on. We rely on our own typography otherwise.
// =============================================================================

const editorTheme = {
  paragraph: "makima-doc-paragraph",
  heading: {
    h1: "makima-doc-h1",
    h2: "makima-doc-h2",
  },
  text: {
    bold: "font-bold",
    italic: "italic",
    underline: "underline",
  },
};

// =============================================================================
// Plugins
// =============================================================================

/**
 * (Re)builds the editor's root content from the directive whenever the
 * directive ID changes. We keep this controlled so that switching documents
 * resets the editor cleanly.
 *
 * We deliberately do NOT re-seed on every directive update — only on id
 * change — so the user's in-flight goal edits aren't trampled by a poll that
 * happens mid-keystroke.
 */
function SeedContentPlugin({
  directive,
  onDraftRestored,
}: {
  directive: DirectiveWithSteps;
  onDraftRestored: (draft: string) => void;
}) {
  const [editor] = useLexicalComposerContext();
  const seededIdRef = useRef<string | null>(null);

  useEffect(() => {
    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();
        root.clear();

        // H1: title (read-only — see ReadOnlyTitlePlugin).
        const heading = $createHeadingNode("h1");
        heading.append($createTextNode(directive.title));
        root.append(heading);

        // Paragraph: goal (editable).
        const goalPara = $createParagraphNode();
        if (initialGoal.length > 0) {
          goalPara.append($createTextNode(initialGoal));
        }
        root.append(goalPara);

        // Steps block (decorator — non-editable).
        root.append($createStepsBlockNode());

        // Trailing empty paragraph so the cursor has somewhere to land below
        // the steps block.
        root.append($createParagraphNode());
      },
      { tag: "history-merge" },
    );

    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;
}

/**
 * Prevents edits to the H1 (title) node. The title is meant to feel like a
 * file name — clicking it shows a caret, but typing is no-op'd. We watch the
 * editor's update stream and, if the H1's text drifts from the seed value,
 * revert it on the next microtask so the user sees the change get rejected
 * rather than silently swallowed.
 */
function ReadOnlyTitlePlugin({ title }: { title: string }) {
  const [editor] = useLexicalComposerContext();

  useEffect(() => {
    let reverting = false;
    return editor.registerUpdateListener(({ editorState }) => {
      if (reverting) return;
      editorState.read(() => {
        const root = $getRoot();
        const first = root.getFirstChild();
        if (!first || first.getType() !== "heading") return;
        const text = first.getTextContent();
        if (text === title) return;
        reverting = true;
        queueMicrotask(() => {
          editor.update(
            () => {
              const r = $getRoot();
              const h = r.getFirstChild();
              if (!h || h.getType() !== "heading") {
                reverting = false;
                return;
              }
              if ($isElementNode(h) && h.getTextContent() !== title) {
                h.getChildren().forEach((c) => c.remove());
                if (title.length > 0) {
                  h.append($createTextNode(title));
                }
              }
              reverting = false;
            },
            { tag: "history-merge" },
          );
        });
      });
    });
  }, [editor, title]);

  return null;
}

/**
 * Watches the goal paragraph (the second top-level child) and reports its
 * current text to the parent on every change. Kept separate from the
 * countdown bar so the bar is purely a UI concern.
 */
function GoalChangePlugin({
  onGoalChange,
}: {
  onGoalChange: (goal: string) => void;
}) {
  return (
    <OnChangePlugin
      ignoreSelectionChange
      onChange={(editorState) => {
        editorState.read(() => {
          const root = $getRoot();
          const children = root.getChildren();
          // The goal lives at index 1 (after the H1 title).
          const goalNode = children[1];
          if (!goalNode) return;
          onGoalChange(goalNode.getTextContent());
        });
      }}
    />
  );
}

/**
 * Wires Lexical commands into UI callbacks. We forward Esc presses (used to
 * cancel the countdown) and Undo (⌘/Ctrl-Z) without intercepting them.
 */
function CountdownKeyBridge({
  onEsc,
  onUndo,
}: {
  onEsc: () => void;
  onUndo: () => void;
}) {
  const [editor] = useLexicalComposerContext();
  useEffect(() => {
    const unEsc = editor.registerCommand(
      KEY_ESCAPE_COMMAND,
      () => {
        onEsc();
        return false; // don't consume; let other handlers run
      },
      COMMAND_PRIORITY_LOW,
    );
    const unUndo = editor.registerCommand(
      UNDO_COMMAND,
      () => {
        onUndo();
        return false;
      },
      COMMAND_PRIORITY_LOW,
    );
    return () => {
      unEsc();
      unUndo();
    };
  }, [editor, onEsc, onUndo]);
  return null;
}

// =============================================================================
// Right-click context menu
// =============================================================================

interface EditorContextMenuProps {
  x: number;
  y: number;
  onClose: () => void;
  onCleanup: () => void;
  onUpdatePR: () => void;
  onPlanOrders: () => void;
}

function EditorContextMenu({
  x,
  y,
  onClose,
  onCleanup,
  onUpdatePR,
  onPlanOrders,
}: EditorContextMenuProps) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const handleClick = (e: MouseEvent) => {
      if (ref.current && !ref.current.contains(e.target as Node)) onClose();
    };
    const handleKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") onClose();
    };
    document.addEventListener("mousedown", handleClick);
    document.addEventListener("keydown", handleKey);
    return () => {
      document.removeEventListener("mousedown", handleClick);
      document.removeEventListener("keydown", handleKey);
    };
  }, [onClose]);

  // Clamp into viewport.
  useEffect(() => {
    if (!ref.current) return;
    const rect = ref.current.getBoundingClientRect();
    const vw = window.innerWidth;
    const vh = window.innerHeight;
    if (rect.right > vw) ref.current.style.left = `${x - rect.width}px`;
    if (rect.bottom > vh) ref.current.style.top = `${y - rect.height}px`;
  }, [x, y]);

  const item =
    "w-full px-3 py-1.5 text-left text-xs font-mono text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] flex items-center gap-2";

  return (
    <div
      ref={ref}
      className="fixed z-50 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] shadow-lg min-w-[180px]"
      style={{ left: x, top: y }}
    >
      <div className="px-3 py-1.5 text-[10px] font-mono text-[#556677] uppercase border-b border-[rgba(117,170,252,0.2)]">
        Document
      </div>
      <button
        type="button"
        className={item}
        onClick={() => {
          onCleanup();
          onClose();
        }}
      >
        <span className="text-[#75aafc]">⎚</span>
        Clean Up
      </button>
      <button
        type="button"
        className={item}
        onClick={() => {
          onUpdatePR();
          onClose();
        }}
      >
        <span className="text-[#75aafc]">↗</span>
        Update PR
      </button>
      <button
        type="button"
        className={item}
        onClick={() => {
          onPlanOrders();
          onClose();
        }}
      >
        <span className="text-[#c084fc]">◆</span>
        Plan Orders
      </button>
    </div>
  );
}

// =============================================================================
// Countdown bar
// =============================================================================

interface SaveCountdownBarProps {
  state: "idle" | "dirty" | "pending" | "saving" | "saved" | "error";
  remainingMs: number;
  liveStart: boolean;
  orchestratorRunning: boolean;
  onSaveNow: () => void;
  onCancel: () => void;
  onToggleLiveStart: (next: boolean) => void;
}

function SaveCountdownBar({
  state,
  remainingMs,
  liveStart,
  orchestratorRunning,
  onSaveNow,
  onCancel,
  onToggleLiveStart,
}: SaveCountdownBarProps) {
  // 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;
  let tone = "border-[rgba(117,170,252,0.3)] text-[#9bc3ff]";

  if (state === "pending") {
    const seconds = Math.max(0, Math.ceil(remainingMs / 1000));
    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;
    tone = "border-emerald-700 text-emerald-300";
  } else if (state === "saved") {
    label = "Saved";
    progressPct = 100;
    tone = "border-emerald-700 text-emerald-300";
  } else {
    label = "Save failed — try again.";
    progressPct = 100;
    tone = "border-red-700 text-red-300";
  }

  return (
    <div
      className={`shrink-0 border-t border-dashed ${tone} bg-[#0a1628]`}
      data-makima-countdown={state}
    >
      <div className="h-0.5 bg-[#10203a]">
        <div
          className="h-full bg-[#75aafc] transition-[width] duration-100 ease-linear"
          style={{ width: `${progressPct}%` }}
        />
      </div>
      <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 shrink-0"
          >
            Discard
          </button>
        )}
      </div>
    </div>
  );
}

// =============================================================================
// Main component
// =============================================================================

export interface DocumentEditorProps {
  directive: DirectiveWithSteps;
  onUpdateGoal: (goal: string) => Promise<void> | void;
  onCleanup: () => Promise<void> | void;
  onCreatePR: () => Promise<void> | void;
  onPickUpOrders: () => Promise<unknown> | unknown;
}

type SaveState = "idle" | "dirty" | "pending" | "saving" | "saved" | "error";

export function DocumentEditor({
  directive,
  onUpdateGoal,
  onCleanup,
  onCreatePR,
  onPickUpOrders,
}: DocumentEditorProps) {
  // ---- Lexical config ----------------------------------------------------
  const initialConfig = useMemo(
    () => ({
      // Re-key the composer when the directive id changes so we get a clean
      // editor state per document. We do this via the `key` prop on
      // <LexicalComposer> below as well.
      namespace: `makima-doc-${directive.id}`,
      onError: (err: Error) => {
        // eslint-disable-next-line no-console
        console.error("[DocumentEditor]", err);
      },
      nodes: [HeadingNode, ListNode, ListItemNode, StepsBlockNode],
      theme: editorTheme,
      editable: true,
    }),
    [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(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(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" || 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]);

  const fireSave = useCallback(async () => {
    const next = pendingGoalRef.current;
    cancelTimers();
    setSaveState("saving");
    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));
      }, SAVED_TOAST_MS);
    } catch (e) {
      // eslint-disable-next-line no-console
      console.error("Failed to save goal", e);
      setSaveState("error");
      window.setTimeout(() => {
        setSaveState((s) => (s === "error" ? "idle" : s));
      }, 2500);
    }
  }, [onUpdateGoal, directive.id]);

  const startOrExtendCountdown = useCallback(() => {
    cancelTimers();
    deadlineRef.current = Date.now() + countdownMs;
    setSaveState("pending");
    setRemainingMs(countdownMs);
    tickRef.current = window.setInterval(() => {
      const remaining = Math.max(0, deadlineRef.current - Date.now());
      setRemainingMs(remaining);
      if (remaining <= 0 && tickRef.current != null) {
        window.clearInterval(tickRef.current);
        tickRef.current = null;
      }
    }, 200);
    timerRef.current = window.setTimeout(() => {
      void fireSave();
    }, countdownMs);
  }, [fireSave, countdownMs]);

  const cancelCountdown = useCallback(() => {
    if (saveState !== "pending" && saveState !== "dirty") return;
    cancelTimers();
    pendingGoalRef.current = directive.goal; // reset pending edit
    setSaveState("idle");
    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;
    if (editor) {
      editor.update(
        () => {
          const root = $getRoot();
          const goalNode = root.getChildren()[1];
          if (!goalNode || !$isElementNode(goalNode)) return;
          goalNode.getChildren().forEach((c) => c.remove());
          if (directive.goal.length > 0) {
            goalNode.append($createTextNode(directive.goal));
          }
        },
        { tag: "history-merge" },
      );
    }
  }, [directive.goal, directive.id, saveState, countdownMs]);

  // Cleanup on unmount.
  useEffect(() => {
    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" || saveState === "dirty") {
          cancelTimers();
          setSaveState("idle");
        }
        return;
      }

      if (liveStart) {
        startOrExtendCountdown();
      } else {
        // Manual mode: stay "dirty" until the user clicks Save now.
        cancelTimers();
        setSaveState("dirty");
      }
    },
    [directive.goal, directive.id, liveStart, saveState, startOrExtendCountdown],
  );

  // ---- Right-click context menu -----------------------------------------
  const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);

  const handleContextMenu = useCallback((e: React.MouseEvent) => {
    e.preventDefault();
    setMenu({ x: e.clientX, y: e.clientY });
  }, []);

  // ---- Render ------------------------------------------------------------
  return (
    <div className="flex flex-col h-full overflow-hidden">
      <StepsBlockContextProvider value={{ directive }}>
        <LexicalComposer key={directive.id} initialConfig={initialConfig}>
          {/* Capture the editor ref via a tiny inline plugin */}
          <EditorRefCapture editorRef={editorRef} />
          <SeedContentPlugin
            directive={directive}
            onDraftRestored={(draft) => {
              pendingGoalRef.current = draft;
              if (liveStart) {
                startOrExtendCountdown();
              } else {
                setSaveState("dirty");
              }
            }}
          />
          <ReadOnlyTitlePlugin title={directive.title} />
          <HistoryPlugin />
          <GoalChangePlugin onGoalChange={handleGoalChange} />
          <CountdownKeyBridge onEsc={cancelCountdown} onUndo={cancelCountdown} />

          <div
            className="flex-1 overflow-auto"
            onContextMenu={handleContextMenu}
          >
            <div className="max-w-3xl mx-auto px-8 py-10">
              <RichTextPlugin
                contentEditable={
                  <ContentEditable
                    aria-placeholder="Describe the directive's goal…"
                    placeholder={
                      <div className="pointer-events-none absolute text-[#445566] font-mono text-[13px] mt-2">
                        Describe the directive's goal…
                      </div>
                    }
                    className="outline-none font-mono text-[13px] leading-relaxed text-[#dbe7ff] [&_.makima-doc-h1]:text-[24px] [&_.makima-doc-h1]:font-medium [&_.makima-doc-h1]:text-white [&_.makima-doc-h1]:mb-3 [&_.makima-doc-h1]:tracking-tight [&_.makima-doc-paragraph]:my-2 [&_.makima-doc-paragraph]:text-[13px] [&_.makima-doc-paragraph]:text-[#c0d0e0] relative"
                  />
                }
                ErrorBoundary={LexicalErrorBoundary}
              />
            </div>
          </div>
        </LexicalComposer>
      </StepsBlockContextProvider>

      <SaveCountdownBar
        state={saveState}
        remainingMs={remainingMs}
        liveStart={liveStart}
        orchestratorRunning={orchestratorRunning}
        onSaveNow={() => void fireSave()}
        onCancel={cancelCountdown}
        onToggleLiveStart={toggleLiveStart}
      />

      {menu && (
        <EditorContextMenu
          x={menu.x}
          y={menu.y}
          onClose={() => setMenu(null)}
          onCleanup={() => {
            void onCleanup();
          }}
          onUpdatePR={() => {
            void onCreatePR();
          }}
          onPlanOrders={() => {
            void onPickUpOrders();
          }}
        />
      )}
    </div>
  );
}

/**
 * Tiny plugin that stashes the LexicalEditor instance on a ref so the parent
 * component can issue updates from outside (e.g. to revert the goal on cancel).
 */
function EditorRefCapture({
  editorRef,
}: {
  editorRef: React.MutableRefObject<LexicalEditor | null>;
}) {
  const [editor] = useLexicalComposerContext();
  useEffect(() => {
    editorRef.current = editor;
    return () => {
      if (editorRef.current === editor) {
        editorRef.current = null;
      }
    };
  }, [editor, editorRef]);
  return null;
}

// Re-export the steps-block helpers so consumers can include the node class
// in their own initial configs if needed.
export { $createStepsBlockNode, $isStepsBlockNode, StepsBlockNode };