Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/components/layout/left-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"
Expand All @@ -54,6 +58,7 @@ export function LeftPane() {
{mode === "clips" && <ClipsPanel onClose={() => setClipsOpen(false)} />}
{mode === "following" && <FollowingPanel onClose={() => setFollowingOpen(false)} />}
{mode === "agent" && <AgentPanel onClose={() => setAgentOpen(false)} />}
{mode === "workflows" && <WorkflowsPanel onClose={() => setWorkflowsOpen(false)} />}
<div className={cn("h-full w-full", mode !== "feed" && "hidden")}>
<FeedView />
</div>
Expand Down
16 changes: 16 additions & 0 deletions src/components/layout/toolkit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -95,6 +96,8 @@ export function Toolkit({
onToggleFollowing,
agentOpen,
onToggleAgent,
workflowsOpen,
onToggleWorkflows,
}: {
sourcesOpen: boolean
onToggleSources: () => void
Expand All @@ -104,6 +107,8 @@ export function Toolkit({
onToggleFollowing: () => void
agentOpen?: boolean
onToggleAgent?: () => void
workflowsOpen?: boolean
onToggleWorkflows?: () => void
}) {
const router = useRouter()
const { isAdmin, budget } = useUserStore()
Expand Down Expand Up @@ -203,6 +208,12 @@ export function Toolkit({
onClick={onToggleSources}
active={sourcesOpen}
/>
<ToolkitButton
icon={Cpu}
ariaLabel="Workflows"
onClick={onToggleWorkflows}
active={workflowsOpen}
/>

{isAdmin && (
<>
Expand Down Expand Up @@ -238,6 +249,8 @@ export function ToolkitFAB({
onToggleFollowing,
agentOpen,
onToggleAgent,
workflowsOpen,
onToggleWorkflows,
}: {
sourcesOpen: boolean
onToggleSources: () => void
Expand All @@ -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()
Expand Down Expand Up @@ -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 }) => (
<button
key={label}
Expand Down
156 changes: 156 additions & 0 deletions src/components/layout/workflows-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
"use client"

import { useEffect, useState } from "react"
import { Cpu, Loader2, X } from "lucide-react"
import { ScrollArea } from "@/components/ui/scroll-area"
import { getWorkflowMarketplace, type WorkflowMarketplaceItem, type CronKind } from "@/lib/graph-api"
import { isMocksEnabled, MOCK_WORKFLOW_MARKETPLACE } from "@/lib/mock-data"
import { cn } from "@/lib/utils"

type FilterKind = "all" | CronKind

const FILTER_OPTIONS: { label: string; value: FilterKind }[] = [
{ label: "All", value: "all" },
{ label: "Ingestion", value: "source" },
{ label: "Janitor", value: "janitor" },
]

function kindBadgeLabel(kind: CronKind): string {
return kind === "source" ? "Ingestion" : "Janitor"
}

function kindBadgeClass(kind: CronKind): string {
return kind === "source"
? "bg-sky-500/15 text-sky-400 border border-sky-500/25"
: "bg-violet-500/15 text-violet-400 border border-violet-500/25"
}

function WorkflowCard({ item }: { item: WorkflowMarketplaceItem }) {
const label = item.label || item.source_type
return (
<div className="flex items-center gap-3 rounded-md border border-border/50 bg-muted/20 px-3 py-2.5 hover:bg-muted/30 transition-colors">
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full",
item.enabled
? "bg-emerald-400 shadow-[0_0_6px_theme(colors.emerald.400)]"
: "bg-muted-foreground/30"
)}
aria-label={item.enabled ? "Enabled" : "Disabled"}
data-testid={item.enabled ? "dot-enabled" : "dot-disabled"}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">{label}</p>
<p className="text-[10px] font-mono text-muted-foreground/70 mt-0.5 truncate">
{item.source_type}
</p>
</div>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-mono font-medium",
kindBadgeClass(item.kind)
)}
>
{kindBadgeLabel(item.kind)}
</span>
</div>
)
}

export function WorkflowsPanel({ onClose }: { onClose: () => void }) {
const [workflows, setWorkflows] = useState<WorkflowMarketplaceItem[]>([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState<FilterKind>("all")

useEffect(() => {
let cancelled = false

async function fetchWorkflows() {
setLoading(true)
try {
if (isMocksEnabled()) {
if (!cancelled) setWorkflows(MOCK_WORKFLOW_MARKETPLACE)
} else {
const items = await getWorkflowMarketplace()
if (!cancelled) setWorkflows(items)
}
} catch (err) {
if (!cancelled) console.error("[workflows-panel] fetch failed:", err)
} finally {
if (!cancelled) setLoading(false)
}
}

fetchWorkflows()
return () => { cancelled = true }
}, [])

const filtered = filter === "all"
? workflows
: workflows.filter((w) => w.kind === filter)

return (
<div className="flex flex-1 min-h-0 flex-col overflow-hidden">
{/* Header */}
<div className="relative z-10 flex items-center justify-between px-4 py-3 border-b border-sidebar-border">
<div>
<h3 className="text-sm font-heading font-semibold tracking-wide text-sidebar-foreground">
Workflow Marketplace
</h3>
<p className="text-[10px] font-mono text-muted-foreground mt-0.5">
{loading ? "Loading…" : `${workflows.length} workflow${workflows.length !== 1 ? "s" : ""}`}
</p>
</div>
<button
onClick={onClose}
aria-label="Close"
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>

{/* Filter chips */}
<div className="relative z-10 flex gap-1.5 px-4 py-2 border-b border-sidebar-border/50">
{FILTER_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setFilter(opt.value)}
className={cn(
"rounded-full px-2.5 py-0.5 text-[11px] font-mono transition-colors",
filter === opt.value
? "bg-primary/15 text-primary border border-primary/30"
: "bg-muted/30 text-muted-foreground border border-border/40 hover:bg-muted/50"
)}
>
{opt.label}
</button>
))}
</div>

{/* Content */}
<ScrollArea className="relative z-10 flex-1 min-h-0">
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 px-4 text-center gap-3">
<Cpu className="h-8 w-8 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">
{workflows.length === 0
? "No workflows configured yet"
: "No workflows match this filter"}
</p>
</div>
) : (
<div className="py-2 px-3 flex flex-col gap-1.5">
{filtered.map((item) => (
<WorkflowCard key={item.ref_id} item={item} />
))}
</div>
)}
</ScrollArea>
</div>
)
}
7 changes: 7 additions & 0 deletions src/components/universe/graph-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -48,6 +50,7 @@ export function GraphPane() {
!followingOpen &&
!agentOpen &&
!clipsOpen &&
!workflowsOpen &&
!selectedNode &&
!searchTerm
const title = graphName || "Knowledge Graph"
Expand Down Expand Up @@ -108,6 +111,8 @@ export function GraphPane() {
onToggleFollowing={() => openPanel(toggleFollowing)}
agentOpen={agentOpen}
onToggleAgent={() => openPanel(toggleAgent)}
workflowsOpen={workflowsOpen}
onToggleWorkflows={() => openPanel(toggleWorkflows)}
/>
</div>
<ToolkitFAB
Expand All @@ -119,6 +124,8 @@ export function GraphPane() {
onToggleFollowing={() => openPanel(toggleFollowing)}
agentOpen={agentOpen}
onToggleAgent={() => openPanel(toggleAgent)}
workflowsOpen={workflowsOpen}
onToggleWorkflows={() => openPanel(toggleWorkflows)}
/>
</div>

Expand Down
38 changes: 32 additions & 6 deletions src/hooks/use-panel-graph-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand All @@ -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<Snapshot>(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

Expand Down Expand Up @@ -90,6 +107,15 @@ export function usePanelGraphSync() {
const res = await api.get<SourcesResponse>(`/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.
Expand All @@ -98,5 +124,5 @@ export function usePanelGraphSync() {

load()
return () => { cancelled = true }
}, [myContentOpen, sourcesOpen, pubKey])
}, [myContentOpen, sourcesOpen, workflowsOpen, pubKey])
}
Loading
Loading