feat(desktop): workflow builder UI with form editor, CRUD, and channel picker (#231)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-03 18:41:31 -07:00
committed by GitHub
parent 26155003bd
commit 5923a7ba1c
21 changed files with 1824 additions and 211 deletions

View File

@@ -50,7 +50,8 @@
"react-markdown": "^10.1.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.5.0"
"tailwind-merge": "^3.5.0",
"yaml": "^2.8.3"
},
"devDependencies": {
"@biomejs/biome": "^2.4.6",

View File

@@ -24,6 +24,7 @@ export default defineConfig({
"**/channel-browser.spec.ts",
"**/messaging.spec.ts",
"**/mentions.spec.ts",
"**/workflows.spec.ts",
],
use: {
...devices["Desktop Chrome"],

47
desktop/pnpm-lock.yaml generated
View File

@@ -89,6 +89,9 @@ importers:
tailwind-merge:
specifier: ^3.5.0
version: 3.5.0
yaml:
specifier: ^2.8.3
version: 2.8.3
devDependencies:
'@biomejs/biome':
specifier: ^2.4.6
@@ -110,7 +113,7 @@ importers:
version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react':
specifier: ^4.6.0
version: 4.7.0(vite@7.3.1(jiti@1.21.7)(yaml@2.8.2))
version: 4.7.0(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3))
autoprefixer:
specifier: ^10.4.27
version: 10.4.27(postcss@8.5.8)
@@ -122,16 +125,16 @@ importers:
version: 8.5.8
tailwindcss:
specifier: ^3.4.17
version: 3.4.19(yaml@2.8.2)
version: 3.4.19(yaml@2.8.3)
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.19(yaml@2.8.2))
version: 1.0.7(tailwindcss@3.4.19(yaml@2.8.3))
typescript:
specifier: ~5.8.3
version: 5.8.3
vite:
specifier: ^7.0.4
version: 7.3.1(jiti@1.21.7)(yaml@2.8.2)
version: 7.3.1(jiti@1.21.7)(yaml@2.8.3)
packages:
@@ -925,6 +928,7 @@ packages:
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
@@ -935,21 +939,25 @@ packages:
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.59.0':
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.59.0':
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.59.0':
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
@@ -960,11 +968,13 @@ packages:
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.59.0':
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
@@ -975,11 +985,13 @@ packages:
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.59.0':
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.59.0':
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
@@ -2013,8 +2025,8 @@ packages:
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yaml@2.8.2:
resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==}
yaml@2.8.3:
resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==}
engines: {node: '>= 14.6'}
hasBin: true
@@ -2909,7 +2921,7 @@ snapshots:
'@ungap/structured-clone@1.3.0': {}
'@vitejs/plugin-react@4.7.0(vite@7.3.1(jiti@1.21.7)(yaml@2.8.2))':
'@vitejs/plugin-react@4.7.0(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3))':
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
@@ -2917,7 +2929,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
vite: 7.3.1(jiti@1.21.7)(yaml@2.8.2)
vite: 7.3.1(jiti@1.21.7)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
@@ -3633,13 +3645,13 @@ snapshots:
camelcase-css: 2.0.1
postcss: 8.5.8
postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.8)(yaml@2.8.2):
postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.8)(yaml@2.8.3):
dependencies:
lilconfig: 3.1.3
optionalDependencies:
jiti: 1.21.7
postcss: 8.5.8
yaml: 2.8.2
yaml: 2.8.3
postcss-nested@6.2.0(postcss@8.5.8):
dependencies:
@@ -3855,11 +3867,11 @@ snapshots:
tailwind-merge@3.5.0: {}
tailwindcss-animate@1.0.7(tailwindcss@3.4.19(yaml@2.8.2)):
tailwindcss-animate@1.0.7(tailwindcss@3.4.19(yaml@2.8.3)):
dependencies:
tailwindcss: 3.4.19(yaml@2.8.2)
tailwindcss: 3.4.19(yaml@2.8.3)
tailwindcss@3.4.19(yaml@2.8.2):
tailwindcss@3.4.19(yaml@2.8.3):
dependencies:
'@alloc/quick-lru': 5.2.0
arg: 5.0.2
@@ -3878,7 +3890,7 @@ snapshots:
postcss: 8.5.8
postcss-import: 15.1.0(postcss@8.5.8)
postcss-js: 4.1.0(postcss@8.5.8)
postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.8)(yaml@2.8.2)
postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.8)(yaml@2.8.3)
postcss-nested: 6.2.0(postcss@8.5.8)
postcss-selector-parser: 6.1.2
resolve: 1.22.11
@@ -3980,7 +3992,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite@7.3.1(jiti@1.21.7)(yaml@2.8.2):
vite@7.3.1(jiti@1.21.7)(yaml@2.8.3):
dependencies:
esbuild: 0.27.3
fdir: 6.5.0(picomatch@4.0.3)
@@ -3991,7 +4003,7 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
jiti: 1.21.7
yaml: 2.8.2
yaml: 2.8.3
warning@4.0.3:
dependencies:
@@ -3999,7 +4011,6 @@ snapshots:
yallist@3.1.1: {}
yaml@2.8.2:
optional: true
yaml@2.8.3: {}
zwitch@2.0.4: {}

View File

@@ -1,4 +1,4 @@
import { Bot, Plus, Settings2, UserRound } from "lucide-react";
import { Bot, Plus, Settings2, UserRound, Zap } from "lucide-react";
import * as React from "react";
import {
@@ -8,6 +8,7 @@ import {
useRelayAgentsQuery,
} from "@/features/agents/hooks";
import { useChannelMembersQuery } from "@/features/channels/hooks";
import { CreateWorkflowDialog } from "@/features/workflows/ui/CreateWorkflowDialog";
import type { Channel } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
@@ -47,6 +48,7 @@ export function ChannelMembersBar({
onManageChannel,
}: ChannelMembersBarProps) {
const [isAddBotOpen, setIsAddBotOpen] = React.useState(false);
const [isCreateWorkflowOpen, setIsCreateWorkflowOpen] = React.useState(false);
const membersQuery = useChannelMembersQuery(channel.id);
const providersQuery = useAcpProvidersQuery();
const backendProvidersQuery = useBackendProvidersQuery();
@@ -111,6 +113,7 @@ export function ChannelMembersBar({
previousChannelIdRef.current = channel.id;
setIsAddBotOpen(false);
setIsCreateWorkflowOpen(false);
}, [channel.id]);
const dialogErrorMessage =
@@ -154,6 +157,21 @@ export function ChannelMembersBar({
<Plus className="h-4 w-4" />
</Button>
<Button
aria-label="Create workflow"
className="h-9 w-9 rounded-full"
data-testid="channel-create-workflow-trigger"
disabled={!canAddAgents}
onClick={() => {
setIsCreateWorkflowOpen(true);
}}
size="icon"
type="button"
variant="outline"
>
<Zap className="h-4 w-4" />
</Button>
<Button
aria-label="Manage channel"
className="h-9 w-9 rounded-full"
@@ -167,6 +185,12 @@ export function ChannelMembersBar({
</Button>
</div>
<CreateWorkflowDialog
channels={[channel]}
onOpenChange={setIsCreateWorkflowOpen}
open={isCreateWorkflowOpen}
/>
<AddChannelBotDialog
backendProviders={backendProvidersQuery.data ?? []}
backendProvidersLoading={backendProvidersQuery.isLoading}

View File

@@ -0,0 +1,162 @@
import { Check, ChevronsUpDown, Search } from "lucide-react";
import * as React from "react";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
function formatChannelLabel(ch: Channel): string {
return `${ch.name} · ${ch.channelType} · ${ch.visibility}`;
}
type ChannelComboboxProps = {
channels: Channel[];
disabled?: boolean;
id?: string;
onChange: (value: string) => void;
value: string;
};
export function ChannelCombobox({
channels,
disabled,
id,
onChange,
value,
}: ChannelComboboxProps) {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const [highlightedIndex, setHighlightedIndex] = React.useState(0);
const selected = channels.find((c) => c.id === value);
const filtered = React.useMemo(() => {
if (!query) return channels;
const q = query.toLowerCase();
return channels.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.channelType?.toLowerCase().includes(q) ||
c.id.toLowerCase().includes(q),
);
}, [channels, query]);
function handleOpenChange(next: boolean) {
setOpen(next);
if (!next) {
setQuery("");
setHighlightedIndex(0);
}
}
function selectChannel(channelId: string) {
onChange(channelId);
handleOpenChange(false);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (filtered.length === 0) return;
switch (e.key) {
case "ArrowDown": {
e.preventDefault();
setHighlightedIndex((i) => (i + 1) % filtered.length);
break;
}
case "ArrowUp": {
e.preventDefault();
setHighlightedIndex((i) => (i - 1 + filtered.length) % filtered.length);
break;
}
case "Enter": {
e.preventDefault();
const target = filtered[highlightedIndex];
if (target) selectChannel(target.id);
break;
}
case "Escape": {
e.preventDefault();
handleOpenChange(false);
break;
}
}
}
return (
<Popover onOpenChange={handleOpenChange} open={open}>
<PopoverTrigger asChild>
<button
aria-expanded={open}
className={cn(
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
!selected && "text-muted-foreground",
)}
disabled={disabled}
id={id}
role="combobox"
type="button"
>
<span className="truncate">
{selected ? formatChannelLabel(selected) : "Select a channel..."}
</span>
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[--radix-popover-trigger-width] p-0"
>
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<input
autoCapitalize="off"
autoComplete="off"
ref={(el) => el?.focus()}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
onChange={(e) => {
setQuery(e.target.value);
setHighlightedIndex(0);
}}
onKeyDown={handleKeyDown}
placeholder="Search channels..."
value={query}
/>
</div>
<div className="max-h-60 overflow-y-auto p-1">
{filtered.length === 0 ? (
<p className="px-3 py-4 text-center text-xs text-muted-foreground">
No channels found.
</p>
) : (
filtered.map((channel, index) => (
<button
className={cn(
"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground",
channel.id === value && "bg-accent/50",
index === highlightedIndex &&
"bg-accent text-accent-foreground",
)}
key={channel.id}
onClick={() => selectChannel(channel.id)}
type="button"
>
<Check
className={cn(
"h-3.5 w-3.5 shrink-0",
channel.id === value ? "opacity-100" : "opacity-0",
)}
/>
<span className="truncate">
{channel.name}{" "}
<span className="text-muted-foreground">
· {channel.channelType} · {channel.visibility}
</span>
</span>
</button>
))
)}
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,17 +1,5 @@
import { ChevronDown } from "lucide-react";
import * as React from "react";
import { useCreateWorkflowMutation } from "@/features/workflows/hooks";
import type { Channel } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Textarea } from "@/shared/ui/textarea";
import { WorkflowDialog } from "./WorkflowDialog";
type CreateWorkflowDialogProps = {
channels: Channel[];
@@ -19,162 +7,17 @@ type CreateWorkflowDialogProps = {
open: boolean;
};
const WORKFLOW_PLACEHOLDER = `name: ui_smoke_delay
trigger:
on: message_posted
steps:
- id: wait_two_seconds
action: delay
duration: 2s`;
export function CreateWorkflowDialog({
channels,
onOpenChange,
open,
}: CreateWorkflowDialogProps) {
const [selectedChannelId, setSelectedChannelId] = React.useState(
channels[0]?.id ?? "",
);
const [yamlDefinition, setYamlDefinition] = React.useState("");
const createMutation = useCreateWorkflowMutation(selectedChannelId);
const selectedChannel =
channels.find((channel) => channel.id === selectedChannelId) ?? null;
const reset = React.useCallback(() => {
setSelectedChannelId(channels[0]?.id ?? "");
setYamlDefinition("");
createMutation.reset();
}, [channels, createMutation]);
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
if (!nextOpen) {
reset();
}
onOpenChange(nextOpen);
},
[onOpenChange, reset],
);
React.useEffect(() => {
if (open && channels.length > 0 && !selectedChannelId) {
setSelectedChannelId(channels[0].id);
}
}, [open, channels, selectedChannelId]);
async function handleCreate() {
if (!selectedChannelId || !yamlDefinition.trim()) return;
try {
await createMutation.mutateAsync(yamlDefinition);
handleOpenChange(false);
} catch {
// React Query stores the error; keep the dialog open and render it inline.
}
}
return (
<Dialog onOpenChange={handleOpenChange} open={open}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Create Workflow</DialogTitle>
<DialogDescription>
Pick a channel, paste YAML, and create a workflow scoped to that
channel.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<label
className="block text-sm font-medium"
htmlFor="wf-channel-select"
>
Channel
</label>
<div className="relative">
<select
className="flex h-11 w-full appearance-none rounded-xl border border-border/70 bg-muted/20 px-3 pr-10 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={channels.length === 0 || createMutation.isPending}
id="wf-channel-select"
onChange={(event) => {
createMutation.reset();
setSelectedChannelId(event.target.value);
}}
value={selectedChannelId}
>
{channels.length === 0 ? (
<option value="">No channels available</option>
) : null}
{channels.map((channel) => (
<option key={channel.id} value={channel.id}>
{channel.name} · {channel.channelType} ·{" "}
{channel.visibility}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
</div>
<p className="text-xs text-muted-foreground">
{selectedChannel
? `New workflows will belong to ${selectedChannel.name}.`
: "Join or create a channel before adding a workflow."}
</p>
</div>
<div className="space-y-1.5">
<label
className="block text-sm font-medium"
htmlFor="wf-yaml-editor"
>
YAML Definition
</label>
<Textarea
className="min-h-[200px] resize-y font-mono text-xs"
id="wf-yaml-editor"
onChange={(event) => {
createMutation.reset();
setYamlDefinition(event.target.value);
}}
placeholder={WORKFLOW_PLACEHOLDER}
value={yamlDefinition}
/>
<p className="text-xs text-muted-foreground">
Use <code>trigger.on</code> with values like{" "}
<code>message_posted</code>, <code>reaction_added</code>,{" "}
<code>webhook</code>, or <code>schedule</code>.
</p>
</div>
</div>
{createMutation.error instanceof Error ? (
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{createMutation.error.message}
</p>
) : null}
<div className="flex justify-end gap-2 pt-4">
<Button
onClick={() => handleOpenChange(false)}
type="button"
variant="outline"
>
Cancel
</Button>
<Button
disabled={
!selectedChannelId ||
!yamlDefinition.trim() ||
createMutation.isPending
}
onClick={handleCreate}
type="button"
>
{createMutation.isPending ? "Creating..." : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
<WorkflowDialog
channels={channels}
mode="create"
onOpenChange={onOpenChange}
open={open}
/>
);
}

View File

@@ -1,4 +1,12 @@
import { Clock, MoreHorizontal, Play, Trash2, Zap } from "lucide-react";
import {
Clock,
Copy,
MoreHorizontal,
Pencil,
Play,
Trash2,
Zap,
} from "lucide-react";
import type { Workflow } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
@@ -14,7 +22,9 @@ type WorkflowCardProps = {
channelName?: string;
onSelect: (workflowId: string) => void;
onTrigger: (workflowId: string) => void;
onDelete: (workflowId: string) => void;
onEdit: (workflow: Workflow) => void;
onDuplicate: (workflow: Workflow) => void;
onDelete: (workflow: Workflow) => void;
};
function StatusBadge({ status }: { status: Workflow["status"] }) {
@@ -38,6 +48,8 @@ export function WorkflowCard({
channelName,
onSelect,
onTrigger,
onEdit,
onDuplicate,
onDelete,
}: WorkflowCardProps) {
return (
@@ -87,9 +99,17 @@ export function WorkflowCard({
<Play className="mr-2 h-4 w-4" />
Trigger
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit(workflow)}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onDuplicate(workflow)}>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => onDelete(workflow.id)}
onClick={() => onDelete(workflow)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete

View File

@@ -0,0 +1,61 @@
import type { Workflow } from "@/shared/api/types";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { Button } from "@/shared/ui/button";
type WorkflowDeleteDialogProps = {
open: boolean;
workflow: Workflow | null;
onConfirm: (workflow: Workflow) => void;
onOpenChange: (open: boolean) => void;
};
export function WorkflowDeleteDialog({
open,
workflow,
onConfirm,
onOpenChange,
}: WorkflowDeleteDialogProps) {
return (
<AlertDialog onOpenChange={onOpenChange} open={open}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete workflow?</AlertDialogTitle>
<AlertDialogDescription>
{workflow
? `Delete "${workflow.name}". This will stop all future triggers and remove the workflow permanently.`
: "Delete this workflow."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
onClick={() => {
if (workflow) {
onConfirm(workflow);
}
}}
type="button"
variant="destructive"
>
Delete
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -1,4 +1,4 @@
import { Play, X } from "lucide-react";
import { Pencil, Play, X } from "lucide-react";
import * as React from "react";
import {
@@ -8,16 +8,19 @@ import {
useWorkflowRunsQuery,
} from "@/features/workflows/hooks";
import { WorkflowRunTrace } from "@/features/workflows/ui/WorkflowRunTrace";
import type { Workflow } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
type WorkflowDetailPanelProps = {
workflowId: string;
onClose: () => void;
onEdit: (workflow: Workflow) => void;
};
export function WorkflowDetailPanel({
workflowId,
onClose,
onEdit,
}: WorkflowDetailPanelProps) {
const workflowQuery = useWorkflowQuery(workflowId);
const runsQuery = useWorkflowRunsQuery(workflowId);
@@ -50,6 +53,16 @@ export function WorkflowDetailPanel({
{workflow?.name ?? "Loading..."}
</h3>
<div className="flex items-center gap-1">
{workflow ? (
<Button
onClick={() => onEdit(workflow)}
size="sm"
variant="outline"
>
<Pencil className="mr-1 h-3 w-3" />
Edit
</Button>
) : null}
<Button
disabled={triggerMutation.isPending}
onClick={() => void handleTrigger()}

View File

@@ -0,0 +1,220 @@
import * as React from "react";
import { stringify as yamlStringify } from "yaml";
import {
useCreateWorkflowMutation,
useUpdateWorkflowMutation,
} from "@/features/workflows/hooks";
import type { Channel, Workflow } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { ChannelCombobox } from "./ChannelCombobox";
import { WorkflowFormBuilder } from "./WorkflowFormBuilder";
import { FieldLabel } from "./workflowFormPrimitives";
type DialogMode = "create" | "edit" | "duplicate";
type WorkflowDialogProps = {
channels: Channel[];
mode: DialogMode;
onOpenChange: (open: boolean) => void;
open: boolean;
workflow?: Workflow | null;
};
function getInitialYaml(
mode: DialogMode,
workflow: Workflow | null | undefined,
): string {
if (!workflow) return "";
const def = { ...workflow.definition };
if (mode === "duplicate") {
def.name = `${def.name ?? workflow.name} (copy)`;
}
return yamlStringify(def);
}
const TITLES: Record<DialogMode, string> = {
create: "Create Workflow",
edit: "Edit Workflow",
duplicate: "Duplicate Workflow",
};
const SUBMIT_LABELS: Record<DialogMode, string> = {
create: "Create",
edit: "Save",
duplicate: "Create Copy",
};
const PENDING_LABELS: Record<DialogMode, string> = {
create: "Creating...",
edit: "Saving...",
duplicate: "Creating...",
};
export function WorkflowDialog({
channels,
mode,
onOpenChange,
open,
workflow,
}: WorkflowDialogProps) {
const channelId =
mode === "edit" && workflow?.channelId
? workflow.channelId
: (channels[0]?.id ?? "");
const [selectedChannelId, setSelectedChannelId] = React.useState(channelId);
const [yamlDefinition, setYamlDefinition] = React.useState(() =>
getInitialYaml(mode, workflow),
);
const createMutation = useCreateWorkflowMutation(selectedChannelId);
const updateMutation = useUpdateWorkflowMutation(workflow?.id ?? "");
const mutation = mode === "edit" ? updateMutation : createMutation;
const selectedChannel =
channels.find((c) => c.id === selectedChannelId) ?? null;
const defaultChannelId = channels[0]?.id ?? "";
const workflowChannelId = workflow?.channelId ?? null;
const resetCreate = createMutation.reset;
const resetUpdate = updateMutation.reset;
// Re-initialize when dialog opens or workflow/mode changes
React.useEffect(() => {
if (open) {
const newChannelId =
mode === "edit" && workflowChannelId
? workflowChannelId
: defaultChannelId;
setSelectedChannelId(newChannelId);
setYamlDefinition(getInitialYaml(mode, workflow));
resetCreate();
resetUpdate();
}
}, [
open,
mode,
workflow,
workflowChannelId,
defaultChannelId,
resetCreate,
resetUpdate,
]);
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
if (!nextOpen) {
resetCreate();
resetUpdate();
}
onOpenChange(nextOpen);
},
[onOpenChange, resetCreate, resetUpdate],
);
async function handleSubmit() {
if (!selectedChannelId || !yamlDefinition.trim()) return;
try {
await mutation.mutateAsync(yamlDefinition);
handleOpenChange(false);
} catch {
// React Query stores the error; keep the dialog open.
}
}
const showChannelSelector = mode !== "edit" && channels.length > 1;
const showChannelInfo = mode !== "edit" && channels.length === 1;
return (
<Dialog onOpenChange={handleOpenChange} open={open}>
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-lg">
<DialogHeader className="flex-shrink-0">
<DialogTitle>{TITLES[mode]}</DialogTitle>
<DialogDescription>
{mode === "edit"
? "Modify the workflow definition."
: channels.length === 1
? "Create a workflow scoped to this channel."
: "Define a workflow and assign it to a channel."}
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto">
{showChannelSelector ? (
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-channel-select">Channel</FieldLabel>
<ChannelCombobox
channels={channels}
disabled={mutation.isPending}
id="wf-channel-select"
onChange={(value) => {
mutation.reset();
setSelectedChannelId(value);
}}
value={selectedChannelId}
/>
<p className="text-xs text-muted-foreground">
{selectedChannel
? `New workflows will belong to ${selectedChannel.name}.`
: "Join or create a channel before adding a workflow."}
</p>
</div>
) : (showChannelInfo || mode === "edit") && selectedChannel ? (
<p className="text-sm text-muted-foreground">
{mode === "edit"
? "Editing workflow in"
: "This workflow will be created in"}{" "}
<span className="font-medium text-foreground">
{selectedChannel.name}
</span>
.
</p>
) : null}
<WorkflowFormBuilder
disabled={mutation.isPending}
onChange={(yaml) => {
mutation.reset();
setYamlDefinition(yaml);
}}
yaml={yamlDefinition}
/>
{mutation.error instanceof Error ? (
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{mutation.error.message}
</p>
) : null}
</div>
<div className="flex flex-shrink-0 justify-end gap-2 border-t border-border pt-4">
<Button
onClick={() => handleOpenChange(false)}
type="button"
variant="outline"
>
Cancel
</Button>
<Button
disabled={
!selectedChannelId || !yamlDefinition.trim() || mutation.isPending
}
onClick={handleSubmit}
type="button"
>
{mutation.isPending ? PENDING_LABELS[mode] : SUBMIT_LABELS[mode]}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,325 @@
import { Code, Plus } from "lucide-react";
import * as React from "react";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { WorkflowStepCard } from "./WorkflowStepCard";
import { FieldLabel, FormSelect } from "./workflowFormPrimitives";
import {
DEFAULT_FORM_STATE,
TRIGGER_LABELS,
TRIGGER_TYPES,
formStateToYaml,
nextStepId,
yamlToFormState,
} from "./workflowFormTypes";
import type {
StepFormState,
TriggerConfig,
TriggerType,
WorkflowFormState,
} from "./workflowFormTypes";
// ---------------------------------------------------------------------------
// Trigger config fields
// ---------------------------------------------------------------------------
function TriggerConfigFields({
trigger,
onUpdate,
}: {
trigger: TriggerConfig;
onUpdate: (trigger: TriggerConfig) => void;
}) {
switch (trigger.on) {
case "message_posted":
return (
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-trigger-filter">
Filter expression (optional)
</FieldLabel>
<Input
autoCapitalize="off"
id="wf-trigger-filter"
onChange={(event) =>
onUpdate({ ...trigger, filter: event.target.value })
}
placeholder='e.g. contains(text, "deploy")'
value={trigger.filter ?? ""}
/>
<p className="text-xs text-muted-foreground">
Evalexpr filter — leave empty to trigger on all messages.
</p>
</div>
);
case "reaction_added":
return (
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-trigger-emoji">
Emoji filter (optional)
</FieldLabel>
<Input
autoCapitalize="off"
id="wf-trigger-emoji"
onChange={(event) =>
onUpdate({ ...trigger, emoji: event.target.value })
}
placeholder="e.g. thumbsup"
value={trigger.emoji ?? ""}
/>
<p className="text-xs text-muted-foreground">
Leave empty to trigger on any reaction.
</p>
</div>
);
case "webhook":
return (
<p className="text-xs text-muted-foreground">
A unique webhook URL will be generated when the workflow is created.
</p>
);
case "schedule":
return (
<div className="space-y-3">
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-trigger-cron">
Cron expression (optional)
</FieldLabel>
<Input
autoCapitalize="off"
id="wf-trigger-cron"
onChange={(event) =>
onUpdate({ ...trigger, cron: event.target.value })
}
placeholder="e.g. 0 9 * * 1-5 (weekdays at 9am UTC)"
value={trigger.cron ?? ""}
/>
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-trigger-interval">
Interval (optional)
</FieldLabel>
<Input
autoCapitalize="off"
id="wf-trigger-interval"
onChange={(event) =>
onUpdate({ ...trigger, interval: event.target.value })
}
placeholder="e.g. 1h, 30m"
value={trigger.interval ?? ""}
/>
</div>
<p className="text-xs text-muted-foreground">
Provide either a cron expression or a simple interval.
</p>
</div>
);
default:
return null;
}
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
type WorkflowFormBuilderProps = {
disabled?: boolean;
onChange: (yaml: string) => void;
yaml: string;
};
export function WorkflowFormBuilder({
disabled,
onChange,
yaml,
}: WorkflowFormBuilderProps) {
// Parse once on mount instead of calling yamlToFormState three times
const initialParseRef = React.useRef(yaml ? yamlToFormState(yaml) : null);
const [mode, setMode] = React.useState<"form" | "yaml">(
initialParseRef.current === null || initialParseRef.current.ok
? "form"
: "yaml",
);
const [formState, setFormState] = React.useState<WorkflowFormState>(
initialParseRef.current?.ok
? initialParseRef.current.state
: DEFAULT_FORM_STATE,
);
const [parseError, setParseError] = React.useState<string | null>(
initialParseRef.current !== null && !initialParseRef.current.ok
? initialParseRef.current.error
: null,
);
const updateFormState = React.useCallback(
(next: WorkflowFormState) => {
setFormState(next);
onChange(formStateToYaml(next));
},
[onChange],
);
const handleToggleMode = React.useCallback(() => {
if (mode === "form") {
setMode("yaml");
setParseError(null);
} else {
const result = yamlToFormState(yaml);
if (result.ok) {
setFormState(result.state);
setParseError(null);
setMode("form");
} else {
setParseError(result.error);
}
}
}, [mode, yaml]);
const addStep = React.useCallback(() => {
updateFormState({
...formState,
steps: [
...formState.steps,
{ id: nextStepId(formState.steps), action: "delay" },
],
});
}, [formState, updateFormState]);
const removeStep = React.useCallback(
(index: number) => {
updateFormState({
...formState,
steps: formState.steps.filter((_, i) => i !== index),
});
},
[formState, updateFormState],
);
const updateStep = React.useCallback(
(index: number, step: StepFormState) => {
const next = [...formState.steps];
next[index] = step;
updateFormState({ ...formState, steps: next });
},
[formState, updateFormState],
);
return (
<div className="space-y-4">
<div className="flex items-center justify-end">
<Button
className="h-7 gap-1.5 text-xs"
disabled={disabled}
onClick={handleToggleMode}
size="sm"
type="button"
variant="ghost"
>
<Code className="h-3.5 w-3.5" />
{mode === "form" ? "Edit as YAML" : "Back to form"}
</Button>
</div>
{parseError ? (
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
Cannot switch to form view: {parseError}
</p>
) : null}
{mode === "yaml" ? (
<div className="space-y-1.5">
<Textarea
className="min-h-[240px] resize-y font-mono text-xs"
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
value={yaml}
/>
<p className="text-xs text-muted-foreground">
Edit the raw YAML definition directly.
</p>
</div>
) : (
<div className="space-y-4">
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-name">Workflow name</FieldLabel>
<Input
autoCapitalize="off"
autoCorrect="off"
disabled={disabled}
id="wf-name"
onChange={(event) =>
updateFormState({ ...formState, name: event.target.value })
}
placeholder="e.g. deploy_notifier"
value={formState.name}
/>
</div>
<div className="space-y-3">
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-trigger-type">Trigger</FieldLabel>
<FormSelect
disabled={disabled}
id="wf-trigger-type"
onChange={(value) =>
updateFormState({
...formState,
trigger: { on: value as TriggerType },
})
}
value={formState.trigger.on}
>
{TRIGGER_TYPES.map((type) => (
<option key={type} value={type}>
{TRIGGER_LABELS[type]}
</option>
))}
</FormSelect>
</div>
<TriggerConfigFields
onUpdate={(trigger) => updateFormState({ ...formState, trigger })}
trigger={formState.trigger}
/>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<FieldLabel>Steps</FieldLabel>
<Button
className="h-7 gap-1.5 text-xs"
disabled={disabled}
onClick={addStep}
size="sm"
type="button"
variant="outline"
>
<Plus className="h-3.5 w-3.5" />
Add step
</Button>
</div>
{formState.steps.length === 0 ? (
<p className="py-4 text-center text-xs text-muted-foreground">
No steps yet — add one to get started.
</p>
) : (
<div className="space-y-2">
{formState.steps.map((step, index) => (
<WorkflowStepCard
index={index}
key={step.id}
onRemove={() => removeStep(index)}
onUpdate={(updated) => updateStep(index, updated)}
step={step}
/>
))}
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,295 @@
import { Trash2 } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { FieldLabel, FormSelect } from "./workflowFormPrimitives";
import { ACTION_LABELS, ACTION_TYPES } from "./workflowFormTypes";
import type { ActionType, StepFormState } from "./workflowFormTypes";
// ---------------------------------------------------------------------------
// Step action config fields
// ---------------------------------------------------------------------------
function StepConfigFields({
step,
prefix,
onUpdate,
}: {
step: StepFormState;
prefix: string;
onUpdate: (step: StepFormState) => void;
}) {
switch (step.action) {
case "delay":
return (
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-duration`}>Duration</FieldLabel>
<Input
autoCapitalize="off"
id={`${prefix}-duration`}
onChange={(event) =>
onUpdate({ ...step, duration: event.target.value })
}
placeholder="e.g. 5s, 1m, 1h"
value={step.duration ?? ""}
/>
</div>
);
case "send_message":
return (
<div className="space-y-2">
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-text`}>Message text</FieldLabel>
<Textarea
className="min-h-[60px] resize-y text-xs"
id={`${prefix}-text`}
onChange={(event) =>
onUpdate({ ...step, text: event.target.value })
}
placeholder="e.g. Deployment started by {{trigger.author}}"
value={step.text ?? ""}
/>
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-channel`}>
Channel override (optional)
</FieldLabel>
<Input
autoCapitalize="off"
id={`${prefix}-channel`}
onChange={(event) =>
onUpdate({ ...step, channel: event.target.value })
}
placeholder="Channel UUID — leave empty for trigger channel"
value={step.channel ?? ""}
/>
</div>
</div>
);
case "send_dm":
return (
<div className="space-y-2">
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-to`}>To (pubkey)</FieldLabel>
<Input
autoCapitalize="off"
id={`${prefix}-to`}
onChange={(event) =>
onUpdate({ ...step, to: event.target.value })
}
placeholder="e.g. {{trigger.author}} or hex pubkey"
value={step.to ?? ""}
/>
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-text`}>Message text</FieldLabel>
<Textarea
className="min-h-[60px] resize-y text-xs"
id={`${prefix}-text`}
onChange={(event) =>
onUpdate({ ...step, text: event.target.value })
}
placeholder="DM content"
value={step.text ?? ""}
/>
</div>
</div>
);
case "call_webhook":
return (
<div className="space-y-2">
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-url`}>URL</FieldLabel>
<Input
autoCapitalize="off"
id={`${prefix}-url`}
onChange={(event) =>
onUpdate({ ...step, url: event.target.value })
}
placeholder="https://..."
value={step.url ?? ""}
/>
{step.url && !step.url.startsWith("https://") ? (
<p className="text-xs text-destructive">
URL must start with https://
</p>
) : null}
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-method`}>
Method (optional)
</FieldLabel>
<FormSelect
id={`${prefix}-method`}
onChange={(value) => onUpdate({ ...step, method: value })}
value={step.method ?? "POST"}
>
<option value="POST">POST</option>
<option value="GET">GET</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
<option value="DELETE">DELETE</option>
</FormSelect>
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-body`}>Body (optional)</FieldLabel>
<Textarea
className="min-h-[60px] resize-y font-mono text-xs"
id={`${prefix}-body`}
onChange={(event) =>
onUpdate({ ...step, body: event.target.value })
}
placeholder='{"key": "{{trigger.text}}"}'
value={step.body ?? ""}
/>
</div>
</div>
);
case "request_approval":
return (
<div className="space-y-2">
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-from`}>From (approver)</FieldLabel>
<Input
autoCapitalize="off"
id={`${prefix}-from`}
onChange={(event) =>
onUpdate({ ...step, from: event.target.value })
}
placeholder="Pubkey or role"
value={step.from ?? ""}
/>
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-message`}>Message</FieldLabel>
<Input
id={`${prefix}-message`}
onChange={(event) =>
onUpdate({ ...step, message: event.target.value })
}
placeholder="Approval request message"
value={step.message ?? ""}
/>
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-timeout`}>
Timeout (optional)
</FieldLabel>
<Input
id={`${prefix}-timeout`}
onChange={(event) =>
onUpdate({ ...step, timeout: event.target.value })
}
placeholder="e.g. 24h"
value={step.timeout ?? ""}
/>
</div>
</div>
);
case "add_reaction":
return (
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-emoji`}>Emoji</FieldLabel>
<Input
autoCapitalize="off"
id={`${prefix}-emoji`}
onChange={(event) =>
onUpdate({ ...step, emoji: event.target.value })
}
placeholder="e.g. thumbsup"
value={step.emoji ?? ""}
/>
</div>
);
case "set_channel_topic":
return (
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-topic`}>Topic</FieldLabel>
<Input
id={`${prefix}-topic`}
onChange={(event) =>
onUpdate({ ...step, topic: event.target.value })
}
placeholder="New channel topic"
value={step.topic ?? ""}
/>
</div>
);
default:
return null;
}
}
// ---------------------------------------------------------------------------
// Step card
// ---------------------------------------------------------------------------
export function WorkflowStepCard({
index,
onRemove,
onUpdate,
step,
}: {
index: number;
onRemove: () => void;
onUpdate: (step: StepFormState) => void;
step: StepFormState;
}) {
const prefix = `wf-step-${index}`;
return (
<div className="space-y-3 rounded-lg border border-border/70 bg-muted/10 p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-muted-foreground">
Step {index + 1}
</span>
<Button
aria-label="Remove step"
className="h-7 w-7"
onClick={onRemove}
size="icon"
type="button"
variant="ghost"
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-id`}>Step ID</FieldLabel>
<Input
autoCapitalize="off"
id={`${prefix}-id`}
onChange={(event) => onUpdate({ ...step, id: event.target.value })}
placeholder="unique_step_id"
value={step.id}
/>
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-action`}>Action</FieldLabel>
<FormSelect
id={`${prefix}-action`}
onChange={(value) => {
const next = { ...step, action: value as ActionType };
if (value === "call_webhook" && !next.method) {
next.method = "POST";
}
onUpdate(next);
}}
value={step.action}
>
{ACTION_TYPES.map((action) => (
<option key={action} value={action}>
{ACTION_LABELS[action]}
</option>
))}
</FormSelect>
</div>
</div>
<StepConfigFields onUpdate={onUpdate} prefix={prefix} step={step} />
</div>
);
}

View File

@@ -3,9 +3,10 @@ import * as React from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { allWorkflowsQueryKey } from "@/features/workflows/hooks";
import { CreateWorkflowDialog } from "@/features/workflows/ui/CreateWorkflowDialog";
import { WorkflowCard } from "@/features/workflows/ui/WorkflowCard";
import { WorkflowDeleteDialog } from "@/features/workflows/ui/WorkflowDeleteDialog";
import { WorkflowDetailPanel } from "@/features/workflows/ui/WorkflowDetailPanel";
import { WorkflowDialog } from "@/features/workflows/ui/WorkflowDialog";
import type { Channel, Workflow } from "@/shared/api/types";
import {
deleteWorkflow,
@@ -23,8 +24,17 @@ type WorkflowWithChannel = {
channelName: string;
};
type DialogState =
| { mode: "closed" }
| { mode: "create" }
| { mode: "edit"; workflow: Workflow }
| { mode: "duplicate"; workflow: Workflow };
export function WorkflowsView({ channels }: WorkflowsViewProps) {
const [isCreateOpen, setIsCreateOpen] = React.useState(false);
const [dialogState, setDialogState] = React.useState<DialogState>({
mode: "closed",
});
const [deleteTarget, setDeleteTarget] = React.useState<Workflow | null>(null);
const [selectedWorkflowId, setSelectedWorkflowId] = React.useState<
string | null
>(null);
@@ -78,16 +88,42 @@ export function WorkflowsView({ channels }: WorkflowsViewProps) {
},
});
const triggerOne = triggerMutation.mutate;
const handleTrigger = React.useCallback(
(workflowId: string) => triggerMutation.mutate(workflowId),
[triggerMutation],
(workflowId: string) => triggerOne(workflowId),
[triggerOne],
);
const handleDelete = React.useCallback(
(workflowId: string) => deleteMutation.mutate(workflowId),
[deleteMutation],
(workflow: Workflow) => setDeleteTarget(workflow),
[],
);
const deleteOne = deleteMutation.mutate;
const handleConfirmDelete = React.useCallback(
(workflow: Workflow) => {
deleteOne(workflow.id);
setDeleteTarget(null);
},
[deleteOne],
);
const handleEdit = React.useCallback(
(workflow: Workflow) => setDialogState({ mode: "edit", workflow }),
[],
);
const handleDuplicate = React.useCallback(
(workflow: Workflow) => setDialogState({ mode: "duplicate", workflow }),
[],
);
const handleDialogOpenChange = React.useCallback((open: boolean) => {
if (!open) {
setDialogState({ mode: "closed" });
}
}, []);
return (
<div
className="flex min-h-0 flex-1 overflow-hidden"
@@ -109,7 +145,7 @@ export function WorkflowsView({ channels }: WorkflowsViewProps) {
/>
</Button>
</div>
<Button onClick={() => setIsCreateOpen(true)} size="sm">
<Button onClick={() => setDialogState({ mode: "create" })} size="sm">
<Plus className="mr-1 h-4 w-4" />
Create Workflow
</Button>
@@ -137,7 +173,7 @@ export function WorkflowsView({ channels }: WorkflowsViewProps) {
<Zap className="h-10 w-10 opacity-30" />
<p className="text-sm">No workflows yet</p>
<Button
onClick={() => setIsCreateOpen(true)}
onClick={() => setDialogState({ mode: "create" })}
size="sm"
variant="outline"
>
@@ -152,6 +188,8 @@ export function WorkflowsView({ channels }: WorkflowsViewProps) {
channelName={channelName}
key={workflow.id}
onDelete={handleDelete}
onDuplicate={handleDuplicate}
onEdit={handleEdit}
onSelect={setSelectedWorkflowId}
onTrigger={handleTrigger}
workflow={workflow}
@@ -166,15 +204,31 @@ export function WorkflowsView({ channels }: WorkflowsViewProps) {
<WorkflowDetailPanel
key={selectedWorkflowId}
onClose={() => setSelectedWorkflowId(null)}
onEdit={handleEdit}
workflowId={selectedWorkflowId}
/>
</div>
) : null}
<CreateWorkflowDialog
<WorkflowDialog
channels={memberChannels}
onOpenChange={setIsCreateOpen}
open={isCreateOpen}
mode={dialogState.mode === "closed" ? "create" : dialogState.mode}
onOpenChange={handleDialogOpenChange}
open={dialogState.mode !== "closed"}
workflow={
dialogState.mode === "edit" || dialogState.mode === "duplicate"
? dialogState.workflow
: null
}
/>
<WorkflowDeleteDialog
onConfirm={handleConfirmDelete}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
open={deleteTarget !== null}
workflow={deleteTarget}
/>
</div>
);

View File

@@ -0,0 +1,48 @@
import { ChevronDown } from "lucide-react";
import type * as React from "react";
export function FormSelect({
children,
disabled,
id,
onChange,
value,
}: {
children: React.ReactNode;
disabled?: boolean;
id?: string;
onChange: (value: string) => void;
value: string;
}) {
return (
<div className="relative">
<select
className="flex h-9 w-full appearance-none rounded-md border border-input bg-transparent px-3 pr-8 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={disabled}
id={id}
onChange={(event) => onChange(event.target.value)}
value={value}
>
{children}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
</div>
);
}
export function FieldLabel({
children,
htmlFor,
}: {
children: React.ReactNode;
htmlFor?: string;
}) {
return (
<label
className="block text-xs font-medium text-muted-foreground"
htmlFor={htmlFor}
>
{children}
</label>
);
}

View File

@@ -0,0 +1,221 @@
import { stringify as yamlStringify, parse as yamlParse } from "yaml";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export const TRIGGER_TYPES = [
"message_posted",
"reaction_added",
"webhook",
"schedule",
] as const;
export type TriggerType = (typeof TRIGGER_TYPES)[number];
export const ACTION_TYPES = [
"delay",
"send_message",
"send_dm",
"call_webhook",
"request_approval",
"add_reaction",
"set_channel_topic",
] as const;
export type ActionType = (typeof ACTION_TYPES)[number];
export type TriggerConfig = {
on: TriggerType;
filter?: string;
emoji?: string;
cron?: string;
interval?: string;
};
export type StepFormState = {
id: string;
action: ActionType;
duration?: string;
text?: string;
channel?: string;
to?: string;
url?: string;
method?: string;
body?: string;
emoji?: string;
topic?: string;
from?: string;
message?: string;
timeout?: string;
};
export type WorkflowFormState = {
name: string;
trigger: TriggerConfig;
steps: StepFormState[];
};
export const DEFAULT_FORM_STATE: WorkflowFormState = {
name: "",
trigger: { on: "message_posted" },
steps: [],
};
export const TRIGGER_LABELS: Record<TriggerType, string> = {
message_posted: "Message Posted",
reaction_added: "Reaction Added",
webhook: "Webhook",
schedule: "Schedule",
};
export const ACTION_LABELS: Record<ActionType, string> = {
delay: "Delay",
send_message: "Send Message",
send_dm: "Send DM",
call_webhook: "Call Webhook",
request_approval: "Request Approval",
add_reaction: "Add Reaction",
set_channel_topic: "Set Channel Topic",
};
// ---------------------------------------------------------------------------
// Serialization helpers
// ---------------------------------------------------------------------------
function actionFieldsForStep(step: StepFormState): Record<string, unknown> {
const fields: Record<string, unknown> = {};
switch (step.action) {
case "delay":
if (step.duration) fields.duration = step.duration;
break;
case "send_message":
if (step.text) fields.text = step.text;
if (step.channel) fields.channel = step.channel;
break;
case "send_dm":
if (step.to) fields.to = step.to;
if (step.text) fields.text = step.text;
break;
case "call_webhook":
if (step.url) fields.url = step.url;
fields.method = step.method || "POST";
if (step.body) fields.body = step.body;
break;
case "request_approval":
if (step.from) fields.from = step.from;
if (step.message) fields.message = step.message;
if (step.timeout) fields.timeout = step.timeout;
break;
case "add_reaction":
if (step.emoji) fields.emoji = step.emoji;
break;
case "set_channel_topic":
if (step.topic) fields.topic = step.topic;
break;
}
return fields;
}
export function formStateToYaml(state: WorkflowFormState): string {
const trigger: Record<string, unknown> = { on: state.trigger.on };
if (state.trigger.on === "message_posted" && state.trigger.filter) {
trigger.filter = state.trigger.filter;
}
if (state.trigger.on === "reaction_added" && state.trigger.emoji) {
trigger.emoji = state.trigger.emoji;
}
if (state.trigger.on === "schedule") {
if (state.trigger.cron) trigger.cron = state.trigger.cron;
if (state.trigger.interval) trigger.interval = state.trigger.interval;
}
const steps = state.steps.map((step) => ({
id: step.id,
action: step.action,
...actionFieldsForStep(step),
}));
return yamlStringify({ name: state.name, trigger, steps });
}
const STEP_ID_PATTERN = /^step_(\d+)$/;
export function nextStepId(existingSteps: StepFormState[]): string {
const existingIds = new Set(existingSteps.map((s) => s.id));
let maxN = 0;
for (const id of existingIds) {
const match = STEP_ID_PATTERN.exec(id);
if (match) maxN = Math.max(maxN, Number(match[1]));
}
let n = maxN + 1;
while (existingIds.has(`step_${n}`)) n++;
return `step_${n}`;
}
export function yamlToFormState(
yaml: string,
): { ok: true; state: WorkflowFormState } | { ok: false; error: string } {
try {
const parsed = yamlParse(yaml);
if (!parsed || typeof parsed !== "object") {
return { ok: false, error: "YAML must be an object" };
}
const triggerOn = parsed.trigger?.on;
if (triggerOn && !TRIGGER_TYPES.includes(triggerOn as TriggerType)) {
return {
ok: false,
error: `Unsupported trigger type "${triggerOn}" — use the YAML editor`,
};
}
const trigger: TriggerConfig = {
on: (triggerOn as TriggerType) ?? TRIGGER_TYPES[0],
filter: parsed.trigger?.filter ?? undefined,
emoji: parsed.trigger?.emoji ?? undefined,
cron: parsed.trigger?.cron ?? undefined,
interval: parsed.trigger?.interval ?? undefined,
};
const rawSteps = parsed.steps ?? [];
if (!Array.isArray(rawSteps)) {
return { ok: false, error: "steps must be a list" };
}
for (const step of rawSteps) {
if (step.action && !ACTION_TYPES.includes(step.action as ActionType)) {
return {
ok: false,
error: `Unsupported action type "${step.action}" — use the YAML editor`,
};
}
}
const steps: StepFormState[] = rawSteps.map(
(step: Record<string, unknown>, index: number) => ({
id: (step.id as string) ?? `step_${index + 1}`,
action: (step.action as ActionType) ?? ACTION_TYPES[0],
duration: step.duration as string | undefined,
text: step.text as string | undefined,
channel: step.channel as string | undefined,
to: step.to as string | undefined,
url: step.url as string | undefined,
method: step.method as string | undefined,
body: step.body as string | undefined,
emoji: step.emoji as string | undefined,
topic: step.topic as string | undefined,
from: step.from as string | undefined,
message: step.message as string | undefined,
timeout: step.timeout as string | undefined,
}),
);
return {
ok: true,
state: { name: (parsed.name as string) ?? "", trigger, steps },
};
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : "Invalid YAML",
};
}
}

View File

@@ -999,6 +999,95 @@ let mockRelayAgents: RawRelayAgent[] = [
status: "away",
},
];
// ── Workflow mocks ─────────────────────────────────────────────────────────
type MockWorkflow = {
id: string;
name: string;
owner_pubkey: string;
channel_id: string | null;
definition: Record<string, unknown>;
status: "active" | "disabled" | "archived";
created_at: number;
updated_at: number;
};
const mockWorkflows: MockWorkflow[] = [];
let mockWorkflowIdCounter = 0;
function handleGetChannelWorkflows(args: { channelId: string }) {
return mockWorkflows.filter((w) => w.channel_id === args.channelId);
}
function handleGetWorkflow(args: { workflowId: string }) {
const workflow = mockWorkflows.find((w) => w.id === args.workflowId);
if (!workflow) throw new Error(`Workflow ${args.workflowId} not found`);
return workflow;
}
function handleCreateWorkflow(args: {
channelId: string;
yamlDefinition: string;
}) {
mockWorkflowIdCounter += 1;
const now = Math.floor(Date.now() / 1000);
// Parse the name from the YAML definition (simple extraction)
const nameMatch = args.yamlDefinition.match(/^name:\s*(.+)$/m);
const name = nameMatch
? nameMatch[1].trim()
: `workflow_${mockWorkflowIdCounter}`;
const workflow: MockWorkflow = {
id: `mock-wf-${mockWorkflowIdCounter}`,
name,
owner_pubkey: MOCK_IDENTITY_PUBKEY,
channel_id: args.channelId,
definition: { raw: args.yamlDefinition },
status: "active",
created_at: now,
updated_at: now,
};
mockWorkflows.push(workflow);
return workflow;
}
function handleUpdateWorkflow(args: {
workflowId: string;
yamlDefinition: string;
}) {
const workflow = mockWorkflows.find((w) => w.id === args.workflowId);
if (!workflow) throw new Error(`Workflow ${args.workflowId} not found`);
const nameMatch = args.yamlDefinition.match(/^name:\s*(.+)$/m);
if (nameMatch) workflow.name = nameMatch[1].trim();
workflow.definition = { raw: args.yamlDefinition };
workflow.updated_at = Math.floor(Date.now() / 1000);
return workflow;
}
function handleDeleteWorkflow(args: { workflowId: string }) {
const index = mockWorkflows.findIndex((w) => w.id === args.workflowId);
if (index === -1) throw new Error(`Workflow ${args.workflowId} not found`);
mockWorkflows.splice(index, 1);
}
function handleTriggerWorkflow(args: { workflowId: string }) {
const workflow = mockWorkflows.find((w) => w.id === args.workflowId);
if (!workflow) throw new Error(`Workflow ${args.workflowId} not found`);
return {
run_id: `mock-run-${Date.now()}`,
workflow_id: workflow.id,
status: "pending",
};
}
function handleGetWorkflowRuns(_args: { workflowId: string }) {
return [];
}
function handleGetRunApprovals(_args: { workflowId: string; runId: string }) {
return [];
}
const mockProfiles = new Map<string, RawProfile>([
[
MOCK_IDENTITY_PUBKEY,
@@ -3552,6 +3641,38 @@ export function maybeInstallE2eTauriMocks() {
}
return disconnectMockSocket((payload as { id: number }).id);
case "get_channel_workflows":
return handleGetChannelWorkflows(
payload as Parameters<typeof handleGetChannelWorkflows>[0],
);
case "get_workflow":
return handleGetWorkflow(
payload as Parameters<typeof handleGetWorkflow>[0],
);
case "create_workflow":
return handleCreateWorkflow(
payload as Parameters<typeof handleCreateWorkflow>[0],
);
case "update_workflow":
return handleUpdateWorkflow(
payload as Parameters<typeof handleUpdateWorkflow>[0],
);
case "delete_workflow":
return handleDeleteWorkflow(
payload as Parameters<typeof handleDeleteWorkflow>[0],
);
case "trigger_workflow":
return handleTriggerWorkflow(
payload as Parameters<typeof handleTriggerWorkflow>[0],
);
case "get_workflow_runs":
return handleGetWorkflowRuns(
payload as Parameters<typeof handleGetWorkflowRuns>[0],
);
case "get_run_approvals":
return handleGetRunApprovals(
payload as Parameters<typeof handleGetRunApprovals>[0],
);
case "plugin:webview|set_webview_zoom":
window.__SPROUT_E2E_WEBVIEW_ZOOM__ = (
payload as { value: number }

View File

@@ -102,7 +102,10 @@ test("create stream with name and description", async ({ page }) => {
await page
.getByTestId("create-stream-description")
.fill("A stream for testing channel creation");
await page.getByRole("button", { name: "Create" }).click();
await page
.getByTestId("create-stream-form")
.getByRole("button", { name: "Create" })
.click();
await expect(page.getByTestId("stream-list")).toContainText(channelName);
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
@@ -117,7 +120,10 @@ test("create stream with special characters", async ({ page }) => {
await page
.getByTestId("create-stream-description")
.fill("Stream with spaces and hyphens");
await page.getByRole("button", { name: "Create" }).click();
await page
.getByTestId("create-stream-form")
.getByRole("button", { name: "Create" })
.click();
await expect(page.getByTestId("stream-list")).toContainText(channelName);
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
@@ -456,7 +462,10 @@ test("manage channel can delete an owned stream", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Create a stream" }).click();
await page.getByTestId("create-stream-name").fill(channelName);
await page.getByRole("button", { name: "Create" }).click();
await page
.getByTestId("create-stream-form")
.getByRole("button", { name: "Create" })
.click();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
await page.getByTestId("channel-management-trigger").click();
@@ -477,7 +486,10 @@ test("canceling channel deletion keeps the owned stream", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Create a stream" }).click();
await page.getByTestId("create-stream-name").fill(channelName);
await page.getByRole("button", { name: "Create" }).click();
await page
.getByTestId("create-stream-form")
.getByRole("button", { name: "Create" })
.click();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
await page.getByTestId("channel-management-trigger").click();

View File

@@ -13,7 +13,10 @@ async function createStream(
if (description !== undefined) {
await page.getByTestId("create-stream-description").fill(description);
}
await page.getByRole("button", { name: "Create" }).click();
await page
.getByTestId("create-stream-form")
.getByRole("button", { name: "Create" })
.click();
await expect(page.getByTestId("stream-list")).toContainText(channelName);
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
@@ -40,7 +43,10 @@ test("create channel and verify in sidebar", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Create a stream" }).click();
await page.getByTestId("create-stream-name").fill(channelName);
await page.getByRole("button", { name: "Create" }).click();
await page
.getByTestId("create-stream-form")
.getByRole("button", { name: "Create" })
.click();
await expect(page.getByTestId("stream-list")).toContainText(channelName);
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
@@ -64,7 +70,10 @@ test("two users see the same channel", async ({
await pageOne.goto("/");
await pageOne.getByRole("button", { name: "Create a stream" }).click();
await pageOne.getByTestId("create-stream-name").fill(channelName);
await pageOne.getByRole("button", { name: "Create" }).click();
await pageOne
.getByTestId("create-stream-form")
.getByRole("button", { name: "Create" })
.click();
await expect(pageOne.getByTestId("stream-list")).toContainText(channelName);
await pageTwo.goto("/");
@@ -166,13 +175,19 @@ test("multiple channels independent", async ({ page }) => {
// Create channel A
await page.getByRole("button", { name: "Create a stream" }).click();
await page.getByTestId("create-stream-name").fill(channelA);
await page.getByRole("button", { name: "Create" }).click();
await page
.getByTestId("create-stream-form")
.getByRole("button", { name: "Create" })
.click();
await expect(page.getByTestId("chat-title")).toHaveText(channelA);
// Create channel B
await page.getByRole("button", { name: "Create a stream" }).click();
await page.getByTestId("create-stream-name").fill(channelB);
await page.getByRole("button", { name: "Create" }).click();
await page
.getByTestId("create-stream-form")
.getByRole("button", { name: "Create" })
.click();
await expect(page.getByTestId("chat-title")).toHaveText(channelB);
// Navigate to channel A and send a message

View File

@@ -62,7 +62,10 @@ test("creates a new mocked stream", async ({ page }) => {
await page
.getByTestId("create-stream-description")
.fill("Release coordination");
await page.getByRole("button", { name: "Create" }).click();
await page
.getByTestId("create-stream-form")
.getByRole("button", { name: "Create" })
.click();
await expect(page.getByTestId("stream-list")).toContainText(channelName);
await expect(page.getByTestId("chat-title")).toHaveText(channelName);

View File

@@ -52,7 +52,10 @@ async function createAndJoinSharedStream(
) {
await ownerPage.getByRole("button", { name: "Create a stream" }).click();
await ownerPage.getByTestId("create-stream-name").fill(channelName);
await ownerPage.getByRole("button", { name: "Create" }).click();
await ownerPage
.getByTestId("create-stream-form")
.getByRole("button", { name: "Create" })
.click();
await expect(ownerPage.getByTestId("stream-list")).toContainText(channelName);
await expect(ownerPage.getByTestId("chat-title")).toHaveText(channelName);
@@ -121,7 +124,10 @@ test("creates a relay-backed stream", async ({ page }) => {
await page
.getByTestId("create-stream-description")
.fill("Created from Playwright");
await page.getByRole("button", { name: "Create" }).click();
await page
.getByTestId("create-stream-form")
.getByRole("button", { name: "Create" })
.click();
await expect(page.getByTestId("stream-list")).toContainText(channelName);
await expect(page.getByTestId("chat-title")).toHaveText(channelName);

View File

@@ -0,0 +1,157 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
async function navigateToWorkflows(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByTestId("open-workflows-view").click();
await expect(page.getByTestId("workflows-view")).toBeVisible();
}
async function createWorkflow(
page: import("@playwright/test").Page,
name: string,
) {
await page.getByRole("button", { name: "Create Workflow" }).click();
await expect(page.getByRole("dialog")).toBeVisible();
// Fill the form — name field
await page.getByLabel("Workflow name").fill(name);
// Add a step
await page.getByRole("button", { name: "Add step" }).click();
// Submit
await page
.getByRole("dialog")
.getByRole("button", { name: "Create" })
.click();
// Wait for dialog to close
await expect(page.getByRole("dialog")).not.toBeVisible();
}
test("navigates to workflows view and shows empty state", async ({ page }) => {
await navigateToWorkflows(page);
await expect(page.getByText("No workflows yet")).toBeVisible();
await expect(
page.getByRole("button", { name: "Create your first workflow" }),
).toBeVisible();
});
test("creates a workflow via the form builder", async ({ page }) => {
const workflowName = `test_workflow_${Date.now()}`;
await navigateToWorkflows(page);
await createWorkflow(page, workflowName);
// Verify workflow appears in the list
await expect(page.getByTestId("workflows-view")).toContainText(workflowName);
});
test("edits an existing workflow", async ({ page }) => {
const originalName = `edit_test_${Date.now()}`;
const updatedName = `${originalName}_updated`;
await navigateToWorkflows(page);
await createWorkflow(page, originalName);
// Verify it exists
await expect(page.getByTestId("workflows-view")).toContainText(originalName);
// Open the dropdown menu and click Edit
await page.getByRole("button", { name: "Workflow actions" }).first().click();
await page.getByRole("menuitem", { name: "Edit" }).click();
// Dialog should open in edit mode
await expect(page.getByRole("dialog")).toBeVisible();
await expect(page.getByText("Edit Workflow")).toBeVisible();
// Change the name
const nameInput = page.getByLabel("Workflow name");
await nameInput.clear();
await nameInput.fill(updatedName);
// Save
await page.getByRole("button", { name: "Save" }).click();
await expect(page.getByRole("dialog")).not.toBeVisible();
// Verify the updated name appears
await expect(page.getByTestId("workflows-view")).toContainText(updatedName);
});
test("duplicates a workflow", async ({ page }) => {
const originalName = `dup_test_${Date.now()}`;
await navigateToWorkflows(page);
await createWorkflow(page, originalName);
// Open the dropdown menu and click Duplicate
await page.getByRole("button", { name: "Workflow actions" }).first().click();
await page.getByRole("menuitem", { name: "Duplicate" }).click();
// Dialog should open in duplicate mode with "(copy)" suffix
await expect(page.getByRole("dialog")).toBeVisible();
await expect(page.getByText("Duplicate Workflow")).toBeVisible();
// Submit the duplicate
await page.getByRole("button", { name: "Create Copy" }).click();
await expect(page.getByRole("dialog")).not.toBeVisible();
// Both the original and copy should exist
await expect(page.getByTestId("workflows-view")).toContainText(originalName);
});
test("deletes a workflow with confirmation", async ({ page }) => {
const workflowName = `delete_test_${Date.now()}`;
await navigateToWorkflows(page);
await createWorkflow(page, workflowName);
// Verify it exists
await expect(page.getByTestId("workflows-view")).toContainText(workflowName);
// Open the dropdown menu and click Delete
await page.getByRole("button", { name: "Workflow actions" }).first().click();
await page.getByRole("menuitem", { name: "Delete" }).click();
// Confirmation dialog should appear with workflow name
await expect(page.getByRole("alertdialog")).toBeVisible();
await expect(page.getByRole("alertdialog")).toContainText(workflowName);
// Confirm deletion
await page.getByRole("button", { name: "Delete" }).click();
await expect(page.getByRole("alertdialog")).not.toBeVisible();
// Verify workflow is gone — back to empty state
await expect(page.getByText("No workflows yet")).toBeVisible();
});
test("triggers a workflow from the detail panel", async ({ page }) => {
const workflowName = `trigger_test_${Date.now()}`;
await navigateToWorkflows(page);
await createWorkflow(page, workflowName);
// Click on the workflow card to open the detail panel
await page.getByRole("button", { name: `View ${workflowName}` }).click();
await expect(page.getByTestId("workflow-detail-panel")).toBeVisible();
// Click the Trigger button
await page
.getByTestId("workflow-detail-panel")
.getByRole("button", { name: "Trigger" })
.click();
// Wait for the trigger to complete (button text changes back from "Triggering...")
await expect(
page
.getByTestId("workflow-detail-panel")
.getByRole("button", { name: "Trigger" }),
).toBeVisible();
});