diff --git a/desktop/src/features/workflows/ui/WorkflowActionsMenu.tsx b/desktop/src/features/workflows/ui/WorkflowActionsMenu.tsx index 69cd3103f3..1114e2b1af 100644 --- a/desktop/src/features/workflows/ui/WorkflowActionsMenu.tsx +++ b/desktop/src/features/workflows/ui/WorkflowActionsMenu.tsx @@ -26,6 +26,7 @@ type WorkflowActionsMenuProps = { onEdit: () => void; onToggleEnabled: () => void; onTrigger: () => void; + showEnabledToggle?: boolean; }; export function WorkflowActionsMenu({ @@ -36,6 +37,7 @@ export function WorkflowActionsMenu({ onEdit, onToggleEnabled, onTrigger, + showEnabledToggle = true, }: WorkflowActionsMenuProps) { return ( @@ -63,37 +65,39 @@ export function WorkflowActionsMenu({ Duplicate - { - if (checked !== isEnabled) onToggleEnabled(); - }} - onSelect={(event) => event.preventDefault()} - > - {isEnabled ? ( - - ) : ( - - )} - Enable - + data-testid="workflow-enabled-switch-visual" + > + + + + ) : null} diff --git a/desktop/src/features/workflows/ui/WorkflowCard.tsx b/desktop/src/features/workflows/ui/WorkflowCard.tsx index 3d3044d63d..48ce8d2db6 100644 --- a/desktop/src/features/workflows/ui/WorkflowCard.tsx +++ b/desktop/src/features/workflows/ui/WorkflowCard.tsx @@ -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; @@ -52,7 +54,7 @@ const ACTION_ICONS: Record = { delay: Timer, request_approval: CircleCheckBig, send_dm: MessageCircle, - send_message: Send, + send_message: MessageSquare, set_channel_topic: Hash, }; @@ -64,15 +66,123 @@ const TRIGGER_ACCENTS: Record = { webhook: "border-orange-300/30 bg-orange-500 text-white", }; -function StatusBadge({ status }: { status: Workflow["status"] }) { +const ACTION_ACCENTS: Record = { + 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 ( + { + 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 ( + + ); +} + +function ActionTileStack({ + actions, + animationSequence = 0, +}: { + actions: Array<{ action: string; emoji: string | null; key: string }>; + animationSequence?: number; +}) { + const visibleActions = actions.slice(0, 3); + return ( 2 && "w-12", )} + data-testid="workflow-card-action-stack" > - {status} + {visibleActions + .map((action, index) => ( + + )) + .reverse()} ); } @@ -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 (
@@ -112,7 +223,7 @@ export function WorkflowCard({ View {workflow.name} -
+
- + onToggleEnabled(workflow)} + /> 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} />
- {triggerSummary ? ( -

- {triggerSummary} -

- ) : null} -

- {workflow.name} +

+ {cardLabel}

- {description ? ( -

- {description} -

- ) : null}
-

- {channelName ? `#${channelName}` : "Channel workflow"} -

+
+ {channelName ? ( +

+ #{channelName} +

+ ) : null} +

+ {workflow.name} +

+
{new Date(workflow.updatedAt * 1000).toLocaleDateString()} diff --git a/desktop/src/features/workflows/ui/WorkflowDialog.tsx b/desktop/src/features/workflows/ui/WorkflowDialog.tsx index fc23515b55..3f8655a3aa 100644 --- a/desktop/src/features/workflows/ui/WorkflowDialog.tsx +++ b/desktop/src/features/workflows/ui/WorkflowDialog.tsx @@ -41,6 +41,7 @@ import { type WorkflowFormBuilderHandle, } from "./WorkflowFormBuilder"; import { WorkflowWebhookSecretDialog } from "./WorkflowWebhookSecretDialog"; +import { getWorkflowActivationWarning } from "./workflowActivationWarning"; import { getWorkflowEnabled } from "./workflowDefinition"; import type { WorkflowEditorPane } from "./workflowEditorPane"; import { @@ -49,6 +50,7 @@ import { yamlToFormState, } from "./workflowFormTypes"; import { + readWorkflowDocumentFields, readWorkflowHeaderState, yamlWithWorkflowEnabled, yamlWithWorkflowName, @@ -117,7 +119,7 @@ function WorkflowNameEditor({ generating: boolean; name: string; onCommit: (name: string) => boolean; - onEditingChange: (editing: boolean) => void; + onEditingChange?: (editing: boolean) => void; }) { const [editing, setEditing] = React.useState(false); const [draft, setDraft] = React.useState(name); @@ -134,7 +136,7 @@ function WorkflowNameEditor({ const changeEditing = React.useCallback( (nextEditing: boolean) => { setEditing(nextEditing); - onEditingChange(nextEditing); + onEditingChange?.(nextEditing); }, [onEditingChange], ); @@ -247,13 +249,10 @@ export function WorkflowDialog({ const [editorParseError, setEditorParseError] = React.useState( null, ); - const [workflowNameEditing, setWorkflowNameEditing] = React.useState(false); const [historyOpen, setHistoryOpen] = React.useState(false); const [channelAutoOpenPending, setChannelAutoOpenPending] = React.useState( mode === "create" && !channelId, ); - const [nameLeadingElement, setNameLeadingElement] = - React.useState(null); const [savedWebhookInfo, setSavedWebhookInfo] = React.useState<{ relayHttpUrl: string | null; relayUrlError: string | null; @@ -262,6 +261,12 @@ export function WorkflowDialog({ } | null>(null); const [discardConfirmationOpen, setDiscardConfirmationOpen] = React.useState(false); + const [activationConfirmationOpen, setActivationConfirmationOpen] = + React.useState(false); + const [pendingCreateYaml, setPendingCreateYaml] = React.useState< + string | null + >(null); + const [formValid, setFormValid] = React.useState(true); const [secretConfirmationOpen, setSecretConfirmationOpen] = React.useState(false); const [generatingName, setGeneratingName] = React.useState(false); @@ -299,6 +304,9 @@ export function WorkflowDialog({ let active = true; setSavedWebhookInfo(null); setDiscardConfirmationOpen(false); + setActivationConfirmationOpen(false); + setPendingCreateYaml(null); + setFormValid(true); resetCreate(); resetUpdate(); @@ -338,6 +346,8 @@ export function WorkflowDialog({ resetCreate(); resetUpdate(); setDiscardConfirmationOpen(false); + setActivationConfirmationOpen(false); + setPendingCreateYaml(null); onOpenChange(false); }, [onOpenChange, resetCreate, resetUpdate]); @@ -402,14 +412,12 @@ export function WorkflowDialog({ [closeDialog, isDirty, onOpenChange, savedWebhookInfo], ); - async function handleSubmit() { - if (!selectedChannelId || !yamlDefinition.trim()) return; - + async function saveWorkflow(yaml: string) { try { - const saved = await mutation.mutateAsync(yamlDefinition); + const saved = await mutation.mutateAsync(yaml); initialValuesRef.current = { channelId: selectedChannelId, - yaml: yamlDefinition, + yaml, }; if (saved.webhookSecret) { allowNavigationRef.current = false; @@ -441,6 +449,37 @@ export function WorkflowDialog({ } } + function handleSubmit() { + if (!selectedChannelId || !yamlDefinition.trim() || !formValid) return; + + const documentEnabled = readWorkflowDocumentFields(yamlDefinition).enabled; + const savedEnabled = workflowSnapshot + ? getWorkflowEnabled(workflowSnapshot.definition) + : null; + const enablesWorkflow = + documentEnabled !== false && (mode !== "edit" || savedEnabled === false); + if (enablesWorkflow && getWorkflowActivationWarning(yamlDefinition)) { + const disabledYaml = yamlWithWorkflowEnabled(yamlDefinition, false); + if (disabledYaml === null) return; + setPendingCreateYaml(disabledYaml); + setActivationConfirmationOpen(true); + return; + } + + void saveWorkflow(yamlDefinition); + } + + function handleCreateActivation(enabled: boolean) { + if (pendingCreateYaml === null) return; + const yaml = enabled + ? yamlWithWorkflowEnabled(pendingCreateYaml, true) + : pendingCreateYaml; + if (yaml === null) return; + setActivationConfirmationOpen(false); + setPendingCreateYaml(null); + void saveWorkflow(yaml); + } + const handleEditorModeChange = React.useCallback( (nextMode: string) => { if (nextMode === editorMode) return; @@ -504,6 +543,10 @@ export function WorkflowDialog({ setYamlDefinition(nextYaml); }, [mutation.reset, workflowEnabled]); const showChannelSelector = mode !== "edit"; + const activationWarning = + pendingCreateYaml === null + ? null + : getWorkflowActivationWarning(pendingCreateYaml); return ( <> @@ -539,17 +582,6 @@ export function WorkflowDialog({ generating={generatingName} name={workflowName} onCommit={handleWorkflowNameCommit} - onEditingChange={setWorkflowNameEditing} - /> -
@@ -634,13 +666,14 @@ export function WorkflowDialog({ channels={channels} disabled={mutation.isPending} mode={editorMode} - nameLeadingContainer={mode === "edit" ? null : nameLeadingElement} + nameLeadingContainer={null} onChange={(yaml) => { mutation.reset(); yamlDefinitionRef.current = yaml; setYamlDefinition(yaml); }} onSelectedNodeChange={onEditorPaneChange} + onValidityChange={setFormValid} parseError={editorParseError} ref={formBuilderRef} scopeField={ @@ -744,6 +777,7 @@ export function WorkflowDialog({ disabled={ !selectedChannelId || !yamlDefinition.trim() || + !formValid || mutation.isPending } onClick={handleSubmit} @@ -759,6 +793,48 @@ export function WorkflowDialog({ + { + setActivationConfirmationOpen(nextOpen); + if (!nextOpen) setPendingCreateYaml(null); + }} + open={activationConfirmationOpen} + > + + + + {activationWarning?.title ?? "Turn on this workflow?"} + + + {activationWarning?.description ?? + "Turn it on to let it run immediately, or keep it off until you’re ready."} + + + + + + + + + + + + + + { setDiscardConfirmationOpen(nextOpen); diff --git a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx index 9a525591eb..58e4b4a960 100644 --- a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx +++ b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx @@ -1,11 +1,15 @@ import { ArrowDown, + CalendarClock, Check, ChevronDown, + GitPullRequest, + MessageSquare, Plus, + SmilePlus, Trash2, + Webhook, X, - Zap, } from "lucide-react"; import { FocusScope } from "@radix-ui/react-focus-scope"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; @@ -13,6 +17,7 @@ import * as React from "react"; import { createPortal } from "react-dom"; import type { Channel } from "@/shared/api/types"; +import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; import { Button } from "@/shared/ui/button"; import { cn } from "@/shared/lib/cn"; import { @@ -21,15 +26,20 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; -import { Input } from "@/shared/ui/input"; import { Switch } from "@/shared/ui/switch"; import { Textarea } from "@/shared/ui/textarea"; -import { WorkflowEmojiField } from "./WorkflowEmojiField"; -import { WorkflowMessageTextCondition } from "./WorkflowMessageTextConditionEditor"; +import { reactionConditionValue } from "./workflowReactionCondition"; +import { WorkflowTriggerConditions } from "./WorkflowTriggerConditions"; +import { workflowStepDescription } from "./workflowStepDescription"; +import { workflowTriggerDescription } from "./workflowTriggerDescription"; import { WorkflowScheduleFields } from "./WorkflowScheduleFields"; import { WorkflowStepCard } from "./WorkflowStepCard"; +import { + parseConditionExpressions, + conditionValueError, + type ParsedConditionExpression, +} from "./workflowConditionExpression"; import type { WorkflowEditorPane } from "./workflowEditorPane"; -import { FieldLabel } from "./workflowFormPrimitives"; import { DEFAULT_FORM_STATE, ACTION_LABELS, @@ -52,62 +62,39 @@ import type { } from "./workflowFormTypes"; function TriggerConfigFields({ + conditionDrafts, disabled, trigger, + onConditionDraftsChange, onUpdate, }: { + conditionDrafts: ParsedConditionExpression[] | null; disabled?: boolean; trigger: TriggerConfig; + onConditionDraftsChange: (drafts: ParsedConditionExpression[] | null) => void; onUpdate: (trigger: TriggerConfig) => void; }) { switch (trigger.on) { case "message_posted": - return ( - onUpdate({ ...trigger, filter })} - value={trigger.filter ?? ""} - /> - ); case "diff_posted": - return ( -
- - Condition (optional) - - - onUpdate({ ...trigger, filter: event.target.value }) - } - placeholder='e.g. str_contains(trigger_text, "deploy")' - value={trigger.filter ?? ""} - /> -

- Evalexpr. Empty matches all events. -

-
- ); case "reaction_added": return ( -
- - Emoji filter (optional) - - onUpdate({ ...trigger, emoji })} - value={trigger.emoji ?? ""} - /> -

- Empty matches any reaction. -

-
+ + onUpdate({ + ...trigger, + emoji: + trigger.on === "reaction_added" ? undefined : trigger.emoji, + filter, + }) + } + triggerType={trigger.on} + value={reactionConditionValue(trigger)} + /> ); case "webhook": return ( @@ -135,6 +122,7 @@ type WorkflowFormBuilderProps = { mode: WorkflowEditorMode; onChange: (yaml: string) => void; onSelectedNodeChange: (pane: WorkflowEditorPane) => void; + onValidityChange?: (valid: boolean) => void; parseError: string | null; scopeField?: React.ReactNode; selectedNode: WorkflowEditorPane; @@ -275,6 +263,7 @@ function WorkflowNode({ isNumbered && "text-sm font-semibold", )} data-selected={selected} + data-testid="workflow-node-icon" > {isNumbered ? number : icon} @@ -362,12 +351,13 @@ export const WorkflowFormBuilder = React.forwardRef< WorkflowFormBuilderProps >(function WorkflowFormBuilder( { - channels: _channels, + channels, disabled, nameLeadingContainer, mode, onChange, onSelectedNodeChange, + onValidityChange, parseError, scopeField, selectedNode: selectedRouteNode, @@ -383,6 +373,18 @@ export const WorkflowFormBuilder = React.forwardRef< ? initialParseRef.current.state : DEFAULT_FORM_STATE, ); + const [triggerConditionDrafts, setTriggerConditionDrafts] = React.useState< + ParsedConditionExpression[] | null + >(null); + const conditionDraftsValid = + triggerConditionDrafts === null || + triggerConditionDrafts.every( + (condition) => !conditionValueError(condition.field, condition.value), + ); + + React.useEffect(() => { + onValidityChange?.(mode !== "form" || conditionDraftsValid); + }, [conditionDraftsValid, mode, onValidityChange]); const selectedNode = selectedRouteNode?.type === "trigger" || (selectedRouteNode?.type === "step" && @@ -424,6 +426,7 @@ export const WorkflowFormBuilder = React.forwardRef< previousModeRef.current = mode; if (mode === "yaml") { + setTriggerConditionDrafts(null); onSelectedNodeChange(null); return; } @@ -595,6 +598,33 @@ export const WorkflowFormBuilder = React.forwardRef< const selectedStepIndex = selectedStep ? formState.steps.findIndex((step) => step.id === selectedStep.id) : -1; + const triggerEmoji = React.useMemo(() => { + if (formState.trigger.on !== "reaction_added") return undefined; + const legacyEmoji = formState.trigger.emoji?.trim(); + if (legacyEmoji) return legacyEmoji; + if (!formState.trigger.filter) return undefined; + const conditions = parseConditionExpressions( + formState.trigger.filter, + "reaction_added", + ); + return conditions + ?.find( + ({ field, operator }) => + field === "trigger_emoji" && operator === "equals", + ) + ?.value.trim(); + }, [formState.trigger]); + const triggerDescription = workflowTriggerDescription(formState.trigger); + const visibleTriggerDescription = triggerEmoji + ? "Reaction added" + : triggerDescription; + const TriggerIcon = { + diff_posted: GitPullRequest, + message_posted: MessageSquare, + reaction_added: SmilePlus, + schedule: CalendarClock, + webhook: Webhook, + }[formState.trigger.on]; return ( <> @@ -637,10 +667,19 @@ export const WorkflowFormBuilder = React.forwardRef< {scopeField ?
{scopeField}
: null}
    } - label={`Trigger: ${TRIGGER_LABELS[formState.trigger.on]}`} + icon={ + triggerEmoji ? ( + + ) : ( + + ) + } + label={`Trigger: ${triggerDescription}`} onAddAfter={(action) => insertStep(0, action)} onClick={() => selectNode({ type: "trigger" })} selected={selectedNode?.type === "trigger"} @@ -649,16 +688,39 @@ export const WorkflowFormBuilder = React.forwardRef< /> {formState.steps.map((step, index) => { - const stepName = step.name?.trim(); const actionLabel = ACTION_LABELS[step.action]; - const nodeTitle = stepName || actionLabel; + const channelLabel = step.channel + ? channels.find( + (channel) => channel.id === step.channel, + )?.name + : undefined; + const nodeDescription = workflowStepDescription(step, { + channelLabel, + }); + const stepEmoji = + step.action === "add_reaction" + ? step.emoji?.trim() + : undefined; + const visibleNodeDescription = stepEmoji + ? actionLabel + : nodeDescription; + const showActionSubtitle = + !stepEmoji && nodeDescription !== actionLabel; return ( + ) : undefined + } key={step.id} - label={`Step ${index + 1}: ${nodeTitle}`} - number={index + 1} + label={`Step ${index + 1}: ${nodeDescription}`} + number={stepEmoji ? undefined : index + 1} onAddAfter={(action) => insertStep(index + 1, action)} onClick={() => selectNode({ type: "step", stepId: step.id }) @@ -669,7 +731,9 @@ export const WorkflowFormBuilder = React.forwardRef< selectedNode.stepId === step.id } showTitle={false} - subtitle={stepName ? actionLabel : undefined} + subtitle={ + showActionSubtitle ? actionLabel : undefined + } terminal={index === formState.steps.length - 1} title={`Step ${index + 1}`} /> @@ -746,6 +810,7 @@ export const WorkflowFormBuilder = React.forwardRef< disabled={disabled} labels={TRIGGER_LABELS} onChange={(triggerType) => { + setTriggerConditionDrafts(null); const next = withTriggerType( formState, triggerType, @@ -842,7 +907,11 @@ export const WorkflowFormBuilder = React.forwardRef< {selectedNode.type === "trigger" ? (
    updateFormState({ ...formState, trigger }) } @@ -858,6 +927,10 @@ export const WorkflowFormBuilder = React.forwardRef< onUpdate={(updated) => updateStep(selectedStepIndex, updated) } + previousSteps={formState.steps.slice( + 0, + selectedStepIndex, + )} showHeader={false} step={selectedStep} triggerType={formState.trigger.on} diff --git a/desktop/src/features/workflows/ui/WorkflowStepCard.tsx b/desktop/src/features/workflows/ui/WorkflowStepCard.tsx index 20c6d4dad3..539edf2ba9 100644 --- a/desktop/src/features/workflows/ui/WorkflowStepCard.tsx +++ b/desktop/src/features/workflows/ui/WorkflowStepCard.tsx @@ -6,6 +6,7 @@ import { Button } from "@/shared/ui/button"; import { Checkbox } from "@/shared/ui/checkbox"; import { Input } from "@/shared/ui/input"; import { Textarea } from "@/shared/ui/textarea"; +import { WorkflowTemplateTextarea } from "./WorkflowTemplateTextarea"; import { WorkflowDurationField } from "./WorkflowDurationField"; import { WorkflowMessageTextCondition } from "./WorkflowMessageTextConditionEditor"; import { FieldLabel, FormSelect } from "./workflowFormPrimitives"; @@ -109,6 +110,7 @@ function StepConfigFields({ step, prefix, disabled, + previousSteps, triggerType, workflowChannelId, onUpdate, @@ -116,6 +118,7 @@ function StepConfigFields({ step: StepFormState; prefix: string; disabled?: boolean; + previousSteps: StepFormState[]; triggerType: TriggerType; workflowChannelId?: string | null; onUpdate: (step: StepFormState) => void; @@ -142,15 +145,15 @@ function StepConfigFields({
    Message text -