Skip to content
Draft
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
1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-router": "1.168.23",
"@tanstack/react-router-devtools": "1.166.13",
"@xyflow/react": "12.6.4",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
Expand Down
60 changes: 60 additions & 0 deletions app/src/components/workflow-editor/EdgeContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { GitBranch, Trash2 } from "lucide-react"

interface EdgeContextMenuProps {
x: number
y: number
edgeId: string
onDelete: (id: string) => void
onAddCondition: (id: string) => void
onClose: () => void
}

export function EdgeContextMenu({
x,
y,
edgeId,
onDelete,
onAddCondition,
onClose,
}: EdgeContextMenuProps) {
const handleDelete = () => {
onDelete(edgeId)
onClose()
}

const handleAddCondition = () => {
onAddCondition(edgeId)
onClose()
}

return (
<>
{/* Backdrop */}
<div className="fixed inset-0 z-40" onClick={onClose} />

{/* Menu */}
<div
className="fixed z-50 min-w-[160px] overflow-hidden rounded-lg border border-neutral-700 bg-neutral-800 shadow-xl"
style={{ top: y, left: x }}
>
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-neutral-200 transition-colors hover:bg-neutral-700"
onClick={handleAddCondition}
>
<GitBranch className="size-3.5 text-orange-400" />
Add condition
</button>
<div className="h-px bg-neutral-700" />
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-red-400 transition-colors hover:bg-red-400/10"
onClick={handleDelete}
>
<Trash2 className="size-3.5" />
Delete edge
</button>
</div>
</>
)
}
90 changes: 90 additions & 0 deletions app/src/components/workflow-editor/InspectorPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type { Node } from "@xyflow/react"
import { Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"

interface InspectorPanelProps {
selectedNode: Node | null
onUpdateNode: (id: string, data: Record<string, unknown>) => void
onDeleteNode: (id: string) => void
className?: string
}

export function InspectorPanel({
selectedNode,
onUpdateNode,
onDeleteNode,
className,
}: InspectorPanelProps) {
if (!selectedNode) {
return (
<div
className={cn(
"flex flex-col items-center justify-center border-neutral-700 border-l bg-neutral-900 p-4 text-center",
className
)}
>
<p className="text-[11px] text-neutral-500">
Select a node to inspect its properties
</p>
</div>
)
}

const data = selectedNode.data as Record<string, unknown>

const handleChange = (key: string, value: string) => {
onUpdateNode(selectedNode.id, { ...data, [key]: value })
}

// Generic field renderers
const fields: Array<{ key: string; label: string }> = Object.keys(data)
.filter((k) => k !== "status")
.map((k) => ({
key: k,
label: k.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase()),
}))

return (
<div
className={cn(
"flex flex-col gap-3 overflow-y-auto border-neutral-700 border-l bg-neutral-900 p-3",
className
)}
>
<div className="flex items-center justify-between">
<p className="text-[10px] font-semibold uppercase tracking-widest text-neutral-500">
Properties
</p>
<Button
variant="ghost"
size="icon-xs"
onClick={() => onDeleteNode(selectedNode.id)}
className="text-red-400 hover:bg-red-400/10 hover:text-red-300"
title="Delete node"
>
<Trash2 />
</Button>
</div>

<div className="rounded-lg border border-neutral-700/60 bg-neutral-800/40 px-2.5 py-1.5">
<p className="text-[10px] text-neutral-500">Type</p>
<p className="text-xs font-medium text-neutral-200 capitalize">
{selectedNode.type}
</p>
</div>

{fields.map(({ key, label }) => (
<div key={key} className="flex flex-col gap-1">
<label className="text-[10px] text-neutral-500">{label}</label>
<Input
value={String(data[key] ?? "")}
onChange={(e) => handleChange(key, e.target.value)}
className="border-neutral-700 bg-neutral-800 text-neutral-100 text-xs"
/>
</div>
))}
</div>
)
}
99 changes: 99 additions & 0 deletions app/src/components/workflow-editor/NodePanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { Bot, GitBranch, Play, Send, Zap } from "lucide-react"
import type { DragEvent } from "react"
import type { WorkflowNodeType } from "@/hooks/use-workflow"
import { cn } from "@/lib/utils"

interface NodeCategory {
type: WorkflowNodeType
label: string
icon: React.ReactNode
description: string
color: string
}

const NODE_CATEGORIES: NodeCategory[] = [
{
type: "trigger",
label: "Trigger",
icon: <Zap className="size-4" />,
description: "Start your workflow",
color: "text-yellow-400 border-yellow-400/30 bg-yellow-400/5",
},
{
type: "action",
label: "Action",
icon: <Play className="size-4" />,
description: "HTTP, script, transform",
color: "text-sky-400 border-sky-400/30 bg-sky-400/5",
},
{
type: "agent",
label: "Agent",
icon: <Bot className="size-4" />,
description: "AI agent invocation",
color: "text-violet-400 border-violet-400/30 bg-violet-400/5",
},
{
type: "condition",
label: "Condition",
icon: <GitBranch className="size-4" />,
description: "Branch on if/else",
color: "text-orange-400 border-orange-400/30 bg-orange-400/5",
},
{
type: "output",
label: "Output",
icon: <Send className="size-4" />,
description: "Email, file, webhook",
color: "text-emerald-400 border-emerald-400/30 bg-emerald-400/5",
},
]

interface NodePanelProps {
className?: string
}

export function NodePanel({ className }: NodePanelProps) {
const onDragStart = (
e: DragEvent<HTMLDivElement>,
type: WorkflowNodeType
) => {
e.dataTransfer.setData("application/workflow-node-type", type)
e.dataTransfer.effectAllowed = "copy"
}

return (
<div
className={cn(
"flex flex-col gap-2 overflow-y-auto border-neutral-700 border-r bg-neutral-900 p-3",
className
)}
>
<p className="mb-1 text-[10px] font-semibold uppercase tracking-widest text-neutral-500">
Node Types
</p>
{NODE_CATEGORIES.map((cat) => (
<div
key={cat.type}
draggable
onDragStart={(e) => onDragStart(e, cat.type)}
className={cn(
"flex cursor-grab items-center gap-2.5 rounded-lg border px-2.5 py-2 transition-colors select-none active:cursor-grabbing hover:brightness-110",
cat.color
)}
>
{cat.icon}
<div className="min-w-0">
<p className="text-xs font-medium leading-none">{cat.label}</p>
<p className="mt-0.5 truncate text-[10px] text-neutral-500">
{cat.description}
</p>
</div>
</div>
))}
<div className="mt-3 rounded-lg border border-neutral-700 border-dashed p-2.5 text-center text-[10px] text-neutral-600">
Drag nodes onto the canvas
</div>
</div>
)
}
Loading