From 4b1d608b839769052634b4facc345b891d468926 Mon Sep 17 00:00:00 2001 From: soryu Date: Wed, 29 Apr 2026 01:10:11 +0100 Subject: feat: document-mode directive UI proof of concept (Lexical) (#101) * WIP: heartbeat checkpoint * feat: soryu-co/soryu - makima: Backend: feature flag + goal-edit interrupt messaging * WIP: heartbeat checkpoint * WIP: heartbeat checkpoint * feat: soryu-co/soryu - makima: Frontend: Lexical document editor with step blocks, context menu, countdown --- .../src/components/directives/StepsBlockNode.tsx | 281 +++++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 makima/frontend/src/components/directives/StepsBlockNode.tsx (limited to 'makima/frontend/src/components/directives/StepsBlockNode.tsx') 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({ 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; +} -- cgit v1.2.3