lib/openagents_web/components/ai/prompt_input.ex

main at 58e6347eeb72 · 62 KB

defmodule OpenAgentsWeb.AI.PromptInput do
  @moduledoc """
  The composer: everything between a reader's cursor and a submitted turn.

  Ported from Vercel's AI Elements (MIT) — `prompt-input.tsx`, `attachments.tsx`,
  `speech-input.tsx`, `mic-selector.tsx`, `model-selector.tsx`, and `queue.tsx`.
  The Tailwind is the point of the port and is carried across close to
  verbatim; the React around it is not. Four things changed on the way, and
  each one is a rule of this repo rather than a preference:

    * **No React, no Radix, no client state library.** The source keeps input
      text, attachment lists, recording state, and menu state in hooks and
      contexts. Here the caller owns all of it: components take what to draw
      and emit events. The three behaviors markup cannot express — auto-resize
      with Enter-to-submit, microphone capture, and incremental filtering — are
      colocated LiveView hooks, per `AGENTS.md`.

    * **Forms are Phoenix's.** `prompt_input/1` is `Phoenix.Component.form/1`
      driven by a `to_form/2` assign with a required DOM id, and
      `prompt_input_textarea/1` takes a `Phoenix.HTML.FormField`. It renders
      `OpenAgentsWeb.UI.textarea/1` rather than `UI.input/1` because `input/1`
      wraps its control in a `.field` with a bottom margin, and the input group
      needs the control to be a direct flex child.

    * **`bg-accent` means something else here.** In shadcn, `--accent` is a
      quiet hover surface. In OpenAgents it is the indigo brand color (see the
      deliberate name collision noted in `assets/css/app.css`), so every
      `hover:bg-accent` in the source is `hover:bg-muted` here, and
      `hover:text-accent-foreground` is `hover:text-foreground`. Keeping the
      source class would have painted indigo on every hover.

    * **`dark:` is dead in this bundle.** Basecoat declares the variant as
      `&:is(html.dark *)`, and this app themes by `data-theme` on `:root`, so
      every `dark:` utility in the source would compile to a selector that
      never matches. They are dropped rather than left as noise; the palette
      already inverts through the token ladder.

  Icons go through `OpenAgentsWeb.UI.icon/1` and the vendored Apps SDK set
  only. The `lucide-react` glyphs map as: `CornerDownLeft` to
  `arrow-curved-left`, `Square` to `stop`, `X` to `x`, `Spinner` to `spin`,
  `Plus` to `plus`, `Image` to `image-square`, `Monitor` to `desktop`, `Mic` to
  `mic`, `Paperclip` to `paperclip`, `FileText` to `file-document`, `Globe` to
  `globe`, `Music2` to `music`, `Video` to `video`, `ChevronDown` to
  `chevron-down`, and `ChevronsUpDown` to `chevron-up-down`. No Heroicons
  fallback was needed, so `docs/ICONS.md` gains no inventory entry.
  """

  use Phoenix.Component

  alias OpenAgentsWeb.UI

  # ── Shared class recipes ──────────────────────────────────────────────────
  # The shadcn `input-group` primitive is not vendored into this app's CSS
  # bundle (`assets/css/app.css` imports Basecoat components one at a time and
  # `input-group` is not on the list), so its structure lives here as the same
  # utilities the primitive composes. That is what the port asked for anyway.

  @input_group """
  group/input-group relative flex w-full min-w-0 items-center rounded-md border \
  border-input shadow-xs outline-none transition-[color,box-shadow] \
  h-9 has-[>textarea]:h-auto \
  has-[>[data-align=inline-start]]:[&>input]:pl-2 \
  has-[>[data-align=inline-end]]:[&>input]:pr-2 \
  has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col \
  has-[>[data-align=block-start]]:[&>input]:pb-3 \
  has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col \
  has-[>[data-align=block-end]]:[&>input]:pt-3 \
  has-[[data-slot=input-group-control]:focus-visible]:border-ring \
  has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 \
  has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] \
  has-[[data-slot][aria-invalid=true]]:border-destructive \
  has-[[data-slot][aria-invalid=true]]:ring-destructive/20\
  """

  @addon """
  flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 \
  text-sm font-medium text-muted-foreground \
  [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] \
  group-data-[disabled=true]/input-group:opacity-50 \
  order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 \
  group-has-[>input]/input-group:pb-2.5\
  """

  @button_base "flex min-w-0 items-center gap-2 text-sm shadow-none"

  @command_item """
  flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 \
  text-sm outline-none hover:bg-muted hover:text-foreground \
  aria-selected:bg-muted aria-selected:text-foreground \
  data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 \
  [&>svg:not([class*='size-'])]:size-4\
  """

  @statuses [:ready, :submitted, :streaming, :error]
  @variants [:grid, :inline, :list]
  @sizes [:xs, :sm, :icon_xs, :icon_sm]

  # ── PromptInput: the form shell ───────────────────────────────────────────

  @doc """
  The composer form: a `Phoenix.Component.form/1` wrapping the input group.

  The source renders a hidden `<input type="file">` beside the form and drives
  it from React context. The same input is rendered here and driven by the
  colocated `.PromptInput` hook, which also carries the two behaviors the
  source keeps in JavaScript for the same reason: dropped and pasted files are
  written onto the file input through a `DataTransfer` so a normal
  `phx-change` upload sees them, and drag state is published as `data-dragging`
  on the form so the `drop_overlay` slot can appear.

  `accept`, `multiple`, and the upload name are attributes because the file
  transport is the LiveView's business, not this component's.
  """
  attr :id, :string, required: true
  attr :for, :any, required: true, doc: "a `to_form/2` result"
  attr :accept, :string, default: nil, doc: ~s(such as "image/*")
  attr :multiple, :boolean, default: true
  attr :file_input_name, :string, default: nil, doc: "name for the hidden file input"

  attr :submit_on_enter, :boolean,
    default: true,
    doc: "Enter submits and Shift+Enter inserts a newline"

  attr :backspace_event, :string,
    default: nil,
    doc: """
    pushed when Backspace is pressed in an empty textarea, which is how the
    source removes the last attachment
    """

  attr :clear_event, :string,
    default: nil,
    doc: "server event that clears and focuses the textarea after a completed submission"

  attr :clear_on_submit, :boolean,
    default: false,
    doc: "clears the textarea on the next animation frame after a submitted form is serialized"

  attr :class, :any, default: nil
  attr :group_class, :any, default: nil
  attr :rest, :global, include: ~w(method action)
  slot :inner_block, required: true
  slot :drop_overlay, doc: "shown while files are dragged over the composer"

  def prompt_input(assigns) do
    ~H"""
    <div class="contents">
      <input
        type="file"
        id={"#{@id}-files"}
        name={@file_input_name}
        accept={@accept}
        multiple={@multiple}
        aria-label="Upload files"
        title="Upload files"
        class="hidden"
      />
      <.form
        for={@for}
        id={@id}
        class={["group/prompt-input relative w-full", @class]}
        phx-hook=".PromptInput"
        data-file-input={"#{@id}-files"}
        data-submit-on-enter={to_string(@submit_on_enter)}
        data-backspace-event={@backspace_event}
        data-clear-event={@clear_event}
        data-clear-on-submit={to_string(@clear_on_submit)}
        data-dragging="false"
        {@rest}
      >
        <div
          role="group"
          data-slot="input-group"
          class={[input_group(), "overflow-hidden", @group_class]}
        >
          {render_slot(@inner_block)}
        </div>
        <div
          :if={@drop_overlay != []}
          aria-hidden="true"
          class={[
            "pointer-events-none absolute inset-0 hidden items-center justify-center",
            "rounded-md bg-background/80 text-muted-foreground text-sm backdrop-blur-sm",
            "group-data-[dragging=true]/prompt-input:flex"
          ]}
        >
          {render_slot(@drop_overlay)}
        </div>
      </.form>
    </div>
    <script :type={Phoenix.LiveView.ColocatedHook} name=".PromptInput">
      export default {
        mounted() {
          this.textarea = this.el.querySelector("textarea[data-slot='input-group-control']")
          this.fileInput = document.getElementById(this.el.dataset.fileInput)
          this.composing = false
          this.dragDepth = 0

          this.resize = () => {
            const el = this.textarea
            if (!el) return
            // `field-sizing-content` already does this where it is supported.
            // This keeps the control honest everywhere else, and the reset to
            // "auto" must run before scrollHeight is read or the box grows
            // without ever shrinking again.
            el.style.height = "auto"
            el.style.height = `${el.scrollHeight}px`
          }

          this.clear = () => {
            if (!this.textarea) return
            this.textarea.value = ""
            this.resize()
            this.textarea.focus()
          }

          this.onCompositionStart = () => { this.composing = true }
          this.onCompositionEnd = () => { this.composing = false }
          this.onSubmit = () => {
            if (this.el.dataset.clearOnSubmit === "true") {
              requestAnimationFrame(() => this.clear())
            }
          }

          this.onKeyDown = (event) => {
            if (event.key === "Enter" && this.el.dataset.submitOnEnter === "true") {
              if (this.composing || event.isComposing || event.shiftKey) return
              event.preventDefault()
              const submit = this.el.querySelector("button[type='submit']")
              if (submit && submit.disabled) return
              this.el.requestSubmit()
              return
            }

            const backspaceEvent = this.el.dataset.backspaceEvent
            if (event.key === "Backspace" && backspaceEvent && event.currentTarget.value === "") {
              event.preventDefault()
              this.pushEvent(backspaceEvent, {})
            }
          }

          this.adopt = (fileList) => {
            if (!this.fileInput || !fileList || fileList.length === 0) return false
            const transfer = new DataTransfer()
            if (this.fileInput.multiple) {
              for (const file of this.fileInput.files) transfer.items.add(file)
            }
            for (const file of fileList) transfer.items.add(file)
            this.fileInput.files = transfer.files
            this.fileInput.dispatchEvent(new Event("input", { bubbles: true }))
            this.fileInput.dispatchEvent(new Event("change", { bubbles: true }))
            return true
          }

          this.onPaste = (event) => {
            const files = []
            for (const item of event.clipboardData?.items ?? []) {
              if (item.kind !== "file") continue
              const file = item.getAsFile()
              if (file) files.push(file)
            }
            if (files.length > 0 && this.adopt(files)) event.preventDefault()
          }

          this.onDragEnter = (event) => {
            if (!event.dataTransfer?.types?.includes("Files")) return
            this.dragDepth += 1
            this.el.dataset.dragging = "true"
          }

          this.onDragOver = (event) => {
            if (event.dataTransfer?.types?.includes("Files")) event.preventDefault()
          }

          this.onDragLeave = () => {
            this.dragDepth = Math.max(0, this.dragDepth - 1)
            if (this.dragDepth === 0) this.el.dataset.dragging = "false"
          }

          this.onDrop = (event) => {
            if (!event.dataTransfer?.types?.includes("Files")) return
            event.preventDefault()
            this.dragDepth = 0
            this.el.dataset.dragging = "false"
            this.adopt(event.dataTransfer.files)
          }

          if (this.textarea) {
            this.textarea.addEventListener("input", this.resize)
            this.textarea.addEventListener("keydown", this.onKeyDown)
            this.textarea.addEventListener("paste", this.onPaste)
            this.textarea.addEventListener("compositionstart", this.onCompositionStart)
            this.textarea.addEventListener("compositionend", this.onCompositionEnd)
            this.resize()
          }
          if (this.el.dataset.clearEvent) this.handleEvent(this.el.dataset.clearEvent, this.clear)
          this.el.addEventListener("submit", this.onSubmit)
          this.el.addEventListener("dragenter", this.onDragEnter)
          this.el.addEventListener("dragover", this.onDragOver)
          this.el.addEventListener("dragleave", this.onDragLeave)
          this.el.addEventListener("drop", this.onDrop)
        },

        updated() { this.resize() },

        destroyed() {
          if (this.textarea) {
            this.textarea.removeEventListener("input", this.resize)
            this.textarea.removeEventListener("keydown", this.onKeyDown)
            this.textarea.removeEventListener("paste", this.onPaste)
            this.textarea.removeEventListener("compositionstart", this.onCompositionStart)
            this.textarea.removeEventListener("compositionend", this.onCompositionEnd)
          }
          this.el.removeEventListener("dragenter", this.onDragEnter)
          this.el.removeEventListener("submit", this.onSubmit)
          this.el.removeEventListener("dragover", this.onDragOver)
          this.el.removeEventListener("dragleave", this.onDragLeave)
          this.el.removeEventListener("drop", this.onDrop)
        },
      }
    </script>
    """
  end

  @doc """
  A transparent grouping wrapper, so a caller can compose the composer's parts
  in one block without adding a box to the flex layout.
  """
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def prompt_input_body(assigns) do
    ~H"""
    <div class={["contents", @class]} {@rest}>{render_slot(@inner_block)}</div>
    """
  end

  @doc """
  The message control.

  Takes a `Phoenix.HTML.FormField` so the id, name, and value come from the
  form the shell is already driving. The class strip is the source's
  `field-sizing-content max-h-48 min-h-16` on top of the input-group control
  recipe, which flattens the vendored `.textarea` border, radius, background,
  and focus ring — in an input group the *group* carries all four.
  """
  attr :id, :string, default: nil
  attr :field, Phoenix.HTML.FormField, default: nil
  attr :name, :string, default: nil
  attr :value, :string, default: nil
  attr :placeholder, :string, default: "What would you like to know?"
  attr :class, :any, default: nil
  attr :rest, :global, include: ~w(autocomplete disabled maxlength readonly required rows)

  def prompt_input_textarea(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
    assigns
    |> assign(:field, nil)
    |> assign(:id, assigns.id || field.id)
    |> assign(:name, field.name)
    |> assign(:value, Phoenix.HTML.Form.normalize_value("textarea", field.value))
    |> prompt_input_textarea()
  end

  def prompt_input_textarea(assigns) do
    ~H"""
    <UI.textarea
      id={@id}
      name={@name}
      value={@value}
      placeholder={@placeholder}
      data-slot="input-group-control"
      class={[
        "field-sizing-content max-h-48 min-h-16",
        "flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none",
        "focus-visible:ring-0",
        @class
      ]}
      {@rest}
    />
    """
  end

  @doc """
  The strip above the control. Attachments live here.

  The source aligns this addon `block-end` and then reorders it with
  `order-first`, which is the shape kept below: the input group's flex-column
  branch keys off `data-align`, and `order-first` is what actually lifts it.
  """
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def prompt_input_header(assigns) do
    ~H"""
    <div
      role="group"
      data-slot="input-group-addon"
      data-align="block-end"
      class={[addon(), "order-first flex-wrap gap-1", @class]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </div>
    """
  end

  @doc "The strip below the control: tools on one side, submit on the other."
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def prompt_input_footer(assigns) do
    ~H"""
    <div
      role="group"
      data-slot="input-group-addon"
      data-align="block-end"
      class={[addon(), "justify-between gap-1", @class]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </div>
    """
  end

  @doc """
  The composer toolbar. The same shape as `prompt_input_footer/1`, which is the
  name the current source gives it; `toolbar` is kept because that is what the
  part is called everywhere else in AI Elements.
  """
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def prompt_input_toolbar(assigns) do
    ~H"""
    <.prompt_input_footer class={@class} {@rest}>
      {render_slot(@inner_block)}
    </.prompt_input_footer>
    """
  end

  @doc "A run of controls inside the toolbar."
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def prompt_input_tools(assigns) do
    ~H"""
    <div class={["flex min-w-0 items-center gap-1", @class]} {@rest}>
      {render_slot(@inner_block)}
    </div>
    """
  end

  @doc """
  A composer control.

  Renders `OpenAgentsWeb.UI.button/1` with the source's input-group button
  sizing on top, so the product's own button grammar keeps the color and focus
  treatment while the composer keeps AI Elements' geometry.

  The source wraps this in a Radix tooltip. There is no Radix here and the repo
  has no tooltip primitive, so `tooltip` becomes the native `title` attribute:
  the same text, reachable by pointer but not by keyboard. An icon-only control
  still needs its own `aria-label`.
  """
  attr :variant, :atom,
    values: [:primary, :secondary, :outline, :ghost, :destructive],
    default: :ghost

  attr :size, :atom, values: @sizes, default: :icon_sm
  attr :tooltip, :string, default: nil
  attr :type, :string, default: "button"
  attr :class, :any, default: nil
  attr :rest, :global, include: ~w(disabled form name value popovertarget popovertargetaction)
  slot :inner_block, required: true

  def prompt_input_button(assigns) do
    ~H"""
    <UI.button
      type={@type}
      variant={@variant}
      title={@tooltip}
      class={[button_base(), button_size_class(@size), @class]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </UI.button>
    """
  end

  @doc """
  The send control, and the stop control while a turn is in flight.

  `status` is the caller's: `:ready`, `:submitted`, `:streaming`, or `:error` —
  the same four `ChatStatus` values the source reads. `:submitted` and
  `:streaming` are "generating", which renames the control to **Stop**; given
  `on_stop`, the control also stops being a submit button, exactly as the
  source does, so pressing it aborts rather than sending a second turn.
  """
  attr :id, :string, default: nil
  attr :status, :atom, values: @statuses, default: :ready
  attr :on_stop, :any, default: nil, doc: "a `Phoenix.LiveView.JS` command or event name"
  attr :label, :string, default: nil, doc: "overrides the derived accessible name"
  attr :size, :atom, values: @sizes, default: :icon_sm
  attr :class, :any, default: nil
  attr :rest, :global, include: ~w(disabled form name value)
  slot :inner_block

  def prompt_input_submit(assigns) do
    generating? = assigns.status in [:submitted, :streaming]

    assigns =
      assigns
      |> assign(:generating?, generating?)
      |> assign(:stopping?, generating? and not is_nil(assigns.on_stop))

    ~H"""
    <UI.button
      id={@id}
      type={if @stopping?, do: "button", else: "submit"}
      variant={:primary}
      phx-click={@stopping? && @on_stop}
      aria-label={@label || if(@generating?, do: "Stop", else: "Submit")}
      data-status={@status}
      class={[button_base(), button_size_class(@size), "justify-center", @class]}
      {@rest}
    >
      <%= if @inner_block != [] do %>
        {render_slot(@inner_block)}
      <% else %>
        <UI.icon :if={@status == :ready} name="arrow-curved-left" class="size-4" />
        <UI.icon :if={@status == :submitted} name="spin" class="size-4 animate-spin" />
        <UI.icon :if={@status == :streaming} name="stop" class="size-4" />
        <UI.icon :if={@status == :error} name="x" class="size-4" />
      <% end %>
    </UI.button>
    """
  end

  # ── Action menu ───────────────────────────────────────────────────────────

  @doc """
  The composer's add menu.

  A native `popover`, the same bounded disclosure `OpenAgentsWeb.UI.menu/1`
  documents, rather than a port of Radix's dropdown. Render the trigger with
  `prompt_input_action_menu_trigger/1` and give it this menu's id.
  """
  attr :id, :string, required: true
  attr :label, :string, default: "Composer actions"
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def prompt_input_action_menu(assigns) do
    ~H"""
    <UI.menu id={@id} label={@label} class={["min-w-56 p-1", @class]} {@rest}>
      {render_slot(@inner_block)}
    </UI.menu>
    """
  end

  @doc "Opens the action menu. Defaults to the source's plus glyph."
  attr :id, :string, default: nil
  attr :menu, :string, required: true, doc: "the `prompt_input_action_menu/1` id"
  attr :label, :string, default: "Open composer actions"
  attr :class, :any, default: nil
  attr :rest, :global, include: ~w(disabled)
  slot :inner_block

  def prompt_input_action_menu_trigger(assigns) do
    ~H"""
    <.prompt_input_button
      id={@id}
      aria-label={@label}
      popovertarget={@menu}
      popovertargetaction="toggle"
      class={@class}
      {@rest}
    >
      <%= if @inner_block != [] do %>
        {render_slot(@inner_block)}
      <% else %>
        <UI.icon name="plus" class="size-4" />
      <% end %>
    </.prompt_input_button>
    """
  end

  @doc "The action menu's own body, for grouping items."
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def prompt_input_action_menu_content(assigns) do
    ~H"""
    <div class={["flex flex-col gap-0.5", @class]} {@rest}>{render_slot(@inner_block)}</div>
    """
  end

  @doc "One action in the menu."
  attr :id, :string, default: nil
  attr :class, :any, default: nil
  attr :rest, :global, include: ~w(disabled form name value popovertarget popovertargetaction)
  slot :inner_block, required: true

  def prompt_input_action_menu_item(assigns) do
    ~H"""
    <button id={@id} type="button" role="menuitem" class={[command_item(), @class]} {@rest}>
      {render_slot(@inner_block)}
    </button>
    """
  end

  @doc """
  The menu item that opens the file dialog.

  The source calls `attachments.openFileDialog()` through React context. Here
  the item is a label pointing at the composer's hidden file input, so the
  browser opens the dialog and no JavaScript is involved at all.
  """
  attr :id, :string, default: nil
  attr :for, :string, required: true, doc: "the `prompt_input/1` id"
  attr :label, :string, default: "Add photos or files"
  attr :class, :any, default: nil
  attr :rest, :global

  def prompt_input_action_add_attachments(assigns) do
    ~H"""
    <label
      id={@id}
      role="menuitem"
      tabindex="0"
      for={"#{@for}-files"}
      class={[command_item(), "cursor-pointer", @class]}
      {@rest}
    >
      <UI.icon name="image-square" class="mr-2 size-4" />{@label}
    </label>
    """
  end

  @doc """
  The menu item that captures the screen.

  Screen capture is `getDisplayMedia`, which only a user gesture on the client
  can start, so the item carries `phx-click` for the LiveView to hear and the
  capture stays the caller's to wire. The source's inline canvas capture is
  deliberately not reimplemented: it produces a `File`, and file transport
  belongs to the LiveView's uploader.
  """
  attr :id, :string, default: nil
  attr :label, :string, default: "Take screenshot"
  attr :class, :any, default: nil
  attr :rest, :global, include: ~w(disabled)

  def prompt_input_action_add_screenshot(assigns) do
    ~H"""
    <button id={@id} type="button" role="menuitem" class={[command_item(), @class]} {@rest}>
      <UI.icon name="desktop" class="mr-2 size-4" />{@label}
    </button>
    """
  end

  # ── Model select, in the composer toolbar ─────────────────────────────────

  @doc """
  The model picker that sits in the composer toolbar.

  A native `<select>`. Radix's Select splits into Trigger, Value, Content, and
  Item so that a `<div>` can pretend to be a control; a real `<select>` is one
  element that already has the keyboard behavior, the typeahead, and the mobile
  treatment, so the split has nothing left to describe. The source's trigger
  classes are carried onto it, minus `bg-accent` — see the module note.
  """
  attr :id, :string, default: nil
  attr :name, :string, default: nil
  attr :value, :any, default: nil
  attr :label, :string, default: "Model"
  attr :class, :any, default: nil
  attr :rest, :global, include: ~w(disabled form required)
  slot :inner_block, required: true

  def prompt_input_model_select(assigns) do
    ~H"""
    <select
      id={@id}
      name={@name}
      aria-label={@label}
      class={[
        "h-8 rounded-md border-none bg-transparent px-2 font-medium text-muted-foreground text-sm",
        "shadow-none transition-colors",
        "hover:bg-muted hover:text-foreground",
        "aria-expanded:bg-muted aria-expanded:text-foreground",
        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
        @class
      ]}
      {@rest}
    >
      {render_slot(@inner_block, @value)}
    </select>
    """
  end

  @doc "One model in `prompt_input_model_select/1`."
  attr :value, :string, required: true
  attr :selected, :boolean, default: false
  attr :class, :any, default: nil
  attr :rest, :global, include: ~w(disabled)
  slot :inner_block, required: true

  def prompt_input_model_select_item(assigns) do
    ~H"""
    <option value={@value} selected={@selected} class={@class} {@rest}>
      {render_slot(@inner_block)}
    </option>
    """
  end

  # ── Attachments ───────────────────────────────────────────────────────────

  @doc """
  The list of things attached to the next turn.

  `variant` is the source's: `:grid` for the thumbnail strip above the control,
  `:inline` for chips beside it, `:list` for a stacked review. Each child
  `attachment/1` must be told the same variant — React passed it down through
  context, and HEEx has no context.
  """
  attr :id, :string, default: nil
  attr :variant, :atom, values: @variants, default: :grid
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def attachments(assigns) do
    ~H"""
    <div
      id={@id}
      data-slot="attachments"
      data-variant={@variant}
      class={[
        "flex items-start",
        @variant == :list && "flex-col gap-2",
        @variant != :list && "flex-wrap gap-2",
        @variant == :grid && "ml-auto w-fit",
        @class
      ]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </div>
    """
  end

  @doc "One attachment."
  attr :id, :string, default: nil
  attr :variant, :atom, values: @variants, default: :grid
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def attachment(assigns) do
    ~H"""
    <div
      id={@id}
      data-slot="attachment"
      data-variant={@variant}
      class={[
        "group/attachment relative",
        @variant == :grid && "size-24 overflow-hidden rounded-lg",
        @variant == :inline &&
          [
            "flex h-8 cursor-pointer select-none items-center gap-1.5",
            "rounded-md border border-border px-1.5",
            "font-medium text-sm transition-all",
            "hover:bg-muted hover:text-foreground"
          ],
        @variant == :list &&
          [
            "flex w-full items-center gap-3 rounded-lg border p-3",
            "hover:bg-muted/50"
          ],
        @class
      ]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </div>
    """
  end

  @doc """
  The attachment's thumbnail.

  An image or a video poster when `src` is given and the media category can
  show one; otherwise the glyph for the category, which is how the source
  distinguishes an audio file from a document at a glance.
  """
  attr :variant, :atom, values: @variants, default: :grid

  attr :media_category, :atom,
    values: [:image, :video, :audio, :document, :source, :unknown],
    default: :unknown

  attr :src, :string, default: nil
  attr :filename, :string, default: nil
  attr :class, :any, default: nil
  attr :rest, :global

  def attachment_preview(assigns) do
    ~H"""
    <div
      data-slot="attachment-preview"
      class={[
        "flex shrink-0 items-center justify-center overflow-hidden",
        @variant == :grid && "size-full bg-muted",
        @variant == :inline && "size-5 rounded bg-background",
        @variant == :list && "size-12 rounded bg-muted",
        @class
      ]}
      {@rest}
    >
      <img
        :if={@media_category == :image and @src}
        src={@src}
        alt={@filename || "Image"}
        width={if @variant == :grid, do: "96", else: "20"}
        height={if @variant == :grid, do: "96", else: "20"}
        class={["size-full object-cover", @variant != :grid && "rounded"]}
      />
      <video
        :if={@media_category == :video and @src}
        src={@src}
        muted
        class="size-full object-cover"
      />
      <UI.icon
        :if={is_nil(@src) or @media_category not in [:image, :video]}
        name={media_icon(@media_category)}
        class={[if(@variant == :inline, do: "size-3", else: "size-4"), "text-muted-foreground"]}
      />
    </div>
    """
  end

  @doc """
  The attachment's name, and optionally its media type.

  Renders nothing in the `:grid` variant, where the thumbnail is the whole
  item — the same early return the source makes.
  """
  attr :label, :string, required: true
  attr :variant, :atom, values: @variants, default: :inline
  attr :media_type, :string, default: nil
  attr :class, :any, default: nil
  attr :rest, :global

  def attachment_info(assigns) do
    ~H"""
    <div
      :if={@variant != :grid}
      data-slot="attachment-info"
      class={["min-w-0 flex-1", @class]}
      {@rest}
    >
      <span class="block truncate">{@label}</span>
      <span :if={@media_type} class="block truncate text-muted-foreground text-xs">
        {@media_type}
      </span>
    </div>
    """
  end

  @doc """
  Removes one attachment.

  In `:grid` and `:inline` the control is revealed on hover, as in the source.
  That hides a control from a pointer user until they look for it, and it must
  not hide it from anyone else, so the visible label stays `sr-only`, the
  button keeps its own `aria-label`, and `focus-visible` reveals it as well.
  """
  attr :id, :string, default: nil
  attr :variant, :atom, values: @variants, default: :grid
  attr :label, :string, default: "Remove"
  attr :class, :any, default: nil
  attr :rest, :global, include: ~w(disabled form name value)
  slot :inner_block

  def attachment_remove(assigns) do
    ~H"""
    <UI.button
      id={@id}
      type="button"
      variant={:ghost}
      aria-label={@label}
      data-slot="attachment-remove"
      class={[
        @variant == :grid &&
          [
            "absolute top-2 right-2 size-6 rounded-full p-0",
            "bg-background/80 backdrop-blur-sm",
            "opacity-0 transition-opacity group-hover/attachment:opacity-100 focus-visible:opacity-100",
            "hover:bg-background",
            "[&>svg]:size-3"
          ],
        @variant == :inline &&
          [
            "size-5 rounded p-0",
            "opacity-0 transition-opacity group-hover/attachment:opacity-100 focus-visible:opacity-100",
            "[&>svg]:size-2.5"
          ],
        @variant == :list && ["size-8 shrink-0 rounded p-0", "[&>svg]:size-4"],
        @class
      ]}
      {@rest}
    >
      <%= if @inner_block != [] do %>
        {render_slot(@inner_block)}
      <% else %>
        <UI.icon name="x" />
      <% end %>
      <span class="sr-only">{@label}</span>
    </UI.button>
    """
  end

  @doc "What stands in for the attachment list when nothing is attached."
  attr :id, :string, default: nil
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block

  def attachment_empty(assigns) do
    ~H"""
    <div
      id={@id}
      data-slot="attachment-empty"
      class={["flex items-center justify-center p-4 text-muted-foreground text-sm", @class]}
      {@rest}
    >
      <%= if @inner_block != [] do %>
        {render_slot(@inner_block)}
      <% else %>
        No attachments
      <% end %>
    </div>
    """
  end

  # ── Speech input ──────────────────────────────────────────────────────────

  @doc """
  Push-to-talk.

  The source detects the Web Speech API, falls back to `MediaRecorder`, and
  disables itself when neither exists. All three live in the colocated
  `.SpeechInput` hook, which publishes what it found as `data-mode` and its
  state as `data-recording` and `data-processing` on the wrapper, so the whole
  visual treatment — including the three staggered ping rings — is CSS keyed
  off attributes rather than a React render.

  Recognized text is pushed to the LiveView as `transcript_event` with a
  `"text"` key. Recorded audio cannot travel in a LiveView event, so in the
  `MediaRecorder` fallback the hook writes the blob onto the file input named
  by `audio_input` and lets the caller's uploader carry it. Without one, the
  control disables itself in that fallback, which is what the source does when
  no `onAudioRecorded` callback is given.
  """
  attr :id, :string, required: true
  attr :transcript_event, :string, required: true
  attr :audio_input, :string, default: nil, doc: "id of a file input for the recorded audio"
  attr :lang, :string, default: "en-US"
  attr :label, :string, default: "Start voice input"
  attr :stop_label, :string, default: "Stop voice input"
  attr :class, :any, default: nil
  attr :rest, :global, include: ~w(disabled)

  def speech_input(assigns) do
    ~H"""
    <div
      id={@id}
      class="group/speech relative inline-flex items-center justify-center"
      phx-hook=".SpeechInput"
      data-recording="false"
      data-processing="false"
      data-lang={@lang}
      data-transcript-event={@transcript_event}
      data-audio-input={@audio_input}
    >
      <div
        :for={index <- 0..2}
        aria-hidden="true"
        class={[
          "absolute inset-0 hidden animate-ping rounded-full border-2 border-destructive/30",
          "group-data-[recording=true]/speech:block"
        ]}
        style={"animation-delay: #{index * 0.3}s; animation-duration: 2s"}
      >
      </div>
      <UI.button
        id={"#{@id}-button"}
        type="button"
        variant={:primary}
        aria-label={@label}
        data-label={@label}
        data-stop-label={@stop_label}
        class={[
          "relative z-10 size-8 justify-center rounded-full p-0 transition-all duration-300",
          "bg-primary text-primary-foreground hover:bg-primary/80 hover:text-primary-foreground",
          "group-data-[recording=true]/speech:bg-destructive",
          "group-data-[recording=true]/speech:text-white",
          "group-data-[recording=true]/speech:hover:bg-destructive/80",
          @class
        ]}
        {@rest}
      >
        <UI.icon
          name="spin"
          class="hidden size-4 animate-spin group-data-[processing=true]/speech:block"
        />
        <UI.icon
          name="stop"
          class={[
            "hidden size-4 group-data-[recording=true]/speech:block",
            "group-data-[processing=true]/speech:hidden"
          ]}
        />
        <UI.icon
          name="mic"
          class={[
            "size-4 group-data-[recording=true]/speech:hidden",
            "group-data-[processing=true]/speech:hidden"
          ]}
        />
      </UI.button>
    </div>
    <script :type={Phoenix.LiveView.ColocatedHook} name=".SpeechInput">
      const detectMode = () => {
        if (typeof window === "undefined") return "none"
        if ("SpeechRecognition" in window || "webkitSpeechRecognition" in window) {
          return "speech-recognition"
        }
        if ("MediaRecorder" in window && "mediaDevices" in navigator) return "media-recorder"
        return "none"
      }

      export default {
        mounted() {
          this.button = this.el.querySelector("button")
          this.mode = detectMode()
          this.el.dataset.mode = this.mode
          this.chunks = []

          const audioInput = () =>
            this.el.dataset.audioInput ? document.getElementById(this.el.dataset.audioInput) : null

          if (this.mode === "none" || (this.mode === "media-recorder" && !audioInput())) {
            this.button.disabled = true
            return
          }

          this.setRecording = (recording) => {
            this.el.dataset.recording = recording ? "true" : "false"
            this.button.setAttribute(
              "aria-label",
              recording ? this.button.dataset.stopLabel : this.button.dataset.label
            )
          }

          if (this.mode === "speech-recognition") {
            const Recognition = window.SpeechRecognition || window.webkitSpeechRecognition
            this.recognition = new Recognition()
            this.recognition.continuous = true
            this.recognition.interimResults = true
            this.recognition.lang = this.el.dataset.lang

            this.recognition.addEventListener("start", () => this.setRecording(true))
            this.recognition.addEventListener("end", () => this.setRecording(false))
            this.recognition.addEventListener("error", () => this.setRecording(false))
            this.recognition.addEventListener("result", (event) => {
              let text = ""
              for (let i = event.resultIndex; i < event.results.length; i += 1) {
                const result = event.results[i]
                if (result.isFinal) text += result[0]?.transcript ?? ""
              }
              if (text) this.pushEvent(this.el.dataset.transcriptEvent, { text })
            })
          }

          this.startRecorder = async () => {
            try {
              this.stream = await navigator.mediaDevices.getUserMedia({ audio: true })
            } catch {
              this.setRecording(false)
              return
            }
            this.chunks = []
            this.recorder = new MediaRecorder(this.stream)
            this.recorder.addEventListener("dataavailable", (event) => {
              if (event.data.size > 0) this.chunks.push(event.data)
            })
            this.recorder.addEventListener("stop", () => {
              for (const track of this.stream.getTracks()) track.stop()
              this.stream = null
              const blob = new Blob(this.chunks, { type: "audio/webm" })
              const input = audioInput()
              if (blob.size === 0 || !input) return
              this.el.dataset.processing = "true"
              const transfer = new DataTransfer()
              transfer.items.add(new File([blob], "speech.webm", { type: "audio/webm" }))
              input.files = transfer.files
              input.dispatchEvent(new Event("input", { bubbles: true }))
              input.dispatchEvent(new Event("change", { bubbles: true }))
              this.el.dataset.processing = "false"
            })
            this.recorder.start()
            this.setRecording(true)
          }

          this.onClick = () => {
            const recording = this.el.dataset.recording === "true"
            if (this.mode === "speech-recognition") {
              if (recording) { this.recognition.stop() } else { this.recognition.start() }
              return
            }
            if (recording) {
              if (this.recorder && this.recorder.state === "recording") this.recorder.stop()
              this.setRecording(false)
            } else {
              this.startRecorder()
            }
          }

          this.button.addEventListener("click", this.onClick)
        },

        destroyed() {
          if (this.button && this.onClick) this.button.removeEventListener("click", this.onClick)
          if (this.recognition) this.recognition.stop()
          if (this.recorder && this.recorder.state === "recording") this.recorder.stop()
          if (this.stream) { for (const track of this.stream.getTracks()) track.stop() }
        },
      }
    </script>
    """
  end

  # ── Mic selector ──────────────────────────────────────────────────────────

  @doc """
  Chooses the microphone.

  The device list only exists on the client — `enumerateDevices` returns
  nothing useful until microphone permission has been granted, and the source
  requests it when its popover opens. The same sequence is a colocated hook
  here, filling a native `<select>`, so the element carries
  `phx-update="ignore"`: its options are the hook's, and a LiveView patch must
  not discard them.

  Devices the server already knows can still be passed as `mic_selector_item/1`
  children; the hook replaces them once the browser answers.
  """
  attr :id, :string, required: true
  attr :name, :string, default: nil
  attr :label, :string, default: "Microphone"
  attr :placeholder, :string, default: "Select microphone..."
  attr :class, :any, default: nil
  attr :rest, :global, include: ~w(disabled form required)
  slot :inner_block

  def mic_selector(assigns) do
    ~H"""
    <select
      id={@id}
      name={@name}
      aria-label={@label}
      phx-hook=".MicSelector"
      phx-update="ignore"
      data-placeholder={@placeholder}
      class={[
        "h-8 min-w-0 max-w-56 truncate rounded-md border border-input bg-transparent px-2",
        "text-left text-muted-foreground text-sm shadow-none transition-colors",
        "hover:bg-muted hover:text-foreground",
        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
        @class
      ]}
      {@rest}
    >
      <option value="">{@placeholder}</option>
      {render_slot(@inner_block)}
    </select>
    <script :type={Phoenix.LiveView.ColocatedHook} name=".MicSelector">
      // The source strips the trailing hardware id out of a device label and
      // shows it dimmed beside the name. An <option> holds text and nothing
      // else, so the two parts are joined rather than styled apart.
      const deviceIdPattern = /\s*\(([0-9a-f]{4}:[0-9a-f]{4})\)$/i

      export default {
        async mounted() {
          if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
            this.el.disabled = true
            return
          }

          this.load = async () => {
            let devices = []
            try {
              devices = await navigator.mediaDevices.enumerateDevices()
            } catch {
              return
            }
            const inputs = devices.filter((device) => device.kind === "audioinput" && device.label)
            if (inputs.length === 0) return

            const selected = this.el.value
            this.el.replaceChildren()
            this.el.append(new Option(this.el.dataset.placeholder, ""))
            for (const device of inputs) {
              const matches = device.label.match(deviceIdPattern)
              const label = matches
                ? `${device.label.replace(deviceIdPattern, "")} (${matches[1]})`
                : device.label
              this.el.append(new Option(label, device.deviceId))
            }
            if (selected) this.el.value = selected
          }

          this.onDeviceChange = () => this.load()
          navigator.mediaDevices.addEventListener("devicechange", this.onDeviceChange)
          await this.load()
        },

        destroyed() {
          if (this.onDeviceChange) {
            navigator.mediaDevices.removeEventListener("devicechange", this.onDeviceChange)
          }
        },
      }
    </script>
    """
  end

  @doc "One microphone the server already knows about."
  attr :value, :string, required: true
  attr :selected, :boolean, default: false
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def mic_selector_item(assigns) do
    ~H"""
    <option value={@value} selected={@selected} class={@class} {@rest}>
      {render_slot(@inner_block)}
    </option>
    """
  end

  # ── Model selector, the searchable panel ──────────────────────────────────

  @doc """
  The full model picker: a searchable list in a bounded disclosure.

  The source is a `cmdk` command palette inside a Radix dialog. Neither is
  vendored, so this is `OpenAgentsWeb.UI.menu/1` — a native `popover` — with
  the filtering in a colocated hook that matches each item's `data-value` and
  reveals `model_selector_empty/1` when nothing survives.
  """
  attr :id, :string, required: true
  attr :label, :string, default: "Select a model"
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def model_selector(assigns) do
    ~H"""
    <UI.menu
      id={@id}
      label={@label}
      class={["w-80 max-w-[90vw] overflow-hidden p-0", @class]}
      phx-hook=".ModelSelectorFilter"
      {@rest}
    >
      {render_slot(@inner_block)}
    </UI.menu>
    <script :type={Phoenix.LiveView.ColocatedHook} name=".ModelSelectorFilter">
      export default {
        mounted() {
          this.input = this.el.querySelector("[data-slot='model-selector-input']")
          if (!this.input) return

          this.filter = () => {
            const query = this.input.value.trim().toLowerCase()
            let matched = 0
            for (const item of this.el.querySelectorAll("[data-slot='model-selector-item']")) {
              const value = (item.dataset.value || item.textContent || "").toLowerCase()
              const hit = query === "" || value.includes(query)
              item.hidden = !hit
              if (hit) matched += 1
            }
            for (const group of this.el.querySelectorAll("[data-slot='model-selector-group']")) {
              const items = [...group.querySelectorAll("[data-slot='model-selector-item']")]
              group.hidden = items.length > 0 && items.every((item) => item.hidden)
            }
            for (const empty of this.el.querySelectorAll("[data-slot='model-selector-empty']")) {
              empty.hidden = matched > 0
            }
          }

          this.input.addEventListener("input", this.filter)
          this.el.addEventListener("toggle", this.filter)
          this.filter()
        },

        destroyed() {
          if (this.input) this.input.removeEventListener("input", this.filter)
          this.el.removeEventListener("toggle", this.filter)
        },
      }
    </script>
    """
  end

  @doc "Opens the model selector."
  attr :id, :string, default: nil
  attr :panel, :string, required: true, doc: "the `model_selector/1` id"
  attr :class, :any, default: nil
  attr :rest, :global, include: ~w(disabled)
  slot :inner_block, required: true

  def model_selector_trigger(assigns) do
    ~H"""
    <.prompt_input_button
      id={@id}
      size={:sm}
      popovertarget={@panel}
      popovertargetaction="toggle"
      class={@class}
      {@rest}
    >
      {render_slot(@inner_block)}
      <UI.icon name="chevron-up-down" class="size-4 shrink-0 text-muted-foreground" />
    </.prompt_input_button>
    """
  end

  @doc "The panel's search field."
  attr :id, :string, default: nil
  attr :placeholder, :string, default: "Search models..."
  attr :class, :any, default: nil
  attr :rest, :global

  def model_selector_input(assigns) do
    ~H"""
    <div class="flex items-center gap-2 border-b border-border px-3">
      <UI.icon name="magnifying-glass-search" class="size-4 shrink-0 text-muted-foreground" />
      <input
        id={@id}
        type="text"
        role="combobox"
        aria-expanded="true"
        aria-label={@placeholder}
        autocomplete="off"
        placeholder={@placeholder}
        data-slot="model-selector-input"
        class={[
          "h-auto flex-1 border-0 bg-transparent py-3.5 text-sm outline-none",
          "placeholder:text-muted-foreground",
          @class
        ]}
        {@rest}
      />
    </div>
    """
  end

  @doc "The scrolling list of models."
  attr :id, :string, default: nil
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def model_selector_list(assigns) do
    ~H"""
    <div
      id={@id}
      role="listbox"
      class={["max-h-72 scroll-py-1 overflow-y-auto overflow-x-hidden p-1", @class]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </div>
    """
  end

  @doc "Shown when the search matches nothing."
  attr :id, :string, default: nil
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block

  def model_selector_empty(assigns) do
    ~H"""
    <div
      id={@id}
      data-slot="model-selector-empty"
      class={["py-6 text-center text-muted-foreground text-sm", @class]}
      {@rest}
    >
      <%= if @inner_block != [] do %>
        {render_slot(@inner_block)}
      <% else %>
        No models found.
      <% end %>
    </div>
    """
  end

  @doc "A named run of models, such as one provider's."
  attr :id, :string, default: nil
  attr :heading, :string, default: nil
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def model_selector_group(assigns) do
    ~H"""
    <div
      id={@id}
      role="group"
      aria-label={@heading}
      data-slot="model-selector-group"
      class={["overflow-hidden p-1 text-foreground", @class]}
      {@rest}
    >
      <div :if={@heading} class="px-2 py-1.5 font-medium text-muted-foreground text-xs">
        {@heading}
      </div>
      {render_slot(@inner_block)}
    </div>
    """
  end

  @doc """
  One model.

  `value` is what the filter hook matches against, so give it whatever a reader
  would type: the model's name, its provider, or both.
  """
  attr :id, :string, default: nil
  attr :value, :string, required: true
  attr :selected, :boolean, default: false
  attr :class, :any, default: nil
  attr :rest, :global, include: ~w(disabled form name)
  slot :inner_block, required: true

  def model_selector_item(assigns) do
    ~H"""
    <button
      id={@id}
      type="button"
      role="option"
      aria-selected={to_string(@selected)}
      data-slot="model-selector-item"
      data-value={@value}
      class={[command_item(), @class]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </button>
    """
  end

  @doc "A model's name, filling the row between its logo and its shortcut."
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def model_selector_name(assigns) do
    ~H"""
    <span class={["flex-1 truncate text-left", @class]} {@rest}>{render_slot(@inner_block)}</span>
    """
  end

  @doc "A keyboard shortcut, stated at the trailing edge of a row."
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def model_selector_shortcut(assigns) do
    ~H"""
    <span class={["ml-auto text-muted-foreground text-xs tracking-widest", @class]} {@rest}>
      {render_slot(@inner_block)}
    </span>
    """
  end

  @doc "A rule between groups."
  attr :class, :any, default: nil
  attr :rest, :global

  def model_selector_separator(assigns) do
    ~H"""
    <div role="separator" class={["-mx-1 h-px bg-border", @class]} {@rest}></div>
    """
  end

  @doc """
  A provider's mark.

  The source builds the URL from a provider name against `models.dev`. This
  takes `src` instead: a component in this app does not decide which host the
  page fetches from. `dark:invert` is dropped for the reason in the module
  note — the variant never matches here.
  """
  attr :src, :string, required: true
  attr :provider, :string, required: true, doc: "used for the alternative text"
  attr :class, :any, default: nil
  attr :rest, :global

  def model_selector_logo(assigns) do
    ~H"""
    <img
      src={@src}
      alt={"#{@provider} logo"}
      width="12"
      height="12"
      class={["size-3", @class]}
      {@rest}
    />
    """
  end

  @doc "Overlapping provider marks, for a model several providers serve."
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def model_selector_logo_group(assigns) do
    ~H"""
    <div
      class={[
        "flex shrink-0 items-center -space-x-1",
        "[&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1",
        @class
      ]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </div>
    """
  end

  # ── Queue ─────────────────────────────────────────────────────────────────

  @doc "Messages waiting to be sent, held below the composer."
  attr :id, :string, default: nil
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def queue(assigns) do
    ~H"""
    <div
      id={@id}
      data-slot="queue"
      class={[
        "flex flex-col gap-2 rounded-xl border border-border bg-background px-3 pt-2 pb-2 shadow-xs",
        @class
      ]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </div>
    """
  end

  @doc """
  A collapsible run of queued items.

  A native `<details>`, so the disclosure works before any JavaScript loads.
  The chevron in `queue_section_label/1` rotates off this element's `open`
  attribute rather than off Radix's `data-state`.
  """
  attr :id, :string, default: nil
  attr :open, :boolean, default: true
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def queue_section(assigns) do
    ~H"""
    <details id={@id} open={@open} class={["group/queue-section", @class]} {@rest}>
      {render_slot(@inner_block)}
    </details>
    """
  end

  @doc "The section's header, which opens and closes it."
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def queue_section_trigger(assigns) do
    ~H"""
    <summary
      class={[
        "group flex w-full cursor-pointer list-none items-center justify-between rounded-md",
        "bg-muted/40 px-3 py-2 text-left font-medium text-muted-foreground text-sm",
        "transition-colors hover:bg-muted [&::-webkit-details-marker]:hidden",
        @class
      ]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </summary>
    """
  end

  @doc "The section header's own content: a chevron, a count, and a word."
  attr :label, :string, required: true
  attr :count, :integer, default: nil
  attr :class, :any, default: nil
  attr :rest, :global
  slot :icon

  def queue_section_label(assigns) do
    ~H"""
    <span class={["flex items-center gap-2", @class]} {@rest}>
      <UI.icon
        name="chevron-down"
        class="size-4 -rotate-90 transition-transform group-open/queue-section:rotate-0"
      />
      {render_slot(@icon)}
      <span>{[@count, @label] |> Enum.reject(&is_nil/1) |> Enum.join(" ")}</span>
    </span>
    """
  end

  @doc "What the section reveals."
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def queue_section_content(assigns) do
    ~H"""
    <div class={@class} {@rest}>{render_slot(@inner_block)}</div>
    """
  end

  @doc "The bounded scrolling list of queued items."
  attr :id, :string, default: nil
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def queue_list(assigns) do
    ~H"""
    <div id={@id} class={["-mb-1 mt-2 overflow-y-auto", @class]} {@rest}>
      <div class="max-h-40 pr-4">
        <ul>{render_slot(@inner_block)}</ul>
      </div>
    </div>
    """
  end

  @doc "One queued message."
  attr :id, :string, default: nil
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def queue_item(assigns) do
    ~H"""
    <li
      id={@id}
      class={[
        "group/queue-item flex flex-col gap-1 rounded-md px-3 py-1 text-sm",
        "transition-colors hover:bg-muted",
        @class
      ]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </li>
    """
  end

  @doc "The dot that says whether a queued item has been sent."
  attr :completed, :boolean, default: false
  attr :class, :any, default: nil
  attr :rest, :global

  def queue_item_indicator(assigns) do
    ~H"""
    <span
      aria-hidden="true"
      class={[
        "mt-0.5 inline-block size-2.5 rounded-full border",
        if(@completed,
          do: "border-muted-foreground/20 bg-muted-foreground/10",
          else: "border-muted-foreground/50"
        ),
        @class
      ]}
      {@rest}
    ></span>
    """
  end

  @doc "The queued message's text."
  attr :completed, :boolean, default: false
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def queue_item_content(assigns) do
    ~H"""
    <span
      class={[
        "line-clamp-1 grow break-words",
        if(@completed, do: "text-muted-foreground/50 line-through", else: "text-muted-foreground"),
        @class
      ]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </span>
    """
  end

  @doc "Supporting text under a queued message."
  attr :completed, :boolean, default: false
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def queue_item_description(assigns) do
    ~H"""
    <div
      class={[
        "ml-6 text-xs",
        if(@completed, do: "text-muted-foreground/40 line-through", else: "text-muted-foreground"),
        @class
      ]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </div>
    """
  end

  @doc "Controls on a queued item."
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def queue_item_actions(assigns) do
    ~H"""
    <div class={["flex gap-1", @class]} {@rest}>{render_slot(@inner_block)}</div>
    """
  end

  @doc """
  One control on a queued item.

  Revealed on hover in the source. `focus-visible` reveals it as well, because
  a hover-only reveal is a control the keyboard can reach but never see.
  """
  attr :id, :string, default: nil
  attr :label, :string, required: true
  attr :class, :any, default: nil
  attr :rest, :global, include: ~w(disabled form name value)
  slot :inner_block, required: true

  def queue_item_action(assigns) do
    ~H"""
    <UI.button
      id={@id}
      type="button"
      variant={:ghost}
      aria-label={@label}
      class={[
        "size-auto rounded p-1 text-muted-foreground opacity-0 transition-opacity",
        "hover:bg-muted-foreground/10 hover:text-foreground",
        "group-hover/queue-item:opacity-100 focus-visible:opacity-100",
        @class
      ]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </UI.button>
    """
  end

  @doc "The strip of things attached to a queued message."
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def queue_item_attachment(assigns) do
    ~H"""
    <div class={["mt-1 flex flex-wrap gap-2", @class]} {@rest}>{render_slot(@inner_block)}</div>
    """
  end

  @doc "An image attached to a queued message."
  attr :src, :string, required: true
  attr :alt, :string, default: ""
  attr :class, :any, default: nil
  attr :rest, :global

  def queue_item_image(assigns) do
    ~H"""
    <img
      src={@src}
      alt={@alt}
      width="32"
      height="32"
      class={["h-8 w-8 rounded border object-cover", @class]}
      {@rest}
    />
    """
  end

  @doc "A file attached to a queued message."
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def queue_item_file(assigns) do
    ~H"""
    <span
      class={["flex items-center gap-1 rounded border bg-muted px-2 py-1 text-xs", @class]}
      {@rest}
    >
      <UI.icon name="paperclip" class="size-3" />
      <span class="max-w-[100px] truncate">{render_slot(@inner_block)}</span>
    </span>
    """
  end

  @doc """
  What stands in for the queue when nothing is waiting.

  AI Elements has no such part — its queue simply renders no items. An empty
  region with no words leaves a reader unsure whether anything was queued at
  all, so this states the condition, following `attachment_empty/1`.
  """
  attr :id, :string, default: nil
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block

  def queue_empty(assigns) do
    ~H"""
    <div
      id={@id}
      data-slot="queue-empty"
      class={["flex items-center justify-center px-3 py-4 text-muted-foreground text-sm", @class]}
      {@rest}
    >
      <%= if @inner_block != [] do %>
        {render_slot(@inner_block)}
      <% else %>
        Nothing queued
      <% end %>
    </div>
    """
  end

  # ── Private ───────────────────────────────────────────────────────────────

  defp button_size_class(:xs) do
    "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 " <>
      "[&>svg:not([class*='size-'])]:size-3.5"
  end

  defp button_size_class(:sm), do: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5"

  defp button_size_class(:icon_xs),
    do: "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0"

  defp button_size_class(:icon_sm), do: "size-8 p-0 has-[>svg]:p-0"

  defp media_icon(:image), do: "image-square"
  defp media_icon(:video), do: "video"
  defp media_icon(:audio), do: "music"
  defp media_icon(:document), do: "file-document"
  defp media_icon(:source), do: "globe"
  defp media_icon(_unknown), do: "paperclip"

  defp input_group, do: @input_group
  defp addon, do: @addon
  defp button_base, do: @button_base
  defp command_item, do: @command_item
end