/**
* 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.
*/
/**
* Per-document draft key. Contracts under the same directive must not
* share localStorage, otherwise switching contracts would clobber the
* other's unsaved edits. The key is the document id; we keep the
* `directive-goal-draft` prefix for backwards compatibility with
* existing entries (the prefix string is opaque storage).
*/
const DRAFT_KEY = (documentId: string) => `makima:directive-goal-draft:${documentId}`;
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 `body`. 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({
documentId,
title,
body,
onDraftRestored,
}: {
documentId: string;
title: string;
body: string;
onDraftRestored: (draft: string) => void;
}) {
const [editor] = useLexicalComposerContext();
const seededIdRef = useRef<string | null>(null);
useEffect(() => {
if (seededIdRef.current === documentId) return;
seededIdRef.current = documentId;
// If a localStorage draft exists for this document, prefer it over
// the persisted body 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 initialBody = body;
let restoredDraft: string | null = null;
try {
const stored = window.localStorage.getItem(DRAFT_KEY(documentId));
if (stored !== null && stored !== body) {
initialBody = stored;
restoredDraft = stored;
}
} catch {
/* localStorage may be unavailable; fall back to persisted body */
}
editor.update(
() => {
const root = $getRoot();
root.clear();
// H1: title (read-only — see ReadOnlyTitlePlugin).
const heading = $createHeadingNode("h1");
heading.append($createTextNode(title));
root.append(heading);
// Body content. The persisted body 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 body is
// empty.
const blocks =
initialBody.length > 0 ? initialBody.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 body
// 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, documentId, title, body, 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 {
/** The parent directive, used for orchestrator state, repo metadata, and
* the embedded steps panel. The editor body content does NOT come from
* this — pass `body` and `title` explicitly so contracts under the
* same directive can have independent specs. */
directive: DirectiveWithSteps;
/** Document id — scopes the localStorage draft key so per-contract
* drafts don't collide. */
documentId: string;
/** The contract's title, rendered as the H1 heading at the top of the
* editor (read-only). */
title: string;
/** The contract's body markdown, used to seed the editor on mount and
* whenever the document id changes. */
body: string;
onUpdateBody: (body: 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,
documentId,
title,
body,
onUpdateBody,
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-${documentId}`,
onError: (err: Error) => {
// eslint-disable-next-line no-console
console.error("[DocumentEditor]", err);
},
nodes: [HeadingNode, ListNode, ListItemNode, StepsBlockNode],
theme: editorTheme,
editable: true,
}),
[documentId],
);
// ---- 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>(body);
const persistedGoalRef = useRef<string>(body);
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 `body === 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 = body;
}, [body]);
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 = body;
cancelTimers();
setSaveState("idle");
setRemainingMs(countdownMs);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [documentId]);
// If the persisted goal updated externally and matches the pending goal,
// settle the bar.
useEffect(() => {
if (
(saveState === "pending" || saveState === "dirty") &&
pendingGoalRef.current === body
) {
cancelTimers();
setSaveState("idle");
try {
window.localStorage.removeItem(DRAFT_KEY(documentId));
} catch {
/* localStorage may be unavailable; ignore */
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [body]);
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(documentId), 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 onUpdateBody(next);
lastSavedValueRef.current = next;
setSaveState("saved");
// NOTE: we deliberately do NOT clear localStorage here. The roundtrip
// effect below clears it once the polled body 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);
}
}, [onUpdateBody, documentId]);
// Roundtrip-confirmed draft cleanup. Only drops the localStorage draft
// when the polled body 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 &&
body === lastSavedValueRef.current &&
pendingGoalRef.current === lastSavedValueRef.current
) {
try {
window.localStorage.removeItem(DRAFT_KEY(documentId));
} catch {
/* ignore */
}
lastSavedValueRef.current = null;
}
}, [body, documentId]);
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 = body; // reset pending edit
setSaveState("idle");
setRemainingMs(countdownMs);
try {
window.localStorage.removeItem(DRAFT_KEY(documentId));
} 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 (body.length > 0) {
appendInlineMarkdownTo(goalNode, body);
}
},
{ tag: "history-merge" },
);
}
}, [body, documentId, 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(documentId);
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();
};
}, [documentId]);
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 === body) {
window.localStorage.removeItem(DRAFT_KEY(documentId));
} else {
window.localStorage.setItem(DRAFT_KEY(documentId), 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 === body) {
// 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");
}
},
[body, documentId, 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={documentId} initialConfig={initialConfig}>
{/* Capture the editor ref via a tiny inline plugin */}
<EditorRefCapture editorRef={editorRef} />
<SeedContentPlugin
documentId={documentId}
title={title}
body={body}
onDraftRestored={(draft) => {
pendingGoalRef.current = draft;
if (liveStart) {
startOrExtendCountdown();
} else {
setSaveState("dirty");
}
}}
/>
<ReadOnlyTitlePlugin title={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 };