AI

Custom Instructions

An end-user preference form for assistant instructions: autosizing questions with example popovers, per-field character budgets, one switch that gates every field without erasing it, and a Save / Cancel bar whose Cancel stays undoable.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, Lightbulb, LoaderCircle, Lock, TriangleAlert, Undo2 } from "lucide-react"
import { Popover as PopoverPrimitive } from "radix-ui"

import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Shape
 *
 * The questions are data, not markup: one entry per field, so a product that
 * asks three things (name, context, tone) is a prop change and not a fork. The

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/custom-instructions.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "CustomInstructions" component: the
end-user preference form an assistant reads before every new chat. Deps:
lucide-react, Radix Popover (the `radix-ui` package), the shadcn `button`
primitive and cn(). No form library — the answers are plain controlled state.

Contract
- forwardRef<HTMLFormElement>; the root IS a <form> (Save is type="submit",
  onSubmit is preventDefault'd), extending React.FormHTMLAttributes minus
  onSubmit / onChange / defaultValue / children / title.
- Field descriptor: { id, label, hint?, placeholder?, maxLength?, examples? }.
  Export a DEFAULT_FIELDS pair — "What should the assistant know about you?"
  and "How should it respond?", 1500 characters each, four examples each.
- Value: { enabled: boolean; answers: Record<string, string> }.
- value? — the SAVED baseline (controlled); defaultValue? — the uncontrolled
  baseline; defaultDraft? — the initial editing copy, so a restored draft can
  mount already dirty. fields? defaults to DEFAULT_FIELDS.
- onDraftChange?(draft), onSave?(next), onCancel?(discarded).
- status?: "idle" | "saving" | "error", errorMessage?.
- title?, description?, toggleLabel?, minRows? (4), maxRows? (12), disabled?,
  readOnly?, className.
- Answers are keyed by field id. Keys no current field claims are carried
  through every edit and handed back on save untouched — a form must not drop a
  value just for not knowing about it.

Behavior — baseline vs draft (the whole contract)
- Keep ONE draft copy in state and compare it to the baseline through a
  SIGNATURE, not object identity: JSON of [enabled, ...answers of the current
  fields]. Identity would loop forever against a caller that inlines
  value={{…}}, and a whole-record compare would call a stale key a change.
- dirty = draftSig !== savedSig. The Save / Cancel bar exists only while dirty;
  there is no permanently parked bar and no disabled Save.
- When the baseline changes underneath: if our own save was in flight, that is
  the landing — clear the drift flag and flash "Saved". Else if the draft was
  clean, adopt the new baseline silently. Else keep every character the user
  typed and raise a one-line drift notice ("Changed elsewhere — saving replaces
  that copy"). Never overwrite unsaved work to stay in sync.
- Do this in a render-phase "previous signature" comparison, not an effect, so
  the form never paints one frame with the stale baseline.

Behavior — save
- requestSave is a no-op unless dirty, editable, not already saving, not over
  budget, and not locked. Take a ONE-SHOT lock so a double click cannot fire
  onSave twice; release it when the draft, the baseline or the status changes.
- Uncontrolled forms commit locally; controlled ones wait for the parent to hand
  back a new value — which is what makes status="saving" observable at all.
- status="saving": both buttons disabled, spinner on Save, fields stay editable.
- status="error": nothing is cleared, the bar stays, and a destructive-tinted
  role="alert" line explains that every character is still there.
- ⌘S / Ctrl+S and ⌘Enter save from any field: preventDefault (kill the browser
  Save Page dialog) plus stopPropagation (kill an app-level ⌘S doing it twice).
- A landed save replaces the bar with a "Saved" line for ~2.6s, and only while
  the form is clean — a user who kept typing during the save sees the unsaved
  bar instead of a contradictory confirmation.

Behavior — cancel is undoable, not confirmable
- Cancel reverts the draft to the baseline IMMEDIATELY, snapshots what it threw
  away, and replaces the bar with "Changes discarded · Undo" for ~6s. Undo puts
  the snapshot back. No "are you sure?" dialog stands between the user and a
  cancel they meant.
- Any keystroke or toggle inside that window drops the snapshot, so a stale Undo
  can never overwrite text written after the revert.
- Move focus deliberately: the button the keyboard was on is being unmounted, so
  after Cancel focus the Undo button, and after Undo focus Save. Never drop the
  user on <body>.

Behavior — the gate
- The switch is part of the value, so flipping it makes the form dirty like any
  other edit. Off means "not sent", never "erased": every answer stays on screen
  and is saved as-is.
- Gate with readOnly + aria-disabled, NOT disabled: a disabled textarea cannot be
  scrolled, selected or read by a screen reader, and the whole point is that the
  text is still there. Dim it, add a muted explanation line, and point the
  switch's aria-describedby at that line.

Behavior — budgets and counting
- maxLength is a SOFT budget. Over it: the counter and the field border turn
  destructive, the bar reports the total overage, Save is blocked — and not one
  character is truncated. Never let a paste be silently eaten.
- Count graphemes (Intl.Segmenter when available, code points otherwise), not
  string length: a flag emoji is one thing on screen and four UTF-16 units, and
  a budget the user can see must count what the user sees.
- Counter styling is a three-step ladder: muted, then plain foreground from 90%
  of the budget, then destructive past it.
- Fields with no maxLength show a bare count and never block a save.

Behavior — autosize and examples
- Grow each textarea to its content in a LAYOUT effect (height:auto, then
  scrollHeight clamped between minRows and maxRows in computed line-heights),
  then switch overflowY on only past the ceiling. Re-run on width changes via a
  ResizeObserver that ignores height-only entries — reacting to the height you
  just wrote is an observer loop. Disconnect on unmount.
- Each field with examples gets a small "Examples" popover. Picking one does NOT
  close-and-insert in the same tick: remember the choice, close the popover, and
  insert in onCloseAutoFocus with preventDefault — execCommand needs the field
  focused and Radix would otherwise hand focus back to the trigger.
- Insert semantics: a blank field takes the example whole, anything else keeps
  its text and gets the example appended on its own line. Insert through
  document.execCommand("insertText") so the browser's own undo stack survives
  (⌘Z puts the field back); on refusal fall back to a state update and restore
  the caret in a layout effect.

Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the shell, border,
  bg-muted/40 and bg-muted/60 for the gate note and the save bar, bg-popover /
  text-popover-foreground for the examples panel, bg-primary + bg-input for the
  switch track, text-muted-foreground for hints and counters, text-destructive +
  bg-destructive/10 for over-budget and failed saves. No hex, no rgb(), no
  oklch().
- One card: header (title, description, switch) → optional gate note → the
  fields → exactly one of the save bar / Saved line / Undo line → the error
  line. overflow-hidden on the root keeps the bar inside the rounded border;
  the popover is portalled so that clipping cannot reach it.
- Accessibility: every question is a real <label htmlFor>; each textarea is
  aria-describedby its hint and its counter; over-budget fields are
  aria-invalid; the switch is a <button role="switch" aria-checked> named by its
  visible label; the failure line is role="alert"; one sr-only
  role="status" aria-live="polite" region carries saving / saved / discarded /
  over-budget / unsaved in that priority order.
- Motion: the bar enters with motion-safe:animate-in fade + slide, the switch
  knob uses motion-reduce:transition-none, the spinner
  motion-reduce:animate-none, the popover motion-reduce:animate-none. Under
  reduced motion everything still appears and works — it just does not move.
- Clean up on unmount: the flash timer, the ResizeObserver, nothing else is
  allowed to outlive the component.

Customization levers
- Questions: `fields` is the whole content model. Two questions is the default;
  three (name / project / avoid) is a prop change, not a fork. Drop `examples`
  to remove the popover, drop `maxLength` to remove the budget.
- Budget policy: change the soft cap per field, move the 90% nudge threshold, or
  make it hard by passing maxLength to the textarea too — losing pastes is the
  price, which is why it is soft here.
- Undo window (6s) and Saved flash (2.6s): raise for calmer surfaces, lower for
  dense settings pages. Set them to the same value if you want one rhythm.
- Cancel semantics: swap the undo window for an arm-then-confirm Cancel (like a
  destructive discard) when the field is long-form rather than a preference.
- Density: minRows / maxRows control the field height; the card's px-4 / py-3
  rhythm and text-sm / text-xs pairing are the only spacing knobs — override
  through className.
- Copy: title, description and toggleLabel are props; the gate note, the Saved
  line and the fallback error text are one string each in the source.
- Persistence: the component owns no I/O. onSave gives you the whole value —
  PUT it, or write it to localStorage and feed it back as `value` to keep the
  Saving → Saved cycle honest.

Concepts

  • Baseline vs draft — the saved copy and the copy being edited are two separate values, and every question the UI asks ("is there anything to save?", "did the server move?") is a comparison of their signatures rather than of object identities, so a caller that rebuilds value on every render still behaves.
  • Undoable cancel — Cancel reverts immediately and keeps the discarded text for a few seconds behind an Undo button, which is the cheap version of a confirmation dialog: nobody is interrogated for cancelling, and nobody loses a paragraph either. The first keystroke after the revert drops the snapshot so Undo can never resurrect stale text over new work.
  • Gate without erase — the master switch changes whether the instructions are sent, never whether they exist; the fields go read-only and dim rather than disabled, because a disabled textarea cannot be scrolled, selected or reached by a screen reader and the text is the whole point.
  • Soft budget — the character limit warns, colours and blocks the save, but never truncates: a limit that silently eats a paste is a bug wearing a counter. Counting is done in graphemes so an emoji costs what it looks like it costs.
  • One-shot save lock — the first Save press latches until the draft, the baseline or the status changes, so a double click, an Enter plus a click, or ⌘S twice in a row still produce exactly one write.
  • Insert after close — an example is remembered on click, applied only once the popover has closed and focus is back in the field, and written through the platform's own insert command so a plain ⌘Z undoes it like typing.

On This Page