Skip to content
Notra logoNotra UI
Esc
↑↓navigate↵open⌘Jpreview
On this page

GEO Workflow Builder

A workflow builder with a block palette, a playable canvas, a run log and a node sheet.

"use client";

import { MapIcon } from "lucide-react";
import { useState } from "react";

import { Button } from "@/components/ui/button";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";

import { WorkflowCanvas } from "../components/workflow-canvas";
import { WorkflowNodePalette } from "../components/workflow-node-palette";
import { WorkflowNodeSheet } from "../components/workflow-node-sheet";
import { WorkflowRunButton } from "../components/workflow-run-button";
import { WorkflowRunLog } from "../components/workflow-run-log";
import { WorkflowRunTimeline } from "../components/workflow-run-timeline";
import {
  WorkflowShell,
  WorkflowShellCard,
  WorkflowShellHeader,
} from "../components/workflow-shell";
import { WorkflowTimer } from "../components/workflow-timer";
import { WORKFLOW_BLOCKS } from "../constants/workflow-blocks";
import {
  LISTICLE_OUTREACH_WORKFLOW,
  WORKFLOW_TEMPLATES,
} from "../constants/workflow-templates";
import { WORKFLOW_COLUMN } from "../constants/workflow-ui";
import { useWorkflowGraph } from "../hooks/use-workflow-graph";
import { useWorkflowRun } from "../hooks/use-workflow-run";
import {
  createNodeFromBlock,
  formatDuration,
  getRunLog,
  isDeterministic,
} from "../lib/workflow";
import type { WorkflowBlock, WorkflowPoint } from "../types/workflow";

export default function GeoWorkflowBuilder() {
  const [templateId, setTemplateId] = useState(LISTICLE_OUTREACH_WORKFLOW.id);
  const graph = useWorkflowGraph(LISTICLE_OUTREACH_WORKFLOW);
  const run = useWorkflowRun(graph.nodes, graph.edges);
  const [selectedId, setSelectedId] = useState<string>();
  const [sheetOpen, setSheetOpen] = useState(false);
  const [focusId, setFocusId] = useState<string>();
  const [minimap, setMinimap] = useState(false);
  const selected = graph.nodes.find((node) => node.id === selectedId);
  const log = getRunLog(graph.nodes, run.schedule, run.phase, run.elapsedMs);
  const aiSteps = graph.nodes.filter((node) => !isDeterministic(node)).length;

  const loadTemplate = (id: string) => {
    const template = WORKFLOW_TEMPLATES.find((item) => item.id === id);
    if (!template) {
      return;
    }
    run.stop();
    graph.load(template);
    setTemplateId(id);
  };

  const addBlock = (block: WorkflowBlock, position?: WorkflowPoint) => {
    const right = Math.max(0, ...graph.nodes.map((node) => node.position.x));
    const node = createNodeFromBlock(
      block,
      position ?? { x: right + WORKFLOW_COLUMN, y: 24 }
    );
    graph.addNode(node);
    if (!position) {
      setFocusId(node.id);
    }
  };

  const openNode = (id: string) => {
    setSelectedId(id);
    setSheetOpen(true);
  };

  return (
    <WorkflowShell className="h-[46rem] w-full">
      <WorkflowShellHeader className="gap-3 px-1.5">
        <Select
          onValueChange={(value) => {
            if (typeof value === "string") {
              loadTemplate(value);
            }
          }}
          value={templateId}
        >
          <SelectTrigger aria-label="Workflow" className="w-56" size="sm">
            <SelectValue>
              {(value: string | null) =>
                WORKFLOW_TEMPLATES.find((item) => item.id === value)?.title
              }
            </SelectValue>
          </SelectTrigger>
          <SelectContent>
            {WORKFLOW_TEMPLATES.map((template) => (
              <SelectItem key={template.id} value={template.id}>
                {template.title}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
        <span className="text-muted-foreground hidden text-xs sm:inline">
          {graph.nodes.length} steps · {aiSteps} AI · ~
          {formatDuration(run.totalMs)}
        </span>
        <div className="ml-auto flex items-center gap-3">
          <WorkflowTimer
            className="text-xs"
            ms={run.elapsedMs}
            running={run.phase === "running"}
          />
          <WorkflowRunButton
            disabled={graph.nodes.length === 0}
            onPlay={run.play}
            onStop={run.stop}
            phase={run.phase}
            progress={run.elapsedMs / run.totalMs}
          />
        </div>
      </WorkflowShellHeader>
      <WorkflowShellCard className="grid min-h-0 flex-1 overflow-hidden md:grid-cols-[15rem_minmax(0,1fr)]">
        <WorkflowNodePalette
          blocks={WORKFLOW_BLOCKS}
          className="border-table-card-border hidden border-r md:flex"
          onAdd={(block) => addBlock(block)}
        />
        <div className="flex min-h-0 min-w-0 flex-col">
          <WorkflowCanvas
            className="flex-1 rounded-none border-0"
            edgeState={run.edgeState}
            edges={graph.edges}
            focusId={focusId}
            key={templateId}
            minimap={minimap}
            nodeState={run.nodeState}
            nodes={graph.nodes}
            onConnect={graph.connect}
            onDropBlock={(blockId, position) => {
              const block = WORKFLOW_BLOCKS.find((item) => item.id === blockId);
              if (block) {
                addBlock(block, position);
              }
            }}
            onNodeMove={graph.moveNode}
            onNodeRemove={(id) => {
              graph.removeNode(id);
              if (id === selectedId) {
                setSheetOpen(false);
              }
            }}
            onSelectNode={openNode}
            selectedId={sheetOpen ? selectedId : undefined}
            toolbar={
              <Button
                aria-label="Minimap"
                aria-pressed={minimap}
                className="aria-pressed:bg-foreground/10 aria-pressed:text-foreground hidden md:inline-flex"
                onClick={() => setMinimap((value) => !value)}
                size="icon-sm"
                variant="ghost"
              >
                <MapIcon />
              </Button>
            }
          />
          <Tabs
            className="border-table-card-border h-44 shrink-0 gap-0 border-t"
            defaultValue="log"
          >
            <TabsList className="mx-2 mt-2" variant="line">
              <TabsTrigger value="log">Log</TabsTrigger>
              <TabsTrigger value="timeline">Timeline</TabsTrigger>
            </TabsList>
            <TabsContent className="min-h-0 overflow-y-auto py-2" value="log">
              <WorkflowRunLog entries={log} />
            </TabsContent>
            <TabsContent
              className="min-h-0 overflow-y-auto p-3"
              value="timeline"
            >
              <WorkflowRunTimeline
                elapsedMs={run.elapsedMs}
                nodes={graph.nodes}
                phase={run.phase}
                schedule={run.schedule}
              />
            </TabsContent>
          </Tabs>
        </div>
      </WorkflowShellCard>
      <WorkflowNodeSheet
        log={log}
        node={selected}
        onDelete={(id) => {
          graph.removeNode(id);
          setSheetOpen(false);
        }}
        onBranchChange={(id, branch) => {
          run.stop();
          graph.updateNode(id, { branch });
        }}
        onModelChange={(id, model) => graph.updateNode(id, { model })}
        onOpenChange={setSheetOpen}
        open={sheetOpen}
        state={selected ? run.nodeState(selected.id) : undefined}
      />
    </WorkflowShell>
  );
}

A builder for GEO automations on React Flow, composed from the Workflow components. It opens on a listicle outreach workflow: a weekly trigger searches best-of pages with Browserbase and pulls competitor backlinks from Ahrefs in parallel, a deterministic step dedupes and scores them, Claude classifies the fit, and an if/else sends the pages you’re missing from to Firecrawl, a GPT draft, a human approval and Resend.

Press Run workflow to play it. Steps light up as they run, edges march between them, the toolbar timer counts the whole run and each step counts its own. Switch templates in the picker, drag blocks in from the palette, drag from an output handle to an input handle to connect steps, and click a step to open its sheet.

Installation

$ bunx --bun shadcn@latest add @notra/geo-workflow-builder

This adds the workflow components to components/workflow and the block to components/workflow/examples/geo-workflow-builder.tsx.