Skip to content

Workflow Visual Diff Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add an exported WorkflowDiffViewer that visually compares two workflow definitions and clearly separates semantic changes from cosmetic-only changes.

Architecture: Keep comparison logic in pure TypeScript utilities under ui/src/diff/ and keep rendering concerns in a new WorkflowDiffViewer React component. The viewer consumes a typed WorkflowDiffResult, renders the union graph with status styles, and shows summary, legend, warnings, and before/after details. The compare contract is explicit (baseWorkflow + compareWorkflow) and version labels are surfaced in the header as name (vN) when available.

Tech Stack: TypeScript, React 19, @xyflow/react (React Flow v12), Vite, vitest, ESLint.

Spec: docs/superpowers/specs/2026-09-10-workflow-visual-diff-design.md

Global Constraints

  • All commands run from the ui/ directory.
  • Verification gates for behavior changes: npx vitest run and npx tsc --noEmit.
  • Follow existing code style and indentation in touched files (this codebase uses 2-space indentation in TypeScript/TSX under ui/src).
  • Node identity is ID-only (node.id); changed node IDs are remove+add.
  • Position-only node changes are cosmetic-only, not semantic.
  • Edge priority changes are semantic and must be reported as changed.
  • Array ordering differences in nodes/edges lists are ignored.
  • version remains optional; the diff UI must surface vN when present and fall back to name, then id.

Task 1: Build the pure workflow diff engine

Files: - Create: ui/src/diff/workflowDiffTypes.ts - Create: ui/src/diff/workflowDiff.ts - Create: ui/src/diff/workflowDiff.test.ts

Interfaces: - Consumes: Workflow, WorkflowNode, WorkflowEdge from ../types/workflow.ts. - Produces: - export type DiffStatus = 'added' | 'removed' | 'changed' | 'cosmetic' | 'unchanged'; - export interface WorkflowDiffResult { ... } with node/edge records, summary counts, and warnings. - export function diffWorkflows(baseWorkflow: Workflow, compareWorkflow: Workflow): WorkflowDiffResult - export function resolveWorkflowLabel(workflow: Pick<Workflow, 'id' | 'name' | 'version'>): string

  • [ ] Step 1: Write failing tests for the diff contract

Create ui/src/diff/workflowDiff.test.ts:

import { describe, it, expect } from 'vitest';
import { diffWorkflows, resolveWorkflowLabel } from './workflowDiff.ts';
import { type Workflow } from '../types/workflow.ts';

function wf(overrides: Partial<Workflow> = {}): Workflow {
  return {
    id: 'wf-1',
    name: 'Example Workflow',
    version: 1,
    nodes: [
      {
        id: 'start',
        type: 'start',
        name: 'Start',
        config: {},
        position: { x: 50, y: 50 },
      },
      {
        id: 'action-a',
        type: 'action',
        name: 'Action A',
        config: { actionType: 'send-email' },
        position: { x: 250, y: 50 },
      },
    ],
    edges: [
      {
        id: 'e1',
        source: 'start',
        target: 'action-a',
        condition: undefined,
        priority: 0,
        isDefault: true,
        label: 'default',
      },
    ],
    ...overrides,
  };
}

describe('diffWorkflows', () => {
  it('marks identical workflows as unchanged', () => {
    const base = wf();
    const compare = wf();
    const result = diffWorkflows(base, compare);

    expect(result.summary.nodes.unchanged).toBe(2);
    expect(result.summary.edges.unchanged).toBe(1);
    expect(result.warnings).toEqual([]);
  });

  it('marks position-only node edits as cosmetic', () => {
    const base = wf();
    const compare = wf({
      nodes: [
        base.nodes[0],
        { ...base.nodes[1], position: { x: 999, y: 777 } },
      ],
    });

    const result = diffWorkflows(base, compare);
    expect(result.nodes['action-a'].status).toBe('cosmetic');
    expect(result.nodes['action-a'].changes).toEqual(['position']);
  });

  it('marks node semantic changes as changed', () => {
    const base = wf();
    const compare = wf({
      nodes: [
        base.nodes[0],
        {
          ...base.nodes[1],
          name: 'Action A updated',
          config: { actionType: 'http-request' },
        },
      ],
    });

    const result = diffWorkflows(base, compare);
    expect(result.nodes['action-a'].status).toBe('changed');
    expect(result.nodes['action-a'].changes).toContain('name');
    expect(result.nodes['action-a'].changes).toContain('config');
  });

  it('marks priority-only edge edits as semantic changes', () => {
    const base = wf();
    const compare = wf({
      edges: [{ ...base.edges[0], priority: 42 }],
    });

    const result = diffWorkflows(base, compare);
    expect(result.edges.e1.status).toBe('changed');
    expect(result.edges.e1.changes).toEqual(['priority']);
  });

  it('ignores array ordering noise', () => {
    const base = wf();
    const compare = wf({
      nodes: [...base.nodes].reverse(),
      edges: [...base.edges].reverse(),
    });

    const result = diffWorkflows(base, compare);
    expect(result.summary.nodes.changed).toBe(0);
    expect(result.summary.nodes.cosmetic).toBe(0);
    expect(result.summary.edges.changed).toBe(0);
  });

  it('emits warnings for duplicate ids', () => {
    const base = wf({
      nodes: [
        {
          id: 'dup',
          type: 'start',
          name: 'A',
          config: {},
          position: { x: 0, y: 0 },
        },
        {
          id: 'dup',
          type: 'action',
          name: 'B',
          config: {},
          position: { x: 100, y: 0 },
        },
      ],
      edges: [],
    });

    const compare = wf({ nodes: [], edges: [] });
    const result = diffWorkflows(base, compare);

    expect(result.warnings.some((warning) => warning.code === 'DUPLICATE_NODE_ID')).toBe(true);
  });
});

describe('resolveWorkflowLabel', () => {
  it('formats name and version when present', () => {
    expect(resolveWorkflowLabel({ id: 'id-1', name: 'Flow', version: 7 })).toBe('Flow (v7)');
  });

  it('falls back to name then id', () => {
    expect(resolveWorkflowLabel({ id: 'id-2', name: 'Flow 2', version: undefined })).toBe('Flow 2');
    expect(resolveWorkflowLabel({ id: 'id-3', name: '', version: undefined })).toBe('id-3');
  });
});
  • [ ] Step 2: Run the test file and confirm failure

Run: npx vitest run src/diff/workflowDiff.test.ts Expected: FAIL with module not found / missing exports.

  • [ ] Step 3: Implement the types and diff engine minimally

Create ui/src/diff/workflowDiffTypes.ts:

import { type WorkflowEdge, type WorkflowNode } from '../types/workflow.ts';

export type DiffStatus = 'added' | 'removed' | 'changed' | 'cosmetic' | 'unchanged';

export interface DiffWarning {
  code: 'DUPLICATE_NODE_ID' | 'DUPLICATE_EDGE_ID';
  message: string;
}

export interface NodeDiffRecord {
  id: string;
  status: DiffStatus;
  baseNode?: WorkflowNode;
  compareNode?: WorkflowNode;
  changes: string[];
}

export interface EdgeDiffRecord {
  id: string;
  status: DiffStatus;
  baseEdge?: WorkflowEdge;
  compareEdge?: WorkflowEdge;
  changes: string[];
}

export interface DiffSummaryBucket {
  added: number;
  removed: number;
  changed: number;
  cosmetic: number;
  unchanged: number;
}

export interface WorkflowDiffResult {
  nodeOrder: string[];
  edgeOrder: string[];
  nodes: Record<string, NodeDiffRecord>;
  edges: Record<string, EdgeDiffRecord>;
  summary: {
    nodes: DiffSummaryBucket;
    edges: DiffSummaryBucket;
  };
  warnings: DiffWarning[];
}

Create ui/src/diff/workflowDiff.ts:

import { type Workflow, type WorkflowEdge, type WorkflowNode } from '../types/workflow.ts';
import {
  type DiffStatus,
  type DiffWarning,
  type WorkflowDiffResult,
  type NodeDiffRecord,
  type EdgeDiffRecord,
} from './workflowDiffTypes.ts';

function statusBucket(): WorkflowDiffResult['summary']['nodes'] {
  return { added: 0, removed: 0, changed: 0, cosmetic: 0, unchanged: 0 };
}

function bump(bucket: WorkflowDiffResult['summary']['nodes'], status: DiffStatus): void {
  bucket[status] += 1;
}

function duplicateWarnings(kind: 'node' | 'edge', ids: string[]): DiffWarning[] {
  const code = kind === 'node' ? 'DUPLICATE_NODE_ID' : 'DUPLICATE_EDGE_ID';
  return ids.map((id) => ({
    code,
    message: `Duplicate ${kind} id detected: ${id}. Diff results may be ambiguous.`,
  }));
}

function indexNodes(nodes: WorkflowNode[]): { map: Map<string, WorkflowNode>; duplicates: string[] } {
  const map = new Map<string, WorkflowNode>();
  const duplicates = new Set<string>();
  for (const node of nodes) {
    if (map.has(node.id)) duplicates.add(node.id);
    map.set(node.id, node);
  }
  return { map, duplicates: [...duplicates] };
}

function indexEdges(edges: WorkflowEdge[]): { map: Map<string, WorkflowEdge>; duplicates: string[] } {
  const map = new Map<string, WorkflowEdge>();
  const duplicates = new Set<string>();
  for (const edge of edges) {
    if (map.has(edge.id)) duplicates.add(edge.id);
    map.set(edge.id, edge);
  }
  return { map, duplicates: [...duplicates] };
}

function samePosition(a?: WorkflowNode, b?: WorkflowNode): boolean {
  if (!a || !b) return false;
  return a.position.x === b.position.x && a.position.y === b.position.y;
}

function nodeDiff(baseNode?: WorkflowNode, compareNode?: WorkflowNode): NodeDiffRecord {
  if (!baseNode && compareNode) return { id: compareNode.id, status: 'added', compareNode, changes: [] };
  if (baseNode && !compareNode) return { id: baseNode.id, status: 'removed', baseNode, changes: [] };

  const changes: string[] = [];
  if (baseNode!.type !== compareNode!.type) changes.push('type');
  if (baseNode!.name !== compareNode!.name) changes.push('name');
  if (JSON.stringify(baseNode!.config) !== JSON.stringify(compareNode!.config)) changes.push('config');
  if (!samePosition(baseNode, compareNode)) changes.push('position');

  const semanticChanges = changes.filter((field) => field !== 'position');
  const status: DiffStatus = semanticChanges.length > 0
    ? 'changed'
    : changes.length > 0
      ? 'cosmetic'
      : 'unchanged';

  return {
    id: compareNode!.id,
    status,
    baseNode,
    compareNode,
    changes,
  };
}

function edgeDiff(baseEdge?: WorkflowEdge, compareEdge?: WorkflowEdge): EdgeDiffRecord {
  if (!baseEdge && compareEdge) return { id: compareEdge.id, status: 'added', compareEdge, changes: [] };
  if (baseEdge && !compareEdge) return { id: baseEdge.id, status: 'removed', baseEdge, changes: [] };

  const changes: string[] = [];
  if (baseEdge!.source !== compareEdge!.source) changes.push('source');
  if (baseEdge!.target !== compareEdge!.target) changes.push('target');
  if (baseEdge!.condition !== compareEdge!.condition) changes.push('condition');
  if (baseEdge!.priority !== compareEdge!.priority) changes.push('priority');
  if (baseEdge!.isDefault !== compareEdge!.isDefault) changes.push('isDefault');
  if (baseEdge!.label !== compareEdge!.label) changes.push('label');

  const status: DiffStatus = changes.length > 0 ? 'changed' : 'unchanged';
  return {
    id: compareEdge!.id,
    status,
    baseEdge,
    compareEdge,
    changes,
  };
}

/** Compare two workflow definitions and classify node/edge differences. */
export function diffWorkflows(baseWorkflow: Workflow, compareWorkflow: Workflow): WorkflowDiffResult {
  const baseNodes = indexNodes(baseWorkflow.nodes);
  const compareNodes = indexNodes(compareWorkflow.nodes);
  const baseEdges = indexEdges(baseWorkflow.edges);
  const compareEdges = indexEdges(compareWorkflow.edges);

  const warnings: DiffWarning[] = [
    ...duplicateWarnings('node', baseNodes.duplicates),
    ...duplicateWarnings('node', compareNodes.duplicates),
    ...duplicateWarnings('edge', baseEdges.duplicates),
    ...duplicateWarnings('edge', compareEdges.duplicates),
  ];

  const nodeOrder = [...new Set([...baseWorkflow.nodes.map((node) => node.id), ...compareWorkflow.nodes.map(
    (node) => node.id)])];
  const edgeOrder = [...new Set([...baseWorkflow.edges.map((edge) => edge.id), ...compareWorkflow.edges.map(
    (edge) => edge.id)])];

  const nodes: Record<string, NodeDiffRecord> = {};
  const edges: Record<string, EdgeDiffRecord> = {};
  const nodeSummary = statusBucket();
  const edgeSummary = statusBucket();

  for (const id of nodeOrder) {
    const record = nodeDiff(baseNodes.map.get(id), compareNodes.map.get(id));
    nodes[id] = record;
    bump(nodeSummary, record.status);
  }

  for (const id of edgeOrder) {
    const record = edgeDiff(baseEdges.map.get(id), compareEdges.map.get(id));
    edges[id] = record;
    bump(edgeSummary, record.status);
  }

  return {
    nodeOrder,
    edgeOrder,
    nodes,
    edges,
    summary: {
      nodes: nodeSummary,
      edges: edgeSummary,
    },
    warnings,
  };
}

/** Resolve a human-readable workflow label for headers and summaries. */
export function resolveWorkflowLabel(workflow: Pick<Workflow, 'id' | 'name' | 'version'>): string {
  const trimmedName = workflow.name?.trim();
  if (trimmedName && workflow.version !== undefined) {
    return `${trimmedName} (v${workflow.version})`;
  }
  if (trimmedName) return trimmedName;
  return workflow.id;
}
  • [ ] Step 4: Run targeted tests and confirm pass

Run: npx vitest run src/diff/workflowDiff.test.ts Expected: PASS.

  • [ ] Step 5: Run typecheck for new diff module

Run: npx tsc --noEmit Expected: PASS.

  • [ ] Step 6: Commit Task 1
git add src/diff/workflowDiffTypes.ts src/diff/workflowDiff.ts src/diff/workflowDiff.test.ts
git commit -m "Add pure workflow diff engine and tests (#93)"

Task 2: Build diff-to-render mapping helpers

Files: - Create: ui/src/diff/buildDiffRenderModel.ts - Create: ui/src/diff/buildDiffRenderModel.test.ts

Interfaces: - Consumes: Workflow, WorkflowNode, WorkflowEdge; WorkflowDiffResult from Task 1. - Produces: - export interface DiffRenderModel { nodes: WorkflowNode[]; edges: WorkflowEdge[]; } - export function buildDiffRenderModel(baseWorkflow: Workflow, compareWorkflow: Workflow, diff: WorkflowDiffResult): DiffRenderModel

  • [ ] Step 1: Write failing tests for render model generation

Create ui/src/diff/buildDiffRenderModel.test.ts:

import { describe, it, expect } from 'vitest';
import { type Workflow } from '../types/workflow.ts';
import { diffWorkflows } from './workflowDiff.ts';
import { buildDiffRenderModel } from './buildDiffRenderModel.ts';

function makeWorkflow(nodes: Workflow['nodes'], edges: Workflow['edges']): Workflow {
  return { id: 'wf', name: 'WF', nodes, edges };
}

describe('buildDiffRenderModel', () => {
  it('uses compare position when both versions contain the node', () => {
    const base = makeWorkflow(
      [{ id: 'a', type: 'action', name: 'A', config: {}, position: { x: 1, y: 1 } }],
      [],
    );
    const compare = makeWorkflow(
      [{ id: 'a', type: 'action', name: 'A', config: {}, position: { x: 9, y: 9 } }],
      [],
    );

    const diff = diffWorkflows(base, compare);
    const model = buildDiffRenderModel(base, compare, diff);

    expect(model.nodes[0].position).toEqual({ x: 9, y: 9 });
  });

  it('keeps removed nodes in the render model using base coordinates', () => {
    const base = makeWorkflow(
      [{ id: 'gone', type: 'action', name: 'Gone', config: {}, position: { x: 4, y: 2 } }],
      [],
    );
    const compare = makeWorkflow([], []);

    const diff = diffWorkflows(base, compare);
    const model = buildDiffRenderModel(base, compare, diff);

    expect(model.nodes.map((node) => node.id)).toContain('gone');
    expect(model.nodes.find((node) => node.id === 'gone')?.position).toEqual({ x: 4, y: 2 });
  });

  it('includes union of edges by id', () => {
    const base = makeWorkflow(
      [{ id: 'a', type: 'start', name: 'A', config: {}, position: { x: 0, y: 0 } }],
      [{ id: 'e-removed', source: 'a', target: 'a', priority: 0, isDefault: true }],
    );
    const compare = makeWorkflow(
      [{ id: 'a', type: 'start', name: 'A', config: {}, position: { x: 0, y: 0 } }],
      [{ id: 'e-added', source: 'a', target: 'a', priority: 1, isDefault: false }],
    );

    const diff = diffWorkflows(base, compare);
    const model = buildDiffRenderModel(base, compare, diff);

    expect(model.edges.map((edge) => edge.id)).toEqual(expect.arrayContaining(['e-removed', 'e-added']));
  });
});
  • [ ] Step 2: Run the test file and confirm failure

Run: npx vitest run src/diff/buildDiffRenderModel.test.ts Expected: FAIL with missing module/export.

  • [ ] Step 3: Implement the render model helper minimally

Create ui/src/diff/buildDiffRenderModel.ts:

import { type Workflow, type WorkflowEdge, type WorkflowNode } from '../types/workflow.ts';
import { type WorkflowDiffResult } from './workflowDiffTypes.ts';

export interface DiffRenderModel {
  nodes: WorkflowNode[];
  edges: WorkflowEdge[];
}

function preferredNode(baseNode?: WorkflowNode, compareNode?: WorkflowNode): WorkflowNode | undefined {
  if (compareNode && baseNode) return { ...compareNode, position: compareNode.position ?? baseNode.position };
  return compareNode ?? baseNode;
}

function preferredEdge(baseEdge?: WorkflowEdge, compareEdge?: WorkflowEdge): WorkflowEdge | undefined {
  return compareEdge ?? baseEdge;
}

/**
 * Build a render model for the diff graph from the union of node/edge ids.
 */
export function buildDiffRenderModel(
  baseWorkflow: Workflow,
  compareWorkflow: Workflow,
  diff: WorkflowDiffResult,
): DiffRenderModel {
  const baseNodeMap = new Map(baseWorkflow.nodes.map((node) => [node.id, node]));
  const compareNodeMap = new Map(compareWorkflow.nodes.map((node) => [node.id, node]));
  const baseEdgeMap = new Map(baseWorkflow.edges.map((edge) => [edge.id, edge]));
  const compareEdgeMap = new Map(compareWorkflow.edges.map((edge) => [edge.id, edge]));

  const nodes: WorkflowNode[] = diff.nodeOrder
    .map((id) => preferredNode(baseNodeMap.get(id), compareNodeMap.get(id)))
    .filter((node): node is WorkflowNode => !!node);

  const edges: WorkflowEdge[] = diff.edgeOrder
    .map((id) => preferredEdge(baseEdgeMap.get(id), compareEdgeMap.get(id)))
    .filter((edge): edge is WorkflowEdge => !!edge);

  return { nodes, edges };
}
  • [ ] Step 4: Run targeted tests and confirm pass

Run: npx vitest run src/diff/buildDiffRenderModel.test.ts Expected: PASS.

  • [ ] Step 5: Run typecheck to validate interfaces between Task 1 and Task 2

Run: npx tsc --noEmit Expected: PASS.

  • [ ] Step 6: Commit Task 2
git add src/diff/buildDiffRenderModel.ts src/diff/buildDiffRenderModel.test.ts
git commit -m "Add diff render-model mapping helpers (#93)"

Task 3: Implement WorkflowDiffViewer and styles

Files: - Create: ui/src/components/WorkflowDiffViewer.tsx - Create: ui/src/components/WorkflowDiffViewer.css - Modify: ui/src/index.ts

Interfaces: - Consumes: - nodeTypes from ./nodes/nodeTypes.ts - edgeTypes from ./edges/edgeTypes.ts - toReactFlowNodes, toReactFlowEdges from ../utils/conversion.ts - needsLayout, layoutWorkflow from ../layout/layoutWorkflow.ts - diffWorkflows, resolveWorkflowLabel from ../diff/workflowDiff.ts - buildDiffRenderModel from ../diff/buildDiffRenderModel.ts - Produces: - export interface WorkflowDiffViewerProps { baseWorkflow: Workflow; compareWorkflow: Workflow; theme?: FlowTheme; } - export function WorkflowDiffViewer(props: WorkflowDiffViewerProps): JSX.Element - new library exports in src/index.ts

  • [ ] Step 1: Add a failing API-surface test for library exports

Create ui/src/dev/libraryExports.test.ts:

import { describe, it, expect } from 'vitest';
import * as library from '../index.ts';

describe('library exports', () => {
  it('exports WorkflowDiffViewer', () => {
    expect(library.WorkflowDiffViewer).toBeTypeOf('function');
  });
});
  • [ ] Step 2: Run targeted tests and confirm failure

Run: npx vitest run src/dev/libraryExports.test.ts Expected: FAIL because WorkflowDiffViewer is not exported yet.

  • [ ] Step 3: Implement the component and CSS minimally

Create ui/src/components/WorkflowDiffViewer.tsx using this structure:

import { useMemo, useState, useCallback } from 'react';
import { ReactFlow, Background, Controls, ReactFlowProvider, type Node, type Edge } from '@xyflow/react';
import { type Workflow } from '../types/workflow.ts';
import { type FlowTheme } from './WorkflowEditor.tsx';
import { nodeTypes } from './nodes/nodeTypes.ts';
import { edgeTypes } from './edges/edgeTypes.ts';
import { toReactFlowNodes, toReactFlowEdges } from '../utils/conversion.ts';
import { needsLayout, layoutWorkflow } from '../layout/layoutWorkflow.ts';
import { diffWorkflows, resolveWorkflowLabel } from '../diff/workflowDiff.ts';
import { buildDiffRenderModel } from '../diff/buildDiffRenderModel.ts';
import { type DiffStatus } from '../diff/workflowDiffTypes.ts';
import './theme.css';
import './WorkflowDiffViewer.css';

export interface WorkflowDiffViewerProps {
  baseWorkflow: Workflow;
  compareWorkflow: Workflow;
  theme?: FlowTheme;
}

function WorkflowDiffViewerInner({ baseWorkflow, compareWorkflow, theme = 'light' }: WorkflowDiffViewerProps) {
  const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
  const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);

  const diff = useMemo(() => diffWorkflows(baseWorkflow, compareWorkflow), [baseWorkflow, compareWorkflow]);

  const model = useMemo(
    () => buildDiffRenderModel(baseWorkflow, compareWorkflow, diff),
    [baseWorkflow, compareWorkflow, diff],
  );

  const laidOutNodes = useMemo(
    () => (needsLayout(model.nodes) ? layoutWorkflow(model.nodes, model.edges) : model.nodes),
    [model.nodes, model.edges],
  );

  const nodes = useMemo(
    () => toReactFlowNodes(laidOutNodes).map((node) => ({
      ...node,
      className: `flow-diff-node flow-diff-node--${diff.nodes[node.id]?.status ?? 'unchanged'}`,
      draggable: false,
    })),
    [laidOutNodes, diff.nodes],
  );

  const edges = useMemo(
    () => toReactFlowEdges(model.edges).map((edge) => ({
      ...edge,
      className: `flow-diff-edge flow-diff-edge--${diff.edges[edge.id]?.status ?? 'unchanged'}`,
      style: {
        ...(edge.style ?? {}),
        opacity: diff.edges[edge.id]?.status === 'removed' ? 0.45 : 1,
      },
      animated: false,
    })),
    [model.edges, diff.edges],
  );

  const onNodeClick = useCallback((_: unknown, node: Node) => {
    setSelectedEdgeId(null);
    setSelectedNodeId(node.id);
  }, []);

  const onEdgeClick = useCallback((_: unknown, edge: Edge) => {
    setSelectedNodeId(null);
    setSelectedEdgeId(edge.id);
  }, []);

  const selectedNodeDiff = selectedNodeId ? diff.nodes[selectedNodeId] : undefined;
  const selectedEdgeDiff = selectedEdgeId ? diff.edges[selectedEdgeId] : undefined;
  const baseLabel = resolveWorkflowLabel(baseWorkflow);
  const compareLabel = resolveWorkflowLabel(compareWorkflow);

  return (
    <div className="workflow-diff-viewer" data-flow-theme={theme}>
      <div className="workflow-diff-viewer__main">
        <div className="workflow-diff-viewer__header">
          <div>{baseLabel} -> {compareLabel}</div>
          <div className="workflow-diff-viewer__summary">
            Nodes: +{diff.summary.nodes.added} -{diff.summary.nodes.removed} ~{diff.summary.nodes.changed}
            | cosmetic {diff.summary.nodes.cosmetic}
          </div>
        </div>
        {diff.warnings.length > 0 && (
          <div className="workflow-diff-viewer__warnings">
            {diff.warnings.map((warning) => (
              <div key={`${warning.code}-${warning.message}`}>{warning.message}</div>
            ))}
          </div>
        )}
        <div className="workflow-diff-viewer__canvas">
          <ReactFlow
            nodes={nodes}
            edges={edges}
            nodeTypes={nodeTypes}
            edgeTypes={edgeTypes}
            nodesDraggable={false}
            nodesConnectable={false}
            colorMode={theme}
            onNodeClick={onNodeClick}
            onEdgeClick={onEdgeClick}
            fitView
          >
            <Background />
            <Controls showInteractive={false} />
          </ReactFlow>
        </div>
      </div>
      <div className="workflow-diff-viewer__detail">
        {selectedNodeDiff && <DiffDetail kind="Node" id={selectedNodeDiff.id} status={selectedNodeDiff.status}
          changes={selectedNodeDiff.changes} />}
        {selectedEdgeDiff && <DiffDetail kind="Edge" id={selectedEdgeDiff.id} status={selectedEdgeDiff.status}
          changes={selectedEdgeDiff.changes} />}
        {!selectedNodeDiff && !selectedEdgeDiff && (
          <div className="workflow-diff-viewer__empty">Select a node or edge to inspect changes.</div>
        )}
      </div>
    </div>
  );
}

function DiffDetail(props: { kind: 'Node' | 'Edge'; id: string; status: DiffStatus; changes: string[] }) {
  return (
    <div>
      <div className="workflow-diff-viewer__detail-title">{props.kind} {props.id}</div>
      <div className={`workflow-diff-viewer__status workflow-diff-viewer__status--${props.status}`}>
        {props.status}
      </div>
      {props.changes.length > 0 ? (
        <ul className="workflow-diff-viewer__changes">
          {props.changes.map((change) => <li key={change}>{change}</li>)}
        </ul>
      ) : (
        <div className="workflow-diff-viewer__empty">No field-level changes.</div>
      )}
    </div>
  );
}

export function WorkflowDiffViewer(props: WorkflowDiffViewerProps) {
  return (
    <ReactFlowProvider>
      <WorkflowDiffViewerInner {...props} />
    </ReactFlowProvider>
  );
}

Create ui/src/components/WorkflowDiffViewer.css with initial status styles:

.workflow-diff-viewer {
  height: 100%;
  width: 100%;
  display: grid;
  grid-template-columns: 1fr 320px;
  color: var(--flow-text, #151515);
}

.workflow-diff-viewer__main {
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.workflow-diff-viewer__header {
  padding: 10px 12px;
  border-bottom: 1px solid var(--flow-border, #d2d2d2);
  background: var(--flow-bg-primary, #fff);
  font-size: 13px;
  font-weight: 600;
}

.workflow-diff-viewer__summary {
  margin-top: 4px;
  font-size: 12px;
  color: var(--flow-text-subtle, #6a6e73);
  font-weight: 500;
}

.workflow-diff-viewer__warnings {
  padding: 8px 12px;
  border-bottom: 1px solid var(--flow-border, #d2d2d2);
  background: var(--flow-status-waiting-bg, #fff3e0);
  color: var(--flow-status-waiting-text, #5c3000);
  font-size: 12px;
}

.workflow-diff-viewer__canvas {
  flex: 1;
}

.flow-diff-node--added { opacity: 1; }
.flow-diff-node--removed { opacity: 0.5; }
.flow-diff-node--unchanged { opacity: 0.45; }
.flow-diff-node--cosmetic { opacity: 0.85; }
.flow-diff-node--changed { opacity: 1; }

.flow-diff-edge--added .react-flow__edge-path { stroke: var(--flow-status-success, #3e8635); stroke-width: 2.5; }
.flow-diff-edge--removed .react-flow__edge-path { stroke: var(--flow-status-danger, #c9190b); stroke-dasharray: 5 3; }
.flow-diff-edge--changed .react-flow__edge-path { stroke: var(--flow-brand, #06c); stroke-width: 2.5; }
.flow-diff-edge--unchanged .react-flow__edge-path { opacity: 0.35; }

.workflow-diff-viewer__detail {
  border-left: 1px solid var(--flow-border, #d2d2d2);
  background: var(--flow-bg-primary, #fff);
  padding: 12px;
  overflow: auto;
}

.workflow-diff-viewer__detail-title {
  font-size: 14px;
  font-weight: 700;
  margin-bottom: 8px;
}

.workflow-diff-viewer__status {
  display: inline-block;
  padding: 2px 8px;
  border-radius: 12px;
  font-size: 11px;
  font-weight: 700;
  text-transform: uppercase;
  margin-bottom: 8px;
}

.workflow-diff-viewer__status--added {
  background: var(--flow-status-completed-bg, #e6f5e4);
  color: var(--flow-status-completed-text, #1e4620);
}

.workflow-diff-viewer__status--removed {
  background: var(--flow-status-failed-bg, #fce4e4);
  color: var(--flow-status-failed-text, #7d1007);
}

.workflow-diff-viewer__status--changed,
.workflow-diff-viewer__status--cosmetic,
.workflow-diff-viewer__status--unchanged {
  background: var(--flow-bg-secondary, #f0f0f0);
  color: var(--flow-text-subtle, #6a6e73);
}

.workflow-diff-viewer__changes {
  margin: 0;
  padding-left: 18px;
  font-size: 12px;
}

.workflow-diff-viewer__empty {
  font-size: 12px;
  color: var(--flow-text-subtle, #6a6e73);
}

Update ui/src/index.ts:

export { WorkflowDiffViewer, type WorkflowDiffViewerProps } from './components/WorkflowDiffViewer.tsx';
export type {
  DiffStatus,
  DiffWarning,
  NodeDiffRecord,
  EdgeDiffRecord,
  DiffSummaryBucket,
  WorkflowDiffResult,
} from './diff/workflowDiffTypes.ts';
  • [ ] Step 4: Run the export test and confirm pass

Run: npx vitest run src/dev/libraryExports.test.ts Expected: PASS.

  • [ ] Step 5: Run full tests and typecheck

Run: npx vitest run && npx tsc --noEmit Expected: PASS.

  • [ ] Step 6: Commit Task 3
git add src/components/WorkflowDiffViewer.tsx src/components/WorkflowDiffViewer.css src/index.ts \
  src/dev/libraryExports.test.ts
git commit -m "Add WorkflowDiffViewer component and library exports (#93)"

Task 4: Add dev-surface usage sample and polish details panel semantics

Files: - Modify: ui/src/dev/App.tsx - Modify: ui/src/dev/navigationModel.ts - Modify: ui/src/dev/layoutModel.ts (if needed for new nav section behavior) - Modify: ui/src/dev/sampleWorkflows.ts - Create: ui/src/dev/workflowDiffScenarios.ts (optional if this keeps sampleWorkflows.ts focused) - Test: ui/src/dev/navigationModel.test.ts and/or ui/src/dev/layoutModel.test.ts as needed

Interfaces: - Consumes: WorkflowDiffViewer export and existing demo scenario model. - Produces: a usable demo route so developers can manually verify visual diff behavior during npm run dev.

  • [ ] Step 1: Add a failing test for demo navigation model (new diff tab)

Update ui/src/dev/navigationModel.test.ts with one new assertion:

it('includes diff view in demo navigation', () => {
  expect(demoNavItems.some((item) => item.key === 'diff')).toBe(true);
});
  • [ ] Step 2: Run the targeted test and confirm failure

Run: npx vitest run src/dev/navigationModel.test.ts Expected: FAIL because diff tab is not present.

  • [ ] Step 3: Implement dev app wiring for diff scenarios

Implementation checklist:

// navigationModel.ts
export type DemoNavKey = 'editor' | 'viewer' | 'diff';
// add { key: 'diff', label: 'Diff viewer' }

// App.tsx
// import WorkflowDiffViewer and two scenarios for base/compare
// render <WorkflowDiffViewer baseWorkflow={...} compareWorkflow={...} theme={theme} /> when activeView==='diff'

// sampleWorkflows.ts (or workflowDiffScenarios.ts)
// provide at least one pair with semantic + cosmetic changes and one with add/remove changes
  • [ ] Step 4: Re-run targeted test and confirm pass

Run: npx vitest run src/dev/navigationModel.test.ts Expected: PASS.

  • [ ] Step 5: Run full tests and typecheck

Run: npx vitest run && npx tsc --noEmit Expected: PASS.

  • [ ] Step 6: Commit Task 4
git add src/dev/App.tsx src/dev/navigationModel.ts src/dev/layoutModel.ts src/dev/sampleWorkflows.ts \
  src/dev/navigationModel.test.ts
git commit -m "Add demo route and scenarios for WorkflowDiffViewer (#93)"

Self-Review

Spec coverage: - Visual diff component comparing two explicit definitions (baseWorkflow, compareWorkflow) is covered by Task 3. ✅ - Semantic vs cosmetic distinction is implemented and tested in Task 1. ✅ - Array ordering ignored and edge priority semantic behavior are tested in Task 1. ✅ - Version label surfacing (name (vN) fallback behavior) is implemented/tested in Task 1 and displayed in Task 3 header. ✅ - Warning metadata for duplicate IDs is implemented in Task 1 and surfaced in Task 3 warning banner. ✅ - Existing viewer/editor concerns remain separated because this is a new component, not a mode. ✅

Placeholder scan: - No TBD, TODO, or "implement later" markers in task steps. - Each code-writing step contains concrete code or explicit implementation checklist lines. ✅

Type consistency: - WorkflowDiffResult from Task 1 is consumed consistently in Task 2 and Task 3. - resolveWorkflowLabel() signature and behavior are consistent between tests and viewer usage. - Export names in Task 3 (WorkflowDiffViewer, WorkflowDiffViewerProps, diff types) match introduced symbols. ✅