Chat Mode Picker
The composer's capability chips — web search, deep research, canvas, image — with a plan gate, a context gate, a cap, conflicting pairs, and a set that repairs itself when the model changes.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chat-mode-picker.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ChatModePicker": the strip of capability
chips in a chat composer that decides what the NEXT message is sent with (web
search, deep research, canvas, image generation, code interpreter). Use
lucide-react for glyphs and a Radix tooltip; no other runtime dependency.
Contract
- ChatMode: { id: string; label: string; icon?: React.ReactNode;
description?: string; availability?: "available" | "locked" | "unavailable";
reason?: string; tag?: string; conflictsWith?: string[] }.
* locked = the user's plan does not include it (a sales problem).
* unavailable = it exists but not in this context — wrong model, no file
attached, unsupported region (a state problem).
These two never share a message; the whole component exists to keep them apart.
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement> minus
"defaultValue" and "onChange".
- modes: ChatMode[] — the catalogue; render order is also the order ids come back.
- value?: string[] / defaultValue?: string[] / onValueChange?: (ids) => void —
controlled + uncontrolled through one controllable-state hook. Empty is [],
never undefined.
- selection?: "multi" (default) | "single".
- max?: number — cap for multi (single is always 1).
- disabled?: boolean + disabledReason?: string — the assistant is replying.
- onRefuse?: (mode, refusal: "busy" | "locked" | "unavailable" | "capacity").
- label?: string (default "Response modes"), size?: "sm" | "md",
showHint?: boolean, delayDuration?: number.
Behavior — the toggle state machine (one guard chain, in this order)
1. disabled -> refuse("busy") and stop.
2. availability locked / unavailable -> refuse with that kind and stop. The
click is NOT swallowed: it is reported through onRefuse, because a locked
chip is the entry point of the upgrade flow.
3. the mode is already on -> turn it off. Turning off is NEVER refused, not by
the cap and not by a conflict; the way out of a full set must stay open.
4. selection="single" -> the new set is exactly [id]. Pressing the lit chip
still clears it (step 3), which is what makes this a toggle group and not a
radio group.
5. multi: evict every mode that conflicts with the new one FIRST, then test the
cap against the survivors — a chip that frees its own slot must not be
refused. Conflicts are symmetric: `a.conflictsWith = ["b"]` also forbids b+a.
6. cap exceeded -> refuse("capacity"). Do not silently drop the oldest mode; the
user, not the component, decides what to give up.
- Emit the next set sorted by index in `modes`, so two sets can be compared by
join() and a re-order never looks like a change.
Behavior — the selection repairs itself
- Never render the incoming value as-is. Derive the rendered set: skip
duplicates, skip ids that are no longer in `modes`, skip modes that turned
locked/unavailable, and truncate to the cap. Switching model must visibly turn
"Create image" off, not leave a lit chip that silently does nothing.
- Because every repair shortens the array, `derived.length !== value.length` is
an exact set comparison — and it deliberately ignores order, so a differently
ordered but equal set never round-trips an onValueChange back at the parent.
- Rendering is already correct without any effect; run ONE effect purely to tell
the parent, reading the arrays out of a ref so the dependency list is a single
string key. A parent that ignores the call must not be able to start a loop:
nothing it re-renders changes that key.
- Announce the repair, and keep its two causes in separate sentences: a mode the
catalogue took away quotes that mode's own `reason` when there is exactly one
("Create image turned off — Atlas mini cannot generate images."), while ids
trimmed for overflowing the cap say so instead. Ids that simply vanished from
`modes` are dropped silently — there is no label left to name.
Behavior — refusals are visible, not mysterious
- Keep one notice: { seq, message, hint }. `message` goes to an sr-only
role="status" aria-live="polite" region; `hint` (refusals and conflict
evictions only) is also printed as a muted line under the chips.
- The seq number is load-bearing: render the message inside <span key={seq}> so
a REPEATED message is a new node. An unchanged string is not re-announced, and
pressing a locked chip twice must be heard twice.
- Plain on/off does not print a hint — the chip itself already shows it — but it
is still announced.
- While `disabled`, the hint line shows `disabledReason` permanently.
Behavior — keyboard and pointer
- role="toolbar" + aria-orientation="horizontal" around chips that are
aria-pressed toggle buttons. Roving tabindex: the group is ONE Tab stop,
Left/Right (plus Home/End and letter typeahead on the labels) move inside it.
Do not intercept Up/Down — the page still has to scroll.
- Locked and unavailable chips stay focusable and are excluded from nothing:
the one thing a keyboard user wants from them is the reason.
- Use aria-disabled, never the native `disabled` attribute — the latter removes
the Tab stop, kills hover/tooltip and drops the click your upgrade flow needs.
- On click, park the roving tab stop on the pressed chip before running the
toggle, whatever the toggle then decides.
Behavior — cleanup
- Switching a mode ON starts a one-shot halo timer (~480ms). Clear the previous
timer before starting a new one and clear it on unmount. Under
prefers-reduced-motion (matchMedia + useSyncExternalStore, server snapshot
false) the timer never starts at all — the halo is decoration, the toggle is
the function.
- Render nothing when `modes` is empty: an empty toolbar still eats a row of
composer height and still takes a Tab stop.
Rendering & styling
- Semantic tokens only: bg-background / bg-card, border-border, bg-muted +
text-muted-foreground for the resting chip, and the primary family for the
active one (border-primary/60, bg-primary/10, text-primary, ring-primary/20,
ring-primary/40 for the halo), ring-ring + ring-offset-background for focus.
No hex, no rgb(), no oklch().
- Chip: rounded-full pill, icon + label + optional uppercase tag + a lock glyph
when locked. `unavailable` also gets border-dashed so the state survives a
greyscale screenshot and does not rely on opacity alone.
- Active look = tinted fill + primary border + a 1px primary ring; the one-shot
halo is an absolutely positioned inset ring with animate-ping, also carrying
motion-reduce:hidden as a second belt.
- Blocked chips (locked / unavailable / at capacity / inert): opacity-55 +
cursor-not-allowed, but still hoverable and clickable.
- Descriptions and reasons live in a `hidden` node referenced by
aria-describedby, NOT inside the button (content inside a button is swallowed
into its accessible name). That is also what gives touch users the reason,
since a tooltip needs a hover they cannot produce.
- Chips wrap (flex-wrap); a "n/max" counter, aria-hidden because the live region
already says it, sits at the end of the row when a cap is set.
Customization levers
- Density: `size` switches h-7/text-xs against h-8/text-sm; change the pill to
rounded-md for a squarer toolbar without touching any logic.
- Selection model: `selection="single"` + no cap for exclusive effort levels,
`multi` + `max` for a credit budget, `multi` + no max for a pure tool rail.
- Rules: `conflictsWith` is the only exclusion mechanism — express "deep research
owns the turn" by listing every mode it displaces; drop the prop entirely and
the reducer degenerates to plain toggles.
- Refusal policy: swap the capacity branch for an evict-oldest strategy if your
product prefers it (`survivors.slice(1)`), or route refusals to a toast by
wiring onRefuse instead of showing the hint line (showHint={false}).
- Availability sources: drive `availability` from the selected model, the plan,
attachments or a feature flag — the component only reads the field, so the
same chip can be locked for one user and unavailable for another.
- Copy: `reason`, `disabledReason` and `label` are the whole vocabulary; keep
locked lines about the plan and unavailable lines about the context.
- Extra affordances: `tag` carries "Beta" / "2×" markers; add a trailing
overflow menu by rendering the strip with a sliced `modes` array and passing
the rest to your own dropdown — the value stays a flat id set either way.Concepts
- Availability triad — a chip is
available,locked(your plan does not include it) orunavailable(it exists, just not in this context); the two failure modes get different words because one is answered by an upgrade and the other by attaching a file or switching model. - Refusal over silent swap — hitting the cap does not quietly evict the oldest mode; the press is refused, named in a live region and printed as a hint line, so the user keeps ownership of what the reply is powered by.
- Symmetric exclusion —
conflictsWithis read in both directions and resolved before the cap is tested, so a mode that displaces its own competitor is never told the strip is full. - Self-repairing selection — the rendered set is derived from the catalogue every render, so switching model turns off the capability it removed instead of leaving a lit chip that does nothing; one effect, keyed on a single string, tells the parent afterwards.
- Click-through gate — locked chips use
aria-disabled, not the native attribute: they keep their Tab stop, their tooltip and their click, which is exactly what the upgrade flow needs to hear. - Quiet announcements — every outcome goes through one
role="status"region whose message is keyed by a sequence number, so the same refusal pressed twice is heard twice while ordinary on/off never spams the reader.
System Prompt Editor
A system-prompt editing surface: an autosizing monospace field with placeholder highlighting painted behind the caret, a live token estimate against a budget, presets that insert without destroying work, and an unsaved bar that owns Save and Discard.
Context Limit Banner
A context-window warning that escalates from a quiet notice to a blocking wall — a thin usage meter with threshold ticks, rising-edge-only announcements, dismissal that re-arms on escalation, and the three exits out of a full conversation.