AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Lock } from "lucide-react"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { useControllableState } from "@/registry/hooks/use-controllable-state"
import { useRovingTabindex } from "@/registry/hooks/use-roving-tabindex"

/* -------------------------------------------------------------------------- *
 * Chat Mode Picker
 *
 * The "what powers this reply" control that sits in a composer: web search,
 * deep research, canvas, image generation… Each chip is a capability the next

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chat-mode-picker.json

Prompt

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) or unavailable (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 exclusionconflictsWith is 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.

On This Page