/** * 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({ directive: null }); export const StepsBlockContextProvider = StepsBlockContext.Provider; // ============================================================================= // Status palette (matches StepNode.tsx for consistency) // ============================================================================= const STATUS_COLORS: Record = { 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 = { 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 (
{String(step.orderIndex + 1).padStart(2, "0")}
{step.name} {label}
{step.description && (

{step.description}

)}
); } 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 (
steps · loading
); } 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 (
{/* Caption */}
{isOrchestratorRunning && ( )} {caption}
{total > 0 && ( {completed}/{total} done )}
{/* Step diagram */} {steps.length === 0 ? (
{isOrchestratorRunning ? "Planner is generating steps…" : "No steps yet — start the directive or plan orders to populate."}
) : (
    {steps.map((step, idx) => (
  1. {idx < steps.length - 1 && (
    )}
  2. ))}
)}
); } // ============================================================================= // 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 { 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 ; } } export function $createStepsBlockNode(): StepsBlockNode { return new StepsBlockNode(); } export function $isStepsBlockNode( node: LexicalNode | null | undefined, ): node is StepsBlockNode { return node instanceof StepsBlockNode; }