From 7075eba33eda05b859b0f21f360d90129a840b2d Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 13 Aug 2026 23:27:19 -0700 Subject: [PATCH] Show workflow step details Signed-off-by: Taylor Ho --- .../workflows/ui/WorkflowFormBuilder.tsx | 129 +++++------------- .../workflows/ui/WorkflowNodeDescriptions.tsx | 128 +++++++++++++++++ .../workflows/ui/workflowFormTypes.ts | 10 +- .../ui/workflowStepDescription.test.mjs | 86 ++++++++++++ .../workflows/ui/workflowStepDescription.ts | 73 ++++++++++ desktop/tests/e2e/workflows.spec.ts | 45 ++++-- 6 files changed, 358 insertions(+), 113 deletions(-) create mode 100644 desktop/src/features/workflows/ui/WorkflowNodeDescriptions.tsx create mode 100644 desktop/src/features/workflows/ui/workflowStepDescription.test.mjs create mode 100644 desktop/src/features/workflows/ui/workflowStepDescription.ts diff --git a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx index 79750d4d2..b3b1402d5 100644 --- a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx +++ b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx @@ -5,7 +5,6 @@ import { Check, ChevronDown, GitPullRequest, - LoaderCircle, MessageSquare, Plus, SmilePlus, @@ -38,8 +37,11 @@ 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 { + StepReactionDescription, + TriggerNodeDescription, +} from "./WorkflowNodeDescriptions"; import { WorkflowScheduleFields } from "./WorkflowScheduleFields"; import { WorkflowStepCard } from "./WorkflowStepCard"; import { @@ -66,10 +68,8 @@ import type { WorkflowFormState, } from "./workflowFormTypes"; import { defaultScheduleTrigger } from "./workflowSchedule"; -import { - TRIGGER_MESSAGE_LOADING_LABEL, - workflowTriggerDescription, -} from "./workflowTriggerDescription"; +import { workflowTriggerDescription } from "./workflowTriggerDescription"; +import { workflowStepDescription } from "./workflowStepDescription"; const TRIGGER_ICONS: Record = { diff_posted: GitPullRequest, @@ -390,90 +390,6 @@ 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, @@ -789,15 +705,36 @@ export function WorkflowFormBuilder({ /> {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 showActionSubtitle = + nodeDescription !== actionLabel; return ( + ) : ( + nodeDescription + ) + } disabled={disabled} key={step.id} - label={`Step ${index + 1}: ${nodeTitle}`} + label={`Step ${index + 1}: ${nodeDescription}`} number={index + 1} onAddAfter={(action) => insertStep(index + 1, action)} onClick={() => selectNode({ type: "step", index })} @@ -807,7 +744,9 @@ export function WorkflowFormBuilder({ selectedNode.index === index } showTitle={false} - subtitle={stepName ? actionLabel : undefined} + subtitle={ + showActionSubtitle ? actionLabel : undefined + } terminal={index === formState.steps.length - 1} title={`Step ${index + 1}`} /> diff --git a/desktop/src/features/workflows/ui/WorkflowNodeDescriptions.tsx b/desktop/src/features/workflows/ui/WorkflowNodeDescriptions.tsx new file mode 100644 index 000000000..cf3b5a439 --- /dev/null +++ b/desktop/src/features/workflows/ui/WorkflowNodeDescriptions.tsx @@ -0,0 +1,128 @@ +import { LoaderCircle } from "lucide-react"; +import { motion } from "motion/react"; + +import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { TRIGGER_MESSAGE_LOADING_LABEL } from "./workflowTriggerDescription"; + +export 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 StepReactionDescription({ + description, + emoji, + emojiUrl, +}: { + description: string; + emoji: string; + emojiUrl?: string; +}) { + const emojiIndex = description.lastIndexOf(emoji); + if (emojiIndex < 0) return description; + + const prefix = description.slice(0, emojiIndex); + const suffix = description.slice(emojiIndex + emoji.length); + return ( + + {prefix ? {prefix} : null} + {emojiUrl ? ( + {emoji} + ) : ( + + {emoji} + + )} + {suffix ? {suffix} : null} + + ); +} diff --git a/desktop/src/features/workflows/ui/workflowFormTypes.ts b/desktop/src/features/workflows/ui/workflowFormTypes.ts index 68e2296ab..ce61fbf85 100644 --- a/desktop/src/features/workflows/ui/workflowFormTypes.ts +++ b/desktop/src/features/workflows/ui/workflowFormTypes.ts @@ -88,12 +88,12 @@ export const TRIGGER_LABELS: Record = { export const ACTION_LABELS: Record = { delay: "Delay", - send_message: "Send Message", + 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", + call_webhook: "Call webhook", + request_approval: "Request approval", + add_reaction: "Add reaction", + set_channel_topic: "Set channel topic", }; function toHeaderRows( diff --git a/desktop/src/features/workflows/ui/workflowStepDescription.test.mjs b/desktop/src/features/workflows/ui/workflowStepDescription.test.mjs new file mode 100644 index 000000000..6971268bc --- /dev/null +++ b/desktop/src/features/workflows/ui/workflowStepDescription.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { workflowStepDescription } from "./workflowStepDescription.ts"; + +test("describes configured workflow steps on the canvas", () => { + assert.equal( + workflowStepDescription({ + id: "message", + action: "send_message", + name: "say hello", + text: "hey yourself", + }), + "say hello · “hey yourself”", + ); + assert.equal( + workflowStepDescription( + { + id: "channel-message", + action: "send_message", + text: "deploying now", + channel: "channel-id", + }, + { channelLabel: "deployments" }, + ), + "“deploying now” in #deployments", + ); + assert.equal( + workflowStepDescription({ + id: "delay", + action: "delay", + duration: "5m", + }), + "5m", + ); + assert.equal( + workflowStepDescription({ + id: "webhook", + action: "call_webhook", + method: "PATCH", + url: "https://example.com/deploy", + }), + "PATCH https://example.com/deploy", + ); + assert.equal( + workflowStepDescription({ + id: "dm", + action: "send_dm", + text: "Build finished", + to: "team-on-call", + }), + "“Build finished” to team-on-call", + ); + assert.equal( + workflowStepDescription({ + id: "approval", + action: "request_approval", + message: "Ship the release?", + from: "release-managers", + }), + "“Ship the release?” from release-managers", + ); + assert.equal( + workflowStepDescription({ + id: "reaction", + action: "add_reaction", + emoji: ":buzz:", + }), + ":buzz:", + ); + assert.equal( + workflowStepDescription({ + id: "topic", + action: "set_channel_topic", + topic: "Shipping this week", + }), + "“Shipping this week”", + ); +}); + +test("falls back to the action label until a step is configured", () => { + assert.equal( + workflowStepDescription({ id: "message", action: "send_message" }), + "Send message", + ); +}); diff --git a/desktop/src/features/workflows/ui/workflowStepDescription.ts b/desktop/src/features/workflows/ui/workflowStepDescription.ts new file mode 100644 index 000000000..c1f429b82 --- /dev/null +++ b/desktop/src/features/workflows/ui/workflowStepDescription.ts @@ -0,0 +1,73 @@ +import { truncatePubkey } from "@/shared/lib/pubkey"; +import { ACTION_LABELS } from "./workflowFormTypes"; +import type { StepFormState } from "./workflowFormTypes"; + +const MAX_DETAIL_LENGTH = 42; + +function compact(value: string): string { + const normalized = value.trim().replaceAll(/\s+/g, " "); + return normalized.length > MAX_DETAIL_LENGTH + ? `${normalized.slice(0, MAX_DETAIL_LENGTH - 3)}...` + : normalized; +} + +function quoted(value: string | undefined): string | null { + const normalized = value ? compact(value) : ""; + return normalized ? `“${normalized}”` : null; +} + +function destination(value: string | undefined): string | null { + const normalized = value?.trim(); + if (!normalized) return null; + return /^[0-9a-f]{64}$/i.test(normalized) + ? truncatePubkey(normalized) + : compact(normalized); +} + +function configuredStepDetail( + step: StepFormState, + channelLabel?: string, +): string | null { + switch (step.action) { + case "delay": + return step.duration?.trim() || null; + case "send_message": { + const text = quoted(step.text); + const channel = channelLabel ? `#${channelLabel}` : null; + if (text && channel) return `${text} in ${channel}`; + return text ?? channel; + } + case "send_dm": { + const text = quoted(step.text); + const recipient = destination(step.to); + if (text && recipient) return `${text} to ${recipient}`; + return text ?? recipient; + } + case "call_webhook": { + const url = step.url?.trim(); + if (!url) return null; + return `${step.method?.trim() || "POST"} ${compact(url)}`; + } + case "request_approval": { + const message = quoted(step.message); + const approver = destination(step.from); + if (message && approver) return `${message} from ${approver}`; + return message ?? approver; + } + case "add_reaction": + return step.emoji?.trim() || null; + case "set_channel_topic": + return quoted(step.topic); + } +} + +/** Build the concise action summary rendered on a workflow step node. */ +export function workflowStepDescription( + step: StepFormState, + options: { channelLabel?: string } = {}, +): string { + const name = 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/tests/e2e/workflows.spec.ts b/desktop/tests/e2e/workflows.spec.ts index f64be22a0..122aeb81c 100644 --- a/desktop/tests/e2e/workflows.spec.ts +++ b/desktop/tests/e2e/workflows.spec.ts @@ -57,19 +57,19 @@ async function expectUnsupportedActionWarnings( ) { await expect(menu.getByRole("menuitem")).toHaveText([ "Delay", - "Send Message", - "Call Webhook", + "Send message", + "Call webhook", "Send DM", - "Request Approval", - "Add Reaction", - "Set Channel Topic", + "Request approval", + "Add reaction", + "Set channel topic", ]); for (const label of [ "Send DM", - "Request Approval", - "Add Reaction", - "Set Channel Topic", + "Request approval", + "Add reaction", + "Set channel topic", ]) { await expect( menu @@ -78,7 +78,7 @@ async function expectUnsupportedActionWarnings( ).toBeVisible(); } - for (const label of ["Delay", "Send Message", "Call Webhook"]) { + for (const label of ["Delay", "Send message", "Call webhook"]) { await expect( menu .getByRole("menuitem", { name: label, exact: true }) @@ -344,7 +344,7 @@ test("keeps a step condition separate from its action configuration", async ({ await page.getByRole("button", { name: "Create Workflow" }).click(); const dialog = page.getByRole("dialog"); await dialog.getByRole("button", { name: "Add step" }).click(); - await page.getByRole("menuitem", { name: "Send Message" }).click(); + await page.getByRole("menuitem", { name: "Send message" }).click(); const inspector = dialog.getByTestId("workflow-node-inspector"); await expect(inspector.getByLabel("Message text")).toBeVisible(); @@ -518,7 +518,7 @@ test("chooses an add-reaction step emoji with the app emoji picker", async ({ const dialog = page.getByRole("dialog"); await dialog.getByRole("button", { name: "Add step" }).click(); await expectUnsupportedActionWarnings(page.getByRole("menu")); - await page.getByRole("menuitem", { name: "Add Reaction" }).click(); + await page.getByRole("menuitem", { name: "Add reaction" }).click(); const inspector = dialog.getByTestId("workflow-node-inspector"); await expect(inspector).toContainText( @@ -538,6 +538,13 @@ test("chooses an add-reaction step emoji with the app emoji picker", async ({ await picker.getByRole("button", { name: ":buzz:" }).first().click(); await expect(stepEmoji).toContainText(":buzz:"); + const reactionStepNode = dialog.getByRole("button", { + name: "Step 1: :buzz:", + }); + await expect(reactionStepNode).toContainText("Add reaction"); + await expect( + reactionStepNode.getByTestId("workflow-step-reaction-emoji"), + ).toHaveAttribute("alt", ":buzz:"); await dialog.getByRole("tab", { name: "YAML" }).click(); await expect(dialog.getByLabel("Workflow YAML")).toHaveValue( /action: add_reaction[\s\S]*emoji: ":buzz:"/, @@ -713,7 +720,7 @@ test("omits destination controls for a channel workflow", async ({ page }) => { await dialog.getByLabel("Trigger event").click(); await page.getByRole("menuitem", { name: "Webhook" }).click(); await dialog.getByRole("button", { name: "Add step" }).click(); - await page.getByRole("menuitem", { name: "Send Message" }).click(); + await page.getByRole("menuitem", { name: "Send message" }).click(); await expect( dialog.getByRole("combobox", { name: "Channel override (optional)" }), @@ -747,7 +754,7 @@ test("opens node configuration in a contextual inspector", async ({ page }) => { await expect(initialIngress.getByLabel("Add step")).toBeVisible(); await dialog.getByRole("button", { name: "Add step" }).click(); - await page.getByRole("menuitem", { name: "Send Message" }).click(); + await page.getByRole("menuitem", { name: "Send message" }).click(); const triggerNode = dialog.getByRole("button", { name: /^Trigger:/ }); const stepNode = dialog.getByRole("button", { name: /^Step 1:/ }); await expect(inspector.getByLabel("Step ID")).toHaveValue("step_1"); @@ -756,6 +763,18 @@ test("opens node configuration in a contextual inspector", async ({ page }) => { "send_message", ); await expect(inspector.getByLabel("Message text")).toBeVisible(); + await inspector.getByLabel("Message text").fill("hey yourself"); + await expect(stepNode).toHaveAttribute( + "aria-label", + "Step 1: “hey yourself”", + ); + await expect(stepNode).toContainText("Send message"); + await expect(stepNode).toContainText("“hey yourself”"); + await inspector.getByLabel("Name (optional)").fill("say hello"); + await expect(stepNode).toHaveAttribute( + "aria-label", + "Step 1: say hello · “hey yourself”", + ); await expect( inspector.getByRole("heading", { name: "Step condition" }), ).toBeVisible();