Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f09aae2
feat(workflows): insert variables into message steps
tellaho Aug 20, 2026
fac647f
feat(workflows): add manual trigger filters
tellaho Aug 21, 2026
649f566
feat(workflows): describe configured workflow behavior
tellaho Aug 21, 2026
7a234d0
fix(workflows): preserve configured node hierarchy
tellaho Aug 21, 2026
022958c
fix(workflows): align card and filter hierarchy
tellaho Aug 21, 2026
48f5f3f
fix(workflows): render configured reaction emoji
tellaho Aug 21, 2026
4527163
feat(workflows): keep condition choices two columns wide
tellaho Aug 21, 2026
bdd1b83
fix(workflows): streamline card status controls
tellaho Aug 21, 2026
53e3141
fix(workflows): prevent unsafe workflow creation
tellaho Aug 21, 2026
ded18fb
fix(workflows): make yaml edits authoritative
tellaho Aug 21, 2026
645def8
fix(desktop): stabilize workflow card metadata
Aug 21, 2026
cc26a53
fix(workflows): warn only on risky activation
Aug 21, 2026
f995038
test(workflows): align e2e with current editor behavior
tellaho Aug 21, 2026
9373ba4
fix(workflows): classify supported cron schedules
tellaho Aug 21, 2026
687ebae
fix(workflows): normalize condition identifiers
tellaho Aug 21, 2026
c7a0b33
fix(workflows): preserve legacy reaction filters
tellaho Aug 21, 2026
83846cb
fix(workflows): guard risky activation transitions
tellaho Aug 21, 2026
6be07bc
test(workflows): confirm risky card activation
tellaho Aug 21, 2026
ec972bb
fix(workflows): group legacy reaction filters with alternatives
tellaho Aug 21, 2026
afa54e9
fix(workflows): warn for hourly crons with a non-literal hour field
tellaho Aug 21, 2026
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
58 changes: 31 additions & 27 deletions desktop/src/features/workflows/ui/WorkflowActionsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type WorkflowActionsMenuProps = {
onEdit: () => void;
onToggleEnabled: () => void;
onTrigger: () => void;
showEnabledToggle?: boolean;
};

export function WorkflowActionsMenu({
Expand All @@ -36,6 +37,7 @@ export function WorkflowActionsMenu({
onEdit,
onToggleEnabled,
onTrigger,
showEnabledToggle = true,
}: WorkflowActionsMenuProps) {
return (
<DropdownMenu>
Expand Down Expand Up @@ -63,37 +65,39 @@ export function WorkflowActionsMenu({
<Copy className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuCheckboxItem
checked={isEnabled}
className="gap-2 pl-2 [&>span:first-child]:hidden"
disabled={isTogglingEnabled}
onCheckedChange={(checked) => {
if (checked !== isEnabled) onToggleEnabled();
}}
onSelect={(event) => event.preventDefault()}
>
{isEnabled ? (
<Power className="mr-2 h-4 w-4 shrink-0" />
) : (
<PowerOff className="mr-2 h-4 w-4 shrink-0" />
)}
<span>Enable</span>
<span
aria-hidden="true"
className={
"ml-auto inline-flex h-5 w-9 shrink-0 items-center rounded-full border-2 border-transparent transition-colors " +
(isEnabled ? "bg-primary" : "bg-input")
}
data-testid="workflow-enabled-switch-visual"
{showEnabledToggle ? (
<DropdownMenuCheckboxItem
checked={isEnabled}
className="gap-2 pl-2 [&>span:first-child]:hidden"
disabled={isTogglingEnabled}
onCheckedChange={(checked) => {
if (checked !== isEnabled) onToggleEnabled();
}}
onSelect={(event) => event.preventDefault()}
>
{isEnabled ? (
<Power className="mr-2 h-4 w-4 shrink-0" />
) : (
<PowerOff className="mr-2 h-4 w-4 shrink-0" />
)}
<span>Enable</span>
<span
aria-hidden="true"
className={
"block h-4 w-4 rounded-full bg-background transition-transform " +
(isEnabled ? "translate-x-4" : "translate-x-0")
"ml-auto inline-flex h-5 w-9 shrink-0 items-center rounded-full border-2 border-transparent transition-colors " +
(isEnabled ? "bg-primary" : "bg-input")
}
/>
</span>
</DropdownMenuCheckboxItem>
data-testid="workflow-enabled-switch-visual"
>
<span
className={
"block h-4 w-4 rounded-full bg-background transition-transform " +
(isEnabled ? "translate-x-4" : "translate-x-0")
}
/>
</span>
</DropdownMenuCheckboxItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive" onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" />
Expand Down
211 changes: 171 additions & 40 deletions desktop/src/features/workflows/ui/WorkflowCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,27 @@ import {
Hash,
MessageCircle,
MessageSquare,
Send,
SmilePlus,
Timer,
Webhook,
Zap,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import * as React from "react";

import type { Workflow } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Switch } from "@/shared/ui/switch";
import { WorkflowActionsMenu } from "./WorkflowActionsMenu";
import {
getWorkflowDescription,
getWorkflowDisplayStatus,
getWorkflowEnabled,
getWorkflowPrimaryAction,
getWorkflowTriggerSummary,
getWorkflowActionTiles,
getWorkflowCardLabel,
getWorkflowTriggerEmoji,
getWorkflowTriggerType,
} from "./workflowDefinition";
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";

type WorkflowCardProps = {
workflow: Workflow;
Expand Down Expand Up @@ -52,7 +54,7 @@ const ACTION_ICONS: Record<string, LucideIcon> = {
delay: Timer,
request_approval: CircleCheckBig,
send_dm: MessageCircle,
send_message: Send,
send_message: MessageSquare,
set_channel_topic: Hash,
};

Expand All @@ -64,15 +66,123 @@ const TRIGGER_ACCENTS: Record<string, string> = {
webhook: "border-orange-300/30 bg-orange-500 text-white",
};

function StatusBadge({ status }: { status: Workflow["status"] }) {
const ACTION_ACCENTS: Record<string, string> = {
add_reaction: "border-pink-400/30 bg-pink-600 text-white",
call_webhook: "border-orange-300/30 bg-orange-500 text-white",
delay: "border-sky-300/30 bg-sky-500 text-white",
request_approval: "border-emerald-300/30 bg-emerald-600 text-white",
send_dm: "border-indigo-300/30 bg-indigo-600 text-white",
send_message: "border-blue-300/30 bg-blue-600 text-white",
set_channel_topic: "border-violet-300/30 bg-violet-600 text-white",
};

function StatusToggle({
disabled,
enabled,
onToggle,
}: {
disabled: boolean;
enabled: boolean;
onToggle: () => void;
}) {
return (
<Switch
aria-label={enabled ? "Disable workflow" : "Enable workflow"}
checked={enabled}
disabled={disabled}
onCheckedChange={(checked) => {
if (checked !== enabled) onToggle();
}}
/>
);
}

function ActionTile({
action,
animationSequence,
className,
emoji,
index,
}: {
action: string;
animationSequence: number;
className?: string;
emoji: string | null;
index: number;
}) {
const ActionIcon = ACTION_ICONS[action];
const accent = ACTION_ACCENTS[action];
const reduceMotion = useReducedMotion();

return (
<span
aria-hidden="true"
className={cn(
"absolute inset-y-0 flex w-9 items-center justify-center rounded-xl border shadow-xs",
accent ?? "border-border/65 bg-background/80 text-muted-foreground",
className,
)}
>
<motion.span
animate={
animationSequence > 0 && !reduceMotion
? { scale: [1, 1.18, 0.94, 1], y: [0, -5, 1, 0] }
: undefined
}
className="flex items-center justify-center"
transition={{
delay: index * 0.11,
duration: 0.48,
ease: "easeOut",
}}
>
{emoji ? (
<StatusEmoji className="h-6 w-6 text-xl" value={emoji} />
) : ActionIcon ? (
<ActionIcon className="h-5 w-5" />
) : (
<Zap className="h-5 w-5" />
)}
</motion.span>
</span>
);
}

function ActionTileStack({
actions,
animationSequence = 0,
}: {
actions: Array<{ action: string; emoji: string | null; key: string }>;
animationSequence?: number;
}) {
const visibleActions = actions.slice(0, 3);

return (
<span
className={cn(
"rounded-full border border-border/65 bg-background/80 px-2 py-1 text-2xs font-semibold uppercase tracking-wider shadow-xs",
status === "active" ? "text-foreground" : "text-muted-foreground",
"relative h-9",
visibleActions.length === 1 && "w-9",
visibleActions.length === 2 && "w-[2.625rem]",
visibleActions.length > 2 && "w-12",
)}
data-testid="workflow-card-action-stack"
>
{status}
{visibleActions
.map((action, index) => (
<ActionTile
action={action.action}
animationSequence={animationSequence}
className={cn(
index === 0 && "left-0 z-10",
index === 1 && "left-1.5 z-[5] scale-90 opacity-60",
index === 2 && "left-3 scale-75 opacity-35",
)}
emoji={action.emoji}
index={index}
key={`${action.key}-${animationSequence}`}
/>
))
.reverse()}
</span>
);
}
Expand All @@ -88,19 +198,20 @@ export function WorkflowCard({
onDuplicate,
onDelete,
}: WorkflowCardProps) {
const displayStatus = getWorkflowDisplayStatus(workflow);
const triggerSummary = getWorkflowTriggerSummary(workflow.definition);
const description = getWorkflowDescription(workflow.definition);
const [triggerAnimationSequence, setTriggerAnimationSequence] =
React.useState(0);
const isEnabled = getWorkflowEnabled(workflow.definition);
const cardLabel = getWorkflowCardLabel(workflow.definition);
const triggerType = getWorkflowTriggerType(workflow.definition);
const actionType = getWorkflowPrimaryAction(workflow.definition);
const actionTiles = getWorkflowActionTiles(workflow.definition);
const triggerEmoji = getWorkflowTriggerEmoji(workflow.definition);
const TriggerIcon = triggerType ? TRIGGER_ICONS[triggerType] : undefined;
const ActionIcon = actionType ? ACTION_ICONS[actionType] : undefined;
const triggerAccent = triggerType ? TRIGGER_ACCENTS[triggerType] : undefined;

return (
<div
className={cn(
"group relative min-h-60 w-full overflow-hidden rounded-2xl bg-muted/50 p-5 text-left text-foreground shadow-xs transition-colors hover:bg-muted/65",
"group relative flex min-h-60 w-full flex-col overflow-hidden rounded-2xl bg-muted/50 p-5 text-left text-foreground shadow-xs transition-colors hover:bg-muted/65",
)}
data-testid={`workflow-card-${workflow.id}`}
>
Expand All @@ -112,7 +223,7 @@ export function WorkflowCard({
<span className="sr-only">View {workflow.name}</span>
</button>

<div className="pointer-events-none relative z-10 flex h-full min-h-48 flex-col">
<div className="pointer-events-none relative z-10 flex min-h-48 flex-1 flex-col">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2" aria-hidden="true">
<span
Expand All @@ -121,54 +232,74 @@ export function WorkflowCard({
triggerAccent ?? "border-slate-400/30 bg-slate-600 text-white",
)}
>
{TriggerIcon ? (
{triggerEmoji ? (
<StatusEmoji className="h-6 w-6 text-xl" value={triggerEmoji} />
) : TriggerIcon ? (
<TriggerIcon className="h-5 w-5" />
) : (
<Zap className="h-5 w-5" />
)}
</span>
{ActionIcon ? (
{actionTiles.length > 0 ? (
<>
<ArrowRight className="h-4 w-4 text-muted-foreground/60" />
<span className="flex h-9 w-9 items-center justify-center rounded-xl border border-border/65 bg-background/80 text-muted-foreground shadow-xs">
<ActionIcon className="h-5 w-5" />
</span>
<ActionTileStack
actions={actionTiles}
animationSequence={triggerAnimationSequence}
/>
</>
) : null}
</div>

<div className="pointer-events-auto flex items-center gap-1.5">
<StatusBadge status={displayStatus} />
<StatusToggle
disabled={isTogglingEnabled}
enabled={isEnabled}
onToggle={() => onToggleEnabled(workflow)}
/>
<WorkflowActionsMenu
isEnabled={getWorkflowEnabled(workflow.definition)}
isEnabled={isEnabled}
isTogglingEnabled={isTogglingEnabled}
onDelete={() => onDelete(workflow)}
onDuplicate={() => onDuplicate(workflow)}
onEdit={() => onEdit(workflow)}
onToggleEnabled={() => onToggleEnabled(workflow)}
onTrigger={() => onTrigger(workflow.id)}
onTrigger={() => {
setTriggerAnimationSequence((sequence) => sequence + 1);
onTrigger(workflow.id);
}}
showEnabledToggle={false}
/>
</div>
</div>

{triggerSummary ? (
<p className="mt-4 line-clamp-1 text-xs font-semibold text-muted-foreground">
{triggerSummary}
</p>
) : null}
<h3 className="mt-1 line-clamp-4 text-xl font-bold leading-tight tracking-tight">
{workflow.name}
<h3
className="mt-4 line-clamp-4 text-xl font-bold leading-tight tracking-tight"
data-testid="workflow-card-semantic-label"
>
{cardLabel}
</h3>
{description ? (
<p className="mt-2 line-clamp-2 text-sm leading-relaxed text-muted-foreground">
{description}
</p>
) : null}

<div className="mt-auto flex min-w-0 items-end justify-between gap-3 pt-5 text-muted-foreground">
<p className="min-w-0 truncate text-2xs">
{channelName ? `#${channelName}` : "Channel workflow"}
</p>
<div className="min-w-0">
{channelName ? (
<p
className="truncate text-xs font-semibold text-foreground"
data-testid="workflow-card-channel"
>
#{channelName}
</p>
) : null}
<p
className={cn(
"truncate text-2xs text-muted-foreground",
channelName && "mt-0.5",
)}
data-testid="workflow-card-name"
>
{workflow.name}
</p>
</div>
<span className="shrink-0 text-2xs">
{new Date(workflow.updatedAt * 1000).toLocaleDateString()}
</span>
Expand Down
Loading
Loading