/** * 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]> = [ // 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(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 ( { 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) => ( ); return (
{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")}
); } // ============================================================================= // 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(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 (
Contract
); } // ============================================================================= // 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 (
{label} {draftLabel && ( {draftLabel} )} {/* Live-start toggle is always shown so users can flip it from the bar. */} {/* "Save now" is always available when there are unsaved edits, so users don't have to wait for the auto-save countdown. */} {dirtyish && ( )}
); } // ============================================================================= // Main component // ============================================================================= export interface DocumentEditorProps { directive: DirectiveWithSteps; onUpdateGoal: (goal: string) => Promise | void; onCleanup: () => Promise | void; onCreatePR: () => Promise | void; onPickUpOrders: () => Promise | 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 // 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(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("idle"); const [remainingMs, setRemainingMs] = useState(countdownMs); const pendingGoalRef = useRef(directive.goal); const persistedGoalRef = useRef(directive.goal); const timerRef = useRef(null); const tickRef = useRef(null); const deadlineRef = useRef(0); const editorRef = useRef(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(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(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 (
{/* Capture the editor ref via a tiny inline plugin */} { pendingGoalRef.current = draft; if (liveStart) { startOrExtendCountdown(); } else { setSaveState("dirty"); } }} /> {/* 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. */}
Describe the contract's goal…
} 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} />
void fireSave()} onCancel={cancelCountdown} onToggleLiveStart={toggleLiveStart} /> {menu && ( setMenu(null)} onCleanup={() => { void onCleanup(); }} onUpdatePR={() => { void onCreatePR(); }} onPlanOrders={() => { void onPickUpOrders(); }} /> )}
); } /** * 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; }) { 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 };