summaryrefslogblamecommitdiff
path: root/makima/frontend/src/components/directives/StepsBlockNode.tsx
blob: ab3d7da353513665fc406c849eddd7be53757518 (plain) (tree)
























































































































































































































































































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