AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronDown, CornerDownLeft, Cpu, LoaderCircle, PenLine, RotateCcw, X } from "lucide-react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/regenerate-menu.json

Prompt

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 source tells 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 calls onAction once; 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.

On This Page