diff --git a/src/components/layout/left-pane.tsx b/src/components/layout/left-pane.tsx index dc4fa1b..285ac0d 100644 --- a/src/components/layout/left-pane.tsx +++ b/src/components/layout/left-pane.tsx @@ -11,8 +11,9 @@ import { FollowingPanel } from "./following-panel" import { NodePreviewPanel } from "./node-preview-panel" import { cn } from "@/lib/utils" import { AgentPanel } from "@/components/agent/agent-panel" +import { WorkflowsPanel } from "./workflows-panel" -type Mode = "preview" | "sources" | "mycontent" | "clips" | "following" | "agent" | "feed" +type Mode = "preview" | "sources" | "mycontent" | "clips" | "following" | "agent" | "workflows" | "feed" export function LeftPane() { const selectedNode = useGraphStore((s) => s.selectedNode) @@ -22,14 +23,17 @@ export function LeftPane() { const clipsOpen = useAppStore((s) => s.clipsOpen) const followingOpen = useAppStore((s) => s.followingOpen) const agentOpen = useAppStore((s) => s.agentOpen) + const workflowsOpen = useAppStore((s) => s.workflowsOpen) const setSourcesOpen = useAppStore((s) => s.setSourcesOpen) const setMyContentOpen = useAppStore((s) => s.setMyContentOpen) const setClipsOpen = useAppStore((s) => s.setClipsOpen) const setFollowingOpen = useAppStore((s) => s.setFollowingOpen) const setAgentOpen = useAppStore((s) => s.setAgentOpen) + const setWorkflowsOpen = useAppStore((s) => s.setWorkflowsOpen) const schemas = useSchemaStore((s) => s.schemas) function pickMode(): Mode { + if (workflowsOpen) return "workflows" if (agentOpen) return "agent" if (sourcesOpen) return "sources" if (myContentOpen) return "mycontent" @@ -54,6 +58,7 @@ export function LeftPane() { {mode === "clips" && setClipsOpen(false)} />} {mode === "following" && setFollowingOpen(false)} />} {mode === "agent" && setAgentOpen(false)} />} + {mode === "workflows" && setWorkflowsOpen(false)} />}
diff --git a/src/components/layout/toolkit.tsx b/src/components/layout/toolkit.tsx index 7c7b403..b1f3629 100644 --- a/src/components/layout/toolkit.tsx +++ b/src/components/layout/toolkit.tsx @@ -15,6 +15,7 @@ import { Menu, X, MessageSquare, + Cpu, } from "lucide-react" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { useUserStore } from "@/stores/user-store" @@ -95,6 +96,8 @@ export function Toolkit({ onToggleFollowing, agentOpen, onToggleAgent, + workflowsOpen, + onToggleWorkflows, }: { sourcesOpen: boolean onToggleSources: () => void @@ -104,6 +107,8 @@ export function Toolkit({ onToggleFollowing: () => void agentOpen?: boolean onToggleAgent?: () => void + workflowsOpen?: boolean + onToggleWorkflows?: () => void }) { const router = useRouter() const { isAdmin, budget } = useUserStore() @@ -203,6 +208,12 @@ export function Toolkit({ onClick={onToggleSources} active={sourcesOpen} /> + {isAdmin && ( <> @@ -238,6 +249,8 @@ export function ToolkitFAB({ onToggleFollowing, agentOpen, onToggleAgent, + workflowsOpen, + onToggleWorkflows, }: { sourcesOpen: boolean onToggleSources: () => void @@ -247,6 +260,8 @@ export function ToolkitFAB({ onToggleFollowing: () => void agentOpen?: boolean onToggleAgent?: () => void + workflowsOpen?: boolean + onToggleWorkflows?: () => void }) { const [open, setOpen] = useState(false) const router = useRouter() @@ -308,6 +323,7 @@ export function ToolkitFAB({ { icon: BookMarked, label: "My Content", action: onToggleMyContent, active: myContentOpen }, { icon: Heart, label: "Following", action: onToggleFollowing, active: followingOpen }, { icon: Layers, label: "Sources", action: onToggleSources, active: sourcesOpen }, + { icon: Cpu, label: "Workflows", action: onToggleWorkflows ?? (() => {}), active: workflowsOpen ?? false }, ].map(({ icon: Icon, label, action, active }) => ( + + + {/* Filter chips */} +
+ {FILTER_OPTIONS.map((opt) => ( + + ))} +
+ + {/* Content */} + + {loading ? ( +
+ +
+ ) : filtered.length === 0 ? ( +
+ +

+ {workflows.length === 0 + ? "No workflows configured yet" + : "No workflows match this filter"} +

+
+ ) : ( +
+ {filtered.map((item) => ( + + ))} +
+ )} +
+ + ) +} diff --git a/src/components/universe/graph-pane.tsx b/src/components/universe/graph-pane.tsx index 9ec05f6..9706766 100644 --- a/src/components/universe/graph-pane.tsx +++ b/src/components/universe/graph-pane.tsx @@ -30,6 +30,8 @@ export function GraphPane() { const toggleMyContent = useAppStore((s) => s.toggleMyContent) const toggleFollowing = useAppStore((s) => s.toggleFollowing) const toggleAgent = useAppStore((s) => s.toggleAgent) + const workflowsOpen = useAppStore((s) => s.workflowsOpen) + const toggleWorkflows = useAppStore((s) => s.toggleWorkflows) function onSelect(node: GraphNode) { setSelectedNode(node) @@ -48,6 +50,7 @@ export function GraphPane() { !followingOpen && !agentOpen && !clipsOpen && + !workflowsOpen && !selectedNode && !searchTerm const title = graphName || "Knowledge Graph" @@ -108,6 +111,8 @@ export function GraphPane() { onToggleFollowing={() => openPanel(toggleFollowing)} agentOpen={agentOpen} onToggleAgent={() => openPanel(toggleAgent)} + workflowsOpen={workflowsOpen} + onToggleWorkflows={() => openPanel(toggleWorkflows)} /> openPanel(toggleFollowing)} agentOpen={agentOpen} onToggleAgent={() => openPanel(toggleAgent)} + workflowsOpen={workflowsOpen} + onToggleWorkflows={() => openPanel(toggleWorkflows)} /> diff --git a/src/hooks/use-panel-graph-sync.ts b/src/hooks/use-panel-graph-sync.ts index 25e5d9b..fcbde09 100644 --- a/src/hooks/use-panel-graph-sync.ts +++ b/src/hooks/use-panel-graph-sync.ts @@ -2,12 +2,13 @@ import { useEffect, useRef } from "react" import { api } from "@/lib/api" -import { isMocksEnabled, MOCK_CONTENT, MOCK_SOURCES } from "@/lib/mock-data" +import { isMocksEnabled, MOCK_CONTENT, MOCK_SOURCES, MOCK_WORKFLOW_MARKETPLACE } from "@/lib/mock-data" import { useAppStore } from "@/stores/app-store" import { useGraphStore } from "@/stores/graph-store" import { useUserStore } from "@/stores/user-store" import type { Source } from "@/stores/sources-store" -import type { GraphNode, GraphEdge } from "@/lib/graph-api" +import type { GraphNode, GraphEdge, WorkflowMarketplaceItem } from "@/lib/graph-api" +import { getWorkflowMarketplace } from "@/lib/graph-api" interface ContentResponse { nodes: GraphNode[] @@ -34,20 +35,36 @@ function sourceToNode(s: Source): GraphNode { } } +function workflowToNode(w: WorkflowMarketplaceItem): GraphNode { + return { + ref_id: w.ref_id, + node_type: "Workflow", + properties: { + name: w.label || w.source_type, + source_type: w.source_type, + kind: w.kind, + enabled: w.enabled, + }, + } +} + export function usePanelGraphSync() { const myContentOpen = useAppStore((s) => s.myContentOpen) const sourcesOpen = useAppStore((s) => s.sourcesOpen) + const workflowsOpen = useAppStore((s) => s.workflowsOpen) const pubKey = useUserStore((s) => s.pubKey) const snapshot = useRef(null) - const activePanel = useRef<"mycontent" | "sources" | null>(null) + const activePanel = useRef<"mycontent" | "sources" | "workflows" | null>(null) useEffect(() => { - const next: "mycontent" | "sources" | null = myContentOpen + const next: "mycontent" | "sources" | "workflows" | null = myContentOpen ? "mycontent" : sourcesOpen ? "sources" - : null + : workflowsOpen + ? "workflows" + : null if (next === activePanel.current) return @@ -90,6 +107,15 @@ export function usePanelGraphSync() { const res = await api.get(`/radar?skip=0&limit=500`) const nodes = (res.data ?? []).map(sourceToNode) if (!cancelled) useGraphStore.getState().setGraphData(nodes, []) + } else if (next === "workflows") { + if (isMocksEnabled()) { + const nodes = MOCK_WORKFLOW_MARKETPLACE.map(workflowToNode) + if (!cancelled) useGraphStore.getState().setGraphData(nodes, []) + return + } + const items = await getWorkflowMarketplace() + const nodes = items.map(workflowToNode) + if (!cancelled) useGraphStore.getState().setGraphData(nodes, []) } } catch { // Silent — panel itself surfaces its own errors; the graph just stays empty. @@ -98,5 +124,5 @@ export function usePanelGraphSync() { load() return () => { cancelled = true } - }, [myContentOpen, sourcesOpen, pubKey]) + }, [myContentOpen, sourcesOpen, workflowsOpen, pubKey]) } diff --git a/src/lib/__tests__/workflows-panel.test.tsx b/src/lib/__tests__/workflows-panel.test.tsx new file mode 100644 index 0000000..f63a7b1 --- /dev/null +++ b/src/lib/__tests__/workflows-panel.test.tsx @@ -0,0 +1,191 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import type { WorkflowMarketplaceItem } from "@/lib/graph-api" + +// Mock modules before importing the component +vi.mock("@/lib/graph-api", () => ({ + getWorkflowMarketplace: vi.fn(), +})) + +const MOCK_ITEMS: WorkflowMarketplaceItem[] = [ + { + ref_id: "rc-twitter", + source_type: "twitter_handle", + kind: "source", + enabled: true, + label: undefined, + }, + { + ref_id: "rc-youtube", + source_type: "youtube_channel", + kind: "source", + enabled: false, + label: "YouTube Channel", + }, + { + ref_id: "rc-deduplication", + source_type: "deduplication", + kind: "janitor", + enabled: true, + label: "Deduplication", + }, + { + ref_id: "rc-content-review", + source_type: "content_review", + kind: "janitor", + enabled: false, + label: "Content Review", + }, +] + +vi.mock("@/lib/mock-data", () => ({ + isMocksEnabled: vi.fn(() => false), + MOCK_WORKFLOW_MARKETPLACE: [ + { ref_id: "rc-twitter", source_type: "twitter_handle", kind: "source", enabled: true, label: undefined }, + { ref_id: "rc-youtube", source_type: "youtube_channel", kind: "source", enabled: false, label: "YouTube Channel" }, + { ref_id: "rc-deduplication", source_type: "deduplication", kind: "janitor", enabled: true, label: "Deduplication" }, + { ref_id: "rc-content-review", source_type: "content_review", kind: "janitor", enabled: false, label: "Content Review" }, + ], +})) + +import { WorkflowsPanel } from "@/components/layout/workflows-panel" + +describe("WorkflowsPanel", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders loading spinner while fetching", async () => { + const { getWorkflowMarketplace } = await import("@/lib/graph-api") + vi.mocked(getWorkflowMarketplace).mockReturnValue(new Promise(() => {})) // never resolves + + render( {}} />) + expect(document.querySelector(".animate-spin")).toBeTruthy() + }) + + it("renders all workflow cards after loading", async () => { + const { getWorkflowMarketplace } = await import("@/lib/graph-api") + vi.mocked(getWorkflowMarketplace).mockResolvedValue(MOCK_ITEMS) + + render( {}} />) + + await waitFor(() => { + // twitter_handle appears twice (label fallback + sub-text); ensure at least one exists + expect(screen.getAllByText("twitter_handle").length).toBeGreaterThan(0) + expect(screen.getByText("YouTube Channel")).toBeInTheDocument() + expect(screen.getByText("Deduplication")).toBeInTheDocument() + expect(screen.getByText("Content Review")).toBeInTheDocument() + }) + }) + + it("falls back to source_type when label is absent", async () => { + const { getWorkflowMarketplace } = await import("@/lib/graph-api") + vi.mocked(getWorkflowMarketplace).mockResolvedValue(MOCK_ITEMS) + + render( {}} />) + + await waitFor(() => { + // rc-twitter has no label — source_type is rendered as both primary text and sub-text + expect(screen.getAllByText("twitter_handle").length).toBeGreaterThan(0) + }) + }) + + it("shows empty state when list is empty", async () => { + const { getWorkflowMarketplace } = await import("@/lib/graph-api") + vi.mocked(getWorkflowMarketplace).mockResolvedValue([]) + + render( {}} />) + + await waitFor(() => { + expect(screen.getByText("No workflows configured yet")).toBeInTheDocument() + }) + }) + + it("calls onClose when close button is clicked", async () => { + const { getWorkflowMarketplace } = await import("@/lib/graph-api") + vi.mocked(getWorkflowMarketplace).mockResolvedValue(MOCK_ITEMS) + const onClose = vi.fn() + + render() + await waitFor(() => screen.getByText("Deduplication")) + + await userEvent.click(screen.getByRole("button", { name: /close/i })) + expect(onClose).toHaveBeenCalledOnce() + }) + + it("filters to only Ingestion (kind=source) items", async () => { + const { getWorkflowMarketplace } = await import("@/lib/graph-api") + vi.mocked(getWorkflowMarketplace).mockResolvedValue(MOCK_ITEMS) + + render( {}} />) + await waitFor(() => screen.getByText("Deduplication")) + + await userEvent.click(screen.getByRole("button", { name: "Ingestion" })) + + // Source items should be visible (twitter_handle may appear twice as label+sub-text) + expect(screen.getAllByText("twitter_handle").length).toBeGreaterThan(0) + expect(screen.getByText("YouTube Channel")).toBeInTheDocument() + // Janitor items should not be visible + expect(screen.queryByText("Deduplication")).not.toBeInTheDocument() + expect(screen.queryByText("Content Review")).not.toBeInTheDocument() + }) + + it("filters to only Janitor items", async () => { + const { getWorkflowMarketplace } = await import("@/lib/graph-api") + vi.mocked(getWorkflowMarketplace).mockResolvedValue(MOCK_ITEMS) + + render( {}} />) + await waitFor(() => screen.getByText("Deduplication")) + + await userEvent.click(screen.getByRole("button", { name: "Janitor" })) + + // Janitor items should be visible + expect(screen.getByText("Deduplication")).toBeInTheDocument() + expect(screen.getByText("Content Review")).toBeInTheDocument() + // Source items should not be visible + expect(screen.queryByText("twitter_handle")).not.toBeInTheDocument() + expect(screen.queryByText("YouTube Channel")).not.toBeInTheDocument() + }) + + it("shows all items after selecting All filter", async () => { + const { getWorkflowMarketplace } = await import("@/lib/graph-api") + vi.mocked(getWorkflowMarketplace).mockResolvedValue(MOCK_ITEMS) + + render( {}} />) + await waitFor(() => screen.getByText("Deduplication")) + + // Go to Janitor then back to All + await userEvent.click(screen.getByRole("button", { name: "Janitor" })) + await userEvent.click(screen.getByRole("button", { name: "All" })) + + expect(screen.getAllByText("twitter_handle").length).toBeGreaterThan(0) + expect(screen.getByText("Deduplication")).toBeInTheDocument() + }) + + it("renders enabled dot for enabled workflow", async () => { + const { getWorkflowMarketplace } = await import("@/lib/graph-api") + vi.mocked(getWorkflowMarketplace).mockResolvedValue(MOCK_ITEMS) + + render( {}} />) + await waitFor(() => screen.getByText("Deduplication")) + + const enabledDots = document.querySelectorAll("[data-testid='dot-enabled']") + const disabledDots = document.querySelectorAll("[data-testid='dot-disabled']") + // 2 enabled (rc-twitter, rc-deduplication), 2 disabled (rc-youtube, rc-content-review) + expect(enabledDots.length).toBe(2) + expect(disabledDots.length).toBe(2) + }) + + it("uses MOCK_WORKFLOW_MARKETPLACE in mock mode", async () => { + const { isMocksEnabled } = await import("@/lib/mock-data") + vi.mocked(isMocksEnabled).mockReturnValue(true) + + render( {}} />) + + await waitFor(() => { + // MOCK_ITEMS has twitter_handle (no label) — appears as both primary text and sub-text + expect(screen.getAllByText("twitter_handle").length).toBeGreaterThan(0) + }) + }) +}) diff --git a/src/lib/graph-api.ts b/src/lib/graph-api.ts index 11691b6..caa0ddc 100644 --- a/src/lib/graph-api.ts +++ b/src/lib/graph-api.ts @@ -353,6 +353,29 @@ export async function runCron( ) } +export interface WorkflowMarketplaceItem { + ref_id: string + label?: string + source_type: RadarSourceType | JanitorSourceType + kind: CronKind + enabled: boolean +} + +export async function getWorkflowMarketplace( + signal?: AbortSignal +): Promise { + const { isMocksEnabled, MOCK_WORKFLOW_MARKETPLACE } = await import("./mock-data") + if (isMocksEnabled()) { + return MOCK_WORKFLOW_MARKETPLACE + } + const res = await api.get<{ workflows: WorkflowMarketplaceItem[] }>( + '/v2/workflows/marketplace', + undefined, + signal + ) + return res.workflows ?? [] +} + export async function getCronRuns( opts: { source_type?: string; kind?: CronKind; limit?: number }, signal?: AbortSignal diff --git a/src/lib/mock-data.ts b/src/lib/mock-data.ts index eaf7ebe..cd7691a 100644 --- a/src/lib/mock-data.ts +++ b/src/lib/mock-data.ts @@ -740,6 +740,17 @@ export const MOCK_CRON_CONFIGS = [ /** @deprecated Use MOCK_CRON_CONFIGS */ export const MOCK_RADAR_CONFIGS = MOCK_CRON_CONFIGS +import type { CronConfig, WorkflowMarketplaceItem } from "./graph-api" +const MOCK_CRON_CONFIGS_TYPED: CronConfig[] = MOCK_CRON_CONFIGS +export const MOCK_WORKFLOW_MARKETPLACE: WorkflowMarketplaceItem[] = + MOCK_CRON_CONFIGS_TYPED.map(({ ref_id, label, source_type, kind, enabled }) => ({ + ref_id, + label, + source_type, + kind, + enabled, + })) + const MOCK_RUN_NOW = new Date("2026-05-04T09:00:00Z") const mockRunTs = (minutesAgo: number): number => { return (MOCK_RUN_NOW.getTime() - minutesAgo * 60 * 1000) / 1000 diff --git a/src/stores/app-store.ts b/src/stores/app-store.ts index 4deb55f..30caa8f 100644 --- a/src/stores/app-store.ts +++ b/src/stores/app-store.ts @@ -10,6 +10,7 @@ interface AppState { clipsOpen: boolean followingOpen: boolean agentOpen: boolean + workflowsOpen: boolean graphName: string graphDescription: string myContentRefreshKey: number @@ -23,10 +24,12 @@ interface AppActions { setClipsOpen: (val: boolean) => void setFollowingOpen: (open: boolean) => void setAgentOpen: (agentOpen: boolean) => void + setWorkflowsOpen: (val: boolean) => void toggleMyContent: () => void toggleSources: () => void toggleFollowing: () => void toggleAgent: () => void + toggleWorkflows: () => void closeAllPanels: () => void setGraphMeta: (name: string, description: string) => void bumpMyContentRefresh: () => void @@ -42,25 +45,29 @@ export const useAppStore = create((set) => ({ clipsOpen: false, followingOpen: false, agentOpen: false, + workflowsOpen: false, graphName: "", graphDescription: "", myContentRefreshKey: 0, setSearchTerm: (searchTerm) => set({ searchTerm }), setSidebarOpen: (sidebarOpen) => set({ sidebarOpen }), - setMyContentOpen: (myContentOpen) => set({ myContentOpen, sourcesOpen: false, clipsOpen: false, followingOpen: false, agentOpen: false }), - setSourcesOpen: (sourcesOpen) => set({ sourcesOpen, myContentOpen: false, clipsOpen: false, followingOpen: false, agentOpen: false }), - setClipsOpen: (clipsOpen) => set({ clipsOpen, sourcesOpen: false, myContentOpen: false, followingOpen: false, agentOpen: false }), - setFollowingOpen: (open) => set({ followingOpen: open, sourcesOpen: false, myContentOpen: false, clipsOpen: false, agentOpen: false }), - setAgentOpen: (agentOpen) => set({ agentOpen, sourcesOpen: false, myContentOpen: false, clipsOpen: false, followingOpen: false }), + setMyContentOpen: (myContentOpen) => set({ myContentOpen, sourcesOpen: false, clipsOpen: false, followingOpen: false, agentOpen: false, workflowsOpen: false }), + setSourcesOpen: (sourcesOpen) => set({ sourcesOpen, myContentOpen: false, clipsOpen: false, followingOpen: false, agentOpen: false, workflowsOpen: false }), + setClipsOpen: (clipsOpen) => set({ clipsOpen, sourcesOpen: false, myContentOpen: false, followingOpen: false, agentOpen: false, workflowsOpen: false }), + setFollowingOpen: (open) => set({ followingOpen: open, sourcesOpen: false, myContentOpen: false, clipsOpen: false, agentOpen: false, workflowsOpen: false }), + setAgentOpen: (agentOpen) => set({ agentOpen, sourcesOpen: false, myContentOpen: false, clipsOpen: false, followingOpen: false, workflowsOpen: false }), + setWorkflowsOpen: (workflowsOpen) => set({ workflowsOpen, sourcesOpen: false, myContentOpen: false, clipsOpen: false, followingOpen: false, agentOpen: false }), toggleMyContent: () => - set((s) => ({ myContentOpen: !s.myContentOpen, sourcesOpen: false, clipsOpen: false, followingOpen: false, agentOpen: false })), + set((s) => ({ myContentOpen: !s.myContentOpen, sourcesOpen: false, clipsOpen: false, followingOpen: false, agentOpen: false, workflowsOpen: false })), toggleSources: () => - set((s) => ({ sourcesOpen: !s.sourcesOpen, myContentOpen: false, clipsOpen: false, followingOpen: false, agentOpen: false })), + set((s) => ({ sourcesOpen: !s.sourcesOpen, myContentOpen: false, clipsOpen: false, followingOpen: false, agentOpen: false, workflowsOpen: false })), toggleFollowing: () => - set((s) => ({ followingOpen: !s.followingOpen, sourcesOpen: false, myContentOpen: false, clipsOpen: false, agentOpen: false })), + set((s) => ({ followingOpen: !s.followingOpen, sourcesOpen: false, myContentOpen: false, clipsOpen: false, agentOpen: false, workflowsOpen: false })), toggleAgent: () => - set((s) => ({ agentOpen: !s.agentOpen, sourcesOpen: false, myContentOpen: false, clipsOpen: false, followingOpen: false })), - closeAllPanels: () => set({ sourcesOpen: false, myContentOpen: false, clipsOpen: false, followingOpen: false, agentOpen: false }), + set((s) => ({ agentOpen: !s.agentOpen, sourcesOpen: false, myContentOpen: false, clipsOpen: false, followingOpen: false, workflowsOpen: false })), + toggleWorkflows: () => + set((s) => ({ workflowsOpen: !s.workflowsOpen, sourcesOpen: false, myContentOpen: false, clipsOpen: false, followingOpen: false, agentOpen: false })), + closeAllPanels: () => set({ sourcesOpen: false, myContentOpen: false, clipsOpen: false, followingOpen: false, agentOpen: false, workflowsOpen: false }), setGraphMeta: (graphName, graphDescription) => set({ graphName, graphDescription }), bumpMyContentRefresh: () => set((s) => ({ myContentRefreshKey: s.myContentRefreshKey + 1 })),