summaryrefslogblamecommitdiff
path: root/makima/frontend/src/components/directives/DocumentEditor.tsx
blob: 981cab1b6baae4ef5406456930a3d593e00ad436 (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,
  $getSelection,
  $isElementNode,
  $isRangeSelection,
  $isTextNode,
  COMMAND_PRIORITY_LOW,
  FORMAT_TEXT_COMMAND,
  KEY_ESCAPE_COMMAND,
  SELECTION_CHANGE_COMMAND,
  UNDO_COMMAND,
  type LexicalEditor,
  type ElementNode,
  type TextFormatType,
} 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 { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import {
  TEXT_FORMAT_TRANSFORMERS,
  type TextFormatTransformer,
} from "@lexical/markdown";
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;
/**
 * Drafts are written synchronously to localStorage on every keystroke. We used
 * to debounce these by 250ms, but that lost the most recent edits whenever the
 * user navigated away within the debounce window — the cleanup effect cleared
 * the pending timer before it could flush. localStorage.setItem on a small
 * string is sub-millisecond, so debouncing was a premature optimisation.
 */
const DRAFT_KEY = (directiveId: string) => `makima:directive-goal-draft:${directiveId}`;
const LIVE_START_KEY = "makima:liveStartEnabled";

// =============================================================================
// Inline-only markdown round-trip for the goal paragraph.
//
// The directive goal is a single paragraph node in the editor (children[1]).
// We support inline formatting (bold, italic, underline, code, strikethrough)
// and persist it as inline markdown in `directive.goal`. We deliberately do
// NOT handle headings, lists, or blocks here — those would change the document
// shape and the goal column is just TEXT on the backend.
//
// Supported markers (single-format, no nesting except bold+italic):
//   `code`        → format: code
//   ***x***       → format: bold + italic
//   **x**         → format: bold
//   *x* / _x_     → format: italic
//   ~~x~~         → format: strikethrough
//
// Underline is preserved at the editor level via the Cmd+U shortcut and the
// toolbar, but is intentionally not emitted in markdown (it has no native
// markdown syntax). It will silently round-trip as plain text.
// =============================================================================

interface InlineToken {
  text: string;
  bold: boolean;
  italic: boolean;
  code: boolean;
  strike: boolean;
}

const INLINE_MARKERS: Array<[RegExp, Partial<InlineToken>]> = [
  // Code wins first — content inside backticks is literal.
  [/^`([^`]+)`/, { code: true }],
  // ***bold italic***
  [/^\*\*\*([^*]+)\*\*\*/, { bold: true, italic: true }],
  // **bold**
  [/^\*\*([^*]+)\*\*/, { bold: true }],
  // *italic*  or  _italic_
  [/^\*([^*]+)\*/, { italic: true }],
  [/^_([^_]+)_/, { italic: true }],
  // ~~strikethrough~~
  [/^~~([^~]+)~~/, { strike: true }],
];

function tokenizeInlineMarkdown(input: string): InlineToken[] {
  const tokens: InlineToken[] = [];
  let buf = "";
  let i = 0;
  const flushBuf = () => {
    if (buf.length > 0) {
      tokens.push({ text: buf, bold: false, italic: false, code: false, strike: false });
      buf = "";
    }
  };
  while (i < input.length) {
    const slice = input.slice(i);
    let matched = false;
    for (const [re, fmt] of INLINE_MARKERS) {
      const m = re.exec(slice);
      if (m) {
        flushBuf();
        tokens.push({
          text: m[1],
          bold: !!fmt.bold,
          italic: !!fmt.italic,
          code: !!fmt.code,
          strike: !!fmt.strike,
        });
        i += m[0].length;
        matched = true;
        break;
      }
    }
    if (!matched) {
      buf += input[i];
      i++;
    }
  }
  flushBuf();
  return tokens;
}

function appendInlineMarkdownTo(parent: ElementNode, markdown: string): void {
  const tokens = tokenizeInlineMarkdown(markdown);
  for (const t of tokens) {
    const node = $createTextNode(t.text);
    if (t.bold) node.toggleFormat("bold" as TextFormatType);
    if (t.italic) node.toggleFormat("italic" as TextFormatType);
    if (t.code) node.toggleFormat("code" as TextFormatType);
    if (t.strike) node.toggleFormat("strikethrough" as TextFormatType);
    parent.append(node);
  }
}

/**
 * Walk the editor root and serialise every non-decorator paragraph between
 * the H1 title and the end of the document into the goal markdown. This
 * captures user typing wherever it lands — the goal paragraph proper, but
 * also any extra paragraphs the user inserted above or below the StepsBlock.
 * Previously we read only `children[1]` and lost anything outside it.
 */
function serializeGoalFromRoot(root: ElementNode): string {
  const parts: string[] = [];
  const children = root.getChildren();
  for (let i = 0; i < children.length; i++) {
    const node = children[i];
    // Skip the H1 title (always at index 0 by construction).
    if (i === 0 && node.getType() === "heading") continue;
    // Skip the StepsBlock decorator and any other non-element nodes.
    if ($isStepsBlockNode(node)) continue;
    if (!$isElementNode(node)) continue;
    parts.push(serializeInlineMarkdown(node));
  }
  // Trim trailing empties; collapse runs of >2 blank lines.
  return parts
    .join("\n\n")
    .replace(/\n{3,}/g, "\n\n")
    .replace(/^\s+|\s+$/g, "");
}

/**
 * Walk the goal paragraph's children and emit inline markdown. Only TextNodes
 * are emitted — anything else falls back to its plain text content. We always
 * wrap in the most specific marker pair available so the round-trip is stable.
 */
function serializeInlineMarkdown(parent: ElementNode): string {
  let out = "";
  for (const child of parent.getChildren()) {
    if (!$isTextNode(child)) {
      out += child.getTextContent();
      continue;
    }
    let text = child.getTextContent();
    if (text.length === 0) continue;
    // Code is exclusive — applying any other marker would break the literal
    // semantics, so wrap once and skip the rest.
    if (child.hasFormat("code")) {
      out += "`" + text + "`";
      continue;
    }
    const bold = child.hasFormat("bold");
    const italic = child.hasFormat("italic");
    const strike = child.hasFormat("strikethrough");
    if (bold && italic) text = `***${text}***`;
    else if (bold) text = `**${text}**`;
    else if (italic) text = `*${text}*`;
    if (strike) text = `~~${text}~~`;
    out += text;
  }
  return out;
}

// Keep only the inline transformers; stripping the block-level ones keeps the
// MarkdownShortcutPlugin from auto-converting `# ` to a heading or `- ` to a
// list inside the goal paragraph (which would break the document shape).
const INLINE_TRANSFORMERS: TextFormatTransformer[] = TEXT_FORMAT_TRANSFORMERS;

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

        // Goal body. The persisted goal may contain multiple paragraphs
        // (separated by blank lines) and inline markdown — split by blank
        // lines so each block becomes its own ParagraphNode, and parse the
        // inline formatting per paragraph. Always emit at least one
        // paragraph so users have a place to type even when the goal is
        // empty.
        const blocks =
          initialGoal.length > 0 ? initialGoal.split(/\n{2,}/) : [""];
        for (const block of blocks) {
          const p = $createParagraphNode();
          if (block.length > 0) {
            appendInlineMarkdownTo(p, block);
          }
          root.append(p);
        }

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

        // Trailing empty paragraph so the cursor has somewhere to land below
        // the steps block. The trailing area is also captured as goal
        // content by serializeGoalFromRoot, so any typing here is preserved.
        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(() => {
          // Walk the WHOLE root (minus title H1 and StepsBlock decorator) so
          // typing anywhere in the document body is captured. Previously we
          // only read children[1] and silently discarded edits placed in the
          // trailing area below the StepsBlock.
          onGoalChange(serializeGoalFromRoot($getRoot()));
        });
      }}
    />
  );
}

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

/**
 * Render a "Draft saved Ns ago" label that ticks once per second. Returns
 * null when the timestamp is older than 60 seconds (clutter-management).
 */
function useDraftFreshnessLabel(draftSavedAt: number | null): string | null {
  const [now, setNow] = useState(() => Date.now());
  useEffect(() => {
    const id = window.setInterval(() => setNow(Date.now()), 1000);
    return () => window.clearInterval(id);
  }, []);
  if (draftSavedAt == null) return null;
  const ageSec = Math.max(0, Math.floor((now - draftSavedAt) / 1000));
  if (ageSec > 60) return null;
  if (ageSec < 2) return "Draft saved";
  return `Draft saved ${ageSec}s ago`;
}

// =============================================================================
// Floating formatting toolbar
//
// Appears just above the current text selection when the selection covers any
// text inside the goal paragraph. Buttons dispatch FORMAT_TEXT_COMMAND which
// toggles the corresponding format flag on every covered TextNode — Lexical's
// built-in behaviour. Keyboard shortcuts (Cmd/Ctrl+B/I/U) also work via the
// RichTextPlugin even when the toolbar isn't shown.
// =============================================================================

function FloatingFormatToolbar() {
  const [editor] = useLexicalComposerContext();
  const [coords, setCoords] = useState<{ x: number; y: number } | null>(null);
  const [active, setActive] = useState({
    bold: false,
    italic: false,
    underline: false,
    code: false,
    strike: false,
  });

  useEffect(() => {
    const update = () => {
      editor.getEditorState().read(() => {
        const sel = $getSelection();
        if (!$isRangeSelection(sel) || sel.isCollapsed()) {
          setCoords(null);
          return;
        }
        // Only show inside the goal paragraph (children[1] of root).
        const anchorTop = sel.anchor.getNode().getTopLevelElement();
        const focusTop = sel.focus.getNode().getTopLevelElement();
        if (!anchorTop || !focusTop) {
          setCoords(null);
          return;
        }
        const root = $getRoot();
        const goalNode = root.getChildren()[1];
        if (anchorTop.getKey() !== goalNode?.getKey()) {
          setCoords(null);
          return;
        }
        // Selection rect from the DOM.
        const domSel = window.getSelection();
        if (!domSel || domSel.rangeCount === 0) {
          setCoords(null);
          return;
        }
        const rect = domSel.getRangeAt(0).getBoundingClientRect();
        if (rect.width === 0 && rect.height === 0) {
          setCoords(null);
          return;
        }
        setCoords({ x: rect.left + rect.width / 2, y: rect.top });
        setActive({
          bold: sel.hasFormat("bold"),
          italic: sel.hasFormat("italic"),
          underline: sel.hasFormat("underline"),
          code: sel.hasFormat("code"),
          strike: sel.hasFormat("strikethrough"),
        });
      });
    };

    const unselect = editor.registerCommand(
      SELECTION_CHANGE_COMMAND,
      () => {
        update();
        return false;
      },
      COMMAND_PRIORITY_LOW,
    );
    const unupdate = editor.registerUpdateListener(({ editorState }) => {
      // Use the just-committed state for format flags.
      void editorState;
      update();
    });
    return () => {
      unselect();
      unupdate();
    };
  }, [editor]);

  if (!coords) return null;

  const fmt = (type: TextFormatType) => () => {
    editor.dispatchCommand(FORMAT_TEXT_COMMAND, type);
  };

  const button = (label: string, onClick: () => void, isActive: boolean, hint: string) => (
    <button
      type="button"
      onMouseDown={(e) => e.preventDefault()}
      onClick={onClick}
      title={hint}
      className={`px-2 py-1 text-[11px] font-mono uppercase tracking-wide border-r border-[rgba(117,170,252,0.2)] last:border-r-0 transition-colors ${
        isActive
          ? "bg-[#75aafc] text-[#0a1628]"
          : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)]"
      }`}
    >
      {label}
    </button>
  );

  return (
    <div
      className="fixed z-40 flex items-stretch bg-[#0a1628] border border-[rgba(117,170,252,0.35)] shadow-lg pointer-events-auto"
      style={{
        left: coords.x,
        top: coords.y - 8,
        transform: "translate(-50%, -100%)",
      }}
    >
      {button("B", fmt("bold"), active.bold, "Bold (⌘B)")}
      {button("I", fmt("italic"), active.italic, "Italic (⌘I)")}
      {button("U", fmt("underline"), active.underline, "Underline (⌘U)")}
      {button("S", fmt("strikethrough"), active.strike, "Strikethrough")}
      {button("</>", fmt("code"), active.code, "Inline code")}
    </div>
  );
}

// =============================================================================
// 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)]">
        Contract
      </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;
  draftSavedAt: number | null;
  onSaveNow: () => void;
  onCancel: () => void;
  onToggleLiveStart: (next: boolean) => void;
}

function SaveCountdownBar({
  state,
  remainingMs,
  liveStart,
  orchestratorRunning,
  draftSavedAt,
  onSaveNow,
  onCancel,
  onToggleLiveStart,
}: SaveCountdownBarProps) {
  // The bar is now ALWAYS visible. Users explicitly asked to be able to
  // observe save state at all times — and to have a "Save now" button they
  // can hit without waiting for the countdown.

  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));
    // Show ticking countdown in the last 10s, otherwise a quieter label.
    if (remainingMs <= BAR_VISIBLE_MS) {
      label = orchestratorRunning
        ? `Replanning in ${seconds}s — Esc/Undo cancels.`
        : `Saving in ${seconds}s — Esc/Undo cancels.`;
      progressPct = Math.max(
        0,
        Math.min(100, (1 - remainingMs / BAR_VISIBLE_MS) * 100),
      );
    } else {
      label = "Unsaved changes — auto-save soon.";
      progressPct = 0;
    }
  } else if (state === "dirty") {
    label = orchestratorRunning
      ? "Unsaved changes — saving will replan the contract."
      : "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 if (state === "error") {
    label = "Save failed — try again.";
    progressPct = 100;
    tone = "border-red-700 text-red-300";
  } else {
    label = "Up to date.";
    progressPct = 0;
    tone = "border-[rgba(117,170,252,0.2)] text-[#7788aa]";
  }

  // Right-side "Draft saved Xs ago" stamp — re-renders on a 1Hz ticker so
  // the user can see drafts being captured. We only ever surface this when
  // a write has happened in the last minute; otherwise we hide it.
  const draftLabel = useDraftFreshnessLabel(draftSavedAt);

  const dirtyish = state === "dirty" || state === "pending";

  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>

        {draftLabel && (
          <span
            className="text-[10px] font-mono text-[#556677] shrink-0"
            title="Drafts auto-save to this device on every keystroke"
          >
            {draftLabel}
          </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>

        {/* "Save now" is always available when there are unsaved edits, so
            users don't have to wait for the auto-save countdown. */}
        <button
          type="button"
          onClick={onSaveNow}
          disabled={!dirtyish}
          className="text-[10px] font-mono text-emerald-300 hover:text-white disabled:text-[#445566] disabled:cursor-not-allowed border border-emerald-700/60 disabled:border-[#1f2a3a] rounded px-2 py-0.5 shrink-0"
        >
          Save now
        </button>
        {dirtyish && (
          <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 persistedGoalRef = useRef<string>(directive.goal);
  const timerRef = useRef<number | null>(null);
  const tickRef = useRef<number | null>(null);
  const deadlineRef = useRef<number>(0);
  const editorRef = useRef<LexicalEditor | null>(null);
  // Tracks the most recent value the backend was asked to save. Once a poll
  // confirms `directive.goal === lastSavedValueRef.current` AND
  // `pendingGoalRef.current` still matches (i.e. user hasn't typed more),
  // the draft is safe to drop from localStorage. Until then we keep the
  // draft so an interrupted save doesn't lose user content.
  const lastSavedValueRef = useRef<string | null>(null);
  // Timestamp of the most recent localStorage draft write — drives the
  // "Draft saved Xs ago" indicator so users can SEE that drafts are working.
  const [draftSavedAt, setDraftSavedAt] = useState<number | null>(null);

  // Track the persisted goal in a ref so beforeunload handlers can do their
  // own freshness comparison without a stale closure.
  useEffect(() => {
    persistedGoalRef.current = directive.goal;
  }, [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;
    }
  }

  // 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 () => {
    // Read DIRECTLY from the editor so we don't trust pendingGoalRef alone.
    // If the OnChangePlugin missed an event for any reason, this still
    // captures the user's current document state.
    let next = pendingGoalRef.current;
    const editor = editorRef.current;
    if (editor) {
      editor.getEditorState().read(() => {
        next = serializeGoalFromRoot($getRoot());
      });
    }
    pendingGoalRef.current = next;

    // DEFENSE IN DEPTH: write the draft to localStorage BEFORE talking to
    // the backend. If the save errors, the page closes mid-flight, or the
    // network drops, the user's content survives in the draft.
    try {
      window.localStorage.setItem(DRAFT_KEY(directive.id), next);
      setDraftSavedAt(Date.now());
    } catch (err) {
      // eslint-disable-next-line no-console
      console.warn("[makima] pre-save draft flush failed", err);
    }

    cancelTimers();
    setSaveState("saving");
    try {
      await onUpdateGoal(next);
      lastSavedValueRef.current = next;
      setSaveState("saved");
      // NOTE: we deliberately do NOT clear localStorage here. The roundtrip
      // effect below clears it once the polled directive.goal confirms our
      // save persisted AND the user hasn't kept typing past it.
      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));
      }, 4000);
    }
  }, [onUpdateGoal, directive.id]);

  // Roundtrip-confirmed draft cleanup. Only drops the localStorage draft
  // when the polled directive.goal matches what we just saved AND the
  // user hasn't typed anything new in the meantime. Keeps the draft alive
  // through every "we hit save but the page reloads before the poll lands"
  // edge case.
  useEffect(() => {
    if (
      lastSavedValueRef.current !== null &&
      directive.goal === lastSavedValueRef.current &&
      pendingGoalRef.current === lastSavedValueRef.current
    ) {
      try {
        window.localStorage.removeItem(DRAFT_KEY(directive.id));
      } catch {
        /* ignore */
      }
      lastSavedValueRef.current = null;
    }
  }, [directive.goal, 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. The persisted value may contain inline
    // markdown — re-parse it so formatting comes back styled, not as raw
    // asterisks.
    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) {
            appendInlineMarkdownTo(goalNode, directive.goal);
          }
        },
        { tag: "history-merge" },
      );
    }
  }, [directive.goal, directive.id, saveState, countdownMs]);

  // Cleanup on unmount.
  useEffect(() => {
    return () => {
      cancelTimers();
    };
  }, []);

  // Belt-and-braces draft persistence: even though we write synchronously on
  // every keystroke, browsers can swallow the very last edit if the user hits
  // a hard close (tab close, browser quit, mobile background) before React
  // processes the keystroke. These handlers flush whatever is in pendingGoalRef
  // straight to localStorage on every "we're about to be paused" signal.
  useEffect(() => {
    const flush = () => {
      try {
        const value = pendingGoalRef.current;
        const persisted = persistedGoalRef.current;
        const key = DRAFT_KEY(directive.id);
        if (value === persisted) {
          window.localStorage.removeItem(key);
        } else {
          window.localStorage.setItem(key, value);
        }
      } catch (err) {
        // eslint-disable-next-line no-console
        console.warn("[makima] flush handler failed to persist draft", err);
      }
    };
    const onBeforeUnload = () => flush();
    const onPageHide = () => flush();
    const onVisibility = () => {
      if (document.visibilityState === "hidden") flush();
    };
    window.addEventListener("beforeunload", onBeforeUnload);
    window.addEventListener("pagehide", onPageHide);
    document.addEventListener("visibilitychange", onVisibility);
    return () => {
      window.removeEventListener("beforeunload", onBeforeUnload);
      window.removeEventListener("pagehide", onPageHide);
      document.removeEventListener("visibilitychange", onVisibility);
      // Final flush on React unmount (route navigation within the SPA).
      flush();
    };
  }, [directive.id]);

  const handleGoalChange = useCallback(
    (goal: string) => {
      pendingGoalRef.current = goal;

      // 1. Always persist work-in-progress to localStorage IMMEDIATELY so
      //    leaving the page does not lose typing. We previously debounced
      //    this write by 250ms, but unmount could clear the pending timer
      //    before it flushed — losing the most recent edits exactly when
      //    we needed them most.
      try {
        if (goal === directive.goal) {
          window.localStorage.removeItem(DRAFT_KEY(directive.id));
        } else {
          window.localStorage.setItem(DRAFT_KEY(directive.id), goal);
          setDraftSavedAt(Date.now());
        }
      } catch (err) {
        // eslint-disable-next-line no-console
        console.warn("[makima] failed to persist draft", err);
      }

      // 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} />
          {/* Inline markdown shortcuts: typing **foo** auto-formats as bold,
              `foo` as code, etc. We pass only TEXT_FORMAT_TRANSFORMERS so
              block-level shortcuts (# heading, - list) don't fire and
              accidentally restructure the document. */}
          <MarkdownShortcutPlugin transformers={INLINE_TRANSFORMERS} />
          <FloatingFormatToolbar />

          <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 contract's goal…"
                    placeholder={
                      <div className="pointer-events-none absolute text-[#445566] font-mono text-[13px] mt-2">
                        Describe the contract'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}
        draftSavedAt={draftSavedAt}
        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 };