diff options
Diffstat (limited to 'makima/frontend/src/components/directives/StepsBlockNode.tsx')
| -rw-r--r-- | makima/frontend/src/components/directives/StepsBlockNode.tsx | 281 |
1 files changed, 281 insertions, 0 deletions
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; +} |
