From 5c4a43ec22ed22675c80f61b02f7bcea07ab4b67 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 13 Aug 2026 23:09:03 -0700 Subject: [PATCH] Improve workflow trigger details Signed-off-by: Taylor Ho --- .../workflows/ui/AuthorGridPicker.tsx | 2 +- .../workflows/ui/WorkflowConditionBuilder.tsx | 390 ++++++++++-------- .../workflows/ui/WorkflowFormBuilder.tsx | 203 ++++++++- .../ui/workflowConditionExpression.test.mjs | 65 +++ .../ui/workflowConditionExpression.ts | 81 +++- .../ui/workflowTriggerDescription.test.mjs | 69 ++++ .../ui/workflowTriggerDescription.ts | 156 +++++++ desktop/tests/e2e/workflows.spec.ts | 67 ++- 8 files changed, 853 insertions(+), 180 deletions(-) create mode 100644 desktop/src/features/workflows/ui/workflowTriggerDescription.test.mjs create mode 100644 desktop/src/features/workflows/ui/workflowTriggerDescription.ts diff --git a/desktop/src/features/workflows/ui/AuthorGridPicker.tsx b/desktop/src/features/workflows/ui/AuthorGridPicker.tsx index 016644e63..d60896f7f 100644 --- a/desktop/src/features/workflows/ui/AuthorGridPicker.tsx +++ b/desktop/src/features/workflows/ui/AuthorGridPicker.tsx @@ -50,7 +50,7 @@ function matchesPubkeyPrefix(pubkey: string, query: string) { } export function AuthorGridPicker({ - ariaLabel = "Author pubkey", + ariaLabel = "Author", disabled, id, knownPubkeys = [], diff --git a/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx b/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx index 14d0c2fc4..ce5aaf1bb 100644 --- a/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx +++ b/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx @@ -9,14 +9,14 @@ import { MessageIdPicker } from "./MessageIdPicker"; import { WorkflowEmojiField } from "./WorkflowEmojiField"; import { FieldLabel, FormSelect } from "./workflowFormPrimitives"; import { - buildConditionExpression, + buildConditionExpressions, conditionFieldsForTrigger, conditionOperatorsForField, conditionOperatorNeedsValue, CUSTOM_CONDITION_FIELD, defaultConditionOperatorForField, normalizeWebhookField, - parseConditionExpression, + parseConditionExpressions, } from "./workflowConditionExpression"; import type { ConditionOperator, @@ -35,33 +35,39 @@ const OPERATOR_LABELS: Record = { is_empty: "is empty", }; +type ConditionEditorState = { + custom: boolean; + editors: ParsedConditionExpression[]; +}; + +function normalizedEditor( + editor: ParsedConditionExpression, +): ParsedConditionExpression { + const supportedOperators = conditionOperatorsForField(editor.field); + return supportedOperators.includes(editor.operator) + ? editor + : { + ...editor, + operator: defaultConditionOperatorForField(editor.field), + }; +} + function initialEditorState( value: string, triggerType: TriggerType, -): ParsedConditionExpression { - const parsed = parseConditionExpression(value, triggerType); +): ConditionEditorState { + const parsed = parseConditionExpressions(value, triggerType); if (parsed) { - const supportedOperators = conditionOperatorsForField(parsed.field); - return supportedOperators.includes(parsed.operator) - ? parsed - : { - ...parsed, - operator: defaultConditionOperatorForField(parsed.field), - }; + return { custom: false, editors: parsed.map(normalizedEditor) }; } - return { - field: value.trim() ? CUSTOM_CONDITION_FIELD : "", - operator: "contains", - value: "", - webhookField: "", - }; + return { custom: value.trim().length > 0, editors: [] }; } function valueLabel(field: string): string { switch (field) { case "trigger_author": - return "Pubkey"; + return "Author"; case "trigger_message_id": return "Message ID"; case "trigger_emoji": @@ -86,6 +92,148 @@ function valuePlaceholder(field: string): string { } } +function ConditionEditorControls({ + channelId, + disabled, + editor, + idPrefix, + knownAuthorPubkeys, + label, + onChange, +}: { + channelId?: string | null; + disabled?: boolean; + editor: ParsedConditionExpression; + idPrefix: string; + knownAuthorPubkeys: string[]; + label: string; + onChange: (editor: ParsedConditionExpression) => void; +}) { + const needsValue = conditionOperatorNeedsValue(editor.operator); + const operatorOptions = conditionOperatorsForField(editor.field); + const usesExactMatchOperators = operatorOptions.length === 2; + const webhookFieldInvalid = + editor.field === "webhook_field" && + editor.webhookField.length > 0 && + normalizeWebhookField(editor.webhookField) === null; + const controlIdPrefix = `${idPrefix}-${editor.field}`; + + return ( +
+ + {label} + +
+ Match + + onChange({ + ...editor, + operator: operator as ConditionOperator, + }) + } + value={editor.operator} + > + {operatorOptions.map((operator) => ( + + ))} + +
+ + {editor.field === "webhook_field" ? ( +
+ + JSON field name + + + onChange({ + ...editor, + webhookField: event.target.value, + }) + } + placeholder="e.g. environment" + value={editor.webhookField} + /> + {webhookFieldInvalid ? ( +

+ Use letters, numbers, and underscores, starting with a letter or + underscore. Names cannot start with trigger_ or steps_. +

+ ) : null} +
+ ) : null} + + {needsValue ? ( +
+ + {editor.field === "webhook_field" + ? "Value" + : valueLabel(editor.field)} + + {editor.field === "trigger_author" ? ( + onChange({ ...editor, value: pubkey })} + value={editor.value} + /> + ) : editor.field === "trigger_emoji" ? ( + onChange({ ...editor, value: emoji ?? "" })} + value={editor.value} + /> + ) : editor.field === "trigger_message_id" ? ( + + onChange({ ...editor, value: messageId }) + } + value={editor.value} + /> + ) : ( + + onChange({ ...editor, value: event.target.value }) + } + placeholder={ + editor.field === "webhook_field" + ? "Value to match" + : valuePlaceholder(editor.field) + } + value={editor.value} + /> + )} +
+ ) : null} +
+ ); +} + export function WorkflowConditionBuilder({ channelId, channels, @@ -139,7 +287,7 @@ export function WorkflowConditionBuilder({ return [...rankedPubkeys]; }, [channels, identityQuery.data?.pubkey]); - const [editor, setEditor] = React.useState(() => + const [editorState, setEditorState] = React.useState(() => initialEditorState(value, triggerType), ); const previousTriggerType = React.useRef(triggerType); @@ -147,27 +295,22 @@ export function WorkflowConditionBuilder({ React.useEffect(() => { if (previousTriggerType.current === triggerType) return; previousTriggerType.current = triggerType; - setEditor(initialEditorState(value, triggerType)); + setEditorState(initialEditorState(value, triggerType)); }, [triggerType, value]); - const emitEditor = (next: ParsedConditionExpression) => { - setEditor(next); - const expression = buildConditionExpression({ - field: next.field, - operator: next.operator, - value: next.value, - webhookField: next.webhookField, - }); - onChange(expression ?? ""); + const emitEditors = (editors: ParsedConditionExpression[]) => { + setEditorState({ custom: false, editors }); + onChange(buildConditionExpressions(editors)); + }; + + const updateEditor = (next: ParsedConditionExpression) => { + emitEditors( + editorState.editors.map((editor) => + editor.field === next.field ? next : editor, + ), + ); }; - const needsValue = conditionOperatorNeedsValue(editor.operator); - const operatorOptions = conditionOperatorsForField(editor.field); - const usesExactMatchOperators = operatorOptions.length === 2; - const webhookFieldInvalid = - editor.field === "webhook_field" && - editor.webhookField.length > 0 && - normalizeWebhookField(editor.webhookField) === null; const evalexprFields = fields .map((field) => field.value === "webhook_field" ? "trigger_" : field.value, @@ -178,6 +321,9 @@ export function WorkflowConditionBuilder({ ...fields, { label: "Custom", value: CUSTOM_CONDITION_FIELD }, ]; + const selectedFields = new Set( + editorState.editors.map((editor) => editor.field), + ); return (
@@ -187,7 +333,11 @@ export function WorkflowConditionBuilder({ {fieldOptions.map((field) => { const isMatchAll = field.value === ""; const isCustom = field.value === CUSTOM_CONDITION_FIELD; - const isSelected = editor.field === field.value; + const isSelected = isMatchAll + ? !editorState.custom && editorState.editors.length === 0 + : isCustom + ? editorState.custom + : selectedFields.has(field.value); return (
{ - if (isSelected) return; if (isMatchAll) { - setEditor({ - field: "", - operator: "contains", - value: "", - webhookField: "", - }); + setEditorState({ custom: false, editors: [] }); onChange(""); return; } if (isCustom) { - setEditor({ ...editor, field: field.value }); + setEditorState({ custom: true, editors: [] }); return; } - emitEditor({ - field: field.value, - operator: defaultConditionOperatorForField(field.value), - value: "", - webhookField: "", - }); + if (isSelected) { + emitEditors( + editorState.editors.filter( + (editor) => editor.field !== field.value, + ), + ); + } else { + emitEditors([ + ...editorState.editors, + { + field: field.value, + operator: defaultConditionOperatorForField( + field.value, + ), + value: "", + webhookField: "", + }, + ]); + } }} type="button" > @@ -241,7 +398,7 @@ export function WorkflowConditionBuilder({
- {editor.field === CUSTOM_CONDITION_FIELD ? ( + {editorState.custom ? (
Custom expression @@ -259,118 +416,23 @@ export function WorkflowConditionBuilder({ Use an evalexpr expression with {evalexprFields}.

- ) : editor.field ? ( - <> -
- Match - - emitEditor({ - ...editor, - operator: operator as ConditionOperator, - }) - } - value={editor.operator} - > - {operatorOptions.map((operator) => ( - - ))} - -
- - {editor.field === "webhook_field" ? ( -
- - JSON field name - - - emitEditor({ - ...editor, - webhookField: event.target.value, - }) - } - placeholder="e.g. environment" - value={editor.webhookField} - /> - {webhookFieldInvalid ? ( -

- Use letters, numbers, and underscores, starting with a letter - or underscore. Names cannot start with trigger_ or steps_. -

- ) : null} -
- ) : null} - - {needsValue ? ( -
- - {editor.field === "webhook_field" - ? "Value" - : valueLabel(editor.field)} - - {editor.field === "trigger_author" ? ( - - emitEditor({ ...editor, value: pubkey }) - } - value={editor.value} - /> - ) : editor.field === "trigger_emoji" ? ( - - emitEditor({ ...editor, value: emoji ?? "" }) - } - value={editor.value} - /> - ) : editor.field === "trigger_message_id" ? ( - - emitEditor({ ...editor, value: messageId }) - } - value={editor.value} - /> - ) : ( - - emitEditor({ ...editor, value: event.target.value }) - } - placeholder={ - editor.field === "webhook_field" - ? "Value to match" - : valuePlaceholder(editor.field) - } - value={editor.value} - /> - )} -
- ) : null} - - ) : null} + ) : ( + editorState.editors.map((editor) => ( + field.value === editor.field)?.label ?? + editor.field + } + onChange={updateEditor} + /> + )) + )}
); } diff --git a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx index 5cb8d2193..e9351472c 100644 --- a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx +++ b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx @@ -1,17 +1,28 @@ +import { useQuery } from "@tanstack/react-query"; import { ArrowDown, + CalendarClock, Check, ChevronDown, + GitPullRequest, + LoaderCircle, + MessageSquare, Plus, + SmilePlus, Trash2, TriangleAlert, + Webhook, X, - Zap, } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import * as React from "react"; import { createPortal } from "react-dom"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { resolveUserLabel } from "@/features/profile/lib/identity"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { getEventById } from "@/shared/api/tauri"; import type { Channel } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; import { cn } from "@/shared/lib/cn"; @@ -24,10 +35,14 @@ import { import { Input } from "@/shared/ui/input"; import { Switch } from "@/shared/ui/switch"; import { Textarea } from "@/shared/ui/textarea"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; import { WorkflowConditionBuilder } from "./WorkflowConditionBuilder"; import { WorkflowScheduleFields } from "./WorkflowScheduleFields"; import { WorkflowStepCard } from "./WorkflowStepCard"; -import { buildConditionExpression } from "./workflowConditionExpression"; +import { + buildConditionExpression, + parseConditionExpressions, +} from "./workflowConditionExpression"; import { FieldLabel } from "./workflowFormPrimitives"; import { DEFAULT_FORM_STATE, @@ -44,9 +59,22 @@ import type { ActionType, StepFormState, TriggerConfig, + TriggerType, WorkflowFormState, } from "./workflowFormTypes"; import { defaultScheduleTrigger } from "./workflowSchedule"; +import { + TRIGGER_MESSAGE_LOADING_LABEL, + workflowTriggerDescription, +} from "./workflowTriggerDescription"; + +const TRIGGER_ICONS: Record = { + diff_posted: GitPullRequest, + message_posted: MessageSquare, + reaction_added: SmilePlus, + schedule: CalendarClock, + webhook: Webhook, +}; function TriggerConfigFields({ channels, @@ -231,7 +259,7 @@ function WorkflowNode({ terminal, title, }: { - description: string; + description: React.ReactNode; disabled?: boolean; icon?: React.ReactNode; label: string; @@ -359,6 +387,90 @@ function WorkflowNode({ ); } +function TriggerNodeDescription({ + authorAvatarUrl, + authorLabel, + description, + messageLoading, +}: { + authorAvatarUrl?: string | null; + authorLabel?: string | null; + description: string; + messageLoading?: boolean; +}) { + const authorIndex = authorLabel ? description.lastIndexOf(authorLabel) : -1; + if (!authorLabel || authorIndex < 0) { + return ( + + ); + } + const prefix = description.slice(0, authorIndex).trimEnd(); + const suffix = description + .slice(authorIndex + authorLabel.length) + .trimStart(); + + return ( + + {prefix} + + + {authorLabel}{" "} + {suffix ? ( + + ) : null} + + + ); +} + +function TriggerDescriptionText({ + messageLoading, + text, +}: { + messageLoading?: boolean; + text: string; +}) { + const loadingIndex = messageLoading + ? text.indexOf(TRIGGER_MESSAGE_LOADING_LABEL) + : -1; + if (loadingIndex < 0) return text; + + const prefix = text.slice(0, loadingIndex); + const suffix = text.slice( + loadingIndex + TRIGGER_MESSAGE_LOADING_LABEL.length, + ); + return ( + <> + {prefix} + + + {suffix} + + ); +} + export function WorkflowFormBuilder({ channels, disabled, @@ -383,6 +495,72 @@ export function WorkflowFormBuilder({ const [selectionDirection, setSelectionDirection] = React.useState<1 | -1>(1); const shouldReduceMotion = useReducedMotion(); const previousModeRef = React.useRef(mode); + const identityQuery = useIdentityQuery(); + const parsedTriggerConditions = React.useMemo( + () => + formState.trigger.filter + ? parseConditionExpressions( + formState.trigger.filter, + formState.trigger.on, + ) + : [], + [formState.trigger.filter, formState.trigger.on], + ); + const triggerAuthorCondition = parsedTriggerConditions?.find( + (condition) => condition.field === "trigger_author", + ); + const triggerAuthorPubkey = + triggerAuthorCondition && + /^[0-9a-f]{64}$/i.test(triggerAuthorCondition.value) + ? triggerAuthorCondition.value + : null; + const triggerAuthorProfiles = useUsersBatchQuery( + triggerAuthorPubkey ? [triggerAuthorPubkey] : [], + ); + const triggerAuthorProfile = triggerAuthorPubkey + ? triggerAuthorProfiles.data?.profiles[triggerAuthorPubkey.toLowerCase()] + : undefined; + const triggerAuthorLabel = triggerAuthorPubkey + ? resolveUserLabel({ + currentPubkey: identityQuery.data?.pubkey, + profiles: triggerAuthorProfiles.data?.profiles, + pubkey: triggerAuthorPubkey, + }) + : null; + const triggerMessageCondition = parsedTriggerConditions?.find( + (condition) => condition.field === "trigger_message_id", + ); + const triggerMessageId = + triggerMessageCondition && + /^[0-9a-f]{64}$/i.test(triggerMessageCondition.value) + ? triggerMessageCondition.value + : null; + const triggerMessageQuery = useQuery({ + enabled: Boolean(triggerMessageId), + queryKey: ["workflow-trigger-message", workflowChannelId, triggerMessageId], + queryFn: () => getEventById(triggerMessageId ?? ""), + retry: false, + staleTime: 60_000, + }); + const triggerMessage = + triggerMessageQuery.data && + (!workflowChannelId || + triggerMessageQuery.data.tags.some( + (tag) => tag[0] === "h" && tag[1] === workflowChannelId, + )) + ? triggerMessageQuery.data + : null; + const triggerMessageLabel = triggerMessage?.content.trim() || undefined; + const triggerMessageLoading = + Boolean(triggerMessageId) && + triggerMessageQuery.isFetching && + !triggerMessageQuery.data; + const triggerDescription = workflowTriggerDescription(formState.trigger, { + authorLabel: triggerAuthorLabel ?? undefined, + messageLabel: triggerMessageLabel, + messageLoading: triggerMessageLoading, + }); + const TriggerIcon = TRIGGER_ICONS[formState.trigger.on]; const updateFormState = React.useCallback( (next: WorkflowFormState) => { @@ -547,10 +725,23 @@ export function WorkflowFormBuilder({ {scopeField ?
{scopeField}
: null}
    + } disabled={disabled} - icon={} - label={`Trigger: ${TRIGGER_LABELS[formState.trigger.on]}`} + icon={ +