- {TriggerIcon ? (
+ {triggerReaction ? (
+
+ ) : TriggerIcon ? (
) : (
@@ -144,7 +280,16 @@ export function WorkflowCard({
<>
-
+ {actionReactionPresentation ? (
+
+ ) : (
+
+ )}
>
) : null}
@@ -190,11 +335,14 @@ export function WorkflowCard({
{triggerSummary ? (
- {triggerSummary}
+
) : null}
- {cardLabel}
+
{description ? (
diff --git a/desktop/src/features/workflows/ui/workflowDefinition.test.mjs b/desktop/src/features/workflows/ui/workflowDefinition.test.mjs
index a4601945a..ffe3e01bd 100644
--- a/desktop/src/features/workflows/ui/workflowDefinition.test.mjs
+++ b/desktop/src/features/workflows/ui/workflowDefinition.test.mjs
@@ -1,15 +1,18 @@
import assert from "node:assert/strict";
import test from "node:test";
-import { getWorkflowCardLabel } from "./workflowDefinition.ts";
+import {
+ getWorkflowCardLabel,
+ getWorkflowPrimaryActionEmoji,
+} from "./workflowDefinition.ts";
test("builds a plain-language workflow card label", () => {
assert.equal(
getWorkflowCardLabel({
trigger: { on: "message_posted" },
- steps: [{ action: "send_message" }],
+ steps: [{ action: "send_message", text: "Deploying now" }],
}),
- "When a message is posted, send a channel message",
+ "When a message is posted, send “Deploying now”",
);
assert.equal(
@@ -22,6 +25,90 @@ test("builds a plain-language workflow card label", () => {
}),
"When someone reacts with 🔥, wait 5m, then 1 more step",
);
+
+ assert.equal(
+ getWorkflowCardLabel(
+ {
+ trigger: {
+ on: "reaction_added",
+ filter: 'trigger_emoji == "🫠"',
+ },
+ steps: [{ action: "add_reaction", emoji: "👍" }],
+ },
+ { triggerReaction: "🫠" },
+ ),
+ "When someone reacts with 🫠, add a 👍 reaction",
+ );
+
+ assert.equal(
+ getWorkflowCardLabel(
+ {
+ trigger: { on: "message_posted" },
+ steps: [{ action: "send_message", text: "Deploying now" }],
+ },
+ { actionChannelLabel: "releases" },
+ ),
+ "When a message is posted, send “Deploying now” in #releases",
+ );
+
+ assert.equal(
+ getWorkflowCardLabel(
+ {
+ trigger: {
+ on: "message_posted",
+ filter: `trigger_author == "${"a".repeat(64)}"`,
+ },
+ steps: [{ action: "send_message", text: "Deploying now" }],
+ },
+ { triggerDescription: "Message posted by Carl" },
+ ),
+ "When a message is posted by Carl, send “Deploying now”",
+ );
+
+ assert.equal(
+ getWorkflowCardLabel(
+ {
+ trigger: {
+ on: "message_posted",
+ filter: 'str_contains(trigger_text, "deploy")',
+ },
+ steps: [{ action: "call_webhook" }],
+ },
+ { triggerDescription: "Message contains “deploy”" },
+ ),
+ "When a message contains “deploy”, call a webhook",
+ );
+
+ assert.equal(
+ getWorkflowCardLabel(
+ {
+ trigger: {
+ on: "message_posted",
+ filter: `trigger_text == "FUCK" && trigger_author == "${"a".repeat(64)}"`,
+ },
+ steps: [{ action: "send_message", text: "{{trigger.text}} yourself" }],
+ },
+ {
+ triggerDescription: "Message “FUCK” is posted by Carl",
+ },
+ ),
+ "When “FUCK” is posted by Carl, send “{{trigger.text}} yourself”",
+ );
+});
+
+test("returns the primary add-reaction emoji for rich card rendering", () => {
+ assert.equal(
+ getWorkflowPrimaryActionEmoji({
+ steps: [{ action: "add_reaction", emoji: ":blob-wave:" }],
+ }),
+ ":blob-wave:",
+ );
+ assert.equal(
+ getWorkflowPrimaryActionEmoji({
+ steps: [{ action: "send_message", emoji: ":blob-wave:" }],
+ }),
+ null,
+ );
});
test("summarizes common and custom schedules", () => {
diff --git a/desktop/src/features/workflows/ui/workflowDefinition.ts b/desktop/src/features/workflows/ui/workflowDefinition.ts
index 3813082c2..ee3799bff 100644
--- a/desktop/src/features/workflows/ui/workflowDefinition.ts
+++ b/desktop/src/features/workflows/ui/workflowDefinition.ts
@@ -3,9 +3,18 @@ import {
scheduleFormFromTrigger,
SCHEDULE_FREQUENCY_LABELS,
} from "./workflowSchedule";
-import { ACTION_LABELS, TRIGGER_LABELS } from "./workflowFormTypes";
-import type { ActionType } from "./workflowFormTypes";
-import type { TriggerType } from "./workflowFormTypes";
+import {
+ ACTION_LABELS,
+ TRIGGER_LABELS,
+ TRIGGER_TYPES,
+} from "./workflowFormTypes";
+import type {
+ ActionType,
+ StepFormState,
+ TriggerConfig,
+ TriggerType,
+} from "./workflowFormTypes";
+import { workflowStepDescription } from "./workflowStepDescription";
function asRecord(value: unknown): Record | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -44,6 +53,41 @@ export function getWorkflowPrimaryAction(
return nonEmptyString(getWorkflowSteps(definition)[0]?.action);
}
+export function getWorkflowPrimaryActionChannel(
+ definition: Record,
+): string | null {
+ return nonEmptyString(getWorkflowSteps(definition)[0]?.channel);
+}
+
+export function getWorkflowPrimaryActionEmoji(
+ definition: Record,
+): string | null {
+ const step = getWorkflowSteps(definition)[0];
+ return step?.action === "add_reaction" ? nonEmptyString(step.emoji) : null;
+}
+
+export function getWorkflowTriggerConfig(
+ definition: Record,
+): TriggerConfig | null {
+ const trigger = asRecord(definition.trigger);
+ const triggerType = nonEmptyString(trigger?.on);
+ if (
+ !trigger ||
+ !triggerType ||
+ !TRIGGER_TYPES.includes(triggerType as TriggerType)
+ ) {
+ return null;
+ }
+
+ return {
+ on: triggerType as TriggerType,
+ filter: nonEmptyString(trigger.filter) ?? undefined,
+ emoji: nonEmptyString(trigger.emoji) ?? undefined,
+ cron: nonEmptyString(trigger.cron) ?? undefined,
+ interval: nonEmptyString(trigger.interval) ?? undefined,
+ };
+}
+
function getScheduleCardClause(trigger: Record): string {
const schedule = scheduleFormFromTrigger({
on: "schedule",
@@ -69,7 +113,10 @@ function getScheduleCardClause(trigger: Record): string {
}
}
-function getTriggerCardClause(definition: Record): string {
+function getTriggerCardClause(
+ definition: Record,
+ presentedReaction?: string,
+): string {
const trigger = asRecord(definition.trigger);
const triggerType = nonEmptyString(trigger?.on);
if (!trigger || !triggerType) return "When this workflow starts";
@@ -80,7 +127,7 @@ function getTriggerCardClause(definition: Record): string {
? "When a matching message is posted"
: "When a message is posted";
case "reaction_added": {
- const emoji = nonEmptyString(trigger.emoji);
+ const emoji = nonEmptyString(presentedReaction ?? trigger.emoji);
return emoji
? `When someone reacts with ${emoji}`
: "When someone adds a reaction";
@@ -98,32 +145,187 @@ function getTriggerCardClause(definition: Record): string {
}
}
-function getActionCardClause(step: Record): string | null {
+function eventDescriptionCardClause(
+ description: string,
+ event: "message" | "diff",
+): string | null {
+ const subject = event === "message" ? "Message" : "Diff";
+ const eventPhrase = `${subject} posted`;
+ if (!description.startsWith(eventPhrase)) return null;
+
+ const detail = description.slice(eventPhrase.length);
+ if (!detail) return `When a ${event} is posted`;
+ if (detail.startsWith(" is ") || detail.startsWith(" is not ")) {
+ return `When a ${event}${detail}`;
+ }
+ if (
+ detail.startsWith(" containing ") ||
+ detail.startsWith(" without ") ||
+ detail.startsWith(" starting with ") ||
+ detail.startsWith(" ending with ") ||
+ detail === " with text"
+ ) {
+ return `When a ${event}${detail} is posted`;
+ }
+ return `When a ${event} is posted${detail}`;
+}
+
+function textDescriptionCardClause(
+ description: string,
+ event: "message" | "diff",
+): string | null {
+ const subject = event === "message" ? "Message" : "Diff";
+ if (!description.startsWith(`${subject} `)) return null;
+ return `When a ${event}${description.slice(subject.length)}`;
+}
+
+function postedValueCardClause(description: string): string | null {
+ const postedIndex = description.indexOf(" posted");
+ if (postedIndex < 1) return null;
+
+ const value = description.slice(0, postedIndex);
+ if (!value.startsWith("“") && !value.startsWith("Anything except ")) {
+ return null;
+ }
+ const normalizedValue = value.startsWith("Anything")
+ ? `anything${value.slice("Anything".length)}`
+ : value;
+ return `When ${normalizedValue} is posted${description.slice(postedIndex + " posted".length)}`;
+}
+
+function qualifiedPostedEventCardClause(
+ description: string,
+ event: "message" | "diff",
+): string | null {
+ const subject = event === "message" ? "Message" : "Diff";
+ const postedIndex = description.indexOf(" posted");
+ if (postedIndex < 1) return null;
+
+ const suffix = description.slice(postedIndex + " posted".length);
+ const otherTextPrefix = `${subject} with text other than `;
+ if (description.startsWith(otherTextPrefix)) {
+ const value = description.slice(otherTextPrefix.length, postedIndex);
+ return `When a ${event} with text other than ${value} is posted${suffix}`;
+ }
+
+ if (description.startsWith(`${subject} with text posted`)) {
+ return `When a ${event} with text is posted${suffix}`;
+ }
+ if (description.startsWith(`${subject} without text posted`)) {
+ return `When a ${event} without text is posted${suffix}`;
+ }
+ return null;
+}
+
+function exactValueEventCardClause(
+ description: string,
+ event: "message" | "diff",
+): string | null {
+ const subject = event === "message" ? "Message" : "Diff";
+ const prefix = `${subject} “`;
+ const postedMarker = " is posted";
+ if (!description.startsWith(prefix)) return null;
+
+ const postedIndex = description.indexOf(postedMarker);
+ if (postedIndex < prefix.length) return null;
+ const value = description.slice(subject.length + 1, postedIndex);
+ const suffix = description.slice(postedIndex + postedMarker.length);
+ return event === "message"
+ ? `When ${value} is posted${suffix}`
+ : `When a diff ${value} is posted${suffix}`;
+}
+
+function getPresentedTriggerCardClause(description?: string): string | null {
+ if (!description) return null;
+
+ const eventClause =
+ exactValueEventCardClause(description, "message") ??
+ exactValueEventCardClause(description, "diff") ??
+ qualifiedPostedEventCardClause(description, "message") ??
+ qualifiedPostedEventCardClause(description, "diff") ??
+ postedValueCardClause(description) ??
+ eventDescriptionCardClause(description, "message") ??
+ eventDescriptionCardClause(description, "diff") ??
+ textDescriptionCardClause(description, "message") ??
+ textDescriptionCardClause(description, "diff");
+ if (eventClause) return eventClause;
+
+ if (description.startsWith("Reaction added")) {
+ return `When a reaction is added${description.slice("Reaction added".length)}`;
+ }
+ if (
+ description.startsWith("Any reaction ") &&
+ description.endsWith(" added")
+ ) {
+ return `When ${description.slice(0, -" added".length).toLocaleLowerCase()} is added`;
+ }
+ return null;
+}
+
+function getActionCardClause(
+ step: Record,
+ channelLabel?: string,
+): string | null {
const action = nonEmptyString(step.action);
if (!action) return null;
+ const actionLabel = ACTION_LABELS[action as ActionType];
+ const parsedStep: StepFormState | null = actionLabel
+ ? {
+ action: action as ActionType,
+ id: nonEmptyString(step.id) ?? "step_1",
+ duration: nonEmptyString(step.duration) ?? undefined,
+ emoji: nonEmptyString(step.emoji) ?? undefined,
+ from: nonEmptyString(step.from) ?? undefined,
+ message: nonEmptyString(step.message) ?? undefined,
+ method: nonEmptyString(step.method) ?? undefined,
+ name: nonEmptyString(step.name) ?? undefined,
+ text: nonEmptyString(step.text) ?? undefined,
+ to: nonEmptyString(step.to) ?? undefined,
+ topic: nonEmptyString(step.topic) ?? undefined,
+ url: nonEmptyString(step.url) ?? undefined,
+ }
+ : null;
+ const detail = parsedStep
+ ? workflowStepDescription(parsedStep, {
+ channelLabel,
+ includeName: false,
+ })
+ : null;
+ const configuredDetail = detail && detail !== actionLabel ? detail : null;
+
switch (action) {
case "delay": {
- const duration = nonEmptyString(step.duration);
- return duration ? `wait ${duration}` : "wait for a moment";
+ return configuredDetail
+ ? `wait ${configuredDetail}`
+ : "wait for a moment";
}
case "send_message":
- return "send a channel message";
+ if (!configuredDetail) return "send a channel message";
+ return nonEmptyString(step.text)
+ ? `send ${configuredDetail}`
+ : `send a message in ${configuredDetail}`;
case "call_webhook":
- return "call a webhook";
+ return configuredDetail ? `call ${configuredDetail}` : "call a webhook";
case "send_dm":
- return "send a direct message";
+ return configuredDetail
+ ? `send ${configuredDetail}`
+ : "send a direct message";
case "request_approval":
- return "request approval";
+ return configuredDetail
+ ? `request approval: ${configuredDetail}`
+ : "request approval";
case "add_reaction": {
- const emoji = nonEmptyString(step.emoji);
- return emoji ? `add a ${emoji} reaction` : "add a reaction";
+ return configuredDetail
+ ? `add a ${configuredDetail} reaction`
+ : "add a reaction";
}
case "set_channel_topic":
- return "update the channel topic";
+ return configuredDetail
+ ? `set the channel topic to ${configuredDetail}`
+ : "update the channel topic";
default: {
- const knownLabel = ACTION_LABELS[action as ActionType];
- return (knownLabel ?? humanizeIdentifier(action)).toLocaleLowerCase();
+ return (actionLabel ?? humanizeIdentifier(action)).toLocaleLowerCase();
}
}
}
@@ -131,10 +333,19 @@ function getActionCardClause(step: Record): string | null {
/** Build a short plain-language label from the workflow's trigger and steps. */
export function getWorkflowCardLabel(
definition: Record,
+ options: {
+ actionChannelLabel?: string;
+ triggerDescription?: string;
+ triggerReaction?: string;
+ } = {},
): string {
- const triggerClause = getTriggerCardClause(definition);
+ const triggerClause =
+ getPresentedTriggerCardClause(options.triggerDescription) ??
+ getTriggerCardClause(definition, options.triggerReaction);
const steps = getWorkflowSteps(definition);
- const firstAction = steps[0] ? getActionCardClause(steps[0]) : null;
+ const firstAction = steps[0]
+ ? getActionCardClause(steps[0], options.actionChannelLabel)
+ : null;
if (!firstAction) return triggerClause;
const remainingStepCount = steps.length - 1;
diff --git a/desktop/src/features/workflows/ui/workflowStepDescription.test.mjs b/desktop/src/features/workflows/ui/workflowStepDescription.test.mjs
index 6971268bc..7253692e7 100644
--- a/desktop/src/features/workflows/ui/workflowStepDescription.test.mjs
+++ b/desktop/src/features/workflows/ui/workflowStepDescription.test.mjs
@@ -84,3 +84,18 @@ test("falls back to the action label until a step is configured", () => {
"Send message",
);
});
+
+test("can omit a custom step name when composing an index label", () => {
+ assert.equal(
+ workflowStepDescription(
+ {
+ id: "message",
+ action: "send_message",
+ name: "say hello",
+ text: "hey yourself",
+ },
+ { includeName: false },
+ ),
+ "“hey yourself”",
+ );
+});
diff --git a/desktop/src/features/workflows/ui/workflowStepDescription.ts b/desktop/src/features/workflows/ui/workflowStepDescription.ts
index c1f429b82..cddce5d9e 100644
--- a/desktop/src/features/workflows/ui/workflowStepDescription.ts
+++ b/desktop/src/features/workflows/ui/workflowStepDescription.ts
@@ -64,9 +64,9 @@ function configuredStepDetail(
/** Build the concise action summary rendered on a workflow step node. */
export function workflowStepDescription(
step: StepFormState,
- options: { channelLabel?: string } = {},
+ options: { channelLabel?: string; includeName?: boolean } = {},
): string {
- const name = step.name?.trim();
+ const name = options.includeName === false ? undefined : step.name?.trim();
const detail = configuredStepDetail(step, options.channelLabel);
if (name && detail) return `${name} · ${detail}`;
return name || detail || ACTION_LABELS[step.action];
diff --git a/desktop/src/features/workflows/ui/workflowTriggerDescription.test.mjs b/desktop/src/features/workflows/ui/workflowTriggerDescription.test.mjs
index e682015f4..505d33b2c 100644
--- a/desktop/src/features/workflows/ui/workflowTriggerDescription.test.mjs
+++ b/desktop/src/features/workflows/ui/workflowTriggerDescription.test.mjs
@@ -20,7 +20,31 @@ test("describes selected trigger conditions on the workflow canvas", () => {
on: "message_posted",
filter: 'str_contains(trigger_text, "deploy")',
}),
- "Message posted containing “deploy”",
+ "Message contains “deploy”",
+ );
+
+ assert.equal(
+ workflowTriggerDescription({
+ on: "message_posted",
+ filter: '!str_contains(trigger_text, "deploy")',
+ }),
+ "Message doesn’t contain “deploy”",
+ );
+
+ assert.equal(
+ workflowTriggerDescription({
+ on: "message_posted",
+ filter: 'str_starts_with(trigger_text, "deploy")',
+ }),
+ "Message starts with “deploy”",
+ );
+
+ assert.equal(
+ workflowTriggerDescription({
+ on: "message_posted",
+ filter: 'str_ends_with(trigger_text, "done")',
+ }),
+ "Message ends with “done”",
);
assert.equal(
@@ -28,7 +52,7 @@ test("describes selected trigger conditions on the workflow canvas", () => {
on: "message_posted",
filter: 'trigger_text == "deploy"',
}),
- "Message posted is “deploy”",
+ "Message “deploy” is posted",
);
assert.equal(
@@ -36,7 +60,29 @@ test("describes selected trigger conditions on the workflow canvas", () => {
on: "message_posted",
filter: 'trigger_text != "deploy"',
}),
- "Message posted is not “deploy”",
+ "Message with text other than “deploy” posted",
+ );
+
+ assert.equal(
+ workflowTriggerDescription(
+ {
+ on: "message_posted",
+ filter: `trigger_text == "FUCK" && trigger_author == "${"a".repeat(64)}"`,
+ },
+ { authorLabel: "Carl" },
+ ),
+ "Message “FUCK” is posted by Carl",
+ );
+
+ assert.equal(
+ workflowTriggerDescription(
+ {
+ on: "message_posted",
+ filter: `str_contains(trigger_text, "deploy") && trigger_author == "${"a".repeat(64)}"`,
+ },
+ { authorLabel: "Carl" },
+ ),
+ "Message by Carl contains “deploy”",
);
assert.equal(
@@ -44,7 +90,7 @@ test("describes selected trigger conditions on the workflow canvas", () => {
on: "message_posted",
filter: "str_len(trigger_text) == 0",
}),
- "Message posted without text",
+ "Message without text posted",
);
assert.equal(
@@ -52,7 +98,7 @@ test("describes selected trigger conditions on the workflow canvas", () => {
on: "message_posted",
filter: "str_len(trigger_text) > 0",
}),
- "Messaged posted with text",
+ "Message with text posted",
);
assert.equal(
diff --git a/desktop/src/features/workflows/ui/workflowTriggerDescription.ts b/desktop/src/features/workflows/ui/workflowTriggerDescription.ts
index 2e84a9e2f..19011e9a4 100644
--- a/desktop/src/features/workflows/ui/workflowTriggerDescription.ts
+++ b/desktop/src/features/workflows/ui/workflowTriggerDescription.ts
@@ -34,26 +34,25 @@ function textConditionDescription(
eventPhrase: string,
condition: ParsedConditionExpression,
): string {
+ const subject = eventPhrase.replace(/ posted$/, "");
const value = quotedValue(condition.value);
switch (condition.operator) {
case "contains":
- return `${eventPhrase} containing ${value}`;
+ return `${subject} contains ${value}`;
case "not_contains":
- return `${eventPhrase} without ${value}`;
+ return `${subject} doesn’t contain ${value}`;
case "starts_with":
- return `${eventPhrase} starting with ${value}`;
+ return `${subject} starts with ${value}`;
case "ends_with":
- return `${eventPhrase} ending with ${value}`;
+ return `${subject} ends with ${value}`;
case "equals":
- return `${eventPhrase} is ${value}`;
+ return `${subject} ${value} is posted`;
case "not_equals":
- return `${eventPhrase} is not ${value}`;
+ return `${subject} with text other than ${value} posted`;
case "is_not_empty":
- return eventPhrase === "Message posted"
- ? "Messaged posted with text"
- : `${eventPhrase} containing text`;
+ return `${subject} with text posted`;
case "is_empty":
- return `${eventPhrase} without text`;
+ return `${subject} without text posted`;
}
}
@@ -107,10 +106,17 @@ export function workflowTriggerDescription(
if (authorCondition) {
const author =
options.authorLabel ?? truncatePubkey(authorCondition.value);
- description +=
+ const attribution =
authorCondition.operator === "not_equals"
? ` by anyone except ${author}`
: ` by ${author}`;
+ const subject = eventPhrase.replace(/ posted$/, "");
+ description =
+ textCondition &&
+ description.startsWith(subject) &&
+ !/ (?:is )?posted$/.test(description)
+ ? `${subject}${attribution}${description.slice(subject.length)}`
+ : `${description}${attribution}`;
}
if (messageCondition) {
const message = messageReference(