Procedure Builder
A doc-style editor for an agent's step-by-step procedure — a / step picker, IF / ELSE branches with plain-language or code conditions, section jumps, and escalate / switch actions.
Ask the customer for a photo of the damaged or missing item and check it against ReadOrder details.
- IF
Thank the customer and TagEvidence verified
ELSE- Escalate to
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import {
ArrowRightFromLine,
ArrowUpRight,
ChevronDown,
CircleArrowOutUpRight,
Code,
CornerDownRight,
EllipsisVertical,
Heading1,
Plus,
Shuffle,
Text as TextIcon,
Trash2,
Workflow,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
/* ── data model ── */
/** An inline reference inside an instruction, e.g. "Tag · Refund initiated". */
export interface ProcedureChip {
/** Bold leading verb: "Update", "Tag", "Use", "Read". */
verb: string;
label: string;
icon?: React.ComponentType<{ className?: string }>;
}
export type ProcedureSegment = string | ProcedureChip;
export type ProcedureActionKind = "escalate" | "switch" | "workflow" | "end";
export type ProcedureStep =
| { id: string; kind: "instruction"; content: ProcedureSegment[] }
| {
id: string;
kind: "condition";
mode: "text" | "code";
condition: string;
then: ProcedureStep[];
/** Present (possibly empty) when the condition has an ELSE branch. */
else?: ProcedureStep[];
}
| { id: string; kind: "header"; text: string }
/** `target` is the id of a top-level header step. */
| { id: string; kind: "goto"; target?: string }
| { id: string; kind: "action"; action: ProcedureActionKind; target?: string };
export interface ProcedureTargets {
escalate?: string[];
switch?: string[];
workflow?: string[];
}
export interface ProcedureBuilderProps {
initialSteps?: ProcedureStep[];
/** Options offered by the Escalate / Switch / Run workflow pickers. */
targets?: ProcedureTargets;
/** Used in placeholders: "Describe what {agentName} should do…". */
agentName?: string;
onChange?: (steps: ProcedureStep[]) => void;
className?: string;
}
/** Transient row holding the "/Search steps…" input. Never passed to `onChange`. */
type DraftStep = { id: string; kind: "draft" };
type Step = ProcedureStep | DraftStep;
type ConditionStep = Extract<ProcedureStep, { kind: "condition" }>;
type Branch = "then" | "else";
/* ── step catalogue ── */
type StepOption =
| "instruction"
| "condition"
| "header"
| "goto"
| ProcedureActionKind;
interface StepOptionMeta {
label: string;
/** Shorter/longer label used inside a condition branch. */
nestedLabel?: string;
icon: React.ComponentType<{ className?: string }>;
badge: string;
group: "structure" | "action";
}
const BADGE = {
neutral: "bg-muted text-foreground/80",
condition: "bg-violet-100 text-violet-700 dark:bg-violet-500/20 dark:text-violet-300",
goto: "bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300",
action: "bg-orange-100 text-orange-900 dark:bg-orange-500/20 dark:text-orange-200",
};
const STEP_OPTIONS: Record<StepOption, StepOptionMeta> = {
instruction: { label: "Instruction", icon: TextIcon, badge: BADGE.neutral, group: "structure" },
condition: { label: "Condition", icon: ArrowRightFromLine, badge: BADGE.condition, group: "structure" },
header: { label: "Section header", icon: Heading1, badge: BADGE.neutral, group: "structure" },
goto: { label: "Go to", nestedLabel: "Go to section", icon: ArrowUpRight, badge: BADGE.goto, group: "structure" },
escalate: { label: "Escalate", icon: CircleArrowOutUpRight, badge: BADGE.action, group: "action" },
switch: { label: "Switch", icon: Shuffle, badge: BADGE.action, group: "action" },
workflow: { label: "Run workflow", icon: Workflow, badge: BADGE.action, group: "action" },
end: { label: "End", nestedLabel: "End procedure", icon: X, badge: BADGE.action, group: "action" },
};
// Conditions and section headers only make sense at the top level, so a
// branch offers a shorter menu.
const TOP_LEVEL_OPTIONS: StepOption[] = ["instruction", "condition", "header", "goto", "escalate", "switch", "workflow", "end"];
const NESTED_OPTIONS: StepOption[] = ["instruction", "goto", "escalate", "switch", "workflow", "end"];
const ACTION_COPY: Record<ProcedureActionKind, { prefix: string; picker: string }> = {
escalate: { prefix: "Escalate to", picker: "Choose a team" },
switch: { prefix: "Switch to", picker: "Choose a procedure" },
workflow: { prefix: "Run", picker: "Choose a workflow" },
end: { prefix: "End procedure", picker: "" },
};
/* ── tree helpers ── */
let idCounter = 0;
function newId(prefix: string) {
idCounter += 1;
return `${prefix}-${Date.now().toString(36)}-${idCounter}`;
}
function mapSteps(steps: Step[], fn: (step: Step) => Step | null): Step[] {
const out: Step[] = [];
for (const step of steps) {
const next = fn(step);
if (!next) continue;
if (next.kind === "condition") {
out.push({
...next,
then: mapSteps(next.then, fn) as ProcedureStep[],
else: next.else ? (mapSteps(next.else, fn) as ProcedureStep[]) : undefined,
});
} else {
out.push(next);
}
}
return out;
}
function insertAfter(steps: Step[], afterId: string, inserted: Step): Step[] {
const index = steps.findIndex((s) => s.id === afterId);
if (index !== -1) return [...steps.slice(0, index + 1), inserted, ...steps.slice(index + 1)];
return steps.map((s) =>
s.kind === "condition"
? {
...s,
then: insertAfter(s.then, afterId, inserted) as ProcedureStep[],
else: s.else ? (insertAfter(s.else, afterId, inserted) as ProcedureStep[]) : undefined,
}
: s
);
}
function stripDrafts(steps: Step[]): ProcedureStep[] {
return mapSteps(steps, (s) => (s.kind === "draft" ? null : s)) as ProcedureStep[];
}
function createStep(option: StepOption, id: string): ProcedureStep {
switch (option) {
case "instruction":
return { id, kind: "instruction", content: [""] };
case "condition":
return {
id,
kind: "condition",
mode: "text",
condition: "",
then: [{ id: newId("step"), kind: "instruction", content: [""] }],
};
case "header":
return { id, kind: "header", text: "" };
case "goto":
return { id, kind: "goto" };
default:
return { id, kind: "action", action: option };
}
}
const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const PLACEHOLDER_SUFFIX: Record<Branch | "top", string> = {
top: "",
then: " when this condition is met",
else: " when this condition isn't met",
};
/* ── context ── */
interface BuilderContextValue {
agentName: string;
targets: ProcedureTargets;
sections: { id: string; text: string }[];
/** Id of the step that should grab focus when it mounts. */
focusId: string | null;
update: (id: string, fn: (step: Step) => Step | null) => void;
remove: (id: string) => void;
/** Opens a "/Search steps…" row right after `id`. */
draftAfter: (id: string) => void;
/** Opens a "/Search steps…" row at the end of a list (top level when `parentId` is null). */
draftAtEnd: (parentId: string | null, branch?: Branch) => void;
commitDraft: (draftId: string, option: StepOption) => void;
}
const BuilderContext = React.createContext<BuilderContextValue | null>(null);
function useBuilder() {
const ctx = React.useContext(BuilderContext);
if (!ctx) throw new Error("ProcedureBuilder parts must render inside <ProcedureBuilder>");
return ctx;
}
/* ── root ── */
export function ProcedureBuilder({
initialSteps = [],
targets = {},
agentName = "the agent",
onChange,
className,
}: ProcedureBuilderProps) {
const [steps, setSteps] = React.useState<Step[]>(initialSteps);
const [focusId, setFocusId] = React.useState<string | null>(null);
const onChangeRef = React.useRef(onChange);
React.useEffect(() => {
onChangeRef.current = onChange;
});
const isFirstRender = React.useRef(true);
React.useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
onChangeRef.current?.(stripDrafts(steps));
}, [steps]);
const sections = steps
.filter((s): s is Extract<ProcedureStep, { kind: "header" }> => s.kind === "header")
.map((s) => ({ id: s.id, text: s.text }));
// Only one draft row at a time: opening a new one replaces any other.
const withoutDrafts = (list: Step[]) => mapSteps(list, (s) => (s.kind === "draft" ? null : s));
const ctx: BuilderContextValue = {
agentName,
targets,
sections,
focusId,
update: (id, fn) => setSteps((prev) => mapSteps(prev, (s) => (s.id === id ? fn(s) : s))),
remove: (id) =>
setSteps((prev) =>
mapSteps(prev, (s) => {
if (s.id === id) return null;
// A goto pointing at a deleted section loses its target.
if (s.kind === "goto" && s.target === id) return { ...s, target: undefined };
return s;
})
),
draftAfter: (id) => {
const draft: DraftStep = { id: newId("draft"), kind: "draft" };
setFocusId(draft.id);
setSteps((prev) => insertAfter(withoutDrafts(prev), id, draft));
},
draftAtEnd: (parentId, branch = "then") => {
const draft: DraftStep = { id: newId("draft"), kind: "draft" };
setFocusId(draft.id);
setSteps((prev) => {
const clean = withoutDrafts(prev);
if (parentId === null) return [...clean, draft];
return mapSteps(clean, (s) =>
s.id === parentId && s.kind === "condition"
? { ...s, [branch]: [...(s[branch] ?? []), draft as unknown as ProcedureStep] }
: s
);
});
},
commitDraft: (draftId, option) => {
const id = newId("step");
setFocusId(id);
setSteps((prev) => mapSteps(prev, (s) => (s.id === draftId ? createStep(option, id) : s)));
},
};
return (
<BuilderContext.Provider value={ctx}>
<div className={cn("w-full text-sm text-foreground", className)}>
<StepList steps={steps} parentId={null} />
<button
type="button"
onClick={() => ctx.draftAtEnd(null)}
className="ml-8 mt-3 inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Plus className="size-3.5" aria-hidden />
Add step
</button>
</div>
</BuilderContext.Provider>
);
}
/* ── lists ── */
function StepList({ steps, parentId, branch }: { steps: Step[]; parentId: string | null; branch?: Branch }) {
const nested = parentId !== null;
// Section headers are unnumbered so the procedure's numbering stays a plain
// count of things the agent does.
const markers: string[] = [];
for (const step of steps) {
const count = markers.filter(Boolean).length;
markers.push(step.kind === "header" ? "" : nested ? `${LETTERS[count % 26]}.` : `${count + 1}.`);
}
return (
<ol className={cn("flex flex-col", nested ? "gap-2.5" : "gap-5")}>
<AnimatePresence initial={false}>
{steps.map((step, index) => (
<motion.li
key={step.id}
layout="position"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, transition: { duration: 0.12 } }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="grid grid-cols-[2rem_minmax(0,1fr)] items-start"
>
<span
aria-hidden
className={cn(
"select-none font-mono text-[11px] tabular-nums text-muted-foreground",
step.kind === "condition" ? "pt-1" : "pt-[3px]"
)}
>
{markers[index]}
</span>
<StepRow step={step} nested={nested} branch={branch} />
</motion.li>
))}
</AnimatePresence>
</ol>
);
}
function StepRow({ step, nested, branch }: { step: Step; nested: boolean; branch?: Branch }) {
switch (step.kind) {
case "draft":
return <DraftRow id={step.id} nested={nested} />;
case "instruction":
return <InstructionRow step={step} branch={branch} />;
case "condition":
return <ConditionBlock step={step} />;
case "header":
return <HeaderRow step={step} />;
case "goto":
return <GotoRow step={step} />;
case "action":
return <ActionRow step={step} />;
}
}
/* ── draft row: the "/" step picker ── */
function DraftRow({ id, nested }: { id: string; nested: boolean }) {
const { focusId, remove, commitDraft } = useBuilder();
const [query, setQuery] = React.useState("");
const [active, setActive] = React.useState(0);
const inputRef = React.useRef<HTMLInputElement>(null);
const listId = React.useId();
React.useEffect(() => {
if (focusId === id) inputRef.current?.focus();
}, [focusId, id]);
const options = (nested ? NESTED_OPTIONS : TOP_LEVEL_OPTIONS).filter((option) => {
const meta = STEP_OPTIONS[option];
const label = nested ? (meta.nestedLabel ?? meta.label) : meta.label;
return label.toLowerCase().includes(query.trim().toLowerCase());
});
const activeIndex = Math.min(active, Math.max(0, options.length - 1));
function onKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (!options.length) return;
const delta = event.key === "ArrowDown" ? 1 : -1;
setActive((activeIndex + delta + options.length) % options.length);
} else if (event.key === "Enter") {
event.preventDefault();
if (options[activeIndex]) commitDraft(id, options[activeIndex]);
} else if (event.key === "Escape" || (event.key === "Backspace" && query === "")) {
event.preventDefault();
remove(id);
}
}
const structure = options.filter((o) => STEP_OPTIONS[o].group === "structure");
const actions = options.filter((o) => STEP_OPTIONS[o].group === "action");
function renderOption(option: StepOption) {
const meta = STEP_OPTIONS[option];
const index = options.indexOf(option);
const Icon = meta.icon;
return (
<li
key={option}
id={`${listId}-${option}`}
role="option"
aria-selected={index === activeIndex}
onPointerMove={() => setActive(index)}
// Keep focus in the input so blur doesn't dismiss the row mid-click.
onPointerDown={(event) => event.preventDefault()}
onClick={() => commitDraft(id, option)}
className={cn(
"flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm",
index === activeIndex ? "bg-muted" : "text-foreground/90"
)}
>
<span className={cn("flex size-6 shrink-0 items-center justify-center rounded-md", meta.badge)}>
<Icon className="size-3.5" aria-hidden />
</span>
{nested ? (meta.nestedLabel ?? meta.label) : meta.label}
</li>
);
}
return (
<div className="relative">
<div className="inline-flex items-center rounded-md bg-muted/70 px-1.5 py-0.5">
<span aria-hidden className="text-foreground/70">
/
</span>
<input
ref={inputRef}
value={query}
onChange={(event) => {
setQuery(event.target.value);
setActive(0);
}}
onKeyDown={onKeyDown}
onBlur={() => remove(id)}
placeholder="Search steps…"
role="combobox"
aria-label="Add a step"
aria-expanded
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={options[activeIndex] ? `${listId}-${options[activeIndex]}` : undefined}
className="w-28 bg-transparent text-sm placeholder:text-muted-foreground focus:outline-none"
/>
</div>
<motion.div
initial={{ opacity: 0, y: -4, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.14, ease: "easeOut" }}
style={{ transformOrigin: "top left" }}
className="absolute left-0 top-full z-30 mt-1.5 w-60 rounded-xl border bg-popover p-1.5 text-popover-foreground shadow-lg"
>
<ul id={listId} role="listbox" aria-label="Step types">
{structure.map(renderOption)}
{structure.length > 0 && actions.length > 0 && <li role="separator" className="-mx-1.5 my-1.5 h-px bg-border" />}
{actions.map(renderOption)}
{options.length === 0 && <li className="px-2 py-1.5 text-sm text-muted-foreground">No matching steps</li>}
</ul>
</motion.div>
</div>
);
}
/* ── instruction ── */
function InstructionRow({ step, branch }: { step: Extract<ProcedureStep, { kind: "instruction" }>; branch?: Branch }) {
const { agentName, focusId, update, remove, draftAfter } = useBuilder();
const plain = step.content.every((segment) => typeof segment === "string");
// Instructions holding inline references render read-only here; a full
// editor would insert chips through an @-mention picker.
if (!plain) {
return (
<StepShell onRemove={() => remove(step.id)} label="instruction">
<p className="leading-7 text-foreground/90">
{step.content.map((segment, i) =>
typeof segment === "string" ? <React.Fragment key={i}>{segment}</React.Fragment> : <InlineChip key={i} chip={segment} />
)}
</p>
</StepShell>
);
}
const value = step.content.join("");
return (
<StepShell onRemove={() => remove(step.id)} label="instruction">
<AutoTextarea
value={value}
autoFocus={focusId === step.id}
aria-label="Instruction"
placeholder={`Describe what ${agentName} should do${PLACEHOLDER_SUFFIX[branch ?? "top"]}`}
onChange={(text) => update(step.id, (s) => ({ ...(s as typeof step), content: [text] }))}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
draftAfter(step.id);
} else if (event.key === "Backspace" && value === "") {
event.preventDefault();
remove(step.id);
} else if (event.key === "/" && value === "") {
// Typing "/" in an empty instruction swaps it for the step picker.
event.preventDefault();
draftAfter(step.id);
remove(step.id);
}
}}
className="leading-7 text-foreground/90"
/>
</StepShell>
);
}
export function InlineChip({ chip }: { chip: ProcedureChip }) {
const Icon = chip.icon;
return (
<span className="mx-0.5 inline-flex items-center gap-1 whitespace-nowrap rounded-full border border-border/60 bg-muted/60 px-2 py-px align-baseline text-[13px] leading-6">
{Icon && <Icon className="size-3.5 text-foreground/80" aria-hidden />}
<span className="font-semibold">{chip.verb}</span>
<span className="text-foreground/80">{chip.label}</span>
<EllipsisVertical className="-mr-1 size-3.5 text-muted-foreground" aria-hidden />
</span>
);
}
/* ── section header ── */
function HeaderRow({ step }: { step: Extract<ProcedureStep, { kind: "header" }> }) {
const { focusId, update, remove, draftAfter } = useBuilder();
return (
<StepShell onRemove={() => remove(step.id)} label="section">
<AutoTextarea
value={step.text}
autoFocus={focusId === step.id}
aria-label="Section name"
placeholder="Section name"
onChange={(text) => update(step.id, (s) => ({ ...(s as typeof step), text }))}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
draftAfter(step.id);
} else if (event.key === "Backspace" && step.text === "") {
event.preventDefault();
remove(step.id);
}
}}
className="text-base font-semibold leading-7"
/>
</StepShell>
);
}
/* ── go to / actions ── */
function GotoRow({ step }: { step: Extract<ProcedureStep, { kind: "goto" }> }) {
const { sections, focusId, update, remove } = useBuilder();
const options = sections.map((s) => ({ value: s.id, label: s.text || "Untitled section" }));
return (
<StepShell onRemove={() => remove(step.id)} label="go to step">
<div className="flex flex-wrap items-center gap-2 leading-7">
<StepBadge icon={ArrowUpRight} className={BADGE.goto} />
<span>Go to</span>
<TargetPicker
value={step.target}
options={options}
placeholder="Choose a section"
emptyLabel="Add a section header first"
autoOpen={focusId === step.id}
onSelect={(target) => update(step.id, (s) => ({ ...(s as typeof step), target }))}
/>
</div>
</StepShell>
);
}
function ActionRow({ step }: { step: Extract<ProcedureStep, { kind: "action" }> }) {
const { targets, focusId, update, remove } = useBuilder();
const meta = STEP_OPTIONS[step.action];
const copy = ACTION_COPY[step.action];
const options = step.action === "end" ? [] : (targets[step.action] ?? []).map((t) => ({ value: t, label: t }));
return (
<StepShell onRemove={() => remove(step.id)} label={`${meta.label} step`}>
<div className="flex flex-wrap items-center gap-2 leading-7">
<StepBadge icon={meta.icon} className={BADGE.action} />
<span>{copy.prefix}</span>
{step.action !== "end" && (
<TargetPicker
value={step.target}
options={options}
placeholder={copy.picker}
emptyLabel="Nothing to choose yet"
autoOpen={focusId === step.id}
onSelect={(target) => update(step.id, (s) => ({ ...(s as typeof step), target }))}
/>
)}
</div>
</StepShell>
);
}
function TargetPicker({
value,
options,
placeholder,
emptyLabel,
autoOpen,
onSelect,
}: {
value?: string;
options: { value: string; label: string }[];
placeholder: string;
emptyLabel: string;
autoOpen: boolean;
onSelect: (value: string) => void;
}) {
const [open, setOpen] = React.useState(autoOpen);
const [active, setActive] = React.useState(0);
const rootRef = React.useRef<HTMLDivElement>(null);
const buttonRef = React.useRef<HTMLButtonElement>(null);
const listRef = React.useRef<HTMLUListElement>(null);
const listId = React.useId();
const selected = options.find((o) => o.value === value);
useDismiss(rootRef, open, () => setOpen(false));
React.useEffect(() => {
if (open) listRef.current?.focus();
}, [open]);
function close(refocus: boolean) {
setOpen(false);
if (refocus) buttonRef.current?.focus();
}
function onListKeyDown(event: React.KeyboardEvent) {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (!options.length) return;
const delta = event.key === "ArrowDown" ? 1 : -1;
setActive((i) => (i + delta + options.length) % options.length);
} else if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (options[active]) {
onSelect(options[active].value);
close(true);
}
} else if (event.key === "Escape") {
event.preventDefault();
close(true);
} else if (event.key === "Tab") {
setOpen(false);
}
}
return (
<div ref={rootRef} className="relative inline-block">
<button
ref={buttonRef}
type="button"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={open ? listId : undefined}
onClick={() => {
setActive(Math.max(0, options.findIndex((o) => o.value === value)));
setOpen((o) => !o);
}}
className={cn(
"inline-flex items-center gap-1 rounded-full border border-border/60 bg-muted/60 px-2.5 text-[13px] leading-6 transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
!selected && "text-muted-foreground"
)}
>
{selected?.label ?? placeholder}
<ChevronDown className="size-3.5 opacity-60" aria-hidden />
</button>
<AnimatePresence>
{open && (
<motion.ul
ref={listRef}
id={listId}
role="listbox"
tabIndex={-1}
aria-activedescendant={options[active] ? `${listId}-${active}` : undefined}
onKeyDown={onListKeyDown}
initial={{ opacity: 0, y: -4, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.98, transition: { duration: 0.1 } }}
transition={{ duration: 0.14, ease: "easeOut" }}
style={{ transformOrigin: "top left" }}
className="absolute left-0 top-full z-30 mt-1.5 min-w-52 rounded-xl border bg-popover p-1.5 text-popover-foreground shadow-lg focus:outline-none"
>
{options.map((option, index) => (
<li
key={option.value}
id={`${listId}-${index}`}
role="option"
aria-selected={option.value === value}
onPointerMove={() => setActive(index)}
onClick={() => {
onSelect(option.value);
close(true);
}}
className={cn(
"cursor-pointer whitespace-nowrap rounded-lg px-2 py-1.5 text-sm",
index === active && "bg-muted",
option.value === value && "font-medium"
)}
>
{option.label}
</li>
))}
{options.length === 0 && <li className="px-2 py-1.5 text-sm text-muted-foreground">{emptyLabel}</li>}
</motion.ul>
)}
</AnimatePresence>
</div>
);
}
/* ── condition ── */
function ConditionBlock({ step }: { step: ConditionStep }) {
const { focusId, update, remove, draftAtEnd } = useBuilder();
const set = (patch: Partial<ConditionStep>) => update(step.id, (s) => ({ ...(s as ConditionStep), ...patch }));
const hasElse = step.else !== undefined;
return (
<div className="flex flex-col">
<BranchHeader
icon={ArrowRightFromLine}
label="IF"
menuLabel="Condition options"
items={[
hasElse
? { label: "Remove ELSE branch", onSelect: () => set({ else: undefined }) }
: { label: "Add ELSE branch", onSelect: () => set({ else: [{ id: newId("step"), kind: "instruction", content: [""] }] }) },
step.mode === "text"
? { label: "Write as code", onSelect: () => set({ mode: "code" }) }
: { label: "Write in plain language", onSelect: () => set({ mode: "text" }) },
{ label: "Delete condition", destructive: true, onSelect: () => remove(step.id) },
]}
/>
<BranchBody onAdd={() => draftAtEnd(step.id, "then")} addLabel="Add a step to this condition">
<ConditionField
value={step.condition}
mode={step.mode}
autoFocus={focusId === step.id}
onChange={(condition) => set({ condition })}
onToggleMode={() => set({ mode: step.mode === "text" ? "code" : "text" })}
/>
<div className="mt-3">
<StepList steps={step.then} parentId={step.id} branch="then" />
</div>
</BranchBody>
<AnimatePresence initial={false}>
{hasElse && (
<motion.div
key="else"
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6, transition: { duration: 0.12 } }}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<BranchHeader
icon={CornerDownRight}
label="ELSE"
menuLabel="Else options"
items={[{ label: "Remove ELSE branch", destructive: true, onSelect: () => set({ else: undefined }) }]}
/>
<BranchBody onAdd={() => draftAtEnd(step.id, "else")} addLabel="Add a step to the else branch">
<div className="pt-1">
<StepList steps={step.else ?? []} parentId={step.id} branch="else" />
</div>
</BranchBody>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function BranchHeader({
icon,
label,
menuLabel,
items,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
menuLabel: string;
items: MenuItem[];
}) {
return (
<div className="flex items-center gap-2">
<StepBadge icon={icon} className={BADGE.condition} />
<span className="font-mono text-[13px] font-medium tracking-wide">{label}</span>
<KebabMenu label={menuLabel} items={items} />
</div>
);
}
function BranchBody({ children, onAdd, addLabel }: { children: React.ReactNode; onAdd: () => void; addLabel: string }) {
return (
// The rail runs down from the centre of the 1.5rem badge.
<div className="relative ml-3 border-l border-border pb-10 pl-5 pt-2">
{children}
<button
type="button"
onClick={onAdd}
aria-label={addLabel}
className="absolute bottom-0 left-0 flex size-6 -translate-x-1/2 items-center justify-center rounded-full border bg-background text-foreground/80 shadow-xs transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Plus className="size-3.5" aria-hidden />
</button>
</div>
);
}
function ConditionField({
value,
mode,
autoFocus,
onChange,
onToggleMode,
}: {
value: string;
mode: "text" | "code";
autoFocus: boolean;
onChange: (value: string) => void;
onToggleMode: () => void;
}) {
const code = mode === "code";
return (
<div className="group/condition relative rounded-xl border bg-card px-3.5 py-2.5 pr-11 shadow-xs transition-shadow focus-within:border-ring/50 focus-within:ring-2 focus-within:ring-ring/15">
<div className="relative">
{code && (
<pre
aria-hidden
className="pointer-events-none absolute inset-0 whitespace-pre-wrap break-words font-mono text-[13px] leading-6"
>
<HighlightedCode source={value} />
{/* Keeps a trailing newline from collapsing the overlay height. */}{" "}
</pre>
)}
<AutoTextarea
value={value}
autoFocus={autoFocus}
aria-label={code ? "Condition expression" : "Condition"}
placeholder={code ? "order.total > 100" : "Describe when this applies, e.g. the customer reports a damaged item"}
spellCheck={!code}
onChange={onChange}
className={cn(
code
? "font-mono text-[13px] leading-6 text-transparent caret-foreground selection:bg-blue-500/25"
: "leading-7 text-foreground/90"
)}
/>
</div>
<button
type="button"
onClick={onToggleMode}
aria-pressed={code}
aria-label={code ? "Write in plain language" : "Write as code"}
title={code ? "Write in plain language" : "Write as code"}
className={cn(
"absolute right-2 top-2 flex size-7 items-center justify-center rounded-full border bg-background text-foreground/70 transition-[opacity,background-color] hover:bg-muted focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
code ? "opacity-100" : "opacity-0 group-hover/condition:opacity-100 group-focus-within/condition:opacity-100"
)}
>
<Code className="size-3.5" aria-hidden />
</button>
</div>
);
}
const CODE_KEYWORDS = new Set(["and", "or", "not", "in", "true", "false", "null"]);
function HighlightedCode({ source }: { source: string }) {
const tokens = source.match(/\s+|"[^"]*"?|'[^']*'?|\d+(?:\.\d+)?|[A-Za-z_][\w.]*|./g) ?? [];
return tokens.map((token, i) => {
let className = "text-muted-foreground";
if (/^\s+$/.test(token)) className = "";
else if (/^["']/.test(token)) className = "text-emerald-700 dark:text-emerald-400";
else if (/^\d/.test(token)) className = "text-amber-700 dark:text-amber-400";
else if (CODE_KEYWORDS.has(token)) className = "text-foreground";
else if (/^[A-Za-z_]/.test(token)) className = "text-blue-700 dark:text-blue-400";
return (
<span key={i} className={className}>
{token}
</span>
);
});
}
/* ── shared bits ── */
function StepBadge({ icon: Icon, className }: { icon: React.ComponentType<{ className?: string }>; className: string }) {
return (
<span className={cn("flex size-6 shrink-0 items-center justify-center rounded-md", className)}>
<Icon className="size-3.5" aria-hidden />
</span>
);
}
/** Row wrapper with a hover-revealed remove button. */
function StepShell({ children, onRemove, label }: { children: React.ReactNode; onRemove: () => void; label: string }) {
return (
<div className="group/step relative pr-8">
{children}
<button
type="button"
onClick={onRemove}
aria-label={`Remove ${label}`}
className="absolute right-0 top-0.5 flex size-6 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-muted hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover/step:opacity-100"
>
<Trash2 className="size-3.5" aria-hidden />
</button>
</div>
);
}
interface MenuItem {
label: string;
destructive?: boolean;
onSelect: () => void;
}
function KebabMenu({ label, items }: { label: string; items: MenuItem[] }) {
const [open, setOpen] = React.useState(false);
const [active, setActive] = React.useState(0);
const rootRef = React.useRef<HTMLDivElement>(null);
const buttonRef = React.useRef<HTMLButtonElement>(null);
const itemRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
const menuId = React.useId();
useDismiss(rootRef, open, () => setOpen(false));
React.useEffect(() => {
if (open) itemRefs.current[active]?.focus();
}, [open, active]);
function close() {
setOpen(false);
buttonRef.current?.focus();
}
function onKeyDown(event: React.KeyboardEvent) {
const last = items.length - 1;
if (event.key === "ArrowDown") setActive((i) => (i === last ? 0 : i + 1));
else if (event.key === "ArrowUp") setActive((i) => (i === 0 ? last : i - 1));
else if (event.key === "Home") setActive(0);
else if (event.key === "End") setActive(last);
else if (event.key === "Escape") close();
else if (event.key === "Tab") setOpen(false);
else return;
if (event.key !== "Tab") event.preventDefault();
}
return (
<div ref={rootRef} className="relative">
<button
ref={buttonRef}
type="button"
aria-label={label}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
onClick={() => {
setActive(0);
setOpen((o) => !o);
}}
className="flex size-6 items-center justify-center rounded-md text-foreground/70 transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<EllipsisVertical className="size-3.5" aria-hidden />
</button>
<AnimatePresence>
{open && (
<motion.div
id={menuId}
role="menu"
aria-label={label}
onKeyDown={onKeyDown}
initial={{ opacity: 0, y: -4, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.98, transition: { duration: 0.1 } }}
transition={{ duration: 0.14, ease: "easeOut" }}
style={{ transformOrigin: "top left" }}
className="absolute left-0 top-full z-30 mt-1 min-w-48 rounded-xl border bg-popover p-1.5 text-popover-foreground shadow-lg"
>
{items.map((item, index) => (
<button
key={item.label}
ref={(el) => {
itemRefs.current[index] = el;
}}
type="button"
role="menuitem"
tabIndex={index === active ? 0 : -1}
onPointerMove={() => setActive(index)}
onClick={() => {
close();
item.onSelect();
}}
className={cn(
"flex w-full items-center rounded-lg px-2 py-1.5 text-left text-sm focus:bg-muted focus:outline-none",
item.destructive && "text-red-600 dark:text-red-400"
)}
>
{item.label}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function AutoTextarea({
value,
onChange,
autoFocus,
className,
...props
}: Omit<React.ComponentProps<"textarea">, "onChange" | "value"> & {
value: string;
onChange: (value: string) => void;
}) {
const ref = React.useRef<HTMLTextAreaElement>(null);
React.useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "0px";
el.style.height = `${el.scrollHeight}px`;
}, [value]);
React.useEffect(() => {
if (autoFocus) ref.current?.focus();
}, [autoFocus]);
return (
<textarea
ref={ref}
rows={1}
value={value}
onChange={(event) => onChange(event.target.value)}
className={cn(
"block w-full resize-none overflow-hidden bg-transparent p-0 placeholder:text-muted-foreground focus:outline-none",
className
)}
{...props}
/>
);
}
function useDismiss(ref: React.RefObject<HTMLElement | null>, open: boolean, onDismiss: () => void) {
const onDismissRef = React.useRef(onDismiss);
React.useEffect(() => {
onDismissRef.current = onDismiss;
});
React.useEffect(() => {
if (!open) return;
function onPointerDown(event: PointerEvent) {
if (ref.current && !ref.current.contains(event.target as Node)) onDismissRef.current();
}
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open, ref]);
}
A UX spec for this pattern — written for agents implementing or reusing it, not the code.
# Procedure Builder
## Summary
A document-style editor for writing the step-by-step procedure an AI agent follows — numbered instructions in plain language, IF / ELSE branches, section headers, jumps between sections, and terminal actions like Escalate or Switch procedure. Steps are added from a "/" picker, so authoring feels like writing a doc rather than wiring a flowchart, while the result is still structured enough for the agent to execute deterministically where it matters.
## When to use
- Configuring how an agent handles a multi-step support or operations task (refunds, damage claims, account changes) where some steps are judgment calls and some are hard rules.
- When the people writing the procedure are domain experts, not engineers — plain-language instructions and conditions are the default, code is opt-in.
- When branching is shallow (one level of IF / ELSE) and the procedure reads top to bottom.
## When not to use
- Deeply nested or parallel logic, loops, or many-way merges — use a canvas-based Flowchart, where the shape of the graph is the point.
- A single prompt or persona with no ordered steps — a plain instructions field is enough.
- Automations with no AI judgment at all (every condition is a field comparison) — a rules or workflow builder is more honest about what runs.
## Anatomy
- A numbered list of steps (1, 2, 3…). Section headers are unnumbered and break the list into named parts.
- Instruction step: free text describing what the agent should do. Can contain inline reference chips — a bold verb plus a target ("**Update** Resolution method", "**Tag** Refund initiated", "**Use** Order replacement", "**Read** ETA") — for the data, tools, and tags the instruction touches.
- Condition step:
- An "IF" header with a violet branch badge and a ⋮ menu (add/remove ELSE, switch between plain language and code, delete).
- A bordered condition field — prose by default, or a syntax-highlighted expression in code mode, toggled with a `<>` button in its corner.
- A lettered sub-list (A, B, C…) of steps to run when the condition holds, hung off a vertical rail that ends in a "+" button.
- An optional "ELSE" header with the same rail, sub-list, and "+" button.
- Go to step: a blue badge, "Go to", and a picker of the procedure's section headers.
- Action steps: an orange badge marks steps that hand control elsewhere — "Escalate to [team]", "Switch to [procedure]", "Run [workflow]", "End procedure".
- The step picker: a "/Search steps…" input with a menu grouped into structure (Instruction, Condition, Section header, Go to) and, below a divider, actions (Escalate, Switch, Run workflow, End).
## Behavior
- "Add step" at the bottom of the procedure, or the "+" at the end of a branch rail, opens the step picker at that position with focus in the search input. Only one picker is open at a time.
- Typing filters the menu; Arrow keys move the highlight, Enter or a click inserts that step type in place of the picker. Escape, Backspace on an empty search, or moving focus away removes the picker without adding anything.
- Inside a branch the picker offers a reduced menu — no Condition or Section header — because branches are one level deep and sections belong to the top level. Labels get more explicit there ("Go to section", "End procedure").
- Choosing Condition inserts the IF block with focus in the condition field and one empty sub-step whose placeholder explains its role: "Describe what the agent should do when this condition is met."
- Choosing Go to, Escalate, Switch, or Run workflow opens its target picker immediately, since the step is incomplete without one.
- In an instruction, Enter opens the step picker on the next line, Shift+Enter adds a line break, Backspace on an empty instruction deletes it, and "/" in an empty instruction swaps it for the picker — the same muscle memory as block-based docs.
- Go to targets are bound to section headers by identity, not text: renaming a section updates every step that points at it, and deleting a section clears those targets rather than leaving a dangling jump.
- Numbering is derived from position and updates live as steps are added, removed, or branched.
- New steps fade and slide in a few pixels; removed steps fade out and the rest of the list glides into place. Menus scale in from their anchor. Motion is short (≤200 ms) so it never delays typing.
- Every non-condition step reveals a remove button on hover or focus; conditions are removed from their ⋮ menu.
## Content guidelines
- Write instructions as direct imperatives to the agent: "Explain that a reorder can't be placed due to policy," not "The agent should maybe explain…".
- Plain-language conditions should name the observable evidence and give examples ("the image clearly shows spilled or leaking packaging, e.g. sauce, soup, drinks"). Vague conditions produce inconsistent branching.
- Reach for code conditions only for hard thresholds — dates, totals, counts — that must never be left to judgment. Use dotted, human-readable field paths (`order.total`, `policy.auto_approve_limit`).
- Name sections by the goal of that part of the procedure ("Verify the claim", "Offer a resolution") so Go to steps read as sentences.
- End every branch with a clear outcome — a resolution, a Switch, an Escalate, or End — so the agent never falls off the end of a branch ambiguously.
## Accessibility
- The procedure is an ordered list (`<ol>`) and each branch is a nested ordered list, so screen readers announce position and nesting; visual markers ("3.", "A.") are `aria-hidden` duplicates of that structure.
- The step picker input is a `combobox` with `aria-expanded`, `aria-controls`, and `aria-activedescendant` pointing into a `listbox` of `option`s; the highlight follows the keyboard, not just the pointer.
- Target pickers are buttons with `aria-haspopup="listbox"`; the list takes focus on open, supports Arrow keys / Enter / Escape, and returns focus to the button on close.
- The ⋮ menus are `aria-haspopup="menu"` buttons with `role="menu"` / `menuitem` items, roving focus with Arrow keys, Home / End, and Escape to close.
- Every field has an accessible name even though its visible label is a placeholder; the code toggle is a toggle button with `aria-pressed`. Hover-only controls (remove, code toggle) also appear on keyboard focus.
- Syntax highlighting in code mode is a decorative overlay; the underlying textarea holds the real text, so it remains editable and readable by assistive tech.
## Related patterns
- **Flowchart** — Use it instead when the shape of the logic matters more than its wording and a canvas reads better than a doc.
- **Create Agent** — Use it as well to set up the agent the procedure belongs to.
- **Agent Triggers** — Use it as well to decide when the agent runs, while the procedure decides what it does once running.
- **Command Palette** — Use it instead when the same search-and-pick interaction is for navigating, not inserting steps.
Do’s and don’ts
Do use it for
- Configuring how an agent handles a multi-step support or operations task (refunds, damage claims, account changes) where some steps are judgment calls and some are hard rules.
- When the people writing the procedure are domain experts, not engineers — plain-language instructions and conditions are the default, code is opt-in.
- When branching is shallow (one level of IF / ELSE) and the procedure reads top to bottom.
Don’t use it for
- Deeply nested or parallel logic, loops, or many-way merges — use a canvas-based Flowchart, where the shape of the graph is the point.
- A single prompt or persona with no ordered steps — a plain instructions field is enough.
- Automations with no AI judgment at all (every condition is a field comparison) — a rules or workflow builder is more honest about what runs.
Related patterns
Flowchart
Use it instead when the shape of the logic matters more than its wording and a canvas reads better than a doc.
Create Agent
Use it as well to set up the agent the procedure belongs to.
Agent Triggers
Use it as well to decide when the agent runs, while the procedure decides what it does once running.
Command Palette
Use it instead when the same search-and-pick interaction is for navigating, not inserting steps.