agents
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 — the canvas alternative for logic whose shape matters more than its wording.
- Create Agent and Agent Triggers — decide when an agent runs; a Procedure Builder decides what it does once running.
- Command Palette — the same search-and-pick interaction, applied to navigation instead of insertion.