diff --git a/ui/src/components/workflows/canvas/NodePalette.test.tsx b/ui/src/components/workflows/canvas/NodePalette.test.tsx new file mode 100644 index 00000000..2892ad90 --- /dev/null +++ b/ui/src/components/workflows/canvas/NodePalette.test.tsx @@ -0,0 +1,264 @@ +/** + * NodePalette tests. + * + * Covers: rendering all trigger categories, agent list, search filtering, + * drag start sets correct transfer data, collapse/expand toggle, and + * disabled state for non-running agents. + */ + +import { render, screen, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { Agent } from "@/types/orchestrator"; +import { + NodePalette, + PALETTE_DRAG_KEY, + decodeDragData, +} from "./NodePalette"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const mockAgents: Agent[] = [ + { + id: "agent-1", + name: "Running Agent", + status: "running", + config: { + working_dir: "/tmp", + shell: "/bin/sh", + interactive: false, + tool_policy: { mode: "allow_all" }, + }, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }, + { + id: "agent-2", + name: "Stopped Agent", + status: "stopped", + config: { + working_dir: "/tmp", + shell: "/bin/sh", + interactive: false, + tool_policy: { mode: "allow_all" }, + }, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }, +]; + +function renderPalette(props: Partial> = {}) { + return render(); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("NodePalette", () => { + describe("rendering", () => { + it("renders the palette wrapper", () => { + renderPalette(); + expect(screen.getByTestId("node-palette")).toBeInTheDocument(); + }); + + it("shows trigger category sections", () => { + renderPalette(); + expect(screen.getByText("External Sources")).toBeInTheDocument(); + expect(screen.getByText("Schedules")).toBeInTheDocument(); + expect(screen.getByText("Events")).toBeInTheDocument(); + expect(screen.getByText("Internal")).toBeInTheDocument(); + }); + + it("shows all 13 trigger types", () => { + renderPalette(); + expect(screen.getByText("GitHub Issues")).toBeInTheDocument(); + expect(screen.getByText("GitHub Pull Requests")).toBeInTheDocument(); + expect(screen.getByText("Linear Issues")).toBeInTheDocument(); + expect(screen.getByText("Webhook")).toBeInTheDocument(); + expect(screen.getByText("Cron Schedule")).toBeInTheDocument(); + expect(screen.getByText("Delayed Run")).toBeInTheDocument(); + expect(screen.getByText("Agent Lifecycle")).toBeInTheDocument(); + expect(screen.getByText("Agent Idle")).toBeInTheDocument(); + expect(screen.getByText("Dispatch Result")).toBeInTheDocument(); + expect(screen.getByText("Ask Response")).toBeInTheDocument(); + expect(screen.getByText("Manual")).toBeInTheDocument(); + expect(screen.getByText("Queue")).toBeInTheDocument(); + expect(screen.getByText("Composite")).toBeInTheDocument(); + }); + + it("shows agents section with agent names", () => { + renderPalette(); + expect(screen.getByText("Agents")).toBeInTheDocument(); + expect(screen.getByText("Running Agent")).toBeInTheDocument(); + expect(screen.getByText("Stopped Agent")).toBeInTheDocument(); + }); + + it("shows search input", () => { + renderPalette(); + expect(screen.getByTestId("palette-search")).toBeInTheDocument(); + }); + }); + + describe("collapse/expand", () => { + it("starts expanded by default", () => { + renderPalette(); + expect(screen.getByTestId("node-palette")).toHaveAttribute( + "data-collapsed", + "false", + ); + }); + + it("collapses when collapse button is clicked", async () => { + const user = userEvent.setup(); + renderPalette(); + await user.click(screen.getByLabelText("Collapse node palette")); + expect(screen.getByTestId("node-palette")).toHaveAttribute( + "data-collapsed", + "true", + ); + }); + + it("expands when expand button is clicked from collapsed state", async () => { + const user = userEvent.setup(); + renderPalette({ collapsed: true }); + await user.click(screen.getByLabelText("Expand node palette")); + // Internal state only changes if uncontrolled; with prop it stays + expect(screen.getByTestId("node-palette")).toBeInTheDocument(); + }); + + it("calls onCollapsedChange when toggled", async () => { + const user = userEvent.setup(); + const onCollapsedChange = vi.fn(); + renderPalette({ onCollapsedChange }); + await user.click(screen.getByLabelText("Collapse node palette")); + expect(onCollapsedChange).toHaveBeenCalledWith(true); + }); + + it("hides items when collapsed", () => { + renderPalette({ collapsed: true }); + expect(screen.queryByText("GitHub Issues")).not.toBeInTheDocument(); + expect(screen.queryByText("Agents")).not.toBeInTheDocument(); + }); + }); + + describe("search filtering", () => { + it("filters trigger types by name", async () => { + const user = userEvent.setup(); + renderPalette(); + await user.type(screen.getByTestId("palette-search"), "cron"); + expect(screen.getByText("Cron Schedule")).toBeInTheDocument(); + expect(screen.queryByText("GitHub Issues")).not.toBeInTheDocument(); + }); + + it("filters agents by name", async () => { + const user = userEvent.setup(); + renderPalette(); + await user.type(screen.getByTestId("palette-search"), "Running"); + expect(screen.getByText("Running Agent")).toBeInTheDocument(); + expect(screen.queryByText("Stopped Agent")).not.toBeInTheDocument(); + }); + + it("shows 'No results' when nothing matches", async () => { + const user = userEvent.setup(); + renderPalette(); + await user.type( + screen.getByTestId("palette-search"), + "xyzzy-no-match", + ); + expect(screen.getByText("No results")).toBeInTheDocument(); + }); + + it("is case-insensitive", async () => { + const user = userEvent.setup(); + renderPalette(); + await user.type(screen.getByTestId("palette-search"), "WEBHOOK"); + expect(screen.getByText("Webhook")).toBeInTheDocument(); + }); + }); + + describe("drag-and-drop", () => { + it("sets correct drag data for trigger items", () => { + renderPalette(); + const item = screen.getByTestId("palette-item-github_issues"); + + const mockDataTransfer = { + effectAllowed: "", + setData: vi.fn(), + }; + + fireEvent.dragStart(item, { + dataTransfer: mockDataTransfer, + }); + + expect(mockDataTransfer.setData).toHaveBeenCalledWith( + PALETTE_DRAG_KEY, + expect.any(String), + ); + + const [, raw] = mockDataTransfer.setData.mock.calls[0]; + const decoded = decodeDragData(raw); + expect(decoded).toEqual({ + type: "trigger", + triggerType: "github_issues", + }); + }); + + it("sets correct drag data for agent items", () => { + renderPalette(); + const item = screen.getByTestId("palette-item-agent-1"); + + const mockDataTransfer = { + effectAllowed: "", + setData: vi.fn(), + }; + + fireEvent.dragStart(item, { + dataTransfer: mockDataTransfer, + }); + + const [, raw] = mockDataTransfer.setData.mock.calls[0]; + const decoded = decodeDragData(raw); + expect(decoded).toEqual({ + type: "agent", + agentId: "agent-1", + agentName: "Running Agent", + }); + }); + + it("does not fire drag for non-running agents", () => { + renderPalette(); + const item = screen.getByTestId("palette-item-agent-2"); + expect(item).toHaveAttribute("draggable", "false"); + }); + }); + + describe("disabled agent state", () => { + it("marks stopped agent as not draggable", () => { + renderPalette(); + expect( + screen.getByTestId("palette-item-agent-2"), + ).toHaveAttribute("draggable", "false"); + }); + + it("marks running agent as draggable", () => { + renderPalette(); + expect( + screen.getByTestId("palette-item-agent-1"), + ).toHaveAttribute("draggable", "true"); + }); + }); + + describe("empty agents", () => { + it("renders without crashing when no agents provided", () => { + expect(() => render()).not.toThrow(); + }); + + it("does not show Agents section when agents list is empty", () => { + render(); + expect(screen.queryByText("Agents")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/ui/src/components/workflows/canvas/NodePalette.tsx b/ui/src/components/workflows/canvas/NodePalette.tsx new file mode 100644 index 00000000..4d4a629c --- /dev/null +++ b/ui/src/components/workflows/canvas/NodePalette.tsx @@ -0,0 +1,360 @@ +/** + * NodePalette — collapsible sidebar listing draggable trigger types and agents. + * + * Users drag items from the palette onto the WorkflowCanvas to add nodes. + * HTML5 drag-and-drop is used; the canvas onDrop handler reads the transfer + * data to create the appropriate node. + * + * Sections: + * - Trigger Sources — grouped by TriggerCategory, all 14 types + * - Agents — live list of agents from the API (running agents only draggable) + */ + +import { + Activity, + Bot, + CheckCircle, + ChevronLeft, + ChevronRight, + Clock, + GitFork, + GitMerge, + GitPullRequest, + Hand, + ListOrdered, + MessageCircle, + Moon, + Search, + SquareKanban, + Timer, + Webhook, + type LucideIcon, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { AgentStatusBadge } from "@/components/agents/AgentStatusBadge"; +import type { + Agent, + TriggerCategory, + TriggerType, +} from "@/types/orchestrator"; +import { getTriggerCategory, getTriggerLabel } from "@/types/orchestrator"; + +// --------------------------------------------------------------------------- +// Drag transfer data +// --------------------------------------------------------------------------- + +/** Data set on drag events from palette items */ +export type PaletteDragData = + | { type: "trigger"; triggerType: TriggerType } + | { type: "agent"; agentId: string; agentName: string }; + +export const PALETTE_DRAG_KEY = "application/agentd-palette"; + +export function encodeDragData(data: PaletteDragData): string { + return JSON.stringify(data); +} + +export function decodeDragData(raw: string): PaletteDragData | null { + try { + return JSON.parse(raw) as PaletteDragData; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Icon map (mirrors TriggerNode.tsx) +// --------------------------------------------------------------------------- + +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, +}; + +const CATEGORY_ACCENT: Record = { + external: "text-blue-600 dark:text-blue-400", + schedule: "text-violet-600 dark:text-violet-400", + event: "text-amber-600 dark:text-amber-400", + internal: "text-slate-600 dark:text-slate-400", +}; + +const CATEGORY_LABEL: Record = { + external: "External Sources", + schedule: "Schedules", + event: "Events", + internal: "Internal", +}; + +// All trigger types ordered by category +const ALL_TRIGGER_TYPES: TriggerType[] = [ + "github_issues", + "github_pull_requests", + "linear_issues", + "webhook", + "cron", + "delay", + "agent_lifecycle", + "agent_idle", + "dispatch_result", + "ask_response", + "manual", + "queue", + "composite", +]; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +export interface NodePaletteProps { + /** Live agent list from useAgents() */ + agents: Agent[]; + /** Controlled collapsed state (optional — uses internal state if omitted) */ + collapsed?: boolean; + onCollapsedChange?: (collapsed: boolean) => void; +} + +// --------------------------------------------------------------------------- +// PaletteItem — a single draggable row +// --------------------------------------------------------------------------- + +function PaletteItem({ + icon: Icon, + label, + iconClass, + disabled, + disabledReason, + dragData, +}: { + icon: LucideIcon; + label: string; + iconClass?: string; + disabled?: boolean; + disabledReason?: string; + dragData: PaletteDragData; +}) { + function handleDragStart(e: React.DragEvent) { + e.dataTransfer.effectAllowed = "copy"; + e.dataTransfer.setData(PALETTE_DRAG_KEY, encodeDragData(dragData)); + } + + return ( +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Section header +// --------------------------------------------------------------------------- + +function SectionHeader({ label }: { label: string }) { + return ( +

+ {label} +

+ ); +} + +// --------------------------------------------------------------------------- +// NodePalette +// --------------------------------------------------------------------------- + +export function NodePalette({ + agents, + collapsed: collapsedProp, + onCollapsedChange, +}: NodePaletteProps) { + const [internalCollapsed, setInternalCollapsed] = useState(false); + const [search, setSearch] = useState(""); + + const collapsed = collapsedProp ?? internalCollapsed; + + function toggleCollapsed() { + const next = !collapsed; + setInternalCollapsed(next); + onCollapsedChange?.(next); + } + + const searchLower = search.toLowerCase(); + + // Group trigger types by category, filtered by search + const groupedTriggers = useMemo(() => { + const categories: TriggerCategory[] = [ + "external", + "schedule", + "event", + "internal", + ]; + return categories.map((cat) => ({ + category: cat, + types: ALL_TRIGGER_TYPES.filter( + (t) => + getTriggerCategory(t) === cat && + getTriggerLabel(t).toLowerCase().includes(searchLower), + ), + })).filter((g) => g.types.length > 0); + }, [searchLower]); + + // Filter agents by search + const filteredAgents = useMemo( + () => + agents.filter((a) => a.name.toLowerCase().includes(searchLower)), + [agents, searchLower], + ); + + const hasResults = + groupedTriggers.length > 0 || filteredAgents.length > 0; + + if (collapsed) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+ + Palette + + +
+ + {/* Search */} +
+
+
+
+ + {/* Items */} +
+ {!hasResults && ( +

+ No results +

+ )} + + {/* Trigger sections */} + {groupedTriggers.map(({ category, types }) => ( +
+ + {types.map((triggerType) => { + const Icon = TRIGGER_ICONS[triggerType] ?? Hand; + return ( + + ); + })} +
+ ))} + + {/* Agents section */} + {filteredAgents.length > 0 && ( +
+ + {filteredAgents.map((agent) => ( +
+ + +
+ ))} +
+ )} +
+
+ ); +} + +export default NodePalette; diff --git a/ui/src/components/workflows/canvas/index.ts b/ui/src/components/workflows/canvas/index.ts index 71c0fd04..9d228fb5 100644 --- a/ui/src/components/workflows/canvas/index.ts +++ b/ui/src/components/workflows/canvas/index.ts @@ -1,5 +1,12 @@ export type { WorkflowCanvasProps } from "./WorkflowCanvas"; export { WorkflowCanvas } from "./WorkflowCanvas"; +export type { NodePaletteProps, PaletteDragData } from "./NodePalette"; +export { + NodePalette, + PALETTE_DRAG_KEY, + encodeDragData, + decodeDragData, +} from "./NodePalette"; export type { AgentNodeData } from "./nodes/AgentNode"; export { AgentNode } from "./nodes/AgentNode"; export type { TriggerNodeData } from "./nodes/TriggerNode";