Polish workflow message inputs

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-12 23:08:28 -07:00
parent ac175010fa
commit c463f043e4
8 changed files with 565 additions and 48 deletions

View File

@@ -12,18 +12,28 @@ function ChannelPrivacyIcon({ channel }: { channel: Channel }) {
}
type ChannelComboboxProps = {
allowEmpty?: boolean;
ariaLabel?: string;
channels: Channel[];
disabled?: boolean;
emptyLabel?: string;
id?: string;
isChannelDisabled?: (channel: Channel) => boolean;
onChange: (value: string) => void;
variant?: "header" | "field";
value: string;
};
export function ChannelCombobox({
allowEmpty = false,
ariaLabel = "Channel",
channels,
disabled,
emptyLabel = "Choose a channel",
id,
isChannelDisabled,
onChange,
variant = "header",
value,
}: ChannelComboboxProps) {
const [open, setOpen] = React.useState(false);
@@ -42,6 +52,10 @@ export function ChannelCombobox({
c.id.toLowerCase().includes(q),
);
}, [channels, query]);
const selectable = React.useMemo(
() => filtered.filter((channel) => !isChannelDisabled?.(channel)),
[filtered, isChannelDisabled],
);
function handleOpenChange(next: boolean) {
setOpen(next);
@@ -57,22 +71,24 @@ export function ChannelCombobox({
}
function handleKeyDown(e: React.KeyboardEvent) {
if (filtered.length === 0) return;
if (selectable.length === 0) return;
switch (e.key) {
case "ArrowDown": {
e.preventDefault();
setHighlightedIndex((i) => (i + 1) % filtered.length);
setHighlightedIndex((i) => (i + 1) % selectable.length);
break;
}
case "ArrowUp": {
e.preventDefault();
setHighlightedIndex((i) => (i - 1 + filtered.length) % filtered.length);
setHighlightedIndex(
(i) => (i - 1 + selectable.length) % selectable.length,
);
break;
}
case "Enter": {
e.preventDefault();
const target = filtered[highlightedIndex];
const target = selectable[highlightedIndex];
if (target) selectChannel(target.id);
break;
}
@@ -88,10 +104,14 @@ export function ChannelCombobox({
<Popover onOpenChange={handleOpenChange} open={open}>
<PopoverTrigger asChild>
<button
aria-label="Channel"
aria-label={ariaLabel}
aria-expanded={open}
className={cn(
"group flex w-full items-center justify-center rounded-lg border-0 bg-transparent px-3 py-2 text-lg font-semibold focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
"group flex w-full items-center rounded-lg px-3 py-2 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
variant === "header" &&
"justify-center border-0 bg-transparent text-lg font-semibold",
variant === "field" &&
"justify-between border border-input/40 bg-background text-sm font-normal",
!selected && "text-muted-foreground",
)}
disabled={disabled}
@@ -99,19 +119,28 @@ export function ChannelCombobox({
role="combobox"
type="button"
>
<span className="flex min-w-0 items-center justify-center gap-2">
<span
className={cn(
"flex min-w-0 items-center gap-2",
variant === "header" && "justify-center",
)}
>
{selected ? <ChannelPrivacyIcon channel={selected} /> : null}
<span className="truncate">
{selected ? selected.name : "Choose a channel"}
{selected
? selected.name
: value
? "Unavailable channel"
: emptyLabel}
</span>
<ChevronDown className="ml-1 h-5 w-5 shrink-0 text-muted-foreground opacity-50 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" />
</span>
<ChevronDown className="ml-1 h-5 w-5 shrink-0 text-muted-foreground opacity-50 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-(--radix-popover-trigger-width) p-0"
portalled={false}
portalled={variant === "field"}
>
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
@@ -135,38 +164,62 @@ export function ChannelCombobox({
className="max-h-60 overflow-y-auto p-1"
data-testid="channel-combobox-list"
>
{allowEmpty && !query ? (
<button
className={cn(
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground",
!value && "bg-accent/50",
)}
onClick={() => selectChannel("")}
type="button"
>
<span className="h-5 w-5 shrink-0" />
<span className="truncate">{emptyLabel}</span>
<Check
className={cn(
"ml-auto h-4 w-4 shrink-0",
!value ? "opacity-100" : "opacity-0",
)}
/>
</button>
) : null}
{filtered.length === 0 ? (
<p className="px-3 py-4 text-center text-xs text-muted-foreground">
No channels found.
</p>
) : (
filtered.map((channel, index) => (
<button
className={cn(
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground",
channel.id === value && "bg-accent/50",
index === highlightedIndex &&
"bg-accent text-accent-foreground",
)}
key={channel.id}
onClick={() => selectChannel(channel.id)}
type="button"
>
<ChannelPrivacyIcon channel={channel} />
<span className="truncate">
{channel.name}{" "}
<span className="text-muted-foreground">
· {channel.channelType}
</span>
</span>
<Check
filtered.map((channel) => {
const optionDisabled = isChannelDisabled?.(channel) ?? false;
return (
<button
className={cn(
"ml-auto h-4 w-4 shrink-0",
channel.id === value ? "opacity-100" : "opacity-0",
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-45 disabled:hover:bg-transparent disabled:hover:text-foreground",
channel.id === value && "bg-accent/50",
channel.id === selectable[highlightedIndex]?.id &&
!optionDisabled &&
"bg-accent text-accent-foreground",
)}
/>
</button>
))
disabled={optionDisabled}
key={channel.id}
onClick={() => selectChannel(channel.id)}
type="button"
>
<ChannelPrivacyIcon channel={channel} />
<span className="truncate">
{channel.name}{" "}
<span className="text-muted-foreground">
· {channel.channelType}
</span>
</span>
<Check
className={cn(
"ml-auto h-4 w-4 shrink-0",
channel.id === value ? "opacity-100" : "opacity-0",
)}
/>
</button>
);
})
)}
</div>
</PopoverContent>

View File

@@ -256,6 +256,7 @@ export function WorkflowDialog({
<div className="min-h-0 flex-1">
<WorkflowFormBuilder
channels={channels}
disabled={mutation.isPending}
footerLeadingContainer={footerLeadingElement}
mode={editorMode}
@@ -291,6 +292,7 @@ export function WorkflowDialog({
</div>
) : null
}
workflowChannelId={selectedChannelId || null}
yaml={yamlDefinition}
/>
</div>

View File

@@ -11,6 +11,7 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { createPortal } from "react-dom";
import type { Channel } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/lib/cn";
import {
@@ -106,6 +107,7 @@ function TriggerConfigFields({
}
type WorkflowFormBuilderProps = {
channels: Channel[];
disabled?: boolean;
footerLeadingContainer?: HTMLElement | null;
mode: WorkflowEditorMode;
@@ -113,6 +115,7 @@ type WorkflowFormBuilderProps = {
parseError: string | null;
scopeField?: React.ReactNode;
yaml: string;
workflowChannelId?: string | null;
};
export type WorkflowEditorMode = "form" | "yaml";
@@ -323,6 +326,7 @@ function WorkflowNode({
}
export function WorkflowFormBuilder({
channels,
disabled,
footerLeadingContainer,
mode,
@@ -330,6 +334,7 @@ export function WorkflowFormBuilder({
parseError,
scopeField,
yaml,
workflowChannelId,
}: WorkflowFormBuilderProps) {
// Parse once on mount instead of calling yamlToFormState three times
const initialParseRef = React.useRef(yaml ? yamlToFormState(yaml) : null);
@@ -672,15 +677,21 @@ export function WorkflowFormBuilder({
) : selectedStep ? (
<WorkflowStepCard
bare
channels={channels}
disabled={disabled}
index={selectedNode.index}
onRemove={() => removeStep(selectedNode.index)}
onUpdate={(updated) =>
updateStep(selectedNode.index, updated)
}
previousSteps={formState.steps.slice(
0,
selectedNode.index,
)}
showHeader={false}
step={selectedStep}
triggerType={formState.trigger.on}
workflowChannelId={workflowChannelId}
/>
) : null}
</motion.div>

View File

@@ -1,11 +1,14 @@
import { Trash2 } from "lucide-react";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { ChannelCombobox } from "./ChannelCombobox";
import { WorkflowConditionBuilder } from "./WorkflowConditionBuilder";
import { WorkflowEmojiField } from "./WorkflowEmojiField";
import { WorkflowTemplateTextarea } from "./WorkflowTemplateTextarea";
import { FieldLabel, FormSelect } from "./workflowFormPrimitives";
import { WorkflowWebhookHeadersEditor } from "./WorkflowWebhookHeadersEditor";
import type { StepFormState, TriggerType } from "./workflowFormTypes";
@@ -49,13 +52,19 @@ function StepConfigFields({
step,
prefix,
disabled,
channels,
previousSteps,
triggerType,
workflowChannelId,
onUpdate,
}: {
step: StepFormState;
prefix: string;
disabled?: boolean;
channels: Channel[];
previousSteps: StepFormState[];
triggerType: TriggerType;
workflowChannelId?: string | null;
onUpdate: (step: StepFormState) => void;
}) {
switch (step.action) {
@@ -80,15 +89,15 @@ function StepConfigFields({
<div className="space-y-2">
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-text`}>Message text</FieldLabel>
<Textarea
<WorkflowTemplateTextarea
autoCapitalize="off"
className="min-h-[60px] resize-y text-xs"
disabled={disabled}
id={`${prefix}-text`}
onChange={(event) =>
onUpdate({ ...step, text: event.target.value })
}
onValueChange={(text) => onUpdate({ ...step, text })}
placeholder="e.g. Deployment started by {{trigger.author}}"
previousSteps={previousSteps}
triggerType={triggerType}
value={step.text ?? ""}
/>
</div>
@@ -96,21 +105,32 @@ function StepConfigFields({
<FieldLabel htmlFor={`${prefix}-channel`}>
Channel override (optional)
</FieldLabel>
<Input
autoCapitalize="off"
<ChannelCombobox
allowEmpty
ariaLabel="Channel override (optional)"
channels={channels}
disabled={disabled}
id={`${prefix}-channel`}
onChange={(event) =>
onUpdate({ ...step, channel: event.target.value })
emptyLabel={
workflowChannelId
? "Use workflow channel"
: "Use trigger channel"
}
placeholder="Channel UUID"
id={`${prefix}-channel`}
isChannelDisabled={(channel) =>
Boolean(workflowChannelId && channel.id !== workflowChannelId)
}
onChange={(channel) => onUpdate({ ...step, channel })}
value={step.channel ?? ""}
variant="field"
/>
<p className="text-xs text-muted-foreground">
Defaults to the trigger channel. Webhook and manual triggers
require a channel.
{workflowChannelId
? "Defaults to this workflow's channel. Cross-channel posting is not permitted."
: "Defaults to the trigger channel. Webhook and manual triggers require a channel."}
</p>
{triggerType === "webhook" && !(step.channel ?? "").trim() ? (
{triggerType === "webhook" &&
!workflowChannelId &&
!(step.channel ?? "").trim() ? (
<p className="text-xs text-amber-700">
This step will fail for webhook-triggered runs until a channel
override is set.
@@ -303,22 +323,28 @@ function SectionHeading({ title }: { title: string }) {
export function WorkflowStepCard({
bare = false,
channels = [],
showHeader = true,
index,
disabled,
onRemove,
onUpdate,
step,
previousSteps = [],
triggerType,
workflowChannelId,
}: {
bare?: boolean;
channels?: Channel[];
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}`;
@@ -350,11 +376,14 @@ export function WorkflowStepCard({
<section className="space-y-4 pb-5">
<StepConfigFields
channels={channels}
disabled={disabled}
onUpdate={onUpdate}
prefix={prefix}
previousSteps={previousSteps}
step={step}
triggerType={triggerType}
workflowChannelId={workflowChannelId}
/>
</section>

View File

@@ -0,0 +1,179 @@
import * as React from "react";
import { cn } from "@/shared/lib/cn";
import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover";
import { Textarea } from "@/shared/ui/textarea";
import type { StepFormState, TriggerType } from "./workflowFormTypes";
import {
activeTemplateToken,
insertTemplateVariable,
workflowTemplateVariables,
} from "./workflowTemplateVariables";
import type {
ActiveTemplateToken,
WorkflowTemplateVariable,
} from "./workflowTemplateVariables";
export function WorkflowTemplateTextarea({
className,
disabled,
onValueChange,
previousSteps,
triggerType,
value,
...props
}: Omit<React.ComponentProps<typeof Textarea>, "onChange" | "value"> & {
onValueChange: (value: string) => void;
previousSteps: StepFormState[];
triggerType: TriggerType;
value: string;
}) {
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const [token, setToken] = React.useState<ActiveTemplateToken | null>(null);
const [selectedIndex, setSelectedIndex] = React.useState(0);
const variables = React.useMemo(
() => workflowTemplateVariables(triggerType, previousSteps),
[previousSteps, triggerType],
);
const suggestions = React.useMemo(() => {
if (!token) return [];
const query = token.query.toLowerCase();
return variables.filter(
(variable) =>
variable.value.toLowerCase().includes(query) ||
variable.description.toLowerCase().includes(query),
);
}, [token, variables]);
const open = token !== null;
const updateToken = React.useCallback((nextValue: string, caret: number) => {
setToken(activeTemplateToken(nextValue, caret));
setSelectedIndex(0);
}, []);
const selectVariable = React.useCallback(
(variable: WorkflowTemplateVariable) => {
if (!token) return;
const next = insertTemplateVariable(value, token, variable.value);
onValueChange(next.value);
setToken(null);
requestAnimationFrame(() => {
const textarea = textareaRef.current;
textarea?.focus();
textarea?.setSelectionRange(next.caret, next.caret);
});
},
[onValueChange, token, value],
);
return (
<Popover
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) setToken(null);
}}
>
<PopoverAnchor asChild>
<Textarea
{...props}
aria-autocomplete="list"
aria-controls={open ? `${props.id}-template-variables` : undefined}
aria-expanded={open}
className={className}
disabled={disabled}
onChange={(event) => {
const nextValue = event.target.value;
onValueChange(nextValue);
updateToken(nextValue, event.target.selectionStart);
}}
onClick={(event) =>
updateToken(
event.currentTarget.value,
event.currentTarget.selectionStart,
)
}
onKeyDown={(event) => {
if (!open) return;
if (event.key === "Escape") {
event.preventDefault();
setToken(null);
return;
}
if (suggestions.length === 0) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const direction = event.key === "ArrowDown" ? 1 : -1;
setSelectedIndex(
(current) =>
(current + direction + suggestions.length) %
suggestions.length,
);
return;
}
if (event.key === "Enter" || event.key === "Tab") {
event.preventDefault();
selectVariable(suggestions[selectedIndex] ?? suggestions[0]);
}
}}
ref={textareaRef}
value={value}
/>
</PopoverAnchor>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] min-w-72 p-1.5"
id={`${props.id}-template-variables`}
onOpenAutoFocus={(event) => event.preventDefault()}
side="bottom"
sideOffset={6}
>
<div className="max-h-72 overflow-y-auto" role="listbox">
{suggestions.length > 0 ? (
suggestions.map((variable, index) => {
const previousGroup = suggestions[index - 1]?.group;
return (
<React.Fragment key={variable.value}>
{variable.group !== previousGroup ? (
<p className="px-2 pb-1 pt-1.5 text-2xs font-semibold uppercase tracking-wide text-muted-foreground">
{variable.group}
</p>
) : null}
<button
aria-selected={index === selectedIndex}
className={cn(
"flex w-full items-center justify-between gap-3 rounded-md px-2 py-1.5 text-left",
index === selectedIndex
? "bg-muted text-foreground"
: "text-foreground hover:bg-muted/60",
)}
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => setSelectedIndex(index)}
onClick={() => selectVariable(variable)}
role="option"
type="button"
>
<code className="min-w-0 truncate text-xs">
{`{{${variable.value}}}`}
</code>
<span className="shrink-0 text-2xs text-muted-foreground">
{variable.description}
</span>
</button>
</React.Fragment>
);
})
) : (
<p className="px-2 py-2 text-xs text-muted-foreground">
No matching variables
</p>
)}
</div>
{triggerType === "webhook" ? (
<p className="border-t border-border/50 px-2 pb-1 pt-2 text-2xs text-muted-foreground">
JSON fields are available as {"{{trigger.field_name}}"}.
</p>
) : null}
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
activeTemplateToken,
insertTemplateVariable,
workflowTemplateVariables,
} from "./workflowTemplateVariables.ts";
test("finds the active template token at the caret", () => {
assert.deepEqual(activeTemplateToken("hello {{trig", 12), {
start: 6,
end: 12,
query: "trig",
});
assert.equal(activeTemplateToken("hello {{trigger.text}}", 22), null);
assert.equal(activeTemplateToken("hello {{trigger ", 16), null);
});
test("inserts a selected variable and preserves surrounding text", () => {
const token = activeTemplateToken("before {{tri after", 12);
assert.ok(token);
assert.deepEqual(
insertTemplateVariable("before {{tri after", token, "trigger.text"),
{
value: "before {{trigger.text}} after",
caret: 23,
},
);
});
test("replaces the whole template when editing inside an existing token", () => {
const token = activeTemplateToken("{{trigger.text}}", 9);
assert.ok(token);
assert.deepEqual(
insertTemplateVariable("{{trigger.text}}", token, "trigger.author"),
{
value: "{{trigger.author}}",
caret: 18,
},
);
});
test("offers trigger fields and known previous-step outputs", () => {
const variables = workflowTemplateVariables("message_posted", [
{ id: "notify", action: "send_message" },
{ id: "pause", action: "delay" },
]).map(({ value }) => value);
assert.ok(variables.includes("trigger.text"));
assert.ok(variables.includes("trigger.author"));
assert.ok(variables.includes("steps.notify.output.event_id"));
assert.ok(variables.includes("steps.pause.output.slept_secs"));
});
test("only offers values populated by the selected trigger", () => {
const variables = workflowTemplateVariables("schedule", []).map(
({ value }) => value,
);
assert.deepEqual(variables, ["trigger.channel_id", "trigger.timestamp"]);
});

View File

@@ -0,0 +1,149 @@
import type {
ActionType,
StepFormState,
TriggerType,
} from "./workflowFormTypes";
export type WorkflowTemplateVariable = {
description: string;
group: "Trigger" | "Previous steps";
value: string;
};
export type ActiveTemplateToken = {
end: number;
query: string;
start: number;
};
const COMMON_EVENT_VARIABLES: WorkflowTemplateVariable[] = [
{
value: "trigger.author",
description: "Author pubkey",
group: "Trigger",
},
{
value: "trigger.channel_id",
description: "Channel UUID",
group: "Trigger",
},
{
value: "trigger.timestamp",
description: "Unix timestamp",
group: "Trigger",
},
{
value: "trigger.message_id",
description: "Message event ID",
group: "Trigger",
},
];
const STEP_OUTPUTS: Partial<Record<ActionType, Record<string, string>>> = {
delay: { slept_secs: "Seconds elapsed" },
send_message: {
sent: "Whether the message was sent",
event_id: "Sent message ID",
},
call_webhook: { status: "HTTP response status", body: "HTTP response body" },
add_reaction: {
added: "Whether the reaction was added",
status: "HTTP response status",
response: "Relay response",
},
};
function triggerVariables(
triggerType: TriggerType,
): WorkflowTemplateVariable[] {
switch (triggerType) {
case "message_posted":
return [
{
value: "trigger.text",
description: "Message text",
group: "Trigger",
},
...COMMON_EVENT_VARIABLES,
];
case "diff_posted":
return [
{ value: "trigger.text", description: "Diff text", group: "Trigger" },
...COMMON_EVENT_VARIABLES,
];
case "reaction_added":
return [
{
value: "trigger.emoji",
description: "Reaction emoji",
group: "Trigger",
},
...COMMON_EVENT_VARIABLES,
];
case "schedule":
return COMMON_EVENT_VARIABLES.filter(
({ value }) =>
value === "trigger.channel_id" || value === "trigger.timestamp",
);
case "webhook":
return [
{
value: "trigger.channel_id",
description: "Workflow channel UUID",
group: "Trigger",
},
];
}
}
export function workflowTemplateVariables(
triggerType: TriggerType,
previousSteps: StepFormState[],
): WorkflowTemplateVariable[] {
const priorOutputs = previousSteps.flatMap((step) => {
const outputs = STEP_OUTPUTS[step.action];
if (!outputs || !/^[A-Za-z0-9_]+$/.test(step.id)) return [];
return Object.entries(outputs).map(([field, description]) => ({
value: `steps.${step.id}.output.${field}`,
description,
group: "Previous steps" as const,
}));
});
return [...triggerVariables(triggerType), ...priorOutputs];
}
/** Find an unfinished `{{variable` token immediately before the caret. */
export function activeTemplateToken(
value: string,
caret: number,
): ActiveTemplateToken | null {
const beforeCaret = value.slice(0, caret);
const start = beforeCaret.lastIndexOf("{{");
if (start < 0) return null;
const query = beforeCaret.slice(start + 2);
if (query.includes("}}") || !/^[A-Za-z0-9._]*$/.test(query)) return null;
// If the caret is inside an existing template, replace the complete token
// instead of leaving its old suffix behind after a suggestion is chosen.
const closingBraces = value.indexOf("}}", caret);
const nestedOpening = value.indexOf("{{", start + 2);
const end =
closingBraces >= 0 && (nestedOpening < 0 || closingBraces < nestedOpening)
? closingBraces + 2
: caret;
return { start, end, query };
}
export function insertTemplateVariable(
value: string,
token: ActiveTemplateToken,
variable: string,
): { caret: number; value: string } {
const insertion = `{{${variable}}}`;
return {
value: `${value.slice(0, token.start)}${insertion}${value.slice(token.end)}`,
caret: token.start + insertion.length,
};
}

View File

@@ -447,6 +447,39 @@ test("scrolls the channel list with the mouse wheel", async ({ page }) => {
.toBeGreaterThan(0);
});
test("selects a message destination from the channel lookup", async ({
page,
}) => {
await navigateToWorkflows(page);
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog");
await selectFirstChannel(dialog);
await dialog.getByRole("button", { name: "Add step" }).click();
await page.getByRole("menuitem", { name: "Send Message" }).click();
const channelOverride = dialog.getByRole("combobox", {
name: "Channel override (optional)",
});
await expect(channelOverride).toContainText("Use workflow channel");
await channelOverride.click();
const search = page.getByPlaceholder("Search channels...");
await search.fill("random");
await expect(
page.getByRole("button", { name: "random · stream" }),
).toBeDisabled();
await search.fill("agents");
await page.getByRole("button", { name: "agents · stream" }).click();
await expect(channelOverride).toContainText("agents");
await dialog.getByRole("tab", { name: "YAML" }).click();
await expect(dialog.getByLabel("Workflow YAML")).toHaveValue(
/channel: [0-9a-f-]{36}/,
);
});
test("opens node configuration in a contextual inspector", async ({ page }) => {
await navigateToWorkflows(page);