/**
* DocumentEditor — the Lexical-based document body for Document Mode.
*
* Layout (top to bottom):
* - Read-only H1 with the directive's title.
* - Editable paragraph with the directive's goal. Editing the goal triggers
* a 3-second countdown bar at the bottom of the editor; if the timer
* expires, we call updateGoal(). Esc / ⌘Z cancels; further typing extends.
* - A custom non-editable StepsBlock decorator node showing each step.
*
* Right-click anywhere in the editor opens a custom context menu offering
* the three directive-level actions: Clean Up, Update PR, Plan Orders.
*
* Live updates:
* - The directive prop is updated by the parent's useDirective polling.
* - StepsBlockContextProvider wraps the LexicalComposer so that the steps
* block (a Lexical DecoratorNode) sees fresh data on every render
* without us having to mutate the editor state.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
$createParagraphNode,
$createTextNode,
$getRoot,
$isElementNode,
COMMAND_PRIORITY_LOW,
KEY_ESCAPE_COMMAND,
UNDO_COMMAND,
type LexicalEditor,
} from "lexical";
import { $createHeadingNode, HeadingNode } from "@lexical/rich-text";
import { ListNode, ListItemNode } from "@lexical/list";
import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import type { DirectiveWithSteps } from "../../lib/api";
import {
$createStepsBlockNode,
$isStepsBlockNode,
StepsBlockNode,
StepsBlockContextProvider,
} from "./StepsBlockNode";
// =============================================================================
// Constants
// =============================================================================
/**
* Time between the user's last keystroke and the goal being persisted (which
* triggers the orchestrator to (re)plan). Longer when the directive is fresh
* so the user can think; shorter when the orchestrator is already running and
* we want changes to flow through quickly.
*/
const COUNTDOWN_FRESH_MS = 60_000;
const COUNTDOWN_RUNNING_MS = 10_000;
/** The countdown bar only appears once we're inside this many ms from firing. */
const BAR_VISIBLE_MS = 10_000;
const SAVED_TOAST_MS = 1200;
/** Debounce for writing the in-progress draft to localStorage (no backend hit). */
const DRAFT_PERSIST_DEBOUNCE_MS = 250;
const DRAFT_KEY = (directiveId: string) => `makima:directive-goal-draft:${directiveId}`;
const LIVE_START_KEY = "makima:liveStartEnabled";
function isLiveStartEnabled(): boolean {
if (typeof window === "undefined") return true;
const raw = window.localStorage.getItem(LIVE_START_KEY);
// Default: live start ON — preserves existing behaviour for users who never
// touch the toggle.
return raw === null ? true : raw === "true";
}
function setLiveStartEnabled(value: boolean) {
if (typeof window === "undefined") return;
window.localStorage.setItem(LIVE_START_KEY, value ? "true" : "false");
}
// =============================================================================
// Editor theme — minimal, just enough so the rich-text plugin has something to
// hang class names on. We rely on our own typography otherwise.
// =============================================================================
const editorTheme = {
paragraph: "makima-doc-paragraph",
heading: {
h1: "makima-doc-h1",
h2: "makima-doc-h2",
},
text: {
bold: "font-bold",
italic: "italic",
underline: "underline",
},
};
// =============================================================================
// Plugins
// =============================================================================
/**
* (Re)builds the editor's root content from the directive whenever the
* directive ID changes. We keep this controlled so that switching documents
* resets the editor cleanly.
*
* We deliberately do NOT re-seed on every directive update — only on id
* change — so the user's in-flight goal edits aren't trampled by a poll that
* happens mid-keystroke.
*/
function SeedContentPlugin({
directive,
onDraftRestored,
}: {
directive: DirectiveWithSteps;
onDraftRestored: (draft: string) => void;
}) {
const [editor] = useLexicalComposerContext();
const seededIdRef = useRef<string | null>(null);
useEffect(() => {
if (seededIdRef.current === directive.id) return;
seededIdRef.current = directive.id;
// If a localStorage draft exists for this directive, prefer it over the
// persisted goal so the user does not lose unsaved work after navigating
// away. The parent is told about the restored draft so its state machine
// can transition to "dirty" or "pending".
let initialGoal = directive.goal;
let restoredDraft: string | null = null;
try {
const stored = window.localStorage.getItem(DRAFT_KEY(directive.id));
if (stored !== null && stored !== directive.goal) {
initialGoal = stored;
restoredDraft = stored;
}
} catch {
/* localStorage may be unavailable; fall back to persisted goal */
}
editor.update(
() => {
const root = $getRoot();
root.clear();
// H1: title (read-only — see ReadOnlyTitlePlugin).
const heading = $createHeadingNode("h1");
heading.append($createTextNode(directive.title));
root.append(heading);
// Paragraph: goal (editable).
const goalPara = $createParagraphNode();
if (initialGoal.length > 0) {
goalPara.append($createTextNode(initialGoal));
}
root.append(goalPara);
// Steps block (decorator — non-editable).
root.append($createStepsBlockNode());
// Trailing empty paragraph so the cursor has somewhere to land below
// the steps block.
root.append($createParagraphNode());
},
{ tag: "history-merge" },
);
if (restoredDraft !== null) {
// Defer so the parent's state update lands AFTER the editor's seeded
// content (avoids the GoalChangePlugin firing first and double-tracking).
queueMicrotask(() => onDraftRestored(restoredDraft!));
}
}, [editor, directive.id, directive.title, directive.goal, onDraftRestored]);
return null;
}
/**
* Prevents edits to the H1 (title) node. The title is meant to feel like a
* file name — clicking it shows a caret, but typing is no-op'd. We watch the
* editor's update stream and, if the H1's text drifts from the seed value,
* revert it on the next microtask so the user sees the change get rejected
* rather than silently swallowed.
*/
function ReadOnlyTitlePlugin({ title }: { title: string }) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
let reverting = false;
return editor.registerUpdateListener(({ editorState }) => {
if (reverting) return;
editorState.read(() => {
const root = $getRoot();
const first = root.getFirstChild();
if (!first || first.getType() !== "heading") return;
const text = first.getTextContent();
if (text === title) return;
reverting = true;
queueMicrotask(() => {
editor.update(
() => {
const r = $getRoot();
const h = r.getFirstChild();
if (!h || h.getType() !== "heading") {
reverting = false;
return;
}
if ($isElementNode(h) && h.getTextContent() !== title) {
h.getChildren().forEach((c) => c.remove());
if (title.length > 0) {
h.append($createTextNode(title));
}
}
reverting = false;
},
{ tag: "history-merge" },
);
});
});
});
}, [editor, title]);
return null;
}
/**
* Watches the goal paragraph (the second top-level child) and reports its
* current text to the parent on every change. Kept separate from the
* countdown bar so the bar is purely a UI concern.
*/
function GoalChangePlugin({
onGoalChange,
}: {
onGoalChange: (goal: string) => void;
}) {
return (
<OnChangePlugin
ignoreSelectionChange
onChange={(editorState) => {
editorState.read(() => {
const root = $getRoot();
const children = root.getChildren();
// The goal lives at index 1 (after the H1 title).
const goalNode = children[1];
if (!goalNode) return;
onGoalChange(goalNode.getTextContent());
});
}}
/>
);
}
/**
* Wires Lexical commands into UI callbacks. We forward Esc presses (used to
* cancel the countdown) and Undo (⌘/Ctrl-Z) without intercepting them.
*/
function CountdownKeyBridge({
onEsc,
onUndo,
}: {
onEsc: () => void;
onUndo: () => void;
}) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
const unEsc = editor.registerCommand(
KEY_ESCAPE_COMMAND,
() => {
onEsc();
return false; // don't consume; let other handlers run
},
COMMAND_PRIORITY_LOW,
);
const unUndo = editor.registerCommand(
UNDO_COMMAND,
() => {
onUndo();
return false;
},
COMMAND_PRIORITY_LOW,
);
return () => {
unEsc();
unUndo();
};
}, [editor, onEsc, onUndo]);
return null;
}
// =============================================================================
// Right-click context menu
// =============================================================================
interface EditorContextMenuProps {
x: number;
y: number;
onClose: () => void;
onCleanup: () => void;
onUpdatePR: () => void;
onPlanOrders: () => void;
}
function EditorContextMenu({
x,
y,
onClose,
onCleanup,
onUpdatePR,
onPlanOrders,
}: EditorContextMenuProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("mousedown", handleClick);
document.addEventListener("keydown", handleKey);
return () => {
document.removeEventListener("mousedown", handleClick);
document.removeEventListener("keydown", handleKey);
};
}, [onClose]);
// Clamp into viewport.
useEffect(() => {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
if (rect.right > vw) ref.current.style.left = `${x - rect.width}px`;
if (rect.bottom > vh) ref.current.style.top = `${y - rect.height}px`;
}, [x, y]);
const item =
"w-full px-3 py-1.5 text-left text-xs font-mono text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] flex items-center gap-2";
return (
<div
ref={ref}
className="fixed z-50 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] shadow-lg min-w-[180px]"
style={{ left: x, top: y }}
>
<div className="px-3 py-1.5 text-[10px] font-mono text-[#556677] uppercase border-b border-[rgba(117,170,252,0.2)]">
Document
</div>
<button
type="button"
className={item}
onClick={() => {
onCleanup();
onClose();
}}
>
<span className="text-[#75aafc]">⎚</span>
Clean Up
</button>
<button
type="button"
className={item}
onClick={() => {
onUpdatePR();
onClose();
}}
>
<span className="text-[#75aafc]">↗</span>
Update PR
</button>
<button
type="button"
className={item}
onClick={() => {
onPlanOrders();
onClose();
}}
>
<span className="text-[#c084fc]">◆</span>
Plan Orders
</button>
</div>
);
}
// =============================================================================
// Countdown bar
// =============================================================================
interface SaveCountdownBarProps {
state: "idle" | "dirty" | "pending" | "saving" | "saved" | "error";
remainingMs: number;
liveStart: boolean;
orchestratorRunning: boolean;
onSaveNow: () => void;
onCancel: () => void;
onToggleLiveStart: (next: boolean) => void;
}
function SaveCountdownBar({
state,
remainingMs,
liveStart,
orchestratorRunning,
onSaveNow,
onCancel,
onToggleLiveStart,
}: SaveCountdownBarProps) {
// Visibility rules:
// - Always show when actually saving / saved / error (transient feedback).
// - Show when "dirty" if live-start is OFF (user must trigger save).
// - Show when "pending" only inside the last BAR_VISIBLE_MS so the user
// does not feel rushed during the long fresh countdown.
const visible =
state === "saving" ||
state === "saved" ||
state === "error" ||
(state === "dirty" && !liveStart) ||
(state === "pending" && remainingMs <= BAR_VISIBLE_MS);
if (!visible) return null;
let label: string;
let progressPct = 0;
let tone = "border-[rgba(117,170,252,0.3)] text-[#9bc3ff]";
if (state === "pending") {
const seconds = Math.max(0, Math.ceil(remainingMs / 1000));
label = orchestratorRunning
? `Replanning in ${seconds}s — Esc/Undo cancels.`
: `Saving goal in ${seconds}s — Esc/Undo cancels.`;
progressPct = Math.max(
0,
Math.min(100, (1 - remainingMs / BAR_VISIBLE_MS) * 100),
);
} else if (state === "dirty") {
label = orchestratorRunning
? "Unsaved changes — saving will replan the directive."
: "Unsaved changes.";
progressPct = 0;
} else if (state === "saving") {
label = "Saving…";
progressPct = 100;
tone = "border-emerald-700 text-emerald-300";
} else if (state === "saved") {
label = "Saved";
progressPct = 100;
tone = "border-emerald-700 text-emerald-300";
} else {
label = "Save failed — try again.";
progressPct = 100;
tone = "border-red-700 text-red-300";
}
return (
<div
className={`shrink-0 border-t border-dashed ${tone} bg-[#0a1628]`}
data-makima-countdown={state}
>
<div className="h-0.5 bg-[#10203a]">
<div
className="h-full bg-[#75aafc] transition-[width] duration-100 ease-linear"
style={{ width: `${progressPct}%` }}
/>
</div>
<div className="flex items-center gap-3 px-4 py-1.5">
<span className="text-[10px] font-mono flex-1 truncate">{label}</span>
{/* Live-start toggle is always shown so users can flip it from the bar. */}
<label className="flex items-center gap-1.5 text-[10px] font-mono text-[#7788aa] cursor-pointer select-none shrink-0">
<input
type="checkbox"
checked={liveStart}
onChange={(e) => onToggleLiveStart(e.target.checked)}
className="accent-[#75aafc]"
/>
<span>Live start</span>
</label>
{(state === "dirty" || state === "pending") && (
<button
type="button"
onClick={onSaveNow}
className="text-[10px] font-mono text-emerald-300 hover:text-white border border-emerald-700/60 rounded px-2 py-0.5 shrink-0"
>
Save now
</button>
)}
{(state === "dirty" || state === "pending") && (
<button
type="button"
onClick={onCancel}
className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-0.5 shrink-0"
>
Discard
</button>
)}
</div>
</div>
);
}
// =============================================================================
// Main component
// =============================================================================
export interface DocumentEditorProps {
directive: DirectiveWithSteps;
onUpdateGoal: (goal: string) => Promise<void> | void;
onCleanup: () => Promise<void> | void;
onCreatePR: () => Promise<void> | void;
onPickUpOrders: () => Promise<unknown> | unknown;
}
type SaveState = "idle" | "dirty" | "pending" | "saving" | "saved" | "error";
export function DocumentEditor({
directive,
onUpdateGoal,
onCleanup,
onCreatePR,
onPickUpOrders,
}: DocumentEditorProps) {
// ---- Lexical config ----------------------------------------------------
const initialConfig = useMemo(
() => ({
// Re-key the composer when the directive id changes so we get a clean
// editor state per document. We do this via the `key` prop on
// <LexicalComposer> below as well.
namespace: `makima-doc-${directive.id}`,
onError: (err: Error) => {
// eslint-disable-next-line no-console
console.error("[DocumentEditor]", err);
},
nodes: [HeadingNode, ListNode, ListItemNode, StepsBlockNode],
theme: editorTheme,
editable: true,
}),
[directive.id],
);
// ---- Live-start setting (localStorage-backed) -------------------------
const [liveStart, setLiveStartState] = useState<boolean>(isLiveStartEnabled);
const toggleLiveStart = useCallback((next: boolean) => {
setLiveStartEnabled(next);
setLiveStartState(next);
}, []);
// ---- Goal auto-save state machine --------------------------------------
const orchestratorRunning =
!!directive.orchestratorTaskId || !!directive.completionTaskId;
// Pick the right countdown based on whether we'd be restarting work.
const countdownMs = orchestratorRunning ? COUNTDOWN_RUNNING_MS : COUNTDOWN_FRESH_MS;
const [saveState, setSaveState] = useState<SaveState>("idle");
const [remainingMs, setRemainingMs] = useState(countdownMs);
const pendingGoalRef = useRef<string>(directive.goal);
const timerRef = useRef<number | null>(null);
const tickRef = useRef<number | null>(null);
const deadlineRef = useRef<number>(0);
const draftDebounceRef = useRef<number | null>(null);
const editorRef = useRef<LexicalEditor | null>(null);
function cancelTimers() {
if (timerRef.current != null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
if (tickRef.current != null) {
window.clearInterval(tickRef.current);
tickRef.current = null;
}
}
// Reset state when switching directives.
useEffect(() => {
pendingGoalRef.current = directive.goal;
cancelTimers();
setSaveState("idle");
setRemainingMs(countdownMs);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [directive.id]);
// If the persisted goal updated externally and matches the pending goal,
// settle the bar.
useEffect(() => {
if (
(saveState === "pending" || saveState === "dirty") &&
pendingGoalRef.current === directive.goal
) {
cancelTimers();
setSaveState("idle");
try {
window.localStorage.removeItem(DRAFT_KEY(directive.id));
} catch {
/* localStorage may be unavailable; ignore */
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [directive.goal]);
const fireSave = useCallback(async () => {
const next = pendingGoalRef.current;
cancelTimers();
setSaveState("saving");
try {
await onUpdateGoal(next);
setSaveState("saved");
try {
window.localStorage.removeItem(DRAFT_KEY(directive.id));
} catch {
/* ignore */
}
window.setTimeout(() => {
// Only fade if no new edit has reopened a pending state in the meantime.
setSaveState((s) => (s === "saved" ? "idle" : s));
}, SAVED_TOAST_MS);
} catch (e) {
// eslint-disable-next-line no-console
console.error("Failed to save goal", e);
setSaveState("error");
window.setTimeout(() => {
setSaveState((s) => (s === "error" ? "idle" : s));
}, 2500);
}
}, [onUpdateGoal, directive.id]);
const startOrExtendCountdown = useCallback(() => {
cancelTimers();
deadlineRef.current = Date.now() + countdownMs;
setSaveState("pending");
setRemainingMs(countdownMs);
tickRef.current = window.setInterval(() => {
const remaining = Math.max(0, deadlineRef.current - Date.now());
setRemainingMs(remaining);
if (remaining <= 0 && tickRef.current != null) {
window.clearInterval(tickRef.current);
tickRef.current = null;
}
}, 200);
timerRef.current = window.setTimeout(() => {
void fireSave();
}, countdownMs);
}, [fireSave, countdownMs]);
const cancelCountdown = useCallback(() => {
if (saveState !== "pending" && saveState !== "dirty") return;
cancelTimers();
pendingGoalRef.current = directive.goal; // reset pending edit
setSaveState("idle");
setRemainingMs(countdownMs);
try {
window.localStorage.removeItem(DRAFT_KEY(directive.id));
} catch {
/* ignore */
}
// Also revert the editor's goal paragraph back to the persisted value, so
// the user sees the rollback.
const editor = editorRef.current;
if (editor) {
editor.update(
() => {
const root = $getRoot();
const goalNode = root.getChildren()[1];
if (!goalNode || !$isElementNode(goalNode)) return;
goalNode.getChildren().forEach((c) => c.remove());
if (directive.goal.length > 0) {
goalNode.append($createTextNode(directive.goal));
}
},
{ tag: "history-merge" },
);
}
}, [directive.goal, directive.id, saveState, countdownMs]);
// Cleanup on unmount.
useEffect(() => {
return () => {
cancelTimers();
if (draftDebounceRef.current != null) {
window.clearTimeout(draftDebounceRef.current);
draftDebounceRef.current = null;
}
};
}, []);
const handleGoalChange = useCallback(
(goal: string) => {
pendingGoalRef.current = goal;
// 1. Always persist work-in-progress to localStorage (debounced) so
// leaving the page does not lose typing. This is independent of
// whether we will trigger a backend save.
if (draftDebounceRef.current != null) {
window.clearTimeout(draftDebounceRef.current);
}
draftDebounceRef.current = window.setTimeout(() => {
try {
if (goal === directive.goal) {
window.localStorage.removeItem(DRAFT_KEY(directive.id));
} else {
window.localStorage.setItem(DRAFT_KEY(directive.id), goal);
}
} catch {
/* localStorage may be unavailable / full; ignore */
}
draftDebounceRef.current = null;
}, DRAFT_PERSIST_DEBOUNCE_MS);
// 2. State-machine.
if (goal === directive.goal) {
// Edit reverted — cancel the countdown (if any).
if (saveState === "pending" || saveState === "dirty") {
cancelTimers();
setSaveState("idle");
}
return;
}
if (liveStart) {
startOrExtendCountdown();
} else {
// Manual mode: stay "dirty" until the user clicks Save now.
cancelTimers();
setSaveState("dirty");
}
},
[directive.goal, directive.id, liveStart, saveState, startOrExtendCountdown],
);
// ---- Right-click context menu -----------------------------------------
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setMenu({ x: e.clientX, y: e.clientY });
}, []);
// ---- Render ------------------------------------------------------------
return (
<div className="flex flex-col h-full overflow-hidden">
<StepsBlockContextProvider value={{ directive }}>
<LexicalComposer key={directive.id} initialConfig={initialConfig}>
{/* Capture the editor ref via a tiny inline plugin */}
<EditorRefCapture editorRef={editorRef} />
<SeedContentPlugin
directive={directive}
onDraftRestored={(draft) => {
pendingGoalRef.current = draft;
if (liveStart) {
startOrExtendCountdown();
} else {
setSaveState("dirty");
}
}}
/>
<ReadOnlyTitlePlugin title={directive.title} />
<HistoryPlugin />
<GoalChangePlugin onGoalChange={handleGoalChange} />
<CountdownKeyBridge onEsc={cancelCountdown} onUndo={cancelCountdown} />
<div
className="flex-1 overflow-auto"
onContextMenu={handleContextMenu}
>
<div className="max-w-3xl mx-auto px-8 py-10">
<RichTextPlugin
contentEditable={
<ContentEditable
aria-placeholder="Describe the directive's goal…"
placeholder={
<div className="pointer-events-none absolute text-[#445566] font-mono text-[13px] mt-2">
Describe the directive's goal…
</div>
}
className="outline-none font-mono text-[13px] leading-relaxed text-[#dbe7ff] [&_.makima-doc-h1]:text-[24px] [&_.makima-doc-h1]:font-medium [&_.makima-doc-h1]:text-white [&_.makima-doc-h1]:mb-3 [&_.makima-doc-h1]:tracking-tight [&_.makima-doc-paragraph]:my-2 [&_.makima-doc-paragraph]:text-[13px] [&_.makima-doc-paragraph]:text-[#c0d0e0] relative"
/>
}
ErrorBoundary={LexicalErrorBoundary}
/>
</div>
</div>
</LexicalComposer>
</StepsBlockContextProvider>
<SaveCountdownBar
state={saveState}
remainingMs={remainingMs}
liveStart={liveStart}
orchestratorRunning={orchestratorRunning}
onSaveNow={() => void fireSave()}
onCancel={cancelCountdown}
onToggleLiveStart={toggleLiveStart}
/>
{menu && (
<EditorContextMenu
x={menu.x}
y={menu.y}
onClose={() => setMenu(null)}
onCleanup={() => {
void onCleanup();
}}
onUpdatePR={() => {
void onCreatePR();
}}
onPlanOrders={() => {
void onPickUpOrders();
}}
/>
)}
</div>
);
}
/**
* Tiny plugin that stashes the LexicalEditor instance on a ref so the parent
* component can issue updates from outside (e.g. to revert the goal on cancel).
*/
function EditorRefCapture({
editorRef,
}: {
editorRef: React.MutableRefObject<LexicalEditor | null>;
}) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
editorRef.current = editor;
return () => {
if (editorRef.current === editor) {
editorRef.current = null;
}
};
}, [editor, editorRef]);
return null;
}
// Re-export the steps-block helpers so consumers can include the node class
// in their own initial configs if needed.
export { $createStepsBlockNode, $isStepsBlockNode, StepsBlockNode };