cruise 0.2.1

YAML-driven coding agent workflow orchestrator
Documentation
import { useEffect, useState } from "react";
import type { DagDto } from "../types";
import { getSessionDag } from "../lib/commands";

let mermaidInitialized = false;

type DagPanelState =
  | { kind: "loading" }
  | { kind: "error"; message: string }
  | { kind: "svg"; svg: string }
  | { kind: "empty" };

interface WorkflowDagPanelProps {
  sessionId: string;
  panelId: string;
  tabId: string;
  className?: string;
}

export function WorkflowDagPanel({ sessionId, panelId, tabId, className = "" }: WorkflowDagPanelProps) {
  const [state, setState] = useState<DagPanelState>({ kind: "loading" });

  useEffect(() => {
    let active = true;
    const renderId = crypto.randomUUID();
    setState({ kind: "loading" });
    void (async () => {
      try {
        const data = await getSessionDag(sessionId);
        if (!active) return;
        if (data.steps.length === 0) {
          setState({ kind: "empty" });
          return;
        }
        const mermaid = (await import("mermaid")).default;
        if (!mermaidInitialized) {
          mermaid.initialize({ startOnLoad: false, theme: "default" });
          mermaidInitialized = true;
        }
        const { svg } = await mermaid.render(`dag-${renderId}`, buildMermaidSource(data));
        if (active) setState({ kind: "svg", svg });
      } catch (e) {
        if (active) setState({ kind: "error", message: `Failed to render DAG: ${String(e)}` });
      }
    })();
    return () => { active = false; };
  }, [sessionId]);

  return (
    <div id={panelId} role="tabpanel" aria-labelledby={tabId} className={`h-full overflow-auto ${className}`}>
      {state.kind === "error" && <p className="p-4 text-sm text-red-600 dark:text-red-400">{state.message}</p>}
      {state.kind === "loading" && <p className="p-4 text-sm text-gray-500 dark:text-gray-400">Loading DAG…</p>}
      {state.kind === "empty" && <p className="p-4 text-sm text-gray-500 dark:text-gray-400">No DAG available.</p>}
      {state.kind === "svg" && <div className="dag-svg p-4" dangerouslySetInnerHTML={{ __html: state.svg }} />}
    </div>
  );
}


function buildMermaidSource(dag: DagDto): string {
  const lines: string[] = ["graph TD"];
  const nodeId = new Map(dag.steps.map((step, index) => [step.name, `s${index}_${sanitizeNodeId(step.name)}`]));
  for (const step of dag.steps) lines.push(`  ${nodeId.get(step.name)}["${escapeMermaidLabel(step.name)}"]`);
  for (const edge of dag.edges) {
    const from = nodeId.get(edge.from);
    if (!from) continue;
    if (!edge.to) {
      lines.push(`  ${from} --> end_terminal[/END/]`);
      continue;
    }
    const to = nodeId.get(edge.to);
    if (!to) continue;
    const reason = edge.selector ? { [edge.reason]: edge.selector } : edge.reason;
    const label = edgeLabel(reason);
    lines.push(label ? `  ${from} -->|"${escapeMermaidLabel(label)}"| ${to}` : `  ${from} --> ${to}`);
  }
  const start = nodeId.get(dag.startStep);
  if (start) lines.push(`  style ${start} fill:#10b981,color:#fff,stroke:#059669,stroke-width:2px`);
  const current = nodeId.get(dag.currentStep ?? "");
  if (current) lines.push(`  style ${current} fill:#3b82f6,color:#fff,stroke:#2563eb,stroke-width:2px`);
  return lines.join("\n");
}


function sanitizeNodeId(name: string): string { return name.replace(/[^A-Za-z0-9_]/g, "_"); }

const MERMAID_LABEL_ESCAPES: Record<string, string> = {
  "\\": "#92;", "\n": "#10;", "\r": "#10;", "#": "#35;", ";": "#59;", "`": "#96;",
  "[": "#91;", "]": "#93;", "(": "#40;", ")": "#41;", "{": "#123;", "}": "#125;",
  "&": "#amp;", '"': "#quot;", "<": "#lt;", ">": "#gt;", "|": "#124;",
};

function escapeMermaidLabel(label: string): string {
  return label.replace(/[\\\n\r#;`[\](){}&"<>|]/g, (ch) => MERMAID_LABEL_ESCAPES[ch] ?? ch);
}

function edgeLabel(reason: Record<string, string> | string): string | null {
  if (typeof reason === "string") {
    const normalized = reason.toLowerCase();
    return normalized === "sequential" || normalized === "next" ? null : reason;
  }
  const [kind, value] = Object.entries(reason)[0] ?? [];
  if (!kind || kind.toLowerCase() === "sequential" || kind.toLowerCase() === "next") return null;
  return value ? `${kind}: ${value}` : kind;
}