From a2148d4e3117cdda2e1d0a8e3df289bfe04789a3 Mon Sep 17 00:00:00 2001
From: soryu <soryu@soryu.co>
Date: Thu, 30 Apr 2026 12:12:48 +0100
Subject: feat(document-mode): folder layout v2, glow on pending, inline
 formatting, autosave fix (#107)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

## Autosave bug fix (top priority)

The 250ms debounce on the localStorage draft write was racing the unmount
cleanup: typing then navigating within 250ms cleared the pending timer
*before* it flushed, which is exactly when we needed the draft saved.
Drafts are now written synchronously on every keystroke. localStorage
.setItem on a small string is sub-millisecond — the debounce was a
premature optimisation.

## Sidebar v2 (document-directives.tsx)

- Tasks now live in a `tasks/` subfolder inside each directive folder
  (orchestrator, completion, and started step tasks). The pinned `.md`
  document remains at the top of the directive folder.
- Status circles moved to the RIGHT side only (previously rendered on
  both sides, which the user found noisy).
- New `StatusDot` component composes the status colour with two optional
  modifiers: a "live" pulse when the orchestrator is running, and a
  GLOW (amber ring + pulse) when there is a pending user question for
  that directive or task. The glow is sourced from the existing
  SupervisorQuestionsContext, indexed by `directiveId` and `taskId`.
- New `TaskIcon` (terminal) and `CompletionIcon` (PR-bracket) so
  orchestrator/step/completion entries look distinct from the .md file.

## Inline formatting in the editor (DocumentEditor.tsx)

- New `MarkdownShortcutPlugin` (scoped to TEXT_FORMAT_TRANSFORMERS only)
  so typing `**foo**`, `*foo*`, `` `foo` ``, `~~foo~~` auto-formats inline.
  Block-level shortcuts (# heading, - list) are intentionally excluded so
  the document shape (H1 / goal / StepsBlock / trailing para) stays intact.
- New `FloatingFormatToolbar` appears above any non-collapsed selection
  inside the goal paragraph, with B / I / U / S / </> buttons that
  dispatch FORMAT_TEXT_COMMAND. Buttons highlight when the corresponding
  format is active. Standard ⌘B / ⌘I / ⌘U keyboard shortcuts also work
  via the existing RichTextPlugin.
- Round-trip via a small inline-only markdown serializer/parser so
  formatting persists across saves. Supported markers: `\``code\``,
  `***bold-italic***`, `**bold**`, `*italic* / _italic_`, `~~strike~~`.
  Underline survives within the editor session (toolbar / shortcut) but
  has no markdown syntax so it does not round-trip — by design.
- No backend schema change: `directive.goal` is still a TEXT column, it
  just contains inline markdown now.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 .../src/components/directives/DocumentEditor.tsx   | 334 +++++++++++++++++++--
 1 file changed, 304 insertions(+), 30 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 d953d45..270f5c3 100644
--- a/makima/frontend/src/components/directives/DocumentEditor.tsx
+++ b/makima/frontend/src/components/directives/DocumentEditor.tsx
@@ -22,11 +22,18 @@ 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";
@@ -36,7 +43,12 @@ 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,
@@ -60,11 +72,146 @@ 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;
+/**
+ * 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 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);
@@ -149,10 +296,12 @@ function SeedContentPlugin({
         heading.append($createTextNode(directive.title));
         root.append(heading);
 
-        // Paragraph: goal (editable).
+        // Paragraph: goal (editable). The persisted goal may contain inline
+        // markdown — parse it into formatted TextNodes so users see their
+        // bold/italic/code formatting on load.
         const goalPara = $createParagraphNode();
         if (initialGoal.length > 0) {
-          goalPara.append($createTextNode(initialGoal));
+          appendInlineMarkdownTo(goalPara, initialGoal);
         }
         root.append(goalPara);
 
@@ -243,8 +392,11 @@ function GoalChangePlugin({
           const children = root.getChildren();
           // The goal lives at index 1 (after the H1 title).
           const goalNode = children[1];
-          if (!goalNode) return;
-          onGoalChange(goalNode.getTextContent());
+          if (!goalNode || !$isElementNode(goalNode)) return;
+          // Serialize the goal paragraph as INLINE MARKDOWN so bold/italic/code
+          // formatting round-trips through `directive.goal` (a plain TEXT
+          // column on the backend).
+          onGoalChange(serializeInlineMarkdown(goalNode));
         });
       }}
     />
@@ -288,6 +440,129 @@ function CountdownKeyBridge({
   return null;
 }
 
+// =============================================================================
+// 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
 // =============================================================================
@@ -559,7 +834,6 @@ export function DocumentEditor({
   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() {
@@ -656,7 +930,9 @@ export function DocumentEditor({
       /* ignore */
     }
     // Also revert the editor's goal paragraph back to the persisted value, so
-    // the user sees the rollback.
+    // 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(
@@ -666,7 +942,7 @@ export function DocumentEditor({
           if (!goalNode || !$isElementNode(goalNode)) return;
           goalNode.getChildren().forEach((c) => c.remove());
           if (directive.goal.length > 0) {
-            goalNode.append($createTextNode(directive.goal));
+            appendInlineMarkdownTo(goalNode, directive.goal);
           }
         },
         { tag: "history-merge" },
@@ -678,10 +954,6 @@ export function DocumentEditor({
   useEffect(() => {
     return () => {
       cancelTimers();
-      if (draftDebounceRef.current != null) {
-        window.clearTimeout(draftDebounceRef.current);
-        draftDebounceRef.current = null;
-      }
     };
   }, []);
 
@@ -689,24 +961,20 @@ export function DocumentEditor({
     (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 */
+      // 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);
         }
-        draftDebounceRef.current = null;
-      }, DRAFT_PERSIST_DEBOUNCE_MS);
+      } catch {
+        /* localStorage may be unavailable / full; ignore */
+      }
 
       // 2. State-machine.
       if (goal === directive.goal) {
@@ -759,6 +1027,12 @@ export function DocumentEditor({
           <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"
-- 
cgit v1.2.3

