Files
buzz/desktop/src/features/workflows/ui/WorkflowStepCard.tsx
Taylor Ho 0e48ff2691 feat(workflows): clarify workflow setup and activation (#6470)
**Category:** new-feature
**User Impact:** Workflow authors can build filtered, runtime-aware
automations, understand them at a glance, and get a clear warning before
turning on workflows likely to run often.
**Problem:** Workflow setup exposed raw configuration without enough
help composing message templates, filtering triggers, or understanding
saved behavior; activation could also make a broadly triggered workflow
live without explaining its likely frequency.
**Solution:** Batch 3 adds local, deterministic template variables,
trigger filters, and semantic summaries, then refines cards and
activation around configured behavior and a risk-aware warning boundary.
Scheduling remains the already-shipped implementation, advanced
expressions remain lossless, and network-backed identity/message
enrichment stays in Batch 4.

| Message inputs | Trigger filters |
| --- | --- |
| Caret-aware, keyboard-accessible suggestions expose trigger-local
values and safe prior-step outputs in `send_message.text`. | Structured
conditions and validated manual IDs block invalid submission while
preserving advanced expressions. |
| ![Message variable
autocomplete](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6470/message-variables.png)
| ![Structured trigger
filters](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6470/trigger-filters.png)
|

| Workflow cards | Risk-aware activation |
| --- | --- |
| Semantic labels, channel-first hierarchy, configured reaction/action
visuals, real step stacks, and compact status controls make behavior
scannable. | Broad message and frequent schedule triggers explain the
risk before **Turn on**; narrowly scoped triggers proceed without
unnecessary ceremony. |
| ![Semantic workflow
card](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6470/workflow-card.png)
| ![Activation
confirmation](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6470/activation-choice-v2.png)
|

## Changes

<details>
<summary>File changes</summary>

**desktop/src/features/workflows/ui/WorkflowActionsMenu.tsx**  
Separates direct card status controls from secondary actions while
retaining modal status actions.

**desktop/src/features/workflows/ui/WorkflowCard.tsx**  
Adds semantic behavior, channel-first hierarchy, configured
reaction/action visuals, real subsequent-step stacks, status controls,
and reduced-motion-aware trigger feedback.

**desktop/src/features/workflows/ui/WorkflowDialog.tsx**  
Warns before activating broadly triggered workflows while allowing
narrowly scoped workflows to proceed directly.

**desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx**  
Connects structured trigger filters and template-aware step inputs while
preserving schedules, trigger transitions, and selected YAML authority.

**desktop/src/features/workflows/ui/WorkflowStepCard.tsx**  
Replaces generic labels with deterministic configured-step descriptions.

**desktop/src/features/workflows/ui/WorkflowTemplateTextarea.tsx**  
Adds caret-aware variable suggestions with keyboard navigation and focus
restoration.

**desktop/src/features/workflows/ui/WorkflowTriggerConditions.tsx**  
Adds structured local filters, validated author/message IDs, and a
lossless advanced-expression fallback.

**desktop/src/features/workflows/ui/workflowActivationWarning.ts** and
**workflowActivationWarning.test.mjs**
Classify broad message and frequent schedule triggers for contextual
activation warnings.

**desktop/src/features/workflows/ui/workflowConditionExpression.ts** and
**workflowConditionExpression.test.mjs**
Model and cover parsing, serialization, validation, and
advanced-expression preservation.

**desktop/src/features/workflows/ui/workflowDefinition.ts** and
**workflowDefinition.test.mjs**
Preserve trigger/step configuration and derive deterministic card
metadata across YAML round trips.

**desktop/src/features/workflows/ui/workflowStepDescription.ts** and
**workflowStepDescription.test.mjs**
Generate and cover local step summaries.

**desktop/src/features/workflows/ui/workflowTemplateVariables.ts** and
**workflowTemplateVariables.test.mjs**
Define and cover trigger-specific, order-bounded variables and caret
insertion.

**desktop/src/features/workflows/ui/workflowTriggerDescription.ts** and
**workflowTriggerDescription.test.mjs**
Generate and cover semantic trigger summaries without network lookups.

**desktop/tests/e2e/workflow-local-controls.spec.ts** and snapshot  
Cover filters, IDs, advanced expressions, autocomplete, activation
choices, summaries, and YAML authority.

**desktop/tests/e2e/workflow-reaction-picker.spec.ts**  
Covers configured reaction emoji in workflow nodes and summaries.

**desktop/tests/e2e/workflows.spec.ts**  
Covers risk-aware activation warnings, direct safe creation,
duplication, and card status controls.

</details>

## Reproduction steps

1. Create a message-posted workflow in **Workflows**, add a Send message
step, and type `{{trig`; verify keyboard-selectable variables insert at
the caret.
2. Configure message-text and manual ID filters; verify malformed IDs
block submission and advanced expressions survive Form/YAML transitions.
3. Create a broad message workflow; verify **Back** persists nothing,
**Keep off** saves it disabled, and **Turn on** enables it. Confirm a
narrowly triggered webhook skips the warning.
4. Inspect the saved card; verify its channel, semantic behavior,
configured actions/reaction, real step stack, and status are
understandable without opening YAML.

## Validation

Validated at exact clean head `f99503819889b95ee3c61657c5c3850aae35481e`
on base `24ec6a468ec9d0d425ee58fbfc4d416412c446ad`.

- Focused workflow regressions passed 59/60 locally; the only local miss
was a 438-pixel macOS snapshot drift, while the checked-in Linux
baseline comes from the failing CI artifact. Repository pre-push gates
and E2E build/typecheck passed.
- A broader 36-test smoke invocation had 31 passes and five unrelated
pre-existing expectation/snapshot failures, so it is not claimed as
fully green. Adversarial fixes are recorded in [round
one](https://github.com/block/buzz/pull/6470#issuecomment-5366366670)
and [round
two](https://github.com/block/buzz/pull/6470#issuecomment-5366407535).

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Co-authored-by: Mongo <5398c5fd039b963ce132b3e078e7c4af097dd997517bb5e14c2682fe68c25197@buzz.block.builderlab.xyz>
2026-08-21 16:53:50 -07:00

532 lines
17 KiB
TypeScript

import { ChevronRight, Trash2 } from "lucide-react";
import { type ReactNode, useState } from "react";
import { cn } from "@/shared/lib/cn";
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";
import { WorkflowWebhookHeadersEditor } from "./WorkflowWebhookHeadersEditor";
import {
isThreadReplyEligibleTrigger,
supportsMessageTextCondition,
type StepFormState,
type TriggerType,
} from "./workflowFormTypes";
const DEFAULT_STEP_TIMEOUT_SECONDS = 5 * 60;
type StepSetting = "run-controls" | "details";
function StepSettingAccordion({
children,
disabled,
expanded,
label,
onToggle,
summary,
}: {
children: ReactNode;
disabled?: boolean;
expanded: boolean;
label: string;
onToggle: () => void;
summary: string;
}) {
return (
<div>
<button
aria-expanded={expanded}
className="flex min-h-12 w-full items-center gap-3 py-3 text-left transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={disabled}
onClick={onToggle}
type="button"
>
<span className="min-w-0 flex-1 truncate text-base font-medium">
{label}
</span>
<span className="max-w-40 truncate text-sm text-muted-foreground">
{summary}
</span>
<ChevronRight
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground/70 transition-transform duration-150 motion-reduce:transition-none",
expanded && "rotate-90",
)}
/>
</button>
{expanded ? (
<div className="animate-in pb-4 pt-1 fade-in slide-in-from-top-1 duration-150 motion-reduce:animate-none">
{children}
</div>
) : null}
</div>
);
}
function runControlsSummary(step: StepFormState): string {
const hasCondition = Boolean(step.condition?.trim());
const timeout = step.timeoutSecs?.trim();
if (hasCondition && timeout) return `Conditional · ${timeout}`;
if (hasCondition) return "Conditional";
if (timeout) return timeout;
return "Default";
}
function BackendSupportHint({ action }: { action: StepFormState["action"] }) {
switch (action) {
case "send_dm":
return (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-700">
Backend note: `send_dm` is not executed yet, so runs fail at this
step.
</p>
);
case "set_channel_topic":
return (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-700">
Backend note: `set_channel_topic` is not executed yet, so runs fail at
this step.
</p>
);
case "request_approval":
return (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-700">
Backend note: approval gates still stop runs with WF-08; approval
records are not persisted yet.
</p>
);
default:
return null;
}
}
function StepConfigFields({
step,
prefix,
disabled,
previousSteps,
triggerType,
workflowChannelId,
onUpdate,
}: {
step: StepFormState;
prefix: string;
disabled?: boolean;
previousSteps: StepFormState[];
triggerType: TriggerType;
workflowChannelId?: string | null;
onUpdate: (step: StepFormState) => void;
}) {
switch (step.action) {
case "delay":
return (
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-duration`}>Duration</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-duration`}
onChange={(event) =>
onUpdate({ ...step, duration: event.target.value })
}
placeholder="e.g. 5s, 1m, 1h"
value={step.duration ?? ""}
/>
</div>
);
case "send_message":
return (
<div className="space-y-2">
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-text`}>Message text</FieldLabel>
<WorkflowTemplateTextarea
autoCapitalize="off"
className="min-h-[60px] resize-y text-xs"
disabled={disabled}
id={`${prefix}-text`}
onValueChange={(text) => onUpdate({ ...step, text })}
placeholder="e.g. Deployment started by {{trigger.author}}"
previousSteps={previousSteps}
triggerType={triggerType}
value={step.text ?? ""}
/>
</div>
{workflowChannelId ? (
<p className="text-xs text-muted-foreground">
Messages post to the workflow channel selected above.
</p>
) : (
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-channel`}>
Channel override (optional)
</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-channel`}
onChange={(event) =>
onUpdate({ ...step, channel: event.target.value })
}
placeholder="Channel UUID"
value={step.channel ?? ""}
/>
<p className="text-xs text-muted-foreground">
Defaults to the channel that triggered the workflow. Webhook and
manual triggers require a channel.
</p>
{triggerType === "webhook" && !(step.channel ?? "").trim() ? (
<p className="text-xs text-amber-700">
This step will fail for webhook-triggered runs until a channel
override is set.
</p>
) : null}
</div>
)}
{isThreadReplyEligibleTrigger(triggerType) ? (
<div className="flex items-center gap-2">
<Checkbox
checked={step.replyInThread === true}
disabled={disabled}
id={`${prefix}-reply-in-thread`}
onCheckedChange={(checked) =>
onUpdate({ ...step, replyInThread: checked === true })
}
/>
<label className="text-xs" htmlFor={`${prefix}-reply-in-thread`}>
Reply to triggering message in thread
</label>
</div>
) : null}
</div>
);
case "send_dm":
return (
<div className="space-y-2">
<BackendSupportHint action={step.action} />
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-to`}>To (pubkey)</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-to`}
onChange={(event) =>
onUpdate({ ...step, to: event.target.value })
}
placeholder="e.g. {{trigger.author}} or hex pubkey"
value={step.to ?? ""}
/>
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-text`}>Message text</FieldLabel>
<Textarea
autoCapitalize="off"
className="min-h-[60px] resize-y text-xs"
disabled={disabled}
id={`${prefix}-text`}
onChange={(event) =>
onUpdate({ ...step, text: event.target.value })
}
placeholder="DM content"
value={step.text ?? ""}
/>
</div>
</div>
);
case "call_webhook":
return (
<div className="space-y-4">
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-url`}>Endpoint URL</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-url`}
onChange={(event) =>
onUpdate({ ...step, url: event.target.value })
}
placeholder="https://..."
value={step.url ?? ""}
/>
{step.url && !step.url.startsWith("https://") ? (
<p className="text-xs text-destructive">
URL must start with https://
</p>
) : null}
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-method`}>HTTP method</FieldLabel>
<FormSelect
disabled={disabled}
id={`${prefix}-method`}
onChange={(value) => onUpdate({ ...step, method: value })}
value={step.method ?? "POST"}
>
<option value="POST">POST</option>
<option value="GET">GET</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
<option value="DELETE">DELETE</option>
</FormSelect>
</div>
<WorkflowWebhookHeadersEditor
disabled={disabled}
headers={step.headers ?? []}
onChange={(headers) => onUpdate({ ...step, headers })}
stepId={step.id || prefix}
/>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-body`}>
Request body (optional)
</FieldLabel>
<Textarea
autoCapitalize="off"
className="min-h-[60px] resize-y font-mono text-xs"
disabled={disabled}
id={`${prefix}-body`}
onChange={(event) =>
onUpdate({ ...step, body: event.target.value })
}
placeholder='{"key": "{{trigger.text}}"}'
value={step.body ?? ""}
/>
</div>
</div>
);
case "request_approval":
return (
<div className="space-y-2">
<BackendSupportHint action={step.action} />
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-from`}>From (approver)</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-from`}
onChange={(event) =>
onUpdate({ ...step, from: event.target.value })
}
placeholder="Pubkey or role"
value={step.from ?? ""}
/>
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-message`}>Message</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-message`}
onChange={(event) =>
onUpdate({ ...step, message: event.target.value })
}
placeholder="Approval request message"
value={step.message ?? ""}
/>
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-timeout`}>
Timeout (optional)
</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-timeout`}
onChange={(event) =>
onUpdate({ ...step, timeout: event.target.value })
}
placeholder="e.g. 24h"
value={step.timeout ?? ""}
/>
</div>
</div>
);
case "add_reaction":
return (
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-emoji`}>Emoji</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-emoji`}
onChange={(event) =>
onUpdate({ ...step, emoji: event.target.value })
}
placeholder="e.g. thumbsup"
value={step.emoji ?? ""}
/>
</div>
);
case "set_channel_topic":
return (
<div className="space-y-2">
<BackendSupportHint action={step.action} />
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-topic`}>Topic</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-topic`}
onChange={(event) =>
onUpdate({ ...step, topic: event.target.value })
}
placeholder="New channel topic"
value={step.topic ?? ""}
/>
</div>
</div>
);
default:
return null;
}
}
export function WorkflowStepCard({
bare = false,
showHeader = true,
index,
disabled,
onRemove,
onUpdate,
step,
previousSteps = [],
triggerType,
workflowChannelId,
}: {
bare?: boolean;
showHeader?: boolean;
index: number;
disabled?: boolean;
onRemove: () => void;
onUpdate: (step: StepFormState) => void;
step: StepFormState;
previousSteps?: StepFormState[];
triggerType: TriggerType;
workflowChannelId?: string | null;
}) {
const prefix = `wf-step-${index}`;
const [expandedSetting, setExpandedSetting] = useState<StepSetting | null>(
null,
);
const toggleSetting = (setting: StepSetting) => {
setExpandedSetting((current) => (current === setting ? null : setting));
};
return (
<div
className={cn(
"space-y-0",
!bare && "rounded-lg border border-border/70 bg-muted/10 p-3",
)}
>
{showHeader ? (
<div className="mb-3 flex items-center justify-between gap-2">
<span className="text-xs font-medium text-muted-foreground">
Step {index + 1}
</span>
<Button
aria-label="Remove step"
className="h-7 w-7"
disabled={disabled}
onClick={onRemove}
size="icon"
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4 text-muted-foreground" />
</Button>
</div>
) : null}
<section className="space-y-4 pb-5">
<StepConfigFields
disabled={disabled}
onUpdate={onUpdate}
prefix={prefix}
previousSteps={previousSteps}
step={step}
triggerType={triggerType}
workflowChannelId={workflowChannelId}
/>
</section>
<section className="divide-y divide-border/50 border-t border-border/50">
<StepSettingAccordion
disabled={disabled}
expanded={expandedSetting === "run-controls"}
label="Run controls"
onToggle={() => toggleSetting("run-controls")}
summary={runControlsSummary(step)}
>
<div className="space-y-4">
{supportsMessageTextCondition(triggerType) ? (
<WorkflowMessageTextCondition
allowAdvanced={false}
disabled={disabled}
onChange={(condition) => onUpdate({ ...step, condition })}
value={step.condition ?? ""}
/>
) : null}
<WorkflowDurationField
disabled={disabled}
fallbackSeconds={DEFAULT_STEP_TIMEOUT_SECONDS}
id={`${prefix}-timeout-secs`}
label="Timeout"
onChange={(timeoutSecs) => onUpdate({ ...step, timeoutSecs })}
placeholder="5m"
value={step.timeoutSecs ?? ""}
/>
</div>
</StepSettingAccordion>
<StepSettingAccordion
disabled={disabled}
expanded={expandedSetting === "details"}
label="Step details"
onToggle={() => toggleSetting("details")}
summary={step.name?.trim() || step.id}
>
<div className="space-y-4">
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-name`}>
Name (optional)
</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-name`}
onChange={(event) =>
onUpdate({ ...step, name: event.target.value })
}
placeholder="e.g. Notify deployment channel"
value={step.name ?? ""}
/>
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-id`}>Step ID</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-id`}
onChange={(event) =>
onUpdate({ ...step, id: event.target.value })
}
placeholder="unique_step_id"
value={step.id}
/>
<p className="text-xs text-muted-foreground">
Used in configuration and run history.
</p>
</div>
</div>
</StepSettingAccordion>
</section>
</div>
);
}