From b4444b765d0a49eb8fcc8713a1232cbe3d60054f Mon Sep 17 00:00:00 2001 From: Geoff Johnson Date: Tue, 14 Apr 2026 11:35:56 -0700 Subject: [PATCH 1/2] feat(ui): add trigger source node components for workflow canvas From eafef5cde102e22fbc876738d24455e47065e683 Mon Sep 17 00:00:00 2001 From: Geoff Johnson Date: Tue, 14 Apr 2026 11:37:27 -0700 Subject: [PATCH 2/2] feat(ui): add TriggerNode components for all 14 trigger type variants - Create TriggerNode base component with category accent colouring - Support all 14 trigger types with distinct lucide-react icons - Category colour system: external (blue), schedule (violet), event (amber), internal (slate) - Config summary renders the most relevant fields per trigger type - Output handle (right side) for connecting to agent nodes - Selected state with ring highlight, disabled state with opacity/grayscale - Export workflowNodeTypes map for React Flow registration - 31 tests covering all trigger variants, selected/disabled states, custom label --- .../components/workflows/canvas/nodeTypes.ts | 18 + .../canvas/nodes/TriggerNode.test.tsx | 324 ++++++++++++++++++ .../workflows/canvas/nodes/TriggerNode.tsx | 227 ++++++++++++ .../workflows/canvas/nodes/index.ts | 2 + 4 files changed, 571 insertions(+) create mode 100644 ui/src/components/workflows/canvas/nodeTypes.ts create mode 100644 ui/src/components/workflows/canvas/nodes/TriggerNode.test.tsx create mode 100644 ui/src/components/workflows/canvas/nodes/TriggerNode.tsx create mode 100644 ui/src/components/workflows/canvas/nodes/index.ts diff --git a/ui/src/components/workflows/canvas/nodeTypes.ts b/ui/src/components/workflows/canvas/nodeTypes.ts new file mode 100644 index 00000000..41c84ab1 --- /dev/null +++ b/ui/src/components/workflows/canvas/nodeTypes.ts @@ -0,0 +1,18 @@ +/** + * workflowNodeTypes — React Flow node type registry for the workflow canvas. + * + * Import this map and pass it as the `nodeTypes` prop to + * (or directly to ) so React Flow can resolve custom node + * components by their string type key. + * + * New node types (e.g. "agent") will be added here as they are implemented. + */ + +import type { NodeTypes } from "@xyflow/react"; +import { TriggerNode } from "./nodes/TriggerNode"; + +export const workflowNodeTypes = { + trigger: TriggerNode, +} satisfies NodeTypes; + +export default workflowNodeTypes; diff --git a/ui/src/components/workflows/canvas/nodes/TriggerNode.test.tsx b/ui/src/components/workflows/canvas/nodes/TriggerNode.test.tsx new file mode 100644 index 00000000..b7a4568b --- /dev/null +++ b/ui/src/components/workflows/canvas/nodes/TriggerNode.test.tsx @@ -0,0 +1,324 @@ +/** + * TriggerNode tests. + * + * Verifies that each trigger type variant renders the correct icon label, + * config summary, handle placement, and selected/disabled visual states. + */ + +import { render, screen } from "@testing-library/react"; +import { ReactFlowProvider } from "@xyflow/react"; +import { describe, expect, it } from "vitest"; +import type { TriggerConfig } from "@/types/orchestrator"; +import { getTriggerCategory } from "@/types/orchestrator"; +import type { TriggerNodeData } from "./TriggerNode"; +import { TriggerNode } from "./TriggerNode"; + +// React Flow requires ResizeObserver in jsdom +global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function renderTriggerNode( + config: TriggerConfig, + overrides: Partial = {}, +) { + const data: TriggerNodeData = { + triggerConfig: config, + category: getTriggerCategory(config.type), + enabled: true, + ...overrides, + }; + + // React Flow nodes must be rendered inside a ReactFlowProvider + return render( + + + , + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("TriggerNode", () => { + describe("github_issues", () => { + const config: TriggerConfig = { + type: "github_issues", + owner: "geoffjay", + repo: "agentd", + labels: ["bug", "enhancement"], + state: "open", + }; + + it("renders the node wrapper", () => { + renderTriggerNode(config); + expect(screen.getByTestId("trigger-node")).toBeInTheDocument(); + }); + + it("shows the trigger type label", () => { + renderTriggerNode(config); + expect(screen.getByText("GitHub Issues")).toBeInTheDocument(); + }); + + it("shows owner/repo in summary", () => { + renderTriggerNode(config); + expect(screen.getByText(/geoffjay\/agentd/)).toBeInTheDocument(); + }); + + it("has an output handle", () => { + renderTriggerNode(config); + expect( + screen.getByTestId("trigger-node-handle-out"), + ).toBeInTheDocument(); + }); + + it("shows the category badge", () => { + renderTriggerNode(config); + expect(screen.getByText("external")).toBeInTheDocument(); + }); + }); + + describe("github_pull_requests", () => { + const config: TriggerConfig = { + type: "github_pull_requests", + owner: "acme", + repo: "myrepo", + labels: [], + state: "open", + }; + + it("renders the node", () => { + renderTriggerNode(config); + expect(screen.getByTestId("trigger-node")).toBeInTheDocument(); + }); + + it("shows the correct label", () => { + renderTriggerNode(config); + expect(screen.getByText("GitHub Pull Requests")).toBeInTheDocument(); + }); + }); + + describe("cron", () => { + const config: TriggerConfig = { + type: "cron", + expression: "0 */6 * * *", + }; + + it("renders the node", () => { + renderTriggerNode(config); + expect(screen.getByTestId("trigger-node")).toBeInTheDocument(); + }); + + it("shows the cron expression in summary", () => { + renderTriggerNode(config); + expect(screen.getByText("0 */6 * * *")).toBeInTheDocument(); + }); + + it("shows schedule category", () => { + renderTriggerNode(config); + expect(screen.getByText("schedule")).toBeInTheDocument(); + }); + }); + + describe("webhook", () => { + const config: TriggerConfig = { + type: "webhook", + source: "github", + }; + + it("renders the node", () => { + renderTriggerNode(config); + expect(screen.getByText("Webhook")).toBeInTheDocument(); + }); + + it("shows source in summary", () => { + renderTriggerNode(config); + expect(screen.getByText(/Source: github/)).toBeInTheDocument(); + }); + }); + + describe("manual", () => { + const config: TriggerConfig = { type: "manual" }; + + it("renders the node", () => { + renderTriggerNode(config); + expect(screen.getByText("Manual")).toBeInTheDocument(); + }); + + it("shows manual trigger summary", () => { + renderTriggerNode(config); + expect(screen.getByText("Manual trigger")).toBeInTheDocument(); + }); + + it("shows internal category", () => { + renderTriggerNode(config); + expect(screen.getByText("internal")).toBeInTheDocument(); + }); + }); + + describe("linear_issues", () => { + const config: TriggerConfig = { + type: "linear_issues", + labels: [], + team_key: "ENG", + project: "agentd", + }; + + it("renders the node", () => { + renderTriggerNode(config); + expect(screen.getByText("Linear Issues")).toBeInTheDocument(); + }); + + it("shows team/project in summary", () => { + renderTriggerNode(config); + expect(screen.getByText(/ENG.*agentd/)).toBeInTheDocument(); + }); + }); + + describe("agent_lifecycle", () => { + const config: TriggerConfig = { + type: "agent_lifecycle", + event: "session_start", + }; + + it("renders the node", () => { + renderTriggerNode(config); + expect(screen.getByText("Agent Lifecycle")).toBeInTheDocument(); + }); + + it("shows event type in summary", () => { + renderTriggerNode(config); + expect(screen.getByText("session_start")).toBeInTheDocument(); + }); + + it("shows event category", () => { + renderTriggerNode(config); + expect(screen.getByText("event")).toBeInTheDocument(); + }); + }); + + describe("agent_idle", () => { + const config: TriggerConfig = { + type: "agent_idle", + idle_seconds: 600, + }; + + it("renders the node", () => { + renderTriggerNode(config); + expect(screen.getByText("Agent Idle")).toBeInTheDocument(); + }); + + it("shows idle seconds in summary", () => { + renderTriggerNode(config); + expect(screen.getByText(/Idle: 600s/)).toBeInTheDocument(); + }); + }); + + describe("queue", () => { + const config: TriggerConfig = { + type: "queue", + queue_name: "my-queue", + }; + + it("renders the node", () => { + renderTriggerNode(config); + expect(screen.getByText("Queue")).toBeInTheDocument(); + }); + + it("shows queue name in summary", () => { + renderTriggerNode(config); + expect(screen.getByText("my-queue")).toBeInTheDocument(); + }); + }); + + describe("composite", () => { + const config: TriggerConfig = { + type: "composite", + mode: "and", + triggers: [], + }; + + it("renders the node", () => { + renderTriggerNode(config); + expect(screen.getByText("Composite")).toBeInTheDocument(); + }); + + it("shows mode and trigger count in summary", () => { + renderTriggerNode(config); + expect(screen.getByText(/AND.*0 trigger/)).toBeInTheDocument(); + }); + }); + + describe("selected state", () => { + it("does not have ring when not selected", () => { + renderTriggerNode({ type: "manual" }); + const node = screen.getByTestId("trigger-node"); + expect(node.className).not.toContain("ring-2"); + }); + + it("has ring-2 class when selected", () => { + const config: TriggerConfig = { type: "manual" }; + const data: TriggerNodeData = { + triggerConfig: config, + category: "internal", + enabled: true, + }; + render( + + + , + ); + const node = screen.getByTestId("trigger-node"); + expect(node.className).toContain("ring-2"); + }); + }); + + describe("disabled state", () => { + it("shows Disabled text when enabled=false", () => { + renderTriggerNode({ type: "manual" }, { enabled: false }); + expect(screen.getByText("Disabled")).toBeInTheDocument(); + }); + + it("does not show Disabled text when enabled=true", () => { + renderTriggerNode({ type: "manual" }, { enabled: true }); + expect(screen.queryByText("Disabled")).not.toBeInTheDocument(); + }); + }); + + describe("custom label", () => { + it("uses custom label when provided", () => { + renderTriggerNode( + { type: "manual" }, + { label: "My Custom Label" }, + ); + expect(screen.getByText("My Custom Label")).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/src/components/workflows/canvas/nodes/TriggerNode.tsx b/ui/src/components/workflows/canvas/nodes/TriggerNode.tsx new file mode 100644 index 00000000..32870ccc --- /dev/null +++ b/ui/src/components/workflows/canvas/nodes/TriggerNode.tsx @@ -0,0 +1,227 @@ +/** + * TriggerNode — custom React Flow node for workflow trigger sources. + * + * Renders a category-coloured card with: + * - A lucide-react icon matching the trigger type + * - A human-readable label + * - A one-line configuration summary + * - An output handle (right side) for connecting to agent nodes + * - Selected / hover / disabled visual states + * + * All 14 backend trigger types are handled. + */ + +import { + Activity, + CheckCircle, + Clock, + GitFork, + GitMerge, + GitPullRequest, + Hand, + ListOrdered, + MessageCircle, + Moon, + SquareKanban, + Timer, + Webhook, + type LucideIcon, +} from "lucide-react"; +import { Handle, Position, type NodeProps } from "@xyflow/react"; +import type { + TriggerCategory, + TriggerConfig, + TriggerType, +} from "@/types/orchestrator"; +import { getTriggerLabel } from "@/types/orchestrator"; + +// --------------------------------------------------------------------------- +// Node data interface +// --------------------------------------------------------------------------- + +export interface TriggerNodeData extends Record { + triggerConfig: TriggerConfig; + /** Display label (defaults to getTriggerLabel if not provided) */ + label?: string; + category: TriggerCategory; + enabled: boolean; + onConfigChange?: (config: TriggerConfig) => void; +} + +// --------------------------------------------------------------------------- +// Icon and colour mappings +// --------------------------------------------------------------------------- + +const TRIGGER_ICONS: Record = { + github_issues: GitPullRequest, + github_pull_requests: GitMerge, + linear_issues: SquareKanban, + webhook: Webhook, + cron: Clock, + delay: Timer, + agent_lifecycle: Activity, + agent_idle: Moon, + dispatch_result: CheckCircle, + ask_response: MessageCircle, + manual: Hand, + queue: ListOrdered, + composite: GitFork, +}; + +/** Tailwind utility classes (bg, border, icon colour) per category */ +const CATEGORY_COLOURS: Record< + TriggerCategory, + { bg: string; border: string; icon: string; badge: string } +> = { + external: { + bg: "bg-blue-50 dark:bg-blue-950", + border: "border-blue-300 dark:border-blue-700", + icon: "text-blue-600 dark:text-blue-400", + badge: "bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300", + }, + schedule: { + bg: "bg-violet-50 dark:bg-violet-950", + border: "border-violet-300 dark:border-violet-700", + icon: "text-violet-600 dark:text-violet-400", + badge: "bg-violet-100 dark:bg-violet-900 text-violet-700 dark:text-violet-300", + }, + event: { + bg: "bg-amber-50 dark:bg-amber-950", + border: "border-amber-300 dark:border-amber-700", + icon: "text-amber-600 dark:text-amber-400", + badge: "bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300", + }, + internal: { + bg: "bg-slate-50 dark:bg-slate-900", + border: "border-slate-300 dark:border-slate-700", + icon: "text-slate-600 dark:text-slate-400", + badge: "bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300", + }, +}; + +// --------------------------------------------------------------------------- +// Config summary helpers +// --------------------------------------------------------------------------- + +function configSummary(config: TriggerConfig): string { + switch (config.type) { + case "github_issues": + case "github_pull_requests": { + const parts = [`${config.owner}/${config.repo}`]; + if (config.labels.length > 0) parts.push(config.labels[0]); + if (config.labels.length > 1) parts.push(`+${config.labels.length - 1}`); + return parts.join(" "); + } + case "cron": + return config.expression || "No expression"; + case "delay": + return config.run_at + ? new Date(config.run_at).toLocaleString() + : "No date set"; + case "webhook": + return `Source: ${config.source}`; + case "manual": + return "Manual trigger"; + case "linear_issues": { + const parts: string[] = []; + if (config.team_key) parts.push(config.team_key); + if (config.project) parts.push(config.project); + return parts.length > 0 ? parts.join(" / ") : "Linear Issues"; + } + case "agent_lifecycle": + return config.event; + case "agent_idle": + return `Idle: ${config.idle_seconds}s`; + case "dispatch_result": + return config.source_workflow_id + ? `From: ${config.source_workflow_id.slice(0, 8)}…` + : "Any workflow"; + case "composite": + return `${config.mode.toUpperCase()} · ${config.triggers.length} trigger${config.triggers.length !== 1 ? "s" : ""}`; + case "queue": + return config.queue_name || "No queue"; + case "ask_response": + return config.category ?? "Any category"; + default: + return ""; + } +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function TriggerNode({ + data, + selected, +}: NodeProps) { + const { triggerConfig, label, category, enabled } = data; + const colours = CATEGORY_COLOURS[category] ?? CATEGORY_COLOURS.internal; + const Icon = TRIGGER_ICONS[triggerConfig.type] ?? Hand; + const displayLabel = label ?? getTriggerLabel(triggerConfig.type); + const summary = configSummary(triggerConfig); + + return ( +
+ {/* Category badge */} + + {category} + + + {/* Icon + label */} +
+
+ + {/* Config summary */} + {summary && ( +

+ {summary} +

+ )} + + {/* Disabled indicator */} + {!enabled && ( +

+ Disabled +

+ )} + + {/* Output handle — right side */} + +
+ ); +} + +export default TriggerNode; diff --git a/ui/src/components/workflows/canvas/nodes/index.ts b/ui/src/components/workflows/canvas/nodes/index.ts new file mode 100644 index 00000000..9fd320b2 --- /dev/null +++ b/ui/src/components/workflows/canvas/nodes/index.ts @@ -0,0 +1,2 @@ +export type { TriggerNodeData } from "./TriggerNode"; +export { TriggerNode } from "./TriggerNode";