/**
* 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;
}