Aadi

Prompt Input

AI chats

About

A chat composer for AI apps: attach files with upload progress, toggle tools like web search, pick a model, and Send turns into Stop while the reply streams. Paste and drop work too.

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Prompt Input to your project and fits it to your theme.

Code

How Prompt Input is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { Icon, IconNode, PromptInput, PromptInputActions, PromptInputAttachButton, PromptInputAttachments, PromptInputStatus, PromptInputSubmit, PromptInputTextarea, PromptInputToolbar, PromptInputTools, Spinner, createIcon } from "@/components/ui/prompt-input"

const SEND_AT = 1750

const WRITE_AT = SEND_AT + 800

const WRITE_MS = 1300

const DONE_AT = WRITE_AT + WRITE_MS

const SCENE_MS = DONE_AT + 800

/**
 * Opens on the first answer streaming, so the first frame is already in motion, and a still
 * taken about 3.5 s in lands on the next question typed out with its file attached.
 */
const START_MS = SCENE_MS - 2000

/** How long a choice holds a tour before it carries on. */
const RESUME_MS = 1800

/** True until `RESUME_MS` after the last `hold()`, for tours with their own state. */
function useHold() {
  const [held, setHeld] = React.useState(false)
  const timer = React.useRef(0)
  React.useEffect(() => () => window.clearTimeout(timer.current), [])
  const hold = React.useCallback(() => {
    setHeld(true)
    window.clearTimeout(timer.current)
    timer.current = window.setTimeout(() => setHeld(false), RESUME_MS)
  }, [])
  return [held, hold] as const
}

const reducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches

interface Scene {
  prompt: string
  file: { name: string; size: number; type: string; previewUrl?: string }
  modes: string[]
  response: string
}

/** A checkout screen with an empty total, drawn as a tiny SVG so the demo ships no images. */
const checkoutShot = `data:image/svg+xml;utf8,${encodeURIComponent(
  `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 112 112'><rect width='112' height='112' fill='whitesmoke'/><rect x='12' y='14' width='46' height='6' rx='3' fill='darkgray'/><rect x='12' y='30' width='88' height='14' rx='3' fill='white' stroke='gainsboro'/><rect x='12' y='50' width='88' height='14' rx='3' fill='white' stroke='gainsboro'/><rect x='12' y='74' width='26' height='5' rx='2.5' fill='gainsboro'/><rect x='70' y='72' width='30' height='9' rx='2' fill='none' stroke='tomato' stroke-dasharray='3 2'/><rect x='12' y='90' width='88' height='12' rx='3' fill='dimgray'/></svg>`,
)}`

/** Three real looking asks, each with the file it is about. */
const SCENES: Scene[] = [
  {
    prompt:
      "Summarize these churn interviews and list the top three reasons people cancelled.",
    file: { name: "q3-churn-interviews.pdf", size: 2_410_000, type: "application/pdf" },
    modes: [],
    response:
      "Three reasons came up in 14 of the 22 calls: the mobile app signs people out every few days, annual pricing is hard to find, and CSV exports time out on large workspaces.",
  },
  {
    prompt: "Why does the total on this checkout screen come out blank in Safari?",
    file: {
      name: "checkout-safari.png",
      size: 318_000,
      type: "image/png",
      previewUrl: checkoutShot,
    },
    modes: ["web-search"],
    response:
      "The total uses currencyDisplay narrowSymbol, which older Safari rejects with a RangeError. The error is caught and the field renders empty. Fall back to symbol when it throws.",
  },
  {
    prompt: "Add a sliding window to this limiter so bursts at the minute mark are not let through twice.",
    file: { name: "rate-limit.ts", size: 4_200, type: "text/plain" },
    modes: [],
    response:
      "Keep the previous window's count and weight it by how much of that window still overlaps. A burst at 0:59 and another at 1:01 now share one budget instead of two.",
  },
]

/** One scene, in ms: type with the file uploading, send, think, write the answer, rest. */
const TYPE_MS = 1400

const STREAM_AT = SEND_AT + 350

const FIT_CSS = "html,body{overflow:hidden}[data-fit-small] [data-fit-hide]{display:none!important}"

/**
 * 21st shows a demo in a small frame: about 674 by 421 on a component page, 358 by 224 on a
 * phone and 245 by 139 on a profile card. A demo taller than its frame gets scrollbars, which
 * render white on dark pages. FitFrame fills the frame exactly instead.
 *
 * - "scale": the content shrinks until it fits, centered. For visuals such as orbs.
 * - "crop": the content stays at full size from the top and fades out at the bottom. For text
 *   that streams in, where a changing scale would make the page breathe.
 *
 * `minWidth` lays out a wide demo (a table, a two column panel) at that width and scales it down
 * to the frame, instead of letting it collapse to a narrow layout. The frame centers it, so it
 * shrinks evenly on both sides.
 *
 * Anything marked `data-fit-hide` (state buttons, the chatcn.dev line) drops out in frames
 * shorter than `hideBelow`, so a profile card shows the visual alone.
 */
function FitFrame({
  children,
  mode = "scale",
  hideBelow = 360,
  minWidth,
}: {
  children: React.ReactNode
  mode?: "scale" | "crop"
  hideBelow?: number
  minWidth?: string
}) {
  const content = React.useRef<HTMLDivElement>(null)
  const [cropped, setCropped] = React.useState(false)

  React.useLayoutEffect(() => {
    const el = content.current
    if (!el) return
    const measure = () => {
      const vw = window.innerWidth
      const vh = window.innerHeight
      el.toggleAttribute("data-fit-small", vh < hideBelow)
      // Layout sizes ignore the transform, so measuring never feeds back.
      const w = Math.max(el.offsetWidth, el.scrollWidth)
      const h = Math.max(el.offsetHeight, el.scrollHeight)
      const scale = mode === "scale" ? Math.min(1, vw / w, vh / h) : 1
      el.style.transform = scale < 1 ? `scale(${scale})` : ""
      setCropped(mode === "crop" && h > vh + 1)
    }
    measure()
    const observer = new ResizeObserver(measure)
    observer.observe(el)
    // Content that changes inside a fixed width box (a headline that cycles words) never resizes
    // the box itself, so changes below it measure again on the next frame.
    let frame = 0
    const mutations = new MutationObserver(() => {
      cancelAnimationFrame(frame)
      frame = requestAnimationFrame(measure)
    })
    mutations.observe(el, { subtree: true, childList: true, characterData: true })
    window.addEventListener("resize", measure)
    return () => {
      observer.disconnect()
      mutations.disconnect()
      cancelAnimationFrame(frame)
      window.removeEventListener("resize", measure)
    }
  }, [mode, hideBelow])

  const fade = "linear-gradient(to bottom, #000 calc(100% - 40px), transparent)"
  return (
    <div
      className="flex w-full justify-center overflow-hidden"
      style={{
        height: "100dvh",
        alignItems: mode === "crop" ? "flex-start" : "center",
        maskImage: cropped ? fade : undefined,
        WebkitMaskImage: cropped ? fade : undefined,
      }}
    >
      <style>{FIT_CSS}</style>
      <div
        ref={content}
        className="w-full shrink-0"
        style={{ transformOrigin: mode === "crop" ? "top center" : "center", minWidth }}
      >
        {children}
      </div>
    </div>
  )
}

/* -------------------------------------------------------------------------------------------------
 * Helpers
 * -----------------------------------------------------------------------------------------------*/

type AttachmentStatus = "uploading" | "processing" | "ready" | "error"

/* -------------------------------------------------------------------------------------------------
 * Attachment
 * -----------------------------------------------------------------------------------------------*/

interface AttachmentProps extends Omit<React.ComponentProps<"div">, "onError"> {
  name: string
  /** Bytes. */
  size?: number
  /** MIME type. Picks the icon together with the extension. */
  type?: string
  /** Object or remote URL of an image preview. Shown with a hairline outline. */
  previewUrl?: string
  status?: AttachmentStatus
  /** 0 to 1 while `uploading`. */
  progress?: number
  /** Shown when `status` is `error`. Say how to fix it. Default "Upload failed". */
  error?: React.ReactNode
  /** `chip`: one line, 32 px. `card`: two lines, 56 px; images become square thumbnails. */
  variant?: "chip" | "card"
  /** Shows a remove button. Called after the 140 ms exit, so update your list in here. */
  onRemove?: () => void
  /** Shows Retry on errors. */
  onRetry?: () => void
  /** Makes the name a button, for example to open a preview. */
  onOpen?: () => void
}

const ListContext = React.createContext(false)

function focusNeighbor(from: HTMLElement) {
  const list = from.closest("[data-slot=attachment-list]")
  const buttons = list
    ? Array.from(list.querySelectorAll<HTMLElement>("[data-slot=attachment-remove]"))
    : []
  const index = buttons.indexOf(from)
  const next = buttons[index + 1] ?? buttons[index - 1]
  if (next) {
    next.focus()
    return
  }
  // Last one gone: back to the composer, if there is one.
  from
    .closest("[data-slot=prompt-input]")
    ?.querySelector<HTMLElement>("[data-slot=prompt-input-textarea]")
    ?.focus()
}

/* -------------------------------------------------------------------------------------------------
 * Removal: opacity and scale 0.96 over 140 ms, then focus moves to a neighbor so keyboard users
 * never land on <body>.
 * -----------------------------------------------------------------------------------------------*/

const EXIT_MS = 140

function useExit(onDone?: () => void) {
  const [leaving, setLeaving] = React.useState(false)
  const timer = React.useRef<number>(undefined)
  React.useEffect(() => () => window.clearTimeout(timer.current), [])
  const leave = (event: React.MouseEvent<HTMLElement>) => {
    if (leaving) return
    const button = event.currentTarget
    const hadFocus = document.activeElement === button
    setLeaving(true)
    timer.current = window.setTimeout(() => {
      if (hadFocus) focusNeighbor(button)
      onDone?.()
    }, EXIT_MS)
  }
  return { leaving, leave }
}

type FileKind = "image" | "code" | "sheet" | "archive" | "video" | "audio" | "text" | "file"

function extensionOf(name: string) {
  const dot = name.lastIndexOf(".")
  return dot > 0 ? name.slice(dot + 1).toLowerCase() : ""
}

const CODE = new Set([
  "ts",
  "tsx",
  "js",
  "jsx",
  "mjs",
  "py",
  "rb",
  "go",
  "rs",
  "java",
  "kt",
  "swift",
  "c",
  "cpp",
  "h",
  "cs",
  "php",
  "sh",
  "sql",
  "json",
  "yaml",
  "yml",
  "toml",
  "html",
  "css",
  "prisma",
  "graphql",
])

/** Groups a file by MIME type, then by extension, to pick its icon. */
function fileKind(name: string, type = ""): FileKind {
  const ext = extensionOf(name)
  if (type.startsWith("image/")) return "image"
  if (type.startsWith("video/")) return "video"
  if (type.startsWith("audio/")) return "audio"
  if (CODE.has(ext)) return "code"
  if (["csv", "tsv", "xls", "xlsx", "numbers"].includes(ext)) return "sheet"
  if (["zip", "tar", "gz", "tgz", "rar", "7z"].includes(ext)) return "archive"
  if (["png", "jpg", "jpeg", "gif", "webp", "svg", "avif", "heic"].includes(ext)) return "image"
  if (["mp4", "mov", "webm", "mkv"].includes(ext)) return "video"
  if (["mp3", "wav", "m4a", "ogg", "flac"].includes(ext)) return "audio"
  if (["pdf", "txt", "md", "doc", "docx", "rtf", "pages"].includes(ext) || type.startsWith("text/"))
    return "text"
  return "file"
}

const iconNode: IconNode = [
  ["path", {"d":"M7 2.5h5.88a1.5 1.5 0 0 1 1.06.44l5.12 5.12a1.5 1.5 0 0 1 .44 1.06V19a2.5 2.5 0 0 1-2.5 2.5H7A2.5 2.5 0 0 1 4.5 19V5A2.5 2.5 0 0 1 7 2.5"}],
  ["path", {"d":"M13.5 3.25v3.5a1.75 1.75 0 0 0 1.75 1.75h3.5"}],
  ["path", {"d":"M8.25 11.75a1.25 1.25 0 1 0 2.5 0 1.25 1.25 0 1 0-2.5 0"}],
  ["path", {"d":"m7.75 18.5 3-3a1.5 1.5 0 0 1 2.06-.06l3.44 3.06"}],
]

const FileImageIcon: Icon = /* @__PURE__ */ createIcon("file-image", iconNode)

const iconNode2: IconNode = [
  ["path", {"d":"M7 2.5h5.88a1.5 1.5 0 0 1 1.06.44l5.12 5.12a1.5 1.5 0 0 1 .44 1.06V19a2.5 2.5 0 0 1-2.5 2.5H7A2.5 2.5 0 0 1 4.5 19V5A2.5 2.5 0 0 1 7 2.5"}],
  ["path", {"d":"M13.5 3.25v3.5a1.75 1.75 0 0 0 1.75 1.75h3.5"}],
  ["path", {"d":"m10 12-1.5 1.88a1 1 0 0 0 0 1.24L10 17"}],
  ["path", {"d":"m14 12 1.5 1.88a1 1 0 0 1 0 1.24L14 17"}],
]

const FileCodeIcon: Icon = /* @__PURE__ */ createIcon("file-code", iconNode2)

const iconNode3: IconNode = [
  ["path", {"d":"M7 2.5h5.88a1.5 1.5 0 0 1 1.06.44l5.12 5.12a1.5 1.5 0 0 1 .44 1.06V19a2.5 2.5 0 0 1-2.5 2.5H7A2.5 2.5 0 0 1 4.5 19V5A2.5 2.5 0 0 1 7 2.5"}],
  ["path", {"d":"M13.5 3.25v3.5a1.75 1.75 0 0 0 1.75 1.75h3.5"}],
  ["path", {"d":"M8 13h2"}],
  ["path", {"d":"M14 13h2"}],
  ["path", {"d":"M8 17h2"}],
  ["path", {"d":"M14 17h2"}],
]

const FileSpreadsheetIcon: Icon = /* @__PURE__ */ createIcon("file-spreadsheet", iconNode3)

const iconNode4: IconNode = [
  ["path", {"d":"M7 2.5h5.88a1.5 1.5 0 0 1 1.06.44l5.12 5.12a1.5 1.5 0 0 1 .44 1.06V19a2.5 2.5 0 0 1-2.5 2.5H7A2.5 2.5 0 0 1 4.5 19V5A2.5 2.5 0 0 1 7 2.5"}],
  ["path", {"d":"M13.5 3.25v3.5a1.75 1.75 0 0 0 1.75 1.75h3.5"}],
  ["path", {"d":"M10 5.5h.01"}],
  ["path", {"d":"M10 8.5h.01"}],
  ["path", {"d":"M10 11.5h.01"}],
  ["path", {"d":"M9.5 14h1a1.5 1.5 0 0 1 1.5 1.5V17a1.5 1.5 0 0 1-1.5 1.5h-1A1.5 1.5 0 0 1 8 17v-1.5A1.5 1.5 0 0 1 9.5 14"}],
]

const FileArchiveIcon: Icon = /* @__PURE__ */ createIcon("file-archive", iconNode4)

const iconNode5: IconNode = [
  ["path", {"d":"M7 2.5h5.88a1.5 1.5 0 0 1 1.06.44l5.12 5.12a1.5 1.5 0 0 1 .44 1.06V19a2.5 2.5 0 0 1-2.5 2.5H7A2.5 2.5 0 0 1 4.5 19V5A2.5 2.5 0 0 1 7 2.5"}],
  ["path", {"d":"M13.5 3.25v3.5a1.75 1.75 0 0 0 1.75 1.75h3.5"}],
  ["path", {"d":"M8.75 13H12a1.5 1.5 0 0 1 1.5 1.5V17a1.5 1.5 0 0 1-1.5 1.5H8.75a1.5 1.5 0 0 1-1.5-1.5v-2.5a1.5 1.5 0 0 1 1.5-1.5"}],
  ["path", {"d":"M16.5 14v3.5"}],
]

const FileVideoCameraIcon: Icon = /* @__PURE__ */ createIcon("file-video-camera", iconNode5)

const iconNode6: IconNode = [
  ["path", {"d":"M7 2.5h5.88a1.5 1.5 0 0 1 1.06.44l5.12 5.12a1.5 1.5 0 0 1 .44 1.06V19a2.5 2.5 0 0 1-2.5 2.5H7A2.5 2.5 0 0 1 4.5 19V5A2.5 2.5 0 0 1 7 2.5"}],
  ["path", {"d":"M13.5 3.25v3.5a1.75 1.75 0 0 0 1.75 1.75h3.5"}],
  ["path", {"d":"M8.5 16a3.5 3.5 0 0 1 7 0"}],
  ["path", {"d":"M8.5 16v2.5"}],
  ["path", {"d":"M15.5 16v2.5"}],
]

const FileHeadphoneIcon: Icon = /* @__PURE__ */ createIcon("file-headphone", iconNode6)

const iconNode7: IconNode = [
  ["path", {"d":"M7 2.5h5.88a1.5 1.5 0 0 1 1.06.44l5.12 5.12a1.5 1.5 0 0 1 .44 1.06V19a2.5 2.5 0 0 1-2.5 2.5H7A2.5 2.5 0 0 1 4.5 19V5A2.5 2.5 0 0 1 7 2.5"}],
  ["path", {"d":"M13.5 3.25v3.5a1.75 1.75 0 0 0 1.75 1.75h3.5"}],
  ["path", {"d":"M8.25 12.5h7.5"}],
  ["path", {"d":"M8.25 16.5h7.5"}],
  ["path", {"d":"M8.25 8.5H10"}],
]

const FileTextIcon: Icon = /* @__PURE__ */ createIcon("file-text", iconNode7)

const iconNode8: IconNode = [
  ["path", {"d":"M7 2.5h5.88a1.5 1.5 0 0 1 1.06.44l5.12 5.12a1.5 1.5 0 0 1 .44 1.06V19a2.5 2.5 0 0 1-2.5 2.5H7A2.5 2.5 0 0 1 4.5 19V5A2.5 2.5 0 0 1 7 2.5"}],
  ["path", {"d":"M13.5 3.25v3.5a1.75 1.75 0 0 0 1.75 1.75h3.5"}],
]

const FileIcon: Icon = /* @__PURE__ */ createIcon("file", iconNode8)

const KIND_ICON: Record<FileKind, React.ComponentType<{ className?: string }>> = {
  image: FileImageIcon,
  code: FileCodeIcon,
  sheet: FileSpreadsheetIcon,
  archive: FileArchiveIcon,
  video: FileVideoCameraIcon,
  audio: FileHeadphoneIcon,
  text: FileTextIcon,
  file: FileIcon,
}

function percent(progress: number | undefined) {
  return Math.round(Math.min(1, Math.max(0, progress ?? 0)) * 100)
}

/** Short type label for the meta line: `PDF`, `TSX`, `PNG`. */
function typeLabel(name: string, type = "") {
  const ext = extensionOf(name)
  if (ext) return ext.toUpperCase()
  const sub = type.split("/")[1]
  return sub ? sub.toUpperCase() : "File"
}

const NBSP = "\u00a0"

/** `4200` becomes `4.2 KB`. Decimal units, the way macOS and most upload UIs count. */
function formatBytes(bytes: number) {
  if (!Number.isFinite(bytes) || bytes < 0) return ""
  if (bytes < 1000) return `${bytes}${NBSP}B`
  const units = ["KB", "MB", "GB", "TB"]
  let value = bytes / 1000
  let unit = 0
  while (value >= 1000 && unit < units.length - 1) {
    value /= 1000
    unit += 1
  }
  return `${value < 10 ? value.toFixed(1) : Math.round(value)}${NBSP}${units[unit]}`
}

/* -------------------------------------------------------------------------------------------------
 * Progress pie: a hairline ring with a wedge that fills with the real bytes, the way Finder shows a
 * copy. It can never be mistaken for the spinner, so a percentage beside it is always believable.
 * -----------------------------------------------------------------------------------------------*/

// The wedge is a circle of radius 2.75 stroked 5.5 wide: its dash covers a 5.5 px disc.
const PIE_RADIUS = 2.75

const PIE_CIRCUMFERENCE = 2 * Math.PI * PIE_RADIUS

function ProgressRing({
  progress,
  label,
  className,
}: {
  progress: number
  label: string
  className?: string
}) {
  const value = percent(progress)
  return (
    <svg
      viewBox="0 0 16 16"
      fill="none"
      role="progressbar"
      aria-label={label}
      aria-valuemin={0}
      aria-valuemax={100}
      aria-valuenow={value}
      aria-valuetext={`${value}%`}
      data-slot="attachment-progress"
      className={cn("size-4 shrink-0 -rotate-90", className)}
    >
      <circle cx="8" cy="8" r="7" stroke="currentColor" strokeOpacity="0.4" strokeWidth="1.25" />
      <circle
        cx="8"
        cy="8"
        r={PIE_RADIUS}
        stroke="currentColor"
        strokeWidth={PIE_RADIUS * 2}
        strokeDasharray={PIE_CIRCUMFERENCE}
        strokeDashoffset={PIE_CIRCUMFERENCE * (1 - value / 100)}
        // Progress is linear: it maps to bytes, not to a feeling.
        className="transition-[stroke-dashoffset] duration-200 ease-linear motion-reduce:transition-none"
      />
    </svg>
  )
}

const iconNode9: IconNode = [
  ["path", {"d":"M3 12a9 9 0 1 0 18 0 9 9 0 1 0-18 0"}],
  ["path", {"d":"M12 7.75v5"}],
  ["path", {"d":"M12 16.25h.01"}],
]

const CircleAlertIcon: Icon = /* @__PURE__ */ createIcon("circle-alert", iconNode9)

/**
 * Remounts its child when `swapKey` changes after the first render, so the new glyph settles in
 * with the kit's blur crossfade (opacity, scale 0.9 and 2 px blur, 150 ms) in the same footprint.
 */
function Swap({ swapKey, children }: { swapKey: string; children: React.ReactNode }) {
  const first = React.useRef(swapKey)
  const changed = first.current !== swapKey
  return (
    <span
      key={swapKey}
      className={cn(
        "absolute inset-0 grid place-items-center",
        changed &&
          "ease-(--chatcn-ease-out) transition-[opacity,scale,filter] duration-150 starting:scale-90 starting:opacity-0 starting:blur-[2px] motion-reduce:starting:scale-100 motion-reduce:starting:blur-none",
      )}
    >
      {children}
    </span>
  )
}

const iconNode10: IconNode = [
  ["path", {"d":"M5.49 17.46A8.5 8.5 0 1 0 6.54 5.49"}],
  ["path", {"d":"m8.21 8.65-2.94-.26a2 2 0 0 1-1.82-2.16l.26-2.94"}],
]

const RotateCcwIcon: Icon = /* @__PURE__ */ createIcon("rotate-ccw", iconNode10)

const iconNode11: IconNode = [
  ["path", {"d":"m6 6 12 12"}],
  ["path", {"d":"m18 6-3.88 3.88"}],
  ["path", {"d":"M9.88 14.12 6 18"}],
]

const XIcon: Icon = /* @__PURE__ */ createIcon("x", iconNode11)

let context: CanvasRenderingContext2D | null | undefined

/** A text measurer that matches an element's font, from one shared canvas. */
function measurerFor(element: HTMLElement) {
  if (context === undefined) context = document.createElement("canvas").getContext("2d")
  const ctx = context
  if (!ctx) return null
  const style = getComputedStyle(element)
  const spacing = Number.parseFloat(style.letterSpacing) || 0
  const font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`
  return {
    spacing,
    measure: (value: string) => {
      ctx.font = font
      if ("letterSpacing" in ctx) ctx.letterSpacing = `${spacing}px`
      return ctx.measureText(value).width
    },
  }
}

/**
 * Splits a file name into its stem and extension (with the dot). Dotfiles, and names whose last
 * "extension" is really a sentence (`notes v2. final`), keep everything in the stem.
 */
function splitChatcnFileName(name: string): { stem: string; ext: string } {
  const dot = name.lastIndexOf(".")
  if (dot <= 0 || name.length - dot > 10 || /\s/.test(name.slice(dot))) {
    return { stem: name, ext: "" }
  }
  return { stem: name.slice(0, dot), ext: name.slice(dot) }
}

/**
 * The end of the stem worth keeping beside the extension: the last word when it is short
 * (`2026` in `quarterly-report-2026.pdf`), else the last four characters.
 */
function stemTail(stem: string) {
  const word = /[\s\-_.]([^\s\-_.]{2,8})$/.exec(stem)?.[1]
  const tail = word ?? stem.slice(-4).replace(/^[\s\-_.]+/, "")
  return stem.length - tail.length >= 4 ? tail : ""
}

const SEPARATORS = /[\s\-_.]+$/

const ELLIPSIS = "…"

/**
 * Truncates `text` in the middle so it fits `maxWidth`, as one string, so the ellipsis sits flush
 * against both halves: `quarterly-rep…2026.pdf`. The extension is never cut. `measure` returns
 * the rendered width of a string. Exported for tests and for canvas or SVG labels.
 */
function middleTruncateChatcn(
  text: string,
  maxWidth: number,
  measure: (value: string) => number,
): string {
  if (maxWidth <= 0 || measure(text) <= maxWidth) return text
  const { stem, ext } = splitChatcnFileName(text)
  const keep = stemTail(stem)
  // Prefer a tail that tells similar files apart; drop to the bare extension when room is short.
  const tails = keep ? [keep + ext, ext] : [ext]
  for (const [index, tail] of tails.entries()) {
    const headMax = text.length - tail.length
    const minHead = index < tails.length - 1 ? Math.min(6, headMax) : 1
    const join = (n: number) => {
      const head = text.slice(0, n)
      return (head.replace(SEPARATORS, "") || head) + ELLIPSIS + tail
    }
    let lo = 0
    let hi = headMax
    while (lo < hi) {
      const mid = Math.ceil((lo + hi) / 2)
      if (measure(join(mid)) <= maxWidth) lo = mid
      else hi = mid - 1
    }
    if (lo >= minHead) return join(lo)
  }
  return ELLIPSIS + ext
}

const useIsoLayoutEffect = typeof window === "undefined" ? React.useEffect : React.useLayoutEffect

/**
 * Middle truncation that reflows with its container. Render it like this, so the name keeps its
 * full width as the element's natural size (chips still hug long names) and screen readers hear
 * the whole name, while the eye sees the shortened one:
 *
 * ```tsx
 * const name = useChatcnMiddleTruncate(file.name)
 * <span ref={name.ref} title={name.title} className="block min-w-0 overflow-hidden whitespace-nowrap">
 *   <span ref={name.textRef} style={name.textStyle} aria-hidden="true" dir="auto">{name.text}</span>
 *   <span dir="auto" className="block h-0 overflow-hidden">{file.name}</span>
 * </span>
 * ```
 *
 * Measures with a canvas once per width change (a ResizeObserver feeds the width, so there is
 * no layout read in the loop), then checks the rendered line once and corrects a sub-pixel
 * overshoot. The part of a glyph that no longer fits is spread as tracking too small to see
 * (at most 0.4 px a glyph), so a shortened name ends exactly at its box and whatever sits beside
 * it keeps the same gap as beside a full name. Pass `textStyle` to the visible span for that.
 * Works in either direction: the text keeps its own direction through `dir="auto"`.
 */
function useChatcnMiddleTruncate(value: string) {
  const ref = React.useRef<HTMLSpanElement>(null)
  const textRef = React.useRef<HTMLSpanElement>(null)
  const [fit, setFit] = React.useState<{ source: string; text: string; tracking?: string }>({
    source: value,
    text: value,
  })
  const width = React.useRef(-1)
  const corrections = React.useRef(0)

  const run = React.useCallback(
    (available: number) => {
      const element = ref.current
      if (!element || available <= 0) return
      width.current = available
      const measurer = measurerFor(element)
      if (!measurer) return
      const text = middleTruncateChatcn(value, available, measurer.measure)
      let tracking: string | undefined
      if (text !== value) {
        const slack = available - measurer.measure(text) - 0.5
        const extra = Math.min(Math.max(slack / Array.from(text).length, 0), 0.4)
        if (extra > 0.01) tracking = `${(measurer.spacing + extra).toFixed(3)}px`
      }
      setFit((current) =>
        current.source === value && current.text === text && current.tracking === tracking
          ? current
          : { source: value, text, tracking },
      )
    },
    [value],
  )

  useIsoLayoutEffect(() => {
    const element = ref.current
    if (!element) return
    corrections.current = 0
    run(element.getBoundingClientRect().width)
    const observer = new ResizeObserver(([entry]) => {
      const next = entry?.contentRect.width ?? 0
      if (Math.abs(next - width.current) < 0.5) return
      corrections.current = 0
      run(next)
    })
    observer.observe(element)
    // Webfonts change every width: measure again once they are in.
    let live = true
    if (document.fonts && document.fonts.status !== "loaded") {
      document.fonts.ready.then(() => {
        if (live && ref.current) run(ref.current.getBoundingClientRect().width)
      })
    }
    return () => {
      live = false
      observer.disconnect()
    }
  }, [run])

  // Canvas and layout can disagree by a fraction of a pixel (kerning, tabular figures): one check
  // per fit, and at most two corrections, so it never loops.
  useIsoLayoutEffect(() => {
    const element = ref.current
    const line = textRef.current
    if (!element || !line || fit.text === value || corrections.current >= 2) return
    const over = line.getBoundingClientRect().width - element.getBoundingClientRect().width
    if (over > 0.25) {
      corrections.current += 1
      const real = width.current
      run(real - over - 0.5)
      width.current = real
    }
  }, [fit, value, run])

  const fresh = fit.source === value
  const text = fresh ? fit.text : value
  const truncated = text !== value
  const textStyle: React.CSSProperties | undefined =
    fresh && fit.tracking ? { letterSpacing: fit.tracking } : undefined
  return { ref, textRef, text, textStyle, truncated, title: truncated ? value : undefined }
}

/**
 * A file name that truncates in the middle, flush around the ellipsis, keeping its extension:
 * `explain-analyze…queue.txt`. Chips still hug short names; the full name is in the title.
 */
function FileName({ name }: { name: string }) {
  const fit = useChatcnMiddleTruncate(name)
  return (
    <span
      ref={fit.ref}
      title={fit.title}
      data-slot="attachment-name"
      className="block min-w-0 overflow-hidden whitespace-nowrap"
    >
      <span ref={fit.textRef} style={fit.textStyle} aria-hidden="true" dir="auto">
        {fit.text}
      </span>
      <span dir="auto" className="block h-0 overflow-hidden">
        {name}
      </span>
    </span>
  )
}

function Attachment({
  name,
  size,
  type,
  previewUrl,
  status = "ready",
  progress,
  error,
  variant = "chip",
  onRemove,
  onRetry,
  onOpen,
  className,
  ...props
}: AttachmentProps) {
  const inList = React.useContext(ListContext)
  const { leaving, leave } = useExit(onRemove)
  const kind = fileKind(name, type)
  const Icon = KIND_ICON[kind]
  const card = variant === "card"
  const tile = card && previewUrl !== undefined
  const busy = status === "uploading" || status === "processing"
  const failed = status === "error"

  const meta = failed ? (
    (error ?? "Upload failed")
  ) : status === "uploading" ? (
    <>
      {card ? "Uploading " : null}
      <span className="tabular-nums">{percent(progress)}%</span>
    </>
  ) : status === "processing" ? (
    "Processing"
  ) : card ? (
    [typeLabel(name, type), size !== undefined ? formatBytes(size) : null]
      .filter(Boolean)
      .join(" · ")
  ) : size !== undefined ? (
    formatBytes(size)
  ) : null

  const indicator =
    status === "uploading" ? (
      <ProgressRing progress={progress ?? 0} label={`Uploading ${name}`} />
    ) : status === "processing" ? (
      <Spinner label={`Processing ${name}`} className="size-4" />
    ) : null

  const image = previewUrl ? (
    <>
      <img
        src={previewUrl}
        alt={tile ? name : ""}
        draggable={false}
        className="size-full object-cover"
      />
      {/* Hairline so light images never bleed into the surface. */}
      <span className="pointer-events-none absolute inset-0 rounded-[inherit] shadow-[inset_0_0_0_1px_var(--chatcn-alpha-400)]" />
    </>
  ) : null

  const glyph =
    failed && !previewUrl ? (
      <CircleAlertIcon />
    ) : busy ? (
      previewUrl ? (
        <span className="bg-(color:--chatcn-surface) text-(color:--chatcn-fg) shadow-(--chatcn-shadow-sm) grid size-6 place-items-center rounded-full">
          {indicator}
        </span>
      ) : (
        indicator
      )
    ) : previewUrl ? null : (
      <Icon />
    )

  const lead = (
    <span
      data-slot="attachment-preview"
      className={cn(
        "relative grid shrink-0 place-items-center overflow-hidden",
        tile
          ? "rounded-(--chatcn-radius-lg) size-14"
          : card
            ? "rounded-(--chatcn-radius-sm) size-11"
            : "rounded-(--chatcn-radius-xs) size-6",
        card && !previewUrl && "bg-(color:--chatcn-fill)",
        failed ? "text-(color:--chatcn-red)" : "text-(color:--chatcn-fg-muted)",
        "[&_svg]:size-4",
        card && !tile && "[&_svg]:size-5",
      )}
    >
      {previewUrl ? image : null}
      <Swap swapKey={status}>{glyph}</Swap>
      {failed && previewUrl ? (
        <span className="absolute inset-0 grid place-items-center">
          <span className="bg-(color:--chatcn-surface) text-(color:--chatcn-red) shadow-(--chatcn-shadow-sm) grid size-6 place-items-center rounded-full">
            <CircleAlertIcon className="size-4" />
          </span>
        </span>
      ) : null}
    </span>
  )

  const iconButton = cn(
    "chatcn-focus grid shrink-0 place-items-center outline-none",
    "ease-(--chatcn-ease-standard) transition-[background-color,color,opacity] duration-150",
    "active:scale-(--chatcn-press-scale-icon) [&_svg]:size-3.5",
    // Grow the hit area on touch without growing the glyph.
    "relative after:absolute after:-inset-2 after:content-[''] pointer-fine:after:hidden",
  )

  const retry =
    failed && onRetry ? (
      <button
        type="button"
        aria-label={`Retry ${name}`}
        onClick={onRetry}
        data-slot="attachment-retry"
        className={cn(
          iconButton,
          "rounded-(--chatcn-radius-xs) text-(color:--chatcn-fg-muted) hover:bg-(color:--chatcn-fill-hover) hover:text-(color:--chatcn-fg) size-6",
        )}
      >
        <RotateCcwIcon />
      </button>
    ) : null

  const remove = onRemove ? (
    <button
      type="button"
      aria-label={`Remove ${name}`}
      onClick={leave}
      data-slot="attachment-remove"
      className={cn(
        iconButton,
        tile
          ? cn(
              "bg-(color:--chatcn-fg) text-(color:--chatcn-bg) shadow-(--chatcn-shadow-sm) absolute end-1 top-1 size-5 rounded-full [&_svg]:size-3",
              "pointer-fine:opacity-0 pointer-fine:group-focus-within/attachment:opacity-100 pointer-fine:group-hover/attachment:opacity-100",
            )
          : cn(
              "rounded-(--chatcn-radius-xs) text-(color:--chatcn-fg-subtle) hover:bg-(color:--chatcn-fill-hover) hover:text-(color:--chatcn-fg) size-6",
              card &&
                "absolute end-1.5 top-1.5 pointer-fine:opacity-0 pointer-fine:group-focus-within/attachment:opacity-100 pointer-fine:group-hover/attachment:opacity-100",
            ),
      )}
    >
      <XIcon strokeWidth={tile ? 2 : 1.5} />
    </button>
  ) : null

  const title = onOpen ? (
    <button
      type="button"
      onClick={onOpen}
      className="chatcn-focus rounded-(--chatcn-radius-xs) flex min-w-0 text-start outline-none hover:underline"
    >
      <FileName name={name} />
    </button>
  ) : (
    <FileName name={name} />
  )

  return (
    <div
      role={inList ? "listitem" : undefined}
      aria-invalid={failed || undefined}
      data-slot="attachment"
      data-variant={variant}
      data-attachment-state={status}
      className={cn(
        "group/attachment relative shrink-0",
        "motion-safe:animate-[chatcn-tooltip-in_150ms_var(--chatcn-ease-out)]",
        "ease-(--chatcn-ease-out) transition-[opacity,scale] duration-140",
        leaving && "pointer-events-none scale-96 opacity-0 motion-reduce:scale-100",
        tile
          ? "rounded-(--chatcn-radius-lg)"
          : card
            ? "rounded-(--chatcn-radius-lg) bg-(color:--chatcn-card) shadow-(--chatcn-shadow-border) flex h-14 w-60 items-center gap-2.5 p-1.5 pe-8"
            : "rounded-(--chatcn-radius-md) bg-(color:--chatcn-card) shadow-(--chatcn-shadow-border) inline-flex h-8 max-w-64 items-center gap-1.5 ps-1 pe-1",
        className,
      )}
      {...props}
    >
      {tile ? (
        <>
          {onOpen ? (
            <button
              type="button"
              onClick={onOpen}
              aria-label={`Open ${name}`}
              className="chatcn-focus rounded-(--chatcn-radius-lg) block"
            >
              {lead}
            </button>
          ) : (
            lead
          )}
          {failed && onRetry ? (
            <button
              type="button"
              aria-label={`Retry ${name}`}
              onClick={onRetry}
              className="chatcn-focus rounded-(--chatcn-radius-lg) absolute inset-0"
            />
          ) : null}
          {remove}
        </>
      ) : card ? (
        <>
          {lead}
          <span className="flex min-w-0 flex-1 flex-col">
            <span className="text-[0.8125rem]/[1.25rem] text-(color:--chatcn-fg) flex min-w-0 font-medium">{title}</span>
            <span
              className={cn(
                "text-[0.75rem]/[1rem] flex min-w-0 items-center gap-1",
                failed ? "text-(color:--chatcn-red)" : "text-(color:--chatcn-fg-subtle)",
              )}
            >
              <span className="truncate">{meta}</span>
              {retry ? <span className="-my-1 ms-auto">{retry}</span> : null}
            </span>
          </span>
          {remove}
        </>
      ) : (
        <>
          {lead}
          <span className="text-[0.8125rem]/[1.25rem] text-(color:--chatcn-fg) flex min-w-0 font-medium">{title}</span>
          {meta ? (
            <span
              className={cn(
                "text-[0.75rem]/[1rem] shrink-0 whitespace-nowrap",
                failed ? "text-(color:--chatcn-red)" : "text-(color:--chatcn-fg-subtle)",
              )}
            >
              {meta}
            </span>
          ) : null}
          {retry}
          {remove}
        </>
      )}
    </div>
  )
}

/**
 * The only shimmer in chatcn: a status label such as "Thinking". Static under reduced motion.
 * Pass `active={false}` to settle it into plain subtle text.
 */
function ShimmerText({
  active = true,
  className,
  ...props
}: React.ComponentProps<"span"> & { active?: boolean }) {
  return (
    <span
      data-slot="shimmer-text"
      className={cn("font-medium", active ? "chatcn-shimmer" : "text-(color:--chatcn-fg-subtle)", className)}
      {...props}
    />
  )
}

function Turn({
  scene,
  phase,
  written,
}: {
  scene: Scene
  phase: "thinking" | "writing" | "done"
  written: string
}) {
  return (
    <div className="flex flex-col gap-3">
      <div className="flex flex-col items-end gap-2">
        <Attachment
          name={scene.file.name}
          size={scene.file.size}
          type={scene.file.type}
          previewUrl={scene.file.previewUrl}
        />
        <p className="bg-(color:--chatcn-fill) rounded-(--chatcn-radius-xl) text-[0.875rem]/[1.25rem] tracking-[-0.003em] text-(color:--chatcn-fg) max-w-[85%] px-3.5 py-2 text-pretty">
          {scene.prompt}
        </p>
      </div>
      <div className="text-[0.875rem]/[1.25rem] tracking-[-0.003em] text-(color:--chatcn-fg) min-h-5 text-pretty">
        {phase === "thinking" ? <ShimmerText>Thinking</ShimmerText> : <p>{written}</p>}
      </div>
    </div>
  )
}

/* -------------------------------------------------------------------------------------------------
 * AttachmentList: one row that scrolls sideways, fading only the edge that has more.
 * -----------------------------------------------------------------------------------------------*/

const FADE = 24

function AttachmentList({ className, style, children, ...props }: React.ComponentProps<"div">) {
  const ref = React.useRef<HTMLDivElement>(null)
  const [fade, setFade] = React.useState({ start: false, end: false, rtl: false })

  React.useEffect(() => {
    const el = ref.current
    if (!el) return
    const measure = () => {
      const rtl = getComputedStyle(el).direction === "rtl"
      const scrolled = Math.abs(el.scrollLeft)
      const next = {
        start: scrolled > 1,
        end: scrolled + el.clientWidth < el.scrollWidth - 1,
        rtl,
      }
      setFade((prev) =>
        prev.start === next.start && prev.end === next.end && prev.rtl === next.rtl ? prev : next,
      )
    }
    measure()
    el.addEventListener("scroll", measure, { passive: true })
    const observer = new ResizeObserver(measure)
    observer.observe(el)
    // Children come and go; watch the content too.
    const mutations = new MutationObserver(measure)
    mutations.observe(el, { childList: true })
    return () => {
      el.removeEventListener("scroll", measure)
      observer.disconnect()
      mutations.disconnect()
    }
  }, [])

  const mask =
    fade.start || fade.end
      ? `linear-gradient(to ${fade.rtl ? "left" : "right"}, ${
          fade.start ? "transparent" : "black"
        } 0, black ${FADE}px, black calc(100% - ${FADE}px), ${fade.end ? "transparent" : "black"} 100%)`
      : undefined

  return (
    <ListContext.Provider value={true}>
      {/* biome-ignore lint/a11y/useSemanticElements: items are divs so they also work outside a list */}
      <div
        ref={ref}
        role="list"
        aria-label="Attachments"
        data-slot="attachment-list"
        className={cn(
          "flex [scrollbar-width:none] items-center gap-2 overflow-x-auto overscroll-x-contain [&::-webkit-scrollbar]:hidden",
          // Room for the hairline shadows, which overflow clipping would cut.
          "p-px",
          className,
        )}
        style={{ maskImage: mask, WebkitMaskImage: mask, ...style }}
        {...props}
      >
        {children}
      </div>
    </ListContext.Provider>
  )
}

/* -------------------------------------------------------------------------------------------------
 * Modes
 * -----------------------------------------------------------------------------------------------*/

interface ComposerMode {
  id: string
  label: string
  icon?: React.ReactNode
  /** One line in the menu under the label. */
  description?: string
  /** Placeholder for the composer while this mode is on. */
  placeholder?: string
  disabled?: boolean
}

const iconNode12: IconNode = [
  ["path", {"d":"M4 10.75a6.75 6.75 0 1 0 13.5 0 6.75 6.75 0 1 0-13.5 0"}],
  ["path", {"d":"m15.75 15.75 4.5 4.5"}],
  ["path", {"d":"M10.75 6.25C12 7.5 12.5 9 12.5 10.75s-.5 3.25-1.75 4.5"}],
  ["path", {"d":"M10.75 6.25C9.5 7.5 9 9 9 10.75s.5 3.25 1.75 4.5"}],
  ["path", {"d":"M6.5 10.75H15"}],
]

const WebSearchIcon: Icon = /* @__PURE__ */ createIcon("web-search", iconNode12)

const iconNode13: IconNode = [
  ["path", {"d":"M3 12a9 9 0 1 0 18 0 9 9 0 1 0-18 0"}],
  ["path", {"d":"m15.03 10.31-1.38 2.87a1 1 0 0 1-.47.47l-2.87 1.38a1 1 0 0 1-1.34-1.34l1.38-2.87a1 1 0 0 1 .47-.47l2.87-1.38a1 1 0 0 1 1.34 1.34"}],
]

const CompassIcon: Icon = /* @__PURE__ */ createIcon("compass", iconNode13)

const iconNode14: IconNode = [
  ["path", {"d":"M6 4.5h12a3 3 0 0 1 3 3v9a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3v-9a3 3 0 0 1 3-3"}],
  ["path", {"d":"M13 11h3.5a1.5 1.5 0 0 1 1.5 1.5V15a1.5 1.5 0 0 1-1.5 1.5H13a1.5 1.5 0 0 1-1.5-1.5v-2.5A1.5 1.5 0 0 1 13 11"}],
]

const ArtifactIcon: Icon = /* @__PURE__ */ createIcon("artifact", iconNode14)

const iconNode15: IconNode = [
  ["path", {"d":"M6 3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3"}],
  ["path", {"d":"M6.75 8.5a1.75 1.75 0 1 0 3.5 0 1.75 1.75 0 1 0-3.5 0"}],
  ["path", {"d":"m6.5 18 5.59-5.59a2 2 0 0 1 2.82 0L18 15.5"}],
]

const ImageIcon: Icon = /* @__PURE__ */ createIcon("image", iconNode15)

/** The usual four. Pass your own list to `modes` to change or extend it. */
const composerModes: ComposerMode[] = [
  {
    id: "web-search",
    label: "Web search",
    icon: <WebSearchIcon />,
    description: "Search the web and cite sources",
    placeholder: "Search the web…",
  },
  {
    id: "deep-research",
    label: "Deep research",
    icon: <CompassIcon />,
    description: "Read dozens of sources, then write a report",
    placeholder: "Describe what to research…",
  },
  {
    id: "canvas",
    label: "Canvas",
    icon: <ArtifactIcon />,
    description: "Draft code or a document side by side",
    placeholder: "Describe what to write…",
  },
  {
    id: "image",
    label: "Image",
    icon: <ImageIcon />,
    description: "Create or edit an image",
    placeholder: "Describe or edit an image…",
  },
]

interface ModeChipsProps extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
  modes?: ComposerMode[]
  value: string[]
  onValueChange: (value: string[]) => void
}

/* -------------------------------------------------------------------------------------------------
 * ModeChips
 * -----------------------------------------------------------------------------------------------*/

const EXIT_MS2 = 140

function ModeChip({ mode, onRemove }: { mode: ComposerMode; onRemove: () => void }) {
  const [leaving, setLeaving] = React.useState(false)
  const timer = React.useRef<number>(undefined)
  React.useEffect(() => () => window.clearTimeout(timer.current), [])

  const glyph =
    "col-start-1 row-start-1 grid place-items-center transition-[opacity,scale,filter] duration-150 ease-(--chatcn-ease-out) motion-reduce:scale-none! motion-reduce:blur-none!"

  return (
    <button
      type="button"
      aria-label={`Turn off ${mode.label.toLowerCase()}`}
      data-slot="mode-chip"
      onClick={(event) => {
        if (leaving) return
        const button = event.currentTarget
        const hadFocus = document.activeElement === button
        setLeaving(true)
        timer.current = window.setTimeout(() => {
          // Keep keyboard users in the composer when the chip goes away.
          if (hadFocus) {
            button
              .closest("[data-slot=prompt-input]")
              ?.querySelector<HTMLElement>("[data-slot=prompt-input-textarea]")
              ?.focus({ preventScroll: true })
          }
          onRemove()
        }, EXIT_MS2)
      }}
      className={cn(
        "chatcn-focus group/mode-chip inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full ps-2 pe-3 select-none",
        "text-[0.8125rem]/[1.25rem] text-(color:--chatcn-fg) bg-(color:--chatcn-fill) font-medium shadow-[inset_0_0_0_1px_var(--chatcn-alpha-300)]",
        "ease-(--chatcn-ease-out) hover:bg-(color:--chatcn-fill-hover) transition-[background-color,opacity,scale] duration-140",
        "motion-safe:animate-[chatcn-tooltip-in_150ms_var(--chatcn-ease-out)]",
        "pointer-coarse:h-11",
        // In a narrow composer the chip folds to its icon so it never runs under the actions.
        "@max-md/prompt-input:ps-2 @max-md/prompt-input:pe-2",
        leaving && "pointer-events-none scale-96 opacity-0 motion-reduce:scale-100",
      )}
    >
      {/* The icon turns into a cross on hover or focus: the whole chip is the remove button. */}
      <span aria-hidden="true" className="grid size-4 shrink-0 [&_svg]:size-4">
        <span
          className={cn(
            glyph,
            "pointer-fine:group-hover/mode-chip:scale-75 pointer-fine:group-hover/mode-chip:opacity-0 pointer-fine:group-hover/mode-chip:blur-[2px]",
            "pointer-fine:group-focus-visible/mode-chip:scale-75 pointer-fine:group-focus-visible/mode-chip:opacity-0",
            "pointer-coarse:opacity-0",
          )}
        >
          {mode.icon}
        </span>
        <span
          className={cn(
            glyph,
            "scale-75 opacity-0 blur-[2px]",
            "pointer-fine:group-hover/mode-chip:blur-0 pointer-fine:group-hover/mode-chip:scale-100 pointer-fine:group-hover/mode-chip:opacity-100",
            "pointer-fine:group-focus-visible/mode-chip:blur-0 pointer-fine:group-focus-visible/mode-chip:scale-100 pointer-fine:group-focus-visible/mode-chip:opacity-100",
            "pointer-coarse:blur-0 pointer-coarse:scale-100 pointer-coarse:opacity-100",
          )}
        >
          <XIcon />
        </span>
      </span>
      <span className="@max-md/prompt-input:sr-only">{mode.label}</span>
    </button>
  )
}

/** Active modes as chips. Clicking a chip turns its mode off, with a 140 ms scale-out. */
function ModeChips({
  modes = composerModes,
  value,
  onValueChange,
  className,
  ...props
}: ModeChipsProps) {
  // Removals land after the exit; read the latest value then, not the one from the click.
  const latest = React.useRef(value)
  latest.current = value
  const active = value
    .map((id) => modes.find((m) => m.id === id))
    .filter((m): m is ComposerMode => m !== undefined)
  if (active.length === 0) return null
  return (
    // biome-ignore lint/a11y/useSemanticElements: a fieldset would add form semantics to a toolbar
    <div
      role="group"
      aria-label="Active tools"
      data-slot="mode-chips"
      className={cn("flex min-w-0 items-center gap-1", className)}
      {...props}
    >
      {active.map((mode) => (
        <ModeChip
          key={mode.id}
          mode={mode}
          onRemove={() => onValueChange(latest.current.filter((v) => v !== mode.id))}
        />
      ))}
    </div>
  )
}

/* -------------------------------------------------------------------------------------------------
 * Types
 * -----------------------------------------------------------------------------------------------*/

type ModelCapability = "vision" | "tools" | "reasoning"

interface ModelOption {
  id: string
  /** Display name, for example `Claude Sonnet 5.5`. */
  name: string
  /** Provider id. Models are grouped by it. */
  provider: string
  /** One line: what the model is good at, or its context size. */
  description?: string
  capabilities?: ModelCapability[]
  /** Adds a quiet New tag. */
  isNew?: boolean
  disabled?: boolean
  /** Why it is unavailable, for example "Upgrade to use". Replaces the description. */
  disabledReason?: string
}

interface ModelProvider {
  id: string
  name: string
}

interface ModelPickerProps {
  models: ModelOption[]
  /** Provider names and group order. Defaults to provider ids in order of appearance. */
  providers?: ModelProvider[]
  value?: string
  defaultValue?: string
  onValueChange?: (id: string) => void
  /**
   * Provider marks are yours to supply: chatcn bundles no third-party logos. Return a 16 px
   * node; it shows in the trigger and the group labels.
   */
  renderProviderMark?: (providerId: string) => React.ReactNode
  loading?: boolean
  disabled?: boolean
  /** `sm` is a 28 px trigger for dense toolbars. */
  size?: "sm" | "md"
  side?: "top" | "bottom"
  align?: "start" | "center" | "end"
  searchPlaceholder?: string
  /** Capability legend under the list. Default true. */
  showLegend?: boolean
  open?: boolean
  onOpenChange?: (open: boolean) => void
  className?: string
}

const iconNode16: IconNode = [
  ["path", {"d":"M2.77 11.36a10.19 10.19 0 0 1 18.46 0 1.5 1.5 0 0 1 0 1.28 10.19 10.19 0 0 1-18.46 0 1.5 1.5 0 0 1 0-1.28"}],
  ["path", {"d":"M9 12a3 3 0 1 0 6 0 3 3 0 1 0-6 0"}],
]

const EyeIcon: Icon = /* @__PURE__ */ createIcon("eye", iconNode16)

const iconNode17: IconNode = [
  ["path", {"d":"M19.76 6.79a5.25 5.25 0 0 1-7.16 6.88l-6.22 6.21a1.6 1.6 0 0 1-2.26-2.26l6.21-6.22a5.25 5.25 0 0 1 6.88-7.16l-2.42 2.43 2.54 2.54Z"}],
]

const ToolIcon: Icon = /* @__PURE__ */ createIcon("tool", iconNode17)

const iconNode18: IconNode = [
  ["path", {"d":"M4 18a2 2 0 1 0 4 0 2 2 0 1 0-4 0"}],
  ["path", {"d":"M9.5 18h4a3 3 0 0 0 0-6h-3a3 3 0 0 1 0-6h4"}],
  ["path", {"d":"M17.25 4h1.5A1.25 1.25 0 0 1 20 5.25v1.5A1.25 1.25 0 0 1 18.75 8h-1.5A1.25 1.25 0 0 1 16 6.75v-1.5A1.25 1.25 0 0 1 17.25 4"}],
]

const ReasoningIcon: Icon = /* @__PURE__ */ createIcon("reasoning", iconNode18)

const CAPABILITIES: Record<
  ModelCapability,
  { label: string; Icon: React.ComponentType<{ className?: string }> }
> = {
  vision: { label: "Vision", Icon: EyeIcon },
  tools: { label: "Tools", Icon: ToolIcon },
  reasoning: { label: "Reasoning", Icon: ReasoningIcon },
}

/* -------------------------------------------------------------------------------------------------
 * Helpers
 * -----------------------------------------------------------------------------------------------*/

function matches(model: ModelOption, providerName: string, query: string) {
  if (!query) return true
  const haystack = [
    model.name,
    providerName,
    model.description ?? "",
    ...(model.capabilities ?? []).map((c) => CAPABILITIES[c].label),
  ]
    .join(" ")
    .toLowerCase()
  return query
    .toLowerCase()
    .split(/\s+/)
    .filter(Boolean)
    .every((word) => haystack.includes(word))
}

/* -------------------------------------------------------------------------------------------------
 * ModelPicker
 * -----------------------------------------------------------------------------------------------*/

/**
 * Model menu for the composer footer. The trigger shows the current model; the popover groups
 * models by provider with capability glyphs, a New tag and a check on the current one. Typing
 * filters by name, provider, description or capability ("vision"). Arrow keys move, Enter picks.
 */
/**
 * Which edges of a scroll box hide content, so only those edges fade. A callback ref, because the
 * list mounts with the popover; re-measured on scroll, resize and whenever `content` changes.
 */
function useScrollFade(content: unknown) {
  const node = React.useRef<HTMLElement | null>(null)
  const observer = React.useRef<ResizeObserver | null>(null)
  const [edges, setEdges] = React.useState({ top: false, bottom: false })
  const update = React.useCallback(() => {
    const el = node.current
    if (!el) return
    const top = el.scrollTop > 1
    const bottom = el.scrollHeight - el.clientHeight - el.scrollTop > 1
    setEdges((prev) => (prev.top === top && prev.bottom === bottom ? prev : { top, bottom }))
  }, [])
  const ref = React.useCallback(
    (el: HTMLElement | null) => {
      observer.current?.disconnect()
      node.current = el
      if (el && typeof ResizeObserver !== "undefined") {
        observer.current = new ResizeObserver(update)
        observer.current.observe(el)
      }
      update()
    },
    [update],
  )
  React.useLayoutEffect(update, [update, content])
  return { ...edges, update, ref }
}

/* -------------------------------------------------------------------------------------------------
 * Parts
 * -----------------------------------------------------------------------------------------------*/

/**
 * Root. Controlled with `open` and `onOpenChange`, or uncontrolled with `defaultOpen`.
 * Not modal by default: the page stays scrollable and clickable.
 */
function Popover(props: PopoverPrimitive.Root.Props) {
  return <PopoverPrimitive.Root {...props} />
}

/** The button that toggles the popover. Pass `render={<Button />}` to use your own element. */
function PopoverTrigger(props: PopoverPrimitive.Trigger.Props) {
  return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}

const iconNode19: IconNode = [
  ["path", {"d":"m6 9.25 4.23 4.23a2.5 2.5 0 0 0 3.54 0L18 9.25"}],
]

const ChevronDownIcon: Icon = /* @__PURE__ */ createIcon("chevron-down", iconNode19)

interface PopoverContentProps extends PopoverPrimitive.Popup.Props {
  /** Side of the trigger to open on. Flips when there is no room. Default `bottom`. */
  side?: PopoverPrimitive.Positioner.Props["side"]
  /** Alignment along that side. Default `center`. */
  align?: PopoverPrimitive.Positioner.Props["align"]
  /** Gap to the trigger in px. Default 6. */
  sideOffset?: PopoverPrimitive.Positioner.Props["sideOffset"]
  alignOffset?: PopoverPrimitive.Positioner.Props["alignOffset"]
  /** Element to position against instead of the trigger. */
  anchor?: PopoverPrimitive.Positioner.Props["anchor"]
  /** Portal container. Defaults to `document.body`. */
  container?: PopoverPrimitive.Portal.Props["container"]
}

/** Surface shared by popovers and menus: 12 px radius, surface fill, medium shadow. */
const popupSurface = cn(
  "bg-(color:--chatcn-surface) text-(color:--chatcn-fg) shadow-(--chatcn-shadow-md) rounded-(--chatcn-radius-lg) outline-none",
  "max-h-(--available-height) max-w-(--available-width)",
  // Shadows vanish in forced colors; keep an edge.
  "forced-colors:outline-1 forced-colors:outline-solid",
)

/* -------------------------------------------------------------------------------------------------
 * Motion
 *
 * Enter: opacity, scale from 0.96 and 4 px of travel away from the trigger, 180 ms ease-(--chatcn-ease-out),
 * growing out of the trigger through `--transform-origin`. Exit: 140 ms to scale 0.98, no travel.
 * Opened or dismissed with the keyboard, Base UI sets `data-instant` and nothing animates.
 * Reduced motion keeps the fade and drops scale and travel.
 * -----------------------------------------------------------------------------------------------*/

const popupMotion = cn(
  "ease-(--chatcn-ease-out) origin-(--transform-origin) transition-[opacity,scale,translate] duration-180",
  "data-[starting-style]:scale-96 data-[starting-style]:opacity-0",
  "data-[side=bottom]:data-[starting-style]:-translate-y-1 data-[side=top]:data-[starting-style]:translate-y-1",
  "data-[side=left]:data-[starting-style]:translate-x-1 data-[side=right]:data-[starting-style]:-translate-x-1",
  "data-[side=inline-end]:data-[starting-style]:-translate-x-1 data-[side=inline-start]:data-[starting-style]:translate-x-1",
  "data-[ending-style]:scale-98 data-[ending-style]:opacity-0 data-[ending-style]:duration-140",
  "data-[instant]:transition-none",
  "motion-reduce:translate-none! motion-reduce:scale-none!",
)

/**
 * The floating panel. Portalled and positioned for you. Padding defaults to 4 px, the right inset
 * for lists; pass `className="p-3"` for text content.
 */
function PopoverContent({
  side = "bottom",
  align = "center",
  sideOffset = 6,
  alignOffset = 0,
  anchor,
  container,
  className,
  ...props
}: PopoverContentProps) {
  return (
    <PopoverPrimitive.Portal container={container}>
      <PopoverPrimitive.Positioner
        side={side}
        align={align}
        sideOffset={sideOffset}
        alignOffset={alignOffset}
        anchor={anchor}
        className="z-50 outline-none"
      >
        <PopoverPrimitive.Popup
          data-slot="popover-content"
          className={cn(
            popupSurface,
            popupMotion,
            "text-[0.875rem]/[1.25rem] tracking-[-0.003em] chatcn-scroll overflow-auto p-1",
            className,
          )}
          {...props}
        />
      </PopoverPrimitive.Positioner>
    </PopoverPrimitive.Portal>
  )
}

const iconNode20: IconNode = [
  ["path", {"d":"M4 10.75a6.75 6.75 0 1 0 13.5 0 6.75 6.75 0 1 0-13.5 0"}],
  ["path", {"d":"m15.75 15.75 4.5 4.5"}],
]

const SearchIcon: Icon = /* @__PURE__ */ createIcon("search", iconNode20)

const iconNode21: IconNode = [
  ["path", {"d":"M4 10.75a6.75 6.75 0 1 0 13.5 0 6.75 6.75 0 1 0-13.5 0"}],
  ["path", {"d":"m15.75 15.75 4.5 4.5"}],
  ["path", {"d":"m8.25 8.25 5 5"}],
  ["path", {"d":"m13.25 8.25-5 5"}],
]

const SearchXIcon: Icon = /* @__PURE__ */ createIcon("search-x", iconNode21)

const iconNode22: IconNode = [
  ["path", {"d":"M7 10.5h10a3 3 0 0 1 3 3V18a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-4.5a3 3 0 0 1 3-3"}],
  ["path", {"d":"M8 7.5V7a4 4 0 0 1 4-4 4 4 0 0 1 4 4v.5"}],
]

const LockIcon: Icon = /* @__PURE__ */ createIcon("lock", iconNode22)

const iconNode23: IconNode = [
  ["path", {"d":"m4.5 12.75 3.23 3.23a2.5 2.5 0 0 0 3.54 0l8.48-8.48"}],
]

const CheckIcon: Icon = /* @__PURE__ */ createIcon("check", iconNode23)

function ModelPicker({
  models,
  providers,
  value: valueProp,
  defaultValue,
  onValueChange,
  renderProviderMark,
  loading = false,
  disabled = false,
  size = "md",
  side = "bottom",
  align = "start",
  searchPlaceholder = "Search models…",
  showLegend = true,
  open: openProp,
  onOpenChange,
  className,
}: ModelPickerProps) {
  const id = React.useId()
  const [openState, setOpenState] = React.useState(false)
  const open = openProp ?? openState
  const setOpen = (next: boolean) => {
    if (openProp === undefined) setOpenState(next)
    onOpenChange?.(next)
  }

  const [valueState, setValueState] = React.useState(defaultValue ?? models[0]?.id)
  const value = valueProp ?? valueState
  const selected = models.find((m) => m.id === value)

  const [query, setQuery] = React.useState("")
  const [active, setActive] = React.useState<string>()
  const inputRef = React.useRef<HTMLInputElement>(null)
  const listRef = React.useRef<HTMLDivElement>(null)

  const providerList = React.useMemo(() => {
    const list: ModelProvider[] = [...(providers ?? [])]
    for (const m of models) {
      if (!list.some((p) => p.id === m.provider)) list.push({ id: m.provider, name: m.provider })
    }
    return list
  }, [models, providers])

  const groups = React.useMemo(
    () =>
      providerList
        .map((provider) => ({
          provider,
          models: models.filter(
            (m) => m.provider === provider.id && matches(m, provider.name, query.trim()),
          ),
        }))
        .filter((g) => g.models.length > 0),
    [providerList, models, query],
  )
  const flat = React.useMemo(() => groups.flatMap((g) => g.models), [groups])
  const fade = useScrollFade(groups)
  const { ref: fadeRef } = fade
  const setListRef = React.useCallback(
    (el: HTMLDivElement | null) => {
      listRef.current = el
      fadeRef(el)
    },
    [fadeRef],
  )
  const enabled = React.useMemo(() => flat.filter((m) => !m.disabled), [flat])

  // Keep the active option valid as the filter changes.
  React.useEffect(() => {
    if (!open) return
    if (active && enabled.some((m) => m.id === active)) return
    setActive(enabled.find((m) => m.id === value)?.id ?? enabled[0]?.id)
  }, [open, enabled, active, value])

  // Keep it in view. `nearest` never scrolls when it already shows.
  React.useEffect(() => {
    if (!open || !active) return
    const el = listRef.current?.querySelector<HTMLElement>(
      `[data-model-id="${CSS.escape(active)}"]`,
    )
    el?.scrollIntoView({ block: "nearest" })
  }, [open, active])

  const choose = (model: ModelOption) => {
    if (model.disabled) return
    if (valueProp === undefined) setValueState(model.id)
    onValueChange?.(model.id)
    setOpen(false)
  }

  const move = (delta: number) => {
    if (enabled.length === 0) return
    const index = enabled.findIndex((m) => m.id === active)
    const next = (index + delta + enabled.length) % enabled.length
    setActive(enabled[next]?.id)
  }

  const optionId = (modelId: string) => `${id}-option-${modelId}`
  const listId = `${id}-list`

  return (
    <Popover
      open={open}
      onOpenChange={(next) => {
        setOpen(next)
        if (!next) setQuery("")
      }}
    >
      <PopoverTrigger
        disabled={disabled}
        data-slot="model-picker-trigger"
        aria-label={
          selected ? `Model: ${selected.name}` : loading ? "Loading models" : "Choose a model"
        }
        aria-busy={loading || undefined}
        className={cn(
          "chatcn-focus group/model-trigger inline-flex min-w-0 shrink items-center gap-1.5 font-medium whitespace-nowrap select-none",
          "text-(color:--chatcn-fg-muted) hover:bg-(color:--chatcn-fill-hover) hover:text-(color:--chatcn-fg) data-[popup-open]:bg-(color:--chatcn-fill-hover) data-[popup-open]:text-(color:--chatcn-fg)",
          "ease-(--chatcn-ease-standard) transition-[background-color,color,scale] duration-150 active:scale-(--chatcn-press-scale) active:duration-100",
          "disabled:pointer-events-none disabled:opacity-50",
          size === "sm"
            ? "rounded-(--chatcn-radius-sm) text-[0.8125rem]/[1.25rem] h-7 ps-2 pe-1.5"
            : "rounded-(--chatcn-radius-md) text-[0.8125rem]/[1.25rem] h-8 ps-2.5 pe-2",
          "pointer-coarse:h-11",
          className,
        )}
      >
        {selected && renderProviderMark ? (
          <span aria-hidden="true" className="grid size-4 shrink-0 place-items-center">
            {renderProviderMark(selected.provider)}
          </span>
        ) : null}
        {loading && !selected ? <Spinner className="text-(color:--chatcn-fg-subtle)" /> : null}
        <span className="truncate">
          {selected?.name ?? (loading ? "Loading models" : "Choose a model")}
        </span>
        <ChevronDownIcon
          aria-hidden="true"
          className="text-(color:--chatcn-fg-subtle) group-hover/model-trigger:text-(color:--chatcn-fg) size-3.5 shrink-0"
        />
      </PopoverTrigger>

      <PopoverContent
        aria-label="Models"
        side={side}
        align={align}
        // Touch opens on the list so the keyboard does not cover it; everything else searches.
        initialFocus={(type) => (type === "touch" ? true : inputRef.current)}
        data-slot="model-picker-content"
        className="flex w-88 max-w-[calc(100vw-1rem)] flex-col overflow-hidden p-0"
      >
        <div className="border-(color:--chatcn-border) flex h-10 shrink-0 items-center gap-2 border-b px-3">
          <SearchIcon aria-hidden="true" className="text-(color:--chatcn-fg-subtle) size-4 shrink-0" />
          <input
            ref={inputRef}
            value={query}
            onChange={(event) => setQuery(event.target.value)}
            onKeyDown={(event) => {
              if (event.nativeEvent.isComposing) return
              if (event.key === "ArrowDown") {
                event.preventDefault()
                move(1)
              } else if (event.key === "ArrowUp") {
                event.preventDefault()
                move(-1)
              } else if (event.key === "Enter") {
                event.preventDefault()
                const model = enabled.find((m) => m.id === active)
                if (model) choose(model)
              }
            }}
            role="combobox"
            aria-expanded="true"
            aria-controls={listId}
            aria-autocomplete="list"
            aria-activedescendant={active ? optionId(active) : undefined}
            aria-label="Search models"
            placeholder={searchPlaceholder}
            autoComplete="off"
            spellCheck={false}
            className="text-[0.875rem]/[1.25rem] tracking-[-0.003em] text-(color:--chatcn-fg) placeholder:text-(color:--chatcn-fg-subtle) pointer-coarse:text-[1rem]/[1.5rem] pointer-coarse:tracking-[-0.01em] h-full min-w-0 flex-1 bg-transparent outline-none"
          />
        </div>

        <div
          ref={setListRef}
          id={listId}
          role="listbox"
          aria-label="Models"
          onScroll={fade.update}
          data-fade-top={fade.top ? "" : undefined}
          data-fade-bottom={fade.bottom ? "" : undefined}
          className={cn(
            "chatcn-scroll max-h-80 min-h-0 flex-1 scroll-py-1 overflow-y-auto overscroll-contain p-1",
            // The row cut by an edge fades out instead of ending mid-glyph.
            "data-[fade-bottom]:[--chatcn-fade-bottom:24px] data-[fade-top]:[--chatcn-fade-top:24px]",
            "[mask-image:linear-gradient(to_bottom,transparent,#000_var(--chatcn-fade-top,0px),#000_calc(100%-var(--chatcn-fade-bottom,0px)),transparent)]",
          )}
        >
          {loading ? (
            <div className="text-[0.8125rem]/[1.25rem] text-(color:--chatcn-fg-subtle) flex h-24 items-center justify-center gap-2">
              <Spinner />
              Loading models
            </div>
          ) : groups.length === 0 ? (
            <div className="flex flex-col items-center gap-1 px-6 py-8 text-center">
              <SearchXIcon aria-hidden="true" className="text-(color:--chatcn-fg-subtle) mb-1 size-5" />
              <p className="text-[0.8125rem]/[1.25rem] text-(color:--chatcn-fg) font-medium">
                No models match “{query.trim()}”
              </p>
              <p className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-subtle) text-pretty">
                Try a provider or a capability, like vision.
              </p>
            </div>
          ) : (
            groups.map(({ provider, models: groupModels }) => {
              const labelId = `${id}-group-${provider.id}`
              return (
                // biome-ignore lint/a11y/useSemanticElements: option groups inside a listbox
                <div
                  key={provider.id}
                  role="group"
                  aria-labelledby={labelId}
                  className="not-first:mt-1"
                >
                  <div
                    id={labelId}
                    className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-subtle) flex h-7 items-center gap-1.5 px-2 font-medium select-none"
                  >
                    {renderProviderMark ? (
                      <span aria-hidden="true" className="grid size-3.5 place-items-center">
                        {renderProviderMark(provider.id)}
                      </span>
                    ) : null}
                    {provider.name}
                  </div>
                  {groupModels.map((model) => {
                    const isSelected = model.id === value
                    const isActive = model.id === active
                    return (
                      // biome-ignore lint/a11y/useKeyWithClickEvents: keys are handled on the combobox input
                      <div
                        key={model.id}
                        id={optionId(model.id)}
                        role="option"
                        aria-selected={isSelected}
                        aria-disabled={model.disabled || undefined}
                        data-model-id={model.id}
                        data-active={isActive ? "" : undefined}
                        tabIndex={-1}
                        onPointerMove={() => {
                          if (!model.disabled && !isActive) setActive(model.id)
                        }}
                        onClick={() => choose(model)}
                        className={cn(
                          "group/model-option rounded-(--chatcn-radius-md) flex cursor-default items-center gap-3 px-2 py-1.5 select-none",
                          "data-[active]:bg-(color:--chatcn-fill-hover) pointer-coarse:min-h-11",
                          model.disabled && "opacity-55",
                        )}
                      >
                        <span className="flex min-w-0 flex-1 flex-col">
                          <span className="flex min-w-0 items-center gap-1.5">
                            <span className="text-[0.875rem]/[1.25rem] tracking-[-0.003em] text-(color:--chatcn-fg) truncate">
                              {model.name}
                            </span>
                            {model.isNew ? (
                              <span className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-muted) bg-(color:--chatcn-fill) rounded-(--chatcn-radius-xs) shrink-0 px-1.5 leading-[18px] font-medium">
                                New
                              </span>
                            ) : null}
                          </span>
                          {model.disabledReason || model.description ? (
                            <span className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-subtle) flex min-w-0 items-center gap-1">
                              {model.disabled ? (
                                <LockIcon aria-hidden="true" className="size-3 shrink-0" />
                              ) : null}
                              <span className="truncate">
                                {model.disabled
                                  ? (model.disabledReason ?? model.description)
                                  : model.description}
                              </span>
                            </span>
                          ) : null}
                        </span>
                        {/* Fixed slots, so each capability lines up in its own column. */}
                        {/* Quiet until the row is active: names read first, the footer holds the key. */}
                        <span className="text-(color:--chatcn-fg-faint) group-data-[active]/model-option:text-(color:--chatcn-fg-subtle) flex shrink-0 items-center gap-1.5">
                          {(Object.keys(CAPABILITIES) as ModelCapability[]).map((c) => {
                            const { Icon, label } = CAPABILITIES[c]
                            const has = model.capabilities?.includes(c)
                            return (
                              <span
                                key={c}
                                title={has ? label : undefined}
                                className="grid size-3.5 place-items-center"
                              >
                                {has ? (
                                  <>
                                    <Icon aria-hidden="true" className="size-3.5" />
                                    <span className="sr-only">{label}</span>
                                  </>
                                ) : null}
                              </span>
                            )
                          })}
                        </span>
                        <span
                          aria-hidden="true"
                          className="grid size-4 shrink-0 place-items-center"
                        >
                          {isSelected ? <CheckIcon className="text-(color:--chatcn-fg) size-4" /> : null}
                        </span>
                      </div>
                    )
                  })}
                </div>
              )
            })
          )}
        </div>

        {showLegend ? (
          <div
            aria-hidden="true"
            className="border-(color:--chatcn-border) text-[0.75rem]/[1rem] text-(color:--chatcn-fg-subtle) flex h-9 shrink-0 items-center gap-3 border-t px-3"
          >
            {(Object.keys(CAPABILITIES) as ModelCapability[]).map((c) => {
              const { Icon, label } = CAPABILITIES[c]
              return (
                <span key={c} className="flex items-center gap-1.5">
                  <Icon className="size-3.5" />
                  {label}
                </span>
              )
            })}
          </div>
        ) : null}
      </PopoverContent>
    </Popover>
  )
}

/** Fictional models, so no real product is shown with made up specs. */
const models: ModelOption[] = [
  {
    id: "lumen-4",
    name: "Lumen 4",
    provider: "lumen",
    description: "Careful answers for long tasks",
    capabilities: ["vision", "tools", "reasoning"],
  },
  {
    id: "lumen-4-mini",
    name: "Lumen 4 Mini",
    provider: "lumen",
    description: "Quick and low cost",
    capabilities: ["vision", "tools"],
  },
]

const providers = [{ id: "lumen", name: "Lumen Labs" }]

/** The line under each 21st demo that points back to the kit. */
function MoreOn({ slug, children }: { slug: string; children: React.ReactNode }) {
  return (
    <p data-fit-hide className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-faint) text-center text-balance">
      {children}{" "}
      <a
        href={`https://chatcn.dev/docs/components/${slug}`}
        target="_blank"
        rel="noopener"
        className="text-(color:--chatcn-fg-subtle) hover:text-(color:--chatcn-fg) underline underline-offset-2"
      >
        chatcn.dev
      </a>
    </p>
  )
}

/**
 * A composer at work: a question types in while its file uploads, it sends, Send turns into Stop
 * while the answer streams above, and the next question starts. Type or click and the tour waits
 * a moment, then carries on.
 */
export default function PromptInputPreview() {
  const [clock, setClock] = React.useState(START_MS)
  const [held, hold] = useHold()
  const [edited, setEdited] = React.useState<string | null>(null)
  const [model, setModel] = React.useState("lumen-4")

  React.useEffect(() => {
    if (held) return
    setEdited(null)
    if (reducedMotion()) {
      setClock(DONE_AT + 200)
      return
    }
    const began = performance.now() - clock
    const id = window.setInterval(() => setClock(performance.now() - began), 40)
    return () => window.clearInterval(id)
    // biome-ignore lint/correctness/useExhaustiveDependencies: resumes from the clock it held at.
  }, [held])

  const run = Math.floor(clock / SCENE_MS)
  const scene = SCENES[run % SCENES.length]!
  const t = clock % SCENE_MS
  const sent = t >= SEND_AT

  const chars = [...scene.prompt]
  const typed = chars.slice(0, Math.ceil((Math.min(t, TYPE_MS) / TYPE_MS) * chars.length)).join("")
  const progress = Math.min(1, 0.22 + (t / 1300) * 0.78)
  const status: PromptInputStatus =
    t < SEND_AT ? "idle" : t < STREAM_AT ? "submitting" : t < DONE_AT ? "streaming" : "idle"
  const phase = t < WRITE_AT ? "thinking" : t < DONE_AT ? "writing" : "done"
  const words = scene.response.split(" ")
  const shown = Math.ceil((Math.min(Math.max(t - WRITE_AT, 0), WRITE_MS) / WRITE_MS) * words.length)
  const written = words.slice(0, shown).join(" ")

  const value = edited ?? (sent ? "" : typed)

  return (
    <FitFrame minWidth="34rem">
      {/* Any click or key inside holds the tour, so the recorder's own input stays put. */}
      <div
        className="flex w-full flex-col items-center gap-4 px-6 py-5"
        onPointerDown={hold}
        onKeyDown={hold}
      >
        <div className="flex h-[22rem] w-full max-w-[36rem] flex-col justify-end gap-4">
          {/* The latest turn, pinned to the bottom; its top fades as it grows. */}
          <div
            className="flex min-h-0 flex-1 flex-col justify-end gap-6 overflow-hidden [mask-image:linear-gradient(to_bottom,transparent,#000_2.5rem)]"
          >
            {(sent ? [run - 1, run] : [run - 2, run - 1]).map((k) => {
              const s = SCENES[((k % SCENES.length) + SCENES.length) % SCENES.length]!
              const live = k === run
              return (
                <Turn
                  key={k}
                  scene={s}
                  phase={live ? phase : "done"}
                  written={live ? written : s.response}
                />
              )
            })}
          </div>

          <PromptInput
            value={value}
            onValueChange={(v) => {
              hold()
              setEdited(v)
            }}
            onSubmit={() => {
              hold()
              setEdited("")
            }}
            status={status}
            onStop={hold}
            onFiles={hold}
            hasContent={!sent}
          >
            <PromptInputAttachments>
              {!sent ? (
                <AttachmentList>
                  <Attachment
                    key={scene.file.name}
                    variant="card"
                    name={scene.file.name}
                    size={scene.file.size}
                    type={scene.file.type}
                    previewUrl={scene.file.previewUrl}
                    status={progress < 1 ? "uploading" : "ready"}
                    progress={progress}
                  />
                </AttachmentList>
              ) : null}
            </PromptInputAttachments>
            <PromptInputTextarea placeholder="Ask about a file, an error or a screenshot…" />
            <PromptInputToolbar>
              <PromptInputTools>
                <PromptInputAttachButton />
                <ModeChips value={sent ? [] : scene.modes} onValueChange={hold} />
              </PromptInputTools>
              <PromptInputActions>
                <ModelPicker
                  models={models}
                  providers={providers}
                  value={model}
                  onValueChange={setModel}
                  side="top"
                  align="end"
                />
                <PromptInputSubmit />
              </PromptInputActions>
            </PromptInputToolbar>
          </PromptInput>
        </div>
        <MoreOn slug="prompt-input">
          Attachments, paste and drop, Send that turns into Stop. On
        </MoreOn>
      </div>
    </FitFrame>
  )
}

Dependencies

More by Aadi

View all
  • Dither Orb

    Dither Orb

    12
  • Audio Visualizer

    Audio Visualizer

    11
  • Voice Orb

    Voice Orb

    8
  • Agent Lanes

    Agent Lanes

    6
  • Waveform

    Waveform

    4
  • Computer View

    Computer View

    3
  • Chat Search

    Chat Search

    2
  • Stat Card

    Stat Card

    2

More in AI chats

View all
  • AI Actions

    AI Actions

    339
  • Aquarium Prompt

    Aquarium Prompt

    56
  • Illos Knowledge Sources

    Illos Knowledge Sources

    41
  • Commit

    Commit

    33
  • Attachments

    Attachments

    39
  • Color Picker

    Color Picker

    171
  • Chat Bubble

    Chat Bubble

    199
  • AI Suggestions

    AI Suggestions

    20