Regenerate Menu
A split retry control for one assistant answer — the button half re-runs it, the chevron opens a menu with a model submenu that marks the current model and an inline instruction field, and every path emits one discriminated action.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/regenerate-menu.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "RegenerateMenu": the split retry control
that sits under one assistant answer. Use the shadcn dropdown-menu (Radix
DropdownMenu), shadcn Button and Input, lucide-react icons and cn() from
@/lib/utils.
Contract
- forwardRef<HTMLDivElement>, extends React.ComponentPropsWithoutRef<"div">;
unlisted props spread onto the root, className merges through cn().
- onAction: (action: RegenerateAction) => void — the ONLY output.
type RegenerateAction =
| { type: "retry"; source: "button" | "menu" }
| { type: "retry-with-model"; modelId: string; model: RegenerateModel }
| { type: "retry-with-instructions"; instructions: string }
interface RegenerateModel {
id: string; label: string; hint?: string;
disabled?: boolean; reason?: string
}
One discriminated union instead of four callbacks: the consumer writes a
single exhaustive switch and can log, queue or replay an action as data.
- models?: RegenerateModel[] (default a module-level frozen empty array so the
default never looks like a new list every render), currentModelId?: string.
- busy?: boolean and disabled?: boolean — two different locks, see Behavior.
- size?: "default" | "sm", iconOnly?: boolean.
- Every string is a prop: label ("Try again"), busyLabel ("Regenerating…"),
menuLabel (accessible name of the chevron half), modelsLabel,
instructionsLabel, instructionsPlaceholder.
- maxInstructionsLength?: number (default 280), showInstructions?: boolean
(default true), align?: "start" | "center" | "end".
- The component owns exactly three pieces of state: menu open, "drafting"
(field swapped in for its row) and the draft text. `busy` and
`currentModelId` stay upstream — this control never guesses either.
Behavior — the split button
- Two Buttons in an inline-flex row, both variant="outline": the primary half
loses its right radius, the chevron half loses its left radius and pulls back
by -1px so the two borders collapse into one seam. Both get relative +
focus-visible:z-10 so a focus ring is never painted under the neighbour.
- Primary click emits { type: "retry", source: "button" }. The menu's first row
is the same intent with source: "menu" — the two halves must never disagree
about what "try again" means, and the source field is what tells them apart
later.
- ArrowDown on the primary half opens the menu (split-button convention), so
the menu is reachable without travelling to the chevron; Radix then moves
focus into the content.
- If the menu would contain nothing but a duplicate of the primary action —
no model that differs from currentModelId AND showInstructions === false —
do not render the chevron half at all. A split button with an empty menu is
a lie about the affordance.
Behavior — the two locks
- disabled: hard, structural (no permission, message gone). Both halves get the
native disabled attribute.
- busy: a regeneration is already running. Set aria-disabled + aria-busy, NOT
disabled: a control that goes natively disabled under the user's own finger
drops focus to <body> and stops announcing why it stopped working. The
primary swaps its icon for a spinner and its label for busyLabel.
- Both locks swallow every emission at one choke point, and the menu's
onOpenChange refuses `true` while locked — that also covers the keyboard and
pointer paths that never reach your own handlers.
- A lock arriving while the menu is open closes it, and RESETS the open state
rather than merely overriding the rendered value; otherwise clearing the lock
pops the menu back open on top of an answer the user has already moved past.
- One-shot emit lock: an emission sets a ref and starts a ~300ms timer that
clears it; a second emission inside that window is dropped. It exists for the
double-fire windows a menu creates (Enter in the field plus a click on the
send button, a double-click, a repeated Enter while the menu tears down), not
as a rate limiter. Clear the timer on unmount.
Behavior — the model submenu
- Render it only when some model differs from currentModelId: re-running the
model that produced the answer is exactly what "Try again" already is.
- Use a radio group, not checkboxes: exactly one model is the current one and a
screen reader should hear "menuitemradio, checked". Bind value to
currentModelId — the app's truth — so a pick the parent then refuses leaves
the tick where it belongs instead of lying optimistically. Selecting the
already-ticked model is still a legal emission.
- A row can be disabled with a `reason` that replaces its `hint`, so "why not"
sits exactly where "why" was; disabled rows never emit.
- Give the submenu p-0 and put the padding on an inner max-h + overflow-y-auto
scroller, so a long catalogue scrolls without its first and last row clipping
against the rounded edge. Nesting a plain div between the menu content and
the items is safe: Radix registers items through context, not DOM structure.
Behavior — the instruction field inside the menu
- The "Add instructions" row is a menu item whose onSelect calls
preventDefault(): that single line is what keeps the menu open, and it is the
whole reason an inline field inside a menu is possible.
- While drafting, the row is replaced by an input plus a send and a discard
button. Focus the input one frame later (requestAnimationFrame): Radix
focuses the menu content on mount and grabbing focus in the same frame loses
that race. Cancel the frame on unmount.
- TYPEAHEAD FIREWALL — the menu content runs a typeahead on every printable key
that reaches it and jumps focus to the row it matches, which from inside a
text field yanks the caret away mid-word. In the field's keydown handler,
stopPropagation() for character keys only (event.key.length === 1 without
ctrl/meta/alt). Do NOT blanket-stop: Escape is handled by a capture-phase
document listener anyway, and Tab and the arrows must keep reaching the menu
so dismissal and roving focus still work.
- Enter submits, guarded by event.nativeEvent.isComposing so the Enter that
commits an IME candidate does not also send the instruction. Trim before
emitting; an all-whitespace draft is not a submission.
- Because the menu traps Tab, the send and discard buttons are pointer
affordances. State the keyboard contract in a line under the field
("Enter regenerates · Esc closes and keeps the draft") and wire it as the
input's aria-describedby — that line is the accessibility fix, not decoration.
- Closing the menu with an empty field folds the row away; a non-empty draft
survives the round trip and the "Add instructions" row shows a "draft" chip,
because Escape and click-outside are how people dismiss by accident and
losing typed text to that is the one thing this row must not do.
- Discarding clears the draft and moves focus back to the menu content: the
focused input is about to unmount, and without that focus falls to <body>,
the menu loses its anchor and the keyboard path dead-ends.
- maxLength caps typing and pasting; show characters REMAINING, and turn the
number text-destructive near the cap.
Rendering & styling
- Semantic tokens only: the Button/Input primitives' own tokens plus border,
bg-card, text-muted-foreground, text-foreground, text-destructive for the
counter near the cap. No hex, no rgb(), no oklch().
- Non-modal menu (modal={false}): a regenerate menu lives inside a scrolling
transcript, and locking page scroll to open a four-row menu is a worse trade
than letting an outside click both dismiss the menu and land.
- Motion is decorative only: the chevron rotates 180° on open with
transition-transform + motion-reduce:transition-none, the busy spinner is
animate-spin + motion-reduce:animate-none. Under reduced motion the spinner
is a static ring and the state still reads, because busyLabel says it.
- Accessibility: the chevron half carries menuLabel as aria-label and gets
aria-haspopup/aria-expanded from Radix; iconOnly keeps the primary label as
sr-only text; icons are aria-hidden; one sr-only role="status" region holds
busyLabel while busy and is empty otherwise, so nothing is announced on mount
and the run is announced once.
Customization levers
- Rows: drop showInstructions for a re-roll/re-route-only menu, pass models=[]
for instructions only, or add rows (temperature, "answer in another language")
between the separator and the field — the emit choke point is one function,
so a new row is one more union member plus one case.
- Density: size="sm" + iconOnly is the message-action-row form; the default is
the standalone footer form. Both sizes are pure Button size tokens, so they
follow whatever your Button scale is.
- Model rows: hint/reason are free text — put pricing, latency or context
window there, or render a badge instead by swapping the second line.
- Lock policy: EMIT_LOCK_MS (300) guards double-fire only; raise it if your
backend is slow to flip `busy`, or delete it entirely if the parent already
queues. Whether the menu may open while busy is one guard in onOpenChange —
flip it if you support queuing a second run.
- Instruction cap: maxInstructionsLength drives both maxLength and the counter;
swap the Input for a textarea if your users write paragraphs, in which case
move submit to Cmd/Ctrl+Enter and keep the typeahead firewall as-is.
- Seam: the split is two Buttons with joined radii — give the chevron half
variant="ghost", or drop iconOnly and label the halves differently, without
touching the state machine.Concepts
- Split-button duality — the primary half and the menu's first row are the same intent; only
sourcetells them apart. The chevron exists to reveal the variants of retrying, so when there are no variants left to offer it is not rendered at all. - One emission point — every path (button, menu row, radio pick, instruction submit) funnels through a single
emit()that checks both locks, closes the menu and callsonActiononce; adding a row can therefore never add a way to bypass the lock. - One-shot lock — a ~300ms ref-plus-timer window swallows the second emission of a double-fire (Enter and a click on send, a double-click, a repeated Enter during teardown). It is not a rate limiter;
busy, owned by the consumer, is the real one. - Menu-preserving select — a menu item closes the menu on select unless the select event is prevented; that one
preventDefault()is what lets a text field live inside a menu instead of in a dialog the user has to travel to. - Typeahead firewall — the menu jumps focus to whatever row matches the letters you type, so a field inside it must stop character keys from bubbling — and only those, because Escape, Tab and the arrows still have to reach the menu for dismissal and roving focus to work.
- The tick follows the app, not the click — the radio group is bound to
currentModelId, so a model switch the parent rejects (no quota, request failed) leaves the check where it belongs; the menu never reports a state the application is not actually in.
Stop Generating
One button slot for the whole run — Stop while tokens arrive, Continue generating when the reply hit its length cap, nothing when idle, with a scoped Esc binding and a one-shot lock on both actions.
Response Feedback
Thumbs up or down on one AI answer, with a follow-up panel of reason chips and a comment, and exactly one submission per message — locked into a receipt when it lands.