summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/directives
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/components/directives')
-rw-r--r--makima/frontend/src/components/directives/DocumentEditor.tsx664
-rw-r--r--makima/frontend/src/components/directives/StepsBlockNode.tsx281
2 files changed, 945 insertions, 0 deletions
diff --git a/makima/frontend/src/components/directives/DocumentEditor.tsx b/makima/frontend/src/components/directives/DocumentEditor.tsx
new file mode 100644
index 0000000..40fccf1
--- /dev/null
+++ b/makima/frontend/src/components/directives/DocumentEditor.tsx
@@ -0,0 +1,664 @@
+/**
+ * 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
+// =============================================================================
+
+const SAVE_COUNTDOWN_MS = 3000;
+const SAVED_TOAST_MS = 1200;
+
+// =============================================================================
+// 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,
+}: {
+ directive: DirectiveWithSteps;
+}) {
+ const [editor] = useLexicalComposerContext();
+ const seededIdRef = useRef<string | null>(null);
+
+ useEffect(() => {
+ if (seededIdRef.current === directive.id) return;
+ seededIdRef.current = directive.id;
+
+ 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 (directive.goal.length > 0) {
+ goalPara.append($createTextNode(directive.goal));
+ }
+ 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" },
+ );
+ }, [editor, directive.id, directive.title, directive.goal]);
+
+ 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" | "pending" | "saving" | "saved" | "error";
+ remainingMs: number;
+ totalMs: number;
+ onCancel: () => void;
+}
+
+function SaveCountdownBar({
+ state,
+ remainingMs,
+ totalMs,
+ onCancel,
+}: SaveCountdownBarProps) {
+ if (state === "idle") 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 = `Saving goal in ${seconds}s — press Esc or Undo to cancel.`;
+ progressPct = Math.max(0, Math.min(100, (1 - remainingMs / totalMs) * 100));
+ } 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 justify-between px-4 py-1.5">
+ <span className="text-[10px] font-mono">{label}</span>
+ {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"
+ >
+ Cancel
+ </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" | "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],
+ );
+
+ // ---- Goal auto-save state machine --------------------------------------
+ const [saveState, setSaveState] = useState<SaveState>("idle");
+ const [remainingMs, setRemainingMs] = useState(SAVE_COUNTDOWN_MS);
+ const pendingGoalRef = useRef<string>(directive.goal);
+ const timerRef = useRef<number | null>(null);
+ const tickRef = useRef<number | null>(null);
+ const deadlineRef = useRef<number>(0);
+ const editorRef = useRef<LexicalEditor | null>(null);
+
+ // Reset state when switching directives.
+ useEffect(() => {
+ pendingGoalRef.current = directive.goal;
+ cancelTimers();
+ setSaveState("idle");
+ setRemainingMs(SAVE_COUNTDOWN_MS);
+ // 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" && pendingGoalRef.current === directive.goal) {
+ cancelTimers();
+ setSaveState("idle");
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [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;
+ }
+ }
+
+ const fireSave = useCallback(async () => {
+ const next = pendingGoalRef.current;
+ cancelTimers();
+ setSaveState("saving");
+ try {
+ await onUpdateGoal(next);
+ setSaveState("saved");
+ 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]);
+
+ const startOrExtendCountdown = useCallback(() => {
+ cancelTimers();
+ deadlineRef.current = Date.now() + SAVE_COUNTDOWN_MS;
+ setSaveState("pending");
+ setRemainingMs(SAVE_COUNTDOWN_MS);
+ 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;
+ }
+ }, 100);
+ timerRef.current = window.setTimeout(() => {
+ void fireSave();
+ }, SAVE_COUNTDOWN_MS);
+ }, [fireSave]);
+
+ const cancelCountdown = useCallback(() => {
+ if (saveState !== "pending") return;
+ cancelTimers();
+ pendingGoalRef.current = directive.goal; // reset pending edit
+ setSaveState("idle");
+ setRemainingMs(SAVE_COUNTDOWN_MS);
+ // 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, saveState]);
+
+ // Cleanup on unmount.
+ useEffect(() => {
+ return cancelTimers;
+ }, []);
+
+ const handleGoalChange = useCallback(
+ (goal: string) => {
+ pendingGoalRef.current = goal;
+ if (goal === directive.goal) {
+ // Edit reverted — cancel the countdown (if any).
+ if (saveState === "pending") {
+ cancelTimers();
+ setSaveState("idle");
+ }
+ return;
+ }
+ startOrExtendCountdown();
+ },
+ [directive.goal, 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} />
+ <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}
+ totalMs={SAVE_COUNTDOWN_MS}
+ onCancel={cancelCountdown}
+ />
+
+ {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 };
diff --git a/makima/frontend/src/components/directives/StepsBlockNode.tsx b/makima/frontend/src/components/directives/StepsBlockNode.tsx
new file mode 100644
index 0000000..ab3d7da
--- /dev/null
+++ b/makima/frontend/src/components/directives/StepsBlockNode.tsx
@@ -0,0 +1,281 @@
+/**
+ * StepsBlockNode — a Lexical DecoratorNode that renders the directive's steps
+ * as an in-document, non-editable diagram.
+ *
+ * The actual data (steps, orchestratorTaskId, etc.) does NOT live on the node
+ * itself — that would require us to dispatch a Lexical update on every poll,
+ * which is wasteful and fights against Lexical's content-equality model. The
+ * node is a marker that says "render the steps block here", and the React
+ * component pulls live data from a context provided by DocumentEditor. So when
+ * `useDirective` polls and produces new steps, the StepsBlock re-renders
+ * automatically without touching the editor state at all.
+ */
+import {
+ DecoratorNode,
+ type LexicalNode,
+ type NodeKey,
+ type SerializedLexicalNode,
+ type Spread,
+} from "lexical";
+import { createContext, useContext, type JSX } from "react";
+import type { DirectiveStep, DirectiveWithSteps, StepStatus } from "../../lib/api";
+
+// =============================================================================
+// Context provided by DocumentEditor — the StepsBlock reads live directive data
+// =============================================================================
+
+interface StepsBlockContextValue {
+ directive: DirectiveWithSteps | null;
+}
+
+const StepsBlockContext = createContext<StepsBlockContextValue>({ directive: null });
+
+export const StepsBlockContextProvider = StepsBlockContext.Provider;
+
+// =============================================================================
+// Status palette (matches StepNode.tsx for consistency)
+// =============================================================================
+
+const STATUS_COLORS: Record<StepStatus, { bg: string; border: string; text: string; pill: string }> = {
+ pending: {
+ bg: "bg-[#1a2540]",
+ border: "border-[#2a3a5a]",
+ text: "text-[#7788aa]",
+ pill: "bg-[#0f1a30] text-[#7788aa] border-[#2a3a5a]",
+ },
+ ready: {
+ bg: "bg-[#2a2a10]",
+ border: "border-[#4a4a20]",
+ text: "text-yellow-400",
+ pill: "bg-[#1a1a08] text-yellow-300 border-[#4a4a20]",
+ },
+ running: {
+ bg: "bg-[#0a2a1a]",
+ border: "border-[#1a5a3a]",
+ text: "text-green-400",
+ pill: "bg-[#062014] text-green-300 border-[#1a5a3a]",
+ },
+ completed: {
+ bg: "bg-[#0a2a2a]",
+ border: "border-[#1a5a5a]",
+ text: "text-emerald-400",
+ pill: "bg-[#062424] text-emerald-300 border-[#1a5a5a]",
+ },
+ failed: {
+ bg: "bg-[#2a1a1a]",
+ border: "border-[#5a2a2a]",
+ text: "text-red-400",
+ pill: "bg-[#241010] text-red-300 border-[#5a2a2a]",
+ },
+ skipped: {
+ bg: "bg-[#1a1a2a]",
+ border: "border-[#2a2a4a]",
+ text: "text-[#7788aa]",
+ pill: "bg-[#101020] text-[#7788aa] border-[#2a2a4a]",
+ },
+};
+
+const STATUS_LABEL: Record<StepStatus, string> = {
+ pending: "PENDING",
+ ready: "READY",
+ running: "RUNNING",
+ completed: "DONE",
+ failed: "FAILED",
+ skipped: "SKIP",
+};
+
+// =============================================================================
+// React component rendered inside the editor body
+// =============================================================================
+
+function StepCard({ step }: { step: DirectiveStep }) {
+ const colors = STATUS_COLORS[step.status] ?? STATUS_COLORS.pending;
+ const label = STATUS_LABEL[step.status] ?? step.status.toUpperCase();
+ return (
+ <div
+ className={`${colors.bg} border ${colors.border} rounded px-3 py-2 flex items-start gap-3`}
+ >
+ <span
+ className="text-[10px] font-mono text-[#556677] shrink-0 w-5 text-right"
+ aria-hidden
+ >
+ {String(step.orderIndex + 1).padStart(2, "0")}
+ </span>
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center justify-between gap-2">
+ <span className="text-[12px] font-mono text-white truncate">
+ {step.name}
+ </span>
+ <span
+ className={`text-[9px] font-mono uppercase tracking-wide border rounded px-1.5 py-0.5 shrink-0 ${colors.pill}`}
+ >
+ {label}
+ </span>
+ </div>
+ {step.description && (
+ <p className="text-[10px] font-mono text-[#7788aa] truncate mt-0.5">
+ {step.description}
+ </p>
+ )}
+ </div>
+ </div>
+ );
+}
+
+function StepsBlock(): JSX.Element {
+ const { directive } = useContext(StepsBlockContext);
+
+ // While the directive is loading or absent, render a quiet placeholder so the
+ // editor body still has something visible — but make sure it has the same
+ // outline as the loaded view so the document doesn't reflow.
+ if (!directive) {
+ return (
+ <div
+ contentEditable={false}
+ className="my-3 border border-dashed border-[rgba(117,170,252,0.2)] rounded px-3 py-4 select-none"
+ >
+ <div className="text-[10px] font-mono text-[#556677] uppercase tracking-wide">
+ steps · loading
+ </div>
+ </div>
+ );
+ }
+
+ const steps = [...directive.steps].sort((a, b) => a.orderIndex - b.orderIndex);
+ const isOrchestratorRunning = !!directive.orchestratorTaskId;
+ const completed = steps.filter((s) => s.status === "completed").length;
+ const total = steps.length;
+ const caption = isOrchestratorRunning
+ ? "makima is editing this document"
+ : total === 0
+ ? "no steps yet"
+ : total === 1
+ ? "1 step"
+ : `${total} steps`;
+
+ return (
+ <div
+ // contentEditable={false} keeps Lexical from treating this as editable
+ // content — the user can't put a caret inside it.
+ contentEditable={false}
+ // Use a small data attribute so external CSS / tests can target it.
+ data-makima-block="steps"
+ className="my-3 border border-[rgba(117,170,252,0.2)] rounded bg-[#091428] select-none"
+ >
+ {/* Caption */}
+ <div className="flex items-center justify-between px-3 py-1.5 border-b border-dashed border-[rgba(117,170,252,0.15)]">
+ <div className="flex items-center gap-1.5">
+ {isOrchestratorRunning && (
+ <span
+ className="inline-block w-1.5 h-1.5 rounded-full bg-yellow-400 animate-pulse"
+ aria-hidden
+ />
+ )}
+ <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide">
+ {caption}
+ </span>
+ </div>
+ {total > 0 && (
+ <span className="text-[10px] font-mono text-[#556677]">
+ {completed}/{total} done
+ </span>
+ )}
+ </div>
+
+ {/* Step diagram */}
+ {steps.length === 0 ? (
+ <div className="px-3 py-4 text-[11px] font-mono text-[#556677] italic">
+ {isOrchestratorRunning
+ ? "Planner is generating steps…"
+ : "No steps yet — start the directive or plan orders to populate."}
+ </div>
+ ) : (
+ <ol className="px-3 py-3 flex flex-col gap-1.5">
+ {steps.map((step, idx) => (
+ <li key={step.id} className="relative">
+ <StepCard step={step} />
+ {idx < steps.length - 1 && (
+ <div
+ className="absolute left-[18px] -bottom-1 h-1 w-px bg-[rgba(117,170,252,0.2)]"
+ aria-hidden
+ />
+ )}
+ </li>
+ ))}
+ </ol>
+ )}
+ </div>
+ );
+}
+
+// =============================================================================
+// Lexical decorator node
+// =============================================================================
+
+export type SerializedStepsBlockNode = Spread<
+ { /* No fields — the block is a marker; live data comes from context. */ },
+ SerializedLexicalNode
+>;
+
+export class StepsBlockNode extends DecoratorNode<JSX.Element> {
+ static getType(): string {
+ return "makima-steps-block";
+ }
+
+ static clone(node: StepsBlockNode): StepsBlockNode {
+ return new StepsBlockNode(node.__key);
+ }
+
+ constructor(key?: NodeKey) {
+ super(key);
+ }
+
+ createDOM(): HTMLElement {
+ const el = document.createElement("div");
+ el.className = "makima-steps-block-host";
+ return el;
+ }
+
+ updateDOM(): false {
+ return false;
+ }
+
+ static importJSON(_serializedNode: SerializedStepsBlockNode): StepsBlockNode {
+ return $createStepsBlockNode();
+ }
+
+ exportJSON(): SerializedStepsBlockNode {
+ return {
+ type: StepsBlockNode.getType(),
+ version: 1,
+ };
+ }
+
+ isInline(): boolean {
+ return false;
+ }
+
+ isIsolated(): boolean {
+ // Isolated decorator nodes can't be partially selected — the user can only
+ // click into them, not drag a selection into them. That's what we want.
+ return true;
+ }
+
+ isKeyboardSelectable(): boolean {
+ return true;
+ }
+
+ decorate(): JSX.Element {
+ return <StepsBlock />;
+ }
+}
+
+export function $createStepsBlockNode(): StepsBlockNode {
+ return new StepsBlockNode();
+}
+
+export function $isStepsBlockNode(
+ node: LexicalNode | null | undefined,
+): node is StepsBlockNode {
+ return node instanceof StepsBlockNode;
+}