summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/directives/StepsBlockNode.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/components/directives/StepsBlockNode.tsx')
-rw-r--r--makima/frontend/src/components/directives/StepsBlockNode.tsx281
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;
+}