Port AI Elements' composer into HEEx

0b55af9a0c24 · AtlantisPleb · · parent 767d8ae74281

Port AI Elements' composer into HEEx

`OpenAgentsWeb.AI.PromptInput` carries batch C of the AI Elements port: the
prompt input shell and its toolbar, attachments, speech input, the microphone
and model selectors, and the queue. The Tailwind is the point of the exercise
and is copied across close to verbatim from `prompt-input.tsx`,
`attachments.tsx`, `speech-input.tsx`, `mic-selector.tsx`, `model-selector.tsx`,
and `queue.tsx`; the React around it is not, and four things changed on the way.

The source keeps input text, attachment lists, recording state, and menu state
in hooks and contexts, and none of that survives. Callers own the state and the
components take what to draw. The three behaviours markup cannot express are
colocated hooks: `.PromptInput` does auto-resize, Enter-to-submit with
Shift+Enter and IME composition respected, and Backspace-on-empty; it also
writes dropped and pasted files onto the composer's hidden file input through a
`DataTransfer` so an ordinary `phx-change` upload sees them. `.SpeechInput`
does the Web Speech API with the `MediaRecorder` fallback. `.MicSelector` fills
a native `<select>` from `enumerateDevices`, which is why that element carries
`phx-update="ignore"`. Radix's Select, Dialog, DropdownMenu, HoverCard, Command,
Collapsible, and ScrollArea have no port: the selects are native `<select>`s,
the menus are `UI.menu/1` native popovers, the queue section is `<details>`, and
the tooltip is a `title` attribute, because none of those primitives is vendored
into this app's CSS bundle and adding one would be a second component system.

Two class substitutions are load-bearing rather than cosmetic. `--accent` is
shadcn's quiet hover surface and OpenAgents' indigo brand colour, so every
`hover:bg-accent` became `hover:bg-muted`; keeping the source class would have
painted indigo on every hover in the composer. And Basecoat declares the `dark:`
variant as `&:is(html.dark *)` while this app themes by `data-theme` on `:root`,
so `dark:bg-input/30`, `dark:hover:bg-accent/50`, `dark:invert`, and
`dark:bg-transparent` would compile to selectors that never match; they are
dropped rather than left as noise. The shadcn `input-group` primitive is not on
`app.css`'s import list, so its structure lives in the module as the same
utilities it composes, which is what the port asked for anyway.

Every `lucide-react` glyph maps onto the vendored Apps SDK set, so `docs/ICONS.md`
gains no Heroicons fallback entry. The composer form is
`Phoenix.Component.form/1` on a `to_form/2` assign with a required DOM id, and
the textarea takes a `Phoenix.HTML.FormField`; it renders `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 as a direct flex child.

Nothing renders these yet. The catalog entries and the chat rewire land after
all four batches do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016o8HwTaqLKEWCHTjsjFtrB
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • added lib/openagents_web/components/ai/prompt_input.ex
  • added test/openagents_web/components/ai/prompt_input_test.exs

Diff

2 files changed, +2481 -0

lib/openagents_web/components/ai/prompt_input.ex added +1876

@@ -0,0 +1,1876 @@

1
defmodule OpenAgentsWeb.AI.PromptInput do
2
  @moduledoc """
3
  The composer: everything between a reader's cursor and a submitted turn.
4
5
  Ported from Vercel's AI Elements (MIT) — `prompt-input.tsx`, `attachments.tsx`,
6
  `speech-input.tsx`, `mic-selector.tsx`, `model-selector.tsx`, and `queue.tsx`.
7
  The Tailwind is the point of the port and is carried across close to
8
  verbatim; the React around it is not. Four things changed on the way, and
9
  each one is a rule of this repo rather than a preference:
10
11
    * **No React, no Radix, no client state library.** The source keeps input
12
      text, attachment lists, recording state, and menu state in hooks and
13
      contexts. Here the caller owns all of it: components take what to draw
14
      and emit events. The three behaviors markup cannot express — auto-resize
15
      with Enter-to-submit, microphone capture, and incremental filtering — are
16
      colocated LiveView hooks, per `AGENTS.md`.
17
18
    * **Forms are Phoenix's.** `prompt_input/1` is `Phoenix.Component.form/1`
19
      driven by a `to_form/2` assign with a required DOM id, and
20
      `prompt_input_textarea/1` takes a `Phoenix.HTML.FormField`. It renders
21
      `OpenAgentsWeb.UI.textarea/1` rather than `UI.input/1` because `input/1`
22
      wraps its control in a `.field` with a bottom margin, and the input group
23
      needs the control to be a direct flex child.
24
25
    * **`bg-accent` means something else here.** In shadcn, `--accent` is a
26
      quiet hover surface. In OpenAgents it is the indigo brand color (see the
27
      deliberate name collision noted in `assets/css/app.css`), so every
28
      `hover:bg-accent` in the source is `hover:bg-muted` here, and
29
      `hover:text-accent-foreground` is `hover:text-foreground`. Keeping the
30
      source class would have painted indigo on every hover.
31
32
    * **`dark:` is dead in this bundle.** Basecoat declares the variant as
33
      `&:is(html.dark *)`, and this app themes by `data-theme` on `:root`, so
34
      every `dark:` utility in the source would compile to a selector that
35
      never matches. They are dropped rather than left as noise; the palette
36
      already inverts through the token ladder.
37
38
  Icons go through `OpenAgentsWeb.UI.icon/1` and the vendored Apps SDK set
39
  only. The `lucide-react` glyphs map as: `CornerDownLeft` to
40
  `arrow-curved-left`, `Square` to `stop`, `X` to `x`, `Spinner` to `spin`,
41
  `Plus` to `plus`, `Image` to `image-square`, `Monitor` to `desktop`, `Mic` to
42
  `mic`, `Paperclip` to `paperclip`, `FileText` to `file-document`, `Globe` to
43
  `globe`, `Music2` to `music`, `Video` to `video`, `ChevronDown` to
44
  `chevron-down`, and `ChevronsUpDown` to `chevron-up-down`. No Heroicons
45
  fallback was needed, so `docs/ICONS.md` gains no inventory entry.
46
  """
47
48
  use Phoenix.Component
49
50
  alias OpenAgentsWeb.UI
51
52
  # ── Shared class recipes ──────────────────────────────────────────────────
53
  # The shadcn `input-group` primitive is not vendored into this app's CSS
54
  # bundle (`assets/css/app.css` imports Basecoat components one at a time and
55
  # `input-group` is not on the list), so its structure lives here as the same
56
  # utilities the primitive composes. That is what the port asked for anyway.
57
58
  @input_group """
59
  group/input-group relative flex w-full min-w-0 items-center rounded-md border \
60
  border-input shadow-xs outline-none transition-[color,box-shadow] \
61
  h-9 has-[>textarea]:h-auto \
62
  has-[>[data-align=inline-start]]:[&>input]:pl-2 \
63
  has-[>[data-align=inline-end]]:[&>input]:pr-2 \
64
  has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col \
65
  has-[>[data-align=block-start]]:[&>input]:pb-3 \
66
  has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col \
67
  has-[>[data-align=block-end]]:[&>input]:pt-3 \
68
  has-[[data-slot=input-group-control]:focus-visible]:border-ring \
69
  has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 \
70
  has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] \
71
  has-[[data-slot][aria-invalid=true]]:border-destructive \
72
  has-[[data-slot][aria-invalid=true]]:ring-destructive/20\
73
  """
74
75
  @addon """
76
  flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 \
77
  text-sm font-medium text-muted-foreground \
78
  [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] \
79
  group-data-[disabled=true]/input-group:opacity-50 \
80
  order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 \
81
  group-has-[>input]/input-group:pb-2.5\
82
  """
83
84
  @button_base "flex min-w-0 items-center gap-2 text-sm shadow-none"
85
86
  @command_item """
87
  flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 \
88
  text-sm outline-none hover:bg-muted hover:text-foreground \
89
  aria-selected:bg-muted aria-selected:text-foreground \
90
  data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 \
91
  [&>svg:not([class*='size-'])]:size-4\
92
  """
93
94
  @statuses [:ready, :submitted, :streaming, :error]
95
  @variants [:grid, :inline, :list]
96
  @sizes [:xs, :sm, :icon_xs, :icon_sm]
97
98
  # ── PromptInput: the form shell ───────────────────────────────────────────
99
100
  @doc """
101
  The composer form: a `Phoenix.Component.form/1` wrapping the input group.
102
103
  The source renders a hidden `<input type="file">` beside the form and drives
104
  it from React context. The same input is rendered here and driven by the
105
  colocated `.PromptInput` hook, which also carries the two behaviors the
106
  source keeps in JavaScript for the same reason: dropped and pasted files are
107
  written onto the file input through a `DataTransfer` so a normal
108
  `phx-change` upload sees them, and drag state is published as `data-dragging`
109
  on the form so the `drop_overlay` slot can appear.
110
111
  `accept`, `multiple`, and the upload name are attributes because the file
112
  transport is the LiveView's business, not this component's.
113
  """
114
  attr :id, :string, required: true
115
  attr :for, :any, required: true, doc: "a `to_form/2` result"
116
  attr :accept, :string, default: nil, doc: ~s(such as "image/*")
117
  attr :multiple, :boolean, default: true
118
  attr :file_input_name, :string, default: nil, doc: "name for the hidden file input"
119
120
  attr :submit_on_enter, :boolean,
121
    default: true,
122
    doc: "Enter submits and Shift+Enter inserts a newline"
123
124
  attr :backspace_event, :string,
125
    default: nil,
126
    doc: """
127
    pushed when Backspace is pressed in an empty textarea, which is how the
128
    source removes the last attachment
129
    """
130
131
  attr :class, :any, default: nil
132
  attr :group_class, :any, default: nil
133
  attr :rest, :global, include: ~w(method action)
134
  slot :inner_block, required: true
135
  slot :drop_overlay, doc: "shown while files are dragged over the composer"
136
137
  def prompt_input(assigns) do
138
    ~H"""
139
    <div class="contents">
140
      <input
141
        type="file"
142
        id={"#{@id}-files"}
143
        name={@file_input_name}
144
        accept={@accept}
145
        multiple={@multiple}
146
        aria-label="Upload files"
147
        title="Upload files"
148
        class="hidden"
149
      />
150
      <.form
151
        for={@for}
152
        id={@id}
153
        class={["group/prompt-input relative w-full", @class]}
154
        phx-hook=".PromptInput"
155
        data-file-input={"#{@id}-files"}
156
        data-submit-on-enter={to_string(@submit_on_enter)}
157
        data-backspace-event={@backspace_event}
158
        data-dragging="false"
159
        {@rest}
160
      >
161
        <div
162
          role="group"
163
          data-slot="input-group"
164
          class={[input_group(), "overflow-hidden", @group_class]}
165
        >
166
          {render_slot(@inner_block)}
167
        </div>
168
        <div
169
          :if={@drop_overlay != []}
170
          aria-hidden="true"
171
          class={[
172
            "pointer-events-none absolute inset-0 hidden items-center justify-center",
173
            "rounded-md bg-background/80 text-muted-foreground text-sm backdrop-blur-sm",
174
            "group-data-[dragging=true]/prompt-input:flex"
175
          ]}
176
        >
177
          {render_slot(@drop_overlay)}
178
        </div>
179
      </.form>
180
    </div>
181
    <script :type={Phoenix.LiveView.ColocatedHook} name=".PromptInput">
182
      export default {
183
        mounted() {
184
          this.textarea = this.el.querySelector("textarea[data-slot='input-group-control']")
185
          this.fileInput = document.getElementById(this.el.dataset.fileInput)
186
          this.composing = false
187
          this.dragDepth = 0
188
189
          this.resize = () => {
190
            const el = this.textarea
191
            if (!el) return
192
            // `field-sizing-content` already does this where it is supported.
193
            // This keeps the control honest everywhere else, and the reset to
194
            // "auto" must run before scrollHeight is read or the box grows
195
            // without ever shrinking again.
196
            el.style.height = "auto"
197
            el.style.height = `${el.scrollHeight}px`
198
          }
199
200
          this.onCompositionStart = () => { this.composing = true }
201
          this.onCompositionEnd = () => { this.composing = false }
202
203
          this.onKeyDown = (event) => {
204
            if (event.key === "Enter" && this.el.dataset.submitOnEnter === "true") {
205
              if (this.composing || event.isComposing || event.shiftKey) return
206
              event.preventDefault()
207
              const submit = this.el.querySelector("button[type='submit']")
208
              if (submit && submit.disabled) return
209
              this.el.requestSubmit()
210
              return
211
            }
212
213
            const backspaceEvent = this.el.dataset.backspaceEvent
214
            if (event.key === "Backspace" && backspaceEvent && event.currentTarget.value === "") {
215
              event.preventDefault()
216
              this.pushEvent(backspaceEvent, {})
217
            }
218
          }
219
220
          this.adopt = (fileList) => {
221
            if (!this.fileInput || !fileList || fileList.length === 0) return false
222
            const transfer = new DataTransfer()
223
            if (this.fileInput.multiple) {
224
              for (const file of this.fileInput.files) transfer.items.add(file)
225
            }
226
            for (const file of fileList) transfer.items.add(file)
227
            this.fileInput.files = transfer.files
228
            this.fileInput.dispatchEvent(new Event("input", { bubbles: true }))
229
            this.fileInput.dispatchEvent(new Event("change", { bubbles: true }))
230
            return true
231
          }
232
233
          this.onPaste = (event) => {
234
            const files = []
235
            for (const item of event.clipboardData?.items ?? []) {
236
              if (item.kind !== "file") continue
237
              const file = item.getAsFile()
238
              if (file) files.push(file)
239
            }
240
            if (files.length > 0 && this.adopt(files)) event.preventDefault()
241
          }
242
243
          this.onDragEnter = (event) => {
244
            if (!event.dataTransfer?.types?.includes("Files")) return
245
            this.dragDepth += 1
246
            this.el.dataset.dragging = "true"
247
          }
248
249
          this.onDragOver = (event) => {
250
            if (event.dataTransfer?.types?.includes("Files")) event.preventDefault()
251
          }
252
253
          this.onDragLeave = () => {
254
            this.dragDepth = Math.max(0, this.dragDepth - 1)
255
            if (this.dragDepth === 0) this.el.dataset.dragging = "false"
256
          }
257
258
          this.onDrop = (event) => {
259
            if (!event.dataTransfer?.types?.includes("Files")) return
260
            event.preventDefault()
261
            this.dragDepth = 0
262
            this.el.dataset.dragging = "false"
263
            this.adopt(event.dataTransfer.files)
264
          }
265
266
          if (this.textarea) {
267
            this.textarea.addEventListener("input", this.resize)
268
            this.textarea.addEventListener("keydown", this.onKeyDown)
269
            this.textarea.addEventListener("paste", this.onPaste)
270
            this.textarea.addEventListener("compositionstart", this.onCompositionStart)
271
            this.textarea.addEventListener("compositionend", this.onCompositionEnd)
272
            this.resize()
273
          }
274
          this.el.addEventListener("dragenter", this.onDragEnter)
275
          this.el.addEventListener("dragover", this.onDragOver)
276
          this.el.addEventListener("dragleave", this.onDragLeave)
277
          this.el.addEventListener("drop", this.onDrop)
278
        },
279
280
        updated() { this.resize() },
281
282
        destroyed() {
283
          if (this.textarea) {
284
            this.textarea.removeEventListener("input", this.resize)
285
            this.textarea.removeEventListener("keydown", this.onKeyDown)
286
            this.textarea.removeEventListener("paste", this.onPaste)
287
            this.textarea.removeEventListener("compositionstart", this.onCompositionStart)
288
            this.textarea.removeEventListener("compositionend", this.onCompositionEnd)
289
          }
290
          this.el.removeEventListener("dragenter", this.onDragEnter)
291
          this.el.removeEventListener("dragover", this.onDragOver)
292
          this.el.removeEventListener("dragleave", this.onDragLeave)
293
          this.el.removeEventListener("drop", this.onDrop)
294
        },
295
      }
296
    </script>
297
    """
298
  end
299
300
  @doc """
301
  A transparent grouping wrapper, so a caller can compose the composer's parts
302
  in one block without adding a box to the flex layout.
303
  """
304
  attr :class, :any, default: nil
305
  attr :rest, :global
306
  slot :inner_block, required: true
307
308
  def prompt_input_body(assigns) do
309
    ~H"""
310
    <div class={["contents", @class]} {@rest}>{render_slot(@inner_block)}</div>
311
    """
312
  end
313
314
  @doc """
315
  The message control.
316
317
  Takes a `Phoenix.HTML.FormField` so the id, name, and value come from the
318
  form the shell is already driving. The class strip is the source's
319
  `field-sizing-content max-h-48 min-h-16` on top of the input-group control
320
  recipe, which flattens the vendored `.textarea` border, radius, background,
321
  and focus ring — in an input group the *group* carries all four.
322
  """
323
  attr :id, :string, default: nil
324
  attr :field, Phoenix.HTML.FormField, default: nil
325
  attr :name, :string, default: nil
326
  attr :value, :string, default: nil
327
  attr :placeholder, :string, default: "What would you like to know?"
328
  attr :class, :any, default: nil
329
  attr :rest, :global, include: ~w(autocomplete disabled maxlength readonly required rows)
330
331
  def prompt_input_textarea(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
332
    assigns
333
    |> assign(:field, nil)
334
    |> assign(:id, assigns.id || field.id)
335
    |> assign(:name, field.name)
336
    |> assign(:value, Phoenix.HTML.Form.normalize_value("textarea", field.value))
337
    |> prompt_input_textarea()
338
  end
339
340
  def prompt_input_textarea(assigns) do
341
    ~H"""
342
    <UI.textarea
343
      id={@id}
344
      name={@name}
345
      value={@value}
346
      placeholder={@placeholder}
347
      data-slot="input-group-control"
348
      class={[
349
        "field-sizing-content max-h-48 min-h-16",
350
        "flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none",
351
        "focus-visible:ring-0",
352
        @class
353
      ]}
354
      {@rest}
355
    />
356
    """
357
  end
358
359
  @doc """
360
  The strip above the control. Attachments live here.
361
362
  The source aligns this addon `block-end` and then reorders it with
363
  `order-first`, which is the shape kept below: the input group's flex-column
364
  branch keys off `data-align`, and `order-first` is what actually lifts it.
365
  """
366
  attr :class, :any, default: nil
367
  attr :rest, :global
368
  slot :inner_block, required: true
369
370
  def prompt_input_header(assigns) do
371
    ~H"""
372
    <div
373
      role="group"
374
      data-slot="input-group-addon"
375
      data-align="block-end"
376
      class={[addon(), "order-first flex-wrap gap-1", @class]}
377
      {@rest}
378
    >
379
      {render_slot(@inner_block)}
380
    </div>
381
    """
382
  end
383
384
  @doc "The strip below the control: tools on one side, submit on the other."
385
  attr :class, :any, default: nil
386
  attr :rest, :global
387
  slot :inner_block, required: true
388
389
  def prompt_input_footer(assigns) do
390
    ~H"""
391
    <div
392
      role="group"
393
      data-slot="input-group-addon"
394
      data-align="block-end"
395
      class={[addon(), "justify-between gap-1", @class]}
396
      {@rest}
397
    >
398
      {render_slot(@inner_block)}
399
    </div>
400
    """
401
  end
402
403
  @doc """
404
  The composer toolbar. The same shape as `prompt_input_footer/1`, which is the
405
  name the current source gives it; `toolbar` is kept because that is what the
406
  part is called everywhere else in AI Elements.
407
  """
408
  attr :class, :any, default: nil
409
  attr :rest, :global
410
  slot :inner_block, required: true
411
412
  def prompt_input_toolbar(assigns) do
413
    ~H"""
414
    <.prompt_input_footer class={@class} {@rest}>
415
      {render_slot(@inner_block)}
416
    </.prompt_input_footer>
417
    """
418
  end
419
420
  @doc "A run of controls inside the toolbar."
421
  attr :class, :any, default: nil
422
  attr :rest, :global
423
  slot :inner_block, required: true
424
425
  def prompt_input_tools(assigns) do
426
    ~H"""
427
    <div class={["flex min-w-0 items-center gap-1", @class]} {@rest}>
428
      {render_slot(@inner_block)}
429
    </div>
430
    """
431
  end
432
433
  @doc """
434
  A composer control.
435
436
  Renders `OpenAgentsWeb.UI.button/1` with the source's input-group button
437
  sizing on top, so the product's own button grammar keeps the color and focus
438
  treatment while the composer keeps AI Elements' geometry.
439
440
  The source wraps this in a Radix tooltip. There is no Radix here and the repo
441
  has no tooltip primitive, so `tooltip` becomes the native `title` attribute:
442
  the same text, reachable by pointer but not by keyboard. An icon-only control
443
  still needs its own `aria-label`.
444
  """
445
  attr :variant, :atom,
446
    values: [:primary, :secondary, :outline, :ghost, :destructive],
447
    default: :ghost
448
449
  attr :size, :atom, values: @sizes, default: :icon_sm
450
  attr :tooltip, :string, default: nil
451
  attr :type, :string, default: "button"
452
  attr :class, :any, default: nil
453
  attr :rest, :global, include: ~w(disabled form name value popovertarget popovertargetaction)
454
  slot :inner_block, required: true
455
456
  def prompt_input_button(assigns) do
457
    ~H"""
458
    <UI.button
459
      type={@type}
460
      variant={@variant}
461
      title={@tooltip}
462
      class={[button_base(), button_size_class(@size), @class]}
463
      {@rest}
464
    >
465
      {render_slot(@inner_block)}
466
    </UI.button>
467
    """
468
  end
469
470
  @doc """
471
  The send control, and the stop control while a turn is in flight.
472
473
  `status` is the caller's: `:ready`, `:submitted`, `:streaming`, or `:error` —
474
  the same four `ChatStatus` values the source reads. `:submitted` and
475
  `:streaming` are "generating", which renames the control to **Stop**; given
476
  `on_stop`, the control also stops being a submit button, exactly as the
477
  source does, so pressing it aborts rather than sending a second turn.
478
  """
479
  attr :id, :string, default: nil
480
  attr :status, :atom, values: @statuses, default: :ready
481
  attr :on_stop, :any, default: nil, doc: "a `Phoenix.LiveView.JS` command or event name"
482
  attr :label, :string, default: nil, doc: "overrides the derived accessible name"
483
  attr :size, :atom, values: @sizes, default: :icon_sm
484
  attr :class, :any, default: nil
485
  attr :rest, :global, include: ~w(disabled form name value)
486
  slot :inner_block
487
488
  def prompt_input_submit(assigns) do
489
    generating? = assigns.status in [:submitted, :streaming]
490
491
    assigns =
492
      assigns
493
      |> assign(:generating?, generating?)
494
      |> assign(:stopping?, generating? and not is_nil(assigns.on_stop))
495
496
    ~H"""
497
    <UI.button
498
      id={@id}
499
      type={if @stopping?, do: "button", else: "submit"}
500
      variant={:primary}
501
      phx-click={@stopping? && @on_stop}
502
      aria-label={@label || if(@generating?, do: "Stop", else: "Submit")}
503
      data-status={@status}
504
      class={[button_base(), button_size_class(@size), "justify-center", @class]}
505
      {@rest}
506
    >
507
      <%= if @inner_block != [] do %>
508
        {render_slot(@inner_block)}
509
      <% else %>
510
        <UI.icon :if={@status == :ready} name="arrow-curved-left" class="size-4" />
511
        <UI.icon :if={@status == :submitted} name="spin" class="size-4 animate-spin" />
512
        <UI.icon :if={@status == :streaming} name="stop" class="size-4" />
513
        <UI.icon :if={@status == :error} name="x" class="size-4" />
514
      <% end %>
515
    </UI.button>
516
    """
517
  end
518
519
  # ── Action menu ───────────────────────────────────────────────────────────
520
521
  @doc """
522
  The composer's add menu.
523
524
  A native `popover`, the same bounded disclosure `OpenAgentsWeb.UI.menu/1`
525
  documents, rather than a port of Radix's dropdown. Render the trigger with
526
  `prompt_input_action_menu_trigger/1` and give it this menu's id.
527
  """
528
  attr :id, :string, required: true
529
  attr :label, :string, default: "Composer actions"
530
  attr :class, :any, default: nil
531
  attr :rest, :global
532
  slot :inner_block, required: true
533
534
  def prompt_input_action_menu(assigns) do
535
    ~H"""
536
    <UI.menu id={@id} label={@label} class={["min-w-56 p-1", @class]} {@rest}>
537
      {render_slot(@inner_block)}
538
    </UI.menu>
539
    """
540
  end
541
542
  @doc "Opens the action menu. Defaults to the source's plus glyph."
543
  attr :id, :string, default: nil
544
  attr :menu, :string, required: true, doc: "the `prompt_input_action_menu/1` id"
545
  attr :label, :string, default: "Open composer actions"
546
  attr :class, :any, default: nil
547
  attr :rest, :global, include: ~w(disabled)
548
  slot :inner_block
549
550
  def prompt_input_action_menu_trigger(assigns) do
551
    ~H"""
552
    <.prompt_input_button
553
      id={@id}
554
      aria-label={@label}
555
      popovertarget={@menu}
556
      popovertargetaction="toggle"
557
      class={@class}
558
      {@rest}
559
    >
560
      <%= if @inner_block != [] do %>
561
        {render_slot(@inner_block)}
562
      <% else %>
563
        <UI.icon name="plus" class="size-4" />
564
      <% end %>
565
    </.prompt_input_button>
566
    """
567
  end
568
569
  @doc "The action menu's own body, for grouping items."
570
  attr :class, :any, default: nil
571
  attr :rest, :global
572
  slot :inner_block, required: true
573
574
  def prompt_input_action_menu_content(assigns) do
575
    ~H"""
576
    <div class={["flex flex-col gap-0.5", @class]} {@rest}>{render_slot(@inner_block)}</div>
577
    """
578
  end
579
580
  @doc "One action in the menu."
581
  attr :id, :string, default: nil
582
  attr :class, :any, default: nil
583
  attr :rest, :global, include: ~w(disabled form name value popovertarget popovertargetaction)
584
  slot :inner_block, required: true
585
586
  def prompt_input_action_menu_item(assigns) do
587
    ~H"""
588
    <button id={@id} type="button" role="menuitem" class={[command_item(), @class]} {@rest}>
589
      {render_slot(@inner_block)}
590
    </button>
591
    """
592
  end
593
594
  @doc """
595
  The menu item that opens the file dialog.
596
597
  The source calls `attachments.openFileDialog()` through React context. Here
598
  the item is a label pointing at the composer's hidden file input, so the
599
  browser opens the dialog and no JavaScript is involved at all.
600
  """
601
  attr :id, :string, default: nil
602
  attr :for, :string, required: true, doc: "the `prompt_input/1` id"
603
  attr :label, :string, default: "Add photos or files"
604
  attr :class, :any, default: nil
605
  attr :rest, :global
606
607
  def prompt_input_action_add_attachments(assigns) do
608
    ~H"""
609
    <label
610
      id={@id}
611
      role="menuitem"
612
      tabindex="0"
613
      for={"#{@for}-files"}
614
      class={[command_item(), "cursor-pointer", @class]}
615
      {@rest}
616
    >
617
      <UI.icon name="image-square" class="mr-2 size-4" />{@label}
618
    </label>
619
    """
620
  end
621
622
  @doc """
623
  The menu item that captures the screen.
624
625
  Screen capture is `getDisplayMedia`, which only a user gesture on the client
626
  can start, so the item carries `phx-click` for the LiveView to hear and the
627
  capture stays the caller's to wire. The source's inline canvas capture is
628
  deliberately not reimplemented: it produces a `File`, and file transport
629
  belongs to the LiveView's uploader.
630
  """
631
  attr :id, :string, default: nil
632
  attr :label, :string, default: "Take screenshot"
633
  attr :class, :any, default: nil
634
  attr :rest, :global, include: ~w(disabled)
635
636
  def prompt_input_action_add_screenshot(assigns) do
637
    ~H"""
638
    <button id={@id} type="button" role="menuitem" class={[command_item(), @class]} {@rest}>
639
      <UI.icon name="desktop" class="mr-2 size-4" />{@label}
640
    </button>
641
    """
642
  end
643
644
  # ── Model select, in the composer toolbar ─────────────────────────────────
645
646
  @doc """
647
  The model picker that sits in the composer toolbar.
648
649
  A native `<select>`. Radix's Select splits into Trigger, Value, Content, and
650
  Item so that a `<div>` can pretend to be a control; a real `<select>` is one
651
  element that already has the keyboard behavior, the typeahead, and the mobile
652
  treatment, so the split has nothing left to describe. The source's trigger
653
  classes are carried onto it, minus `bg-accent` — see the module note.
654
  """
655
  attr :id, :string, default: nil
656
  attr :name, :string, default: nil
657
  attr :value, :any, default: nil
658
  attr :label, :string, default: "Model"
659
  attr :class, :any, default: nil
660
  attr :rest, :global, include: ~w(disabled form required)
661
  slot :inner_block, required: true
662
663
  def prompt_input_model_select(assigns) do
664
    ~H"""
665
    <select
666
      id={@id}
667
      name={@name}
668
      aria-label={@label}
669
      class={[
670
        "h-8 rounded-md border-none bg-transparent px-2 font-medium text-muted-foreground text-sm",
671
        "shadow-none transition-colors",
672
        "hover:bg-muted hover:text-foreground",
673
        "aria-expanded:bg-muted aria-expanded:text-foreground",
674
        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
675
        @class
676
      ]}
677
      {@rest}
678
    >
679
      {render_slot(@inner_block, @value)}
680
    </select>
681
    """
682
  end
683
684
  @doc "One model in `prompt_input_model_select/1`."
685
  attr :value, :string, required: true
686
  attr :selected, :boolean, default: false
687
  attr :class, :any, default: nil
688
  attr :rest, :global, include: ~w(disabled)
689
  slot :inner_block, required: true
690
691
  def prompt_input_model_select_item(assigns) do
692
    ~H"""
693
    <option value={@value} selected={@selected} class={@class} {@rest}>
694
      {render_slot(@inner_block)}
695
    </option>
696
    """
697
  end
698
699
  # ── Attachments ───────────────────────────────────────────────────────────
700
701
  @doc """
702
  The list of things attached to the next turn.
703
704
  `variant` is the source's: `:grid` for the thumbnail strip above the control,
705
  `:inline` for chips beside it, `:list` for a stacked review. Each child
706
  `attachment/1` must be told the same variant — React passed it down through
707
  context, and HEEx has no context.
708
  """
709
  attr :id, :string, default: nil
710
  attr :variant, :atom, values: @variants, default: :grid
711
  attr :class, :any, default: nil
712
  attr :rest, :global
713
  slot :inner_block, required: true
714
715
  def attachments(assigns) do
716
    ~H"""
717
    <div
718
      id={@id}
719
      data-slot="attachments"
720
      data-variant={@variant}
721
      class={[
722
        "flex items-start",
723
        @variant == :list && "flex-col gap-2",
724
        @variant != :list && "flex-wrap gap-2",
725
        @variant == :grid && "ml-auto w-fit",
726
        @class
727
      ]}
728
      {@rest}
729
    >
730
      {render_slot(@inner_block)}
731
    </div>
732
    """
733
  end
734
735
  @doc "One attachment."
736
  attr :id, :string, default: nil
737
  attr :variant, :atom, values: @variants, default: :grid
738
  attr :class, :any, default: nil
739
  attr :rest, :global
740
  slot :inner_block, required: true
741
742
  def attachment(assigns) do
743
    ~H"""
744
    <div
745
      id={@id}
746
      data-slot="attachment"
747
      data-variant={@variant}
748
      class={[
749
        "group/attachment relative",
750
        @variant == :grid && "size-24 overflow-hidden rounded-lg",
751
        @variant == :inline &&
752
          [
753
            "flex h-8 cursor-pointer select-none items-center gap-1.5",
754
            "rounded-md border border-border px-1.5",
755
            "font-medium text-sm transition-all",
756
            "hover:bg-muted hover:text-foreground"
757
          ],
758
        @variant == :list &&
759
          [
760
            "flex w-full items-center gap-3 rounded-lg border p-3",
761
            "hover:bg-muted/50"
762
          ],
763
        @class
764
      ]}
765
      {@rest}
766
    >
767
      {render_slot(@inner_block)}
768
    </div>
769
    """
770
  end
771
772
  @doc """
773
  The attachment's thumbnail.
774
775
  An image or a video poster when `src` is given and the media category can
776
  show one; otherwise the glyph for the category, which is how the source
777
  distinguishes an audio file from a document at a glance.
778
  """
779
  attr :variant, :atom, values: @variants, default: :grid
780
781
  attr :media_category, :atom,
782
    values: [:image, :video, :audio, :document, :source, :unknown],
783
    default: :unknown
784
785
  attr :src, :string, default: nil
786
  attr :filename, :string, default: nil
787
  attr :class, :any, default: nil
788
  attr :rest, :global
789
790
  def attachment_preview(assigns) do
791
    ~H"""
792
    <div
793
      data-slot="attachment-preview"
794
      class={[
795
        "flex shrink-0 items-center justify-center overflow-hidden",
796
        @variant == :grid && "size-full bg-muted",
797
        @variant == :inline && "size-5 rounded bg-background",
798
        @variant == :list && "size-12 rounded bg-muted",
799
        @class
800
      ]}
801
      {@rest}
802
    >
803
      <img
804
        :if={@media_category == :image and @src}
805
        src={@src}
806
        alt={@filename || "Image"}
807
        width={if @variant == :grid, do: "96", else: "20"}
808
        height={if @variant == :grid, do: "96", else: "20"}
809
        class={["size-full object-cover", @variant != :grid && "rounded"]}
810
      />
811
      <video
812
        :if={@media_category == :video and @src}
813
        src={@src}
814
        muted
815
        class="size-full object-cover"
816
      />
817
      <UI.icon
818
        :if={is_nil(@src) or @media_category not in [:image, :video]}
819
        name={media_icon(@media_category)}
820
        class={[if(@variant == :inline, do: "size-3", else: "size-4"), "text-muted-foreground"]}
821
      />
822
    </div>
823
    """
824
  end
825
826
  @doc """
827
  The attachment's name, and optionally its media type.
828
829
  Renders nothing in the `:grid` variant, where the thumbnail is the whole
830
  item — the same early return the source makes.
831
  """
832
  attr :label, :string, required: true
833
  attr :variant, :atom, values: @variants, default: :inline
834
  attr :media_type, :string, default: nil
835
  attr :class, :any, default: nil
836
  attr :rest, :global
837
838
  def attachment_info(assigns) do
839
    ~H"""
840
    <div
841
      :if={@variant != :grid}
842
      data-slot="attachment-info"
843
      class={["min-w-0 flex-1", @class]}
844
      {@rest}
845
    >
846
      <span class="block truncate">{@label}</span>
847
      <span :if={@media_type} class="block truncate text-muted-foreground text-xs">
848
        {@media_type}
849
      </span>
850
    </div>
851
    """
852
  end
853
854
  @doc """
855
  Removes one attachment.
856
857
  In `:grid` and `:inline` the control is revealed on hover, as in the source.
858
  That hides a control from a pointer user until they look for it, and it must
859
  not hide it from anyone else, so the visible label stays `sr-only`, the
860
  button keeps its own `aria-label`, and `focus-visible` reveals it as well.
861
  """
862
  attr :id, :string, default: nil
863
  attr :variant, :atom, values: @variants, default: :grid
864
  attr :label, :string, default: "Remove"
865
  attr :class, :any, default: nil
866
  attr :rest, :global, include: ~w(disabled form name value)
867
  slot :inner_block
868
869
  def attachment_remove(assigns) do
870
    ~H"""
871
    <UI.button
872
      id={@id}
873
      type="button"
874
      variant={:ghost}
875
      aria-label={@label}
876
      data-slot="attachment-remove"
877
      class={[
878
        @variant == :grid &&
879
          [
880
            "absolute top-2 right-2 size-6 rounded-full p-0",
881
            "bg-background/80 backdrop-blur-sm",
882
            "opacity-0 transition-opacity group-hover/attachment:opacity-100 focus-visible:opacity-100",
883
            "hover:bg-background",
884
            "[&>svg]:size-3"
885
          ],
886
        @variant == :inline &&
887
          [
888
            "size-5 rounded p-0",
889
            "opacity-0 transition-opacity group-hover/attachment:opacity-100 focus-visible:opacity-100",
890
            "[&>svg]:size-2.5"
891
          ],
892
        @variant == :list && ["size-8 shrink-0 rounded p-0", "[&>svg]:size-4"],
893
        @class
894
      ]}
895
      {@rest}
896
    >
897
      <%= if @inner_block != [] do %>
898
        {render_slot(@inner_block)}
899
      <% else %>
900
        <UI.icon name="x" />
901
      <% end %>
902
      <span class="sr-only">{@label}</span>
903
    </UI.button>
904
    """
905
  end
906
907
  @doc "What stands in for the attachment list when nothing is attached."
908
  attr :id, :string, default: nil
909
  attr :class, :any, default: nil
910
  attr :rest, :global
911
  slot :inner_block
912
913
  def attachment_empty(assigns) do
914
    ~H"""
915
    <div
916
      id={@id}
917
      data-slot="attachment-empty"
918
      class={["flex items-center justify-center p-4 text-muted-foreground text-sm", @class]}
919
      {@rest}
920
    >
921
      <%= if @inner_block != [] do %>
922
        {render_slot(@inner_block)}
923
      <% else %>
924
        No attachments
925
      <% end %>
926
    </div>
927
    """
928
  end
929
930
  # ── Speech input ──────────────────────────────────────────────────────────
931
932
  @doc """
933
  Push-to-talk.
934
935
  The source detects the Web Speech API, falls back to `MediaRecorder`, and
936
  disables itself when neither exists. All three live in the colocated
937
  `.SpeechInput` hook, which publishes what it found as `data-mode` and its
938
  state as `data-recording` and `data-processing` on the wrapper, so the whole
939
  visual treatment — including the three staggered ping rings — is CSS keyed
940
  off attributes rather than a React render.
941
942
  Recognized text is pushed to the LiveView as `transcript_event` with a
943
  `"text"` key. Recorded audio cannot travel in a LiveView event, so in the
944
  `MediaRecorder` fallback the hook writes the blob onto the file input named
945
  by `audio_input` and lets the caller's uploader carry it. Without one, the
946
  control disables itself in that fallback, which is what the source does when
947
  no `onAudioRecorded` callback is given.
948
  """
949
  attr :id, :string, required: true
950
  attr :transcript_event, :string, required: true
951
  attr :audio_input, :string, default: nil, doc: "id of a file input for the recorded audio"
952
  attr :lang, :string, default: "en-US"
953
  attr :label, :string, default: "Start voice input"
954
  attr :stop_label, :string, default: "Stop voice input"
955
  attr :class, :any, default: nil
956
  attr :rest, :global, include: ~w(disabled)
957
958
  def speech_input(assigns) do
959
    ~H"""
960
    <div
961
      id={@id}
962
      class="group/speech relative inline-flex items-center justify-center"
963
      phx-hook=".SpeechInput"
964
      data-recording="false"
965
      data-processing="false"
966
      data-lang={@lang}
967
      data-transcript-event={@transcript_event}
968
      data-audio-input={@audio_input}
969
    >
970
      <div
971
        :for={index <- 0..2}
972
        aria-hidden="true"
973
        class={[
974
          "absolute inset-0 hidden animate-ping rounded-full border-2 border-destructive/30",
975
          "group-data-[recording=true]/speech:block"
976
        ]}
977
        style={"animation-delay: #{index * 0.3}s; animation-duration: 2s"}
978
      >
979
      </div>
980
      <UI.button
981
        id={"#{@id}-button"}
982
        type="button"
983
        variant={:primary}
984
        aria-label={@label}
985
        data-label={@label}
986
        data-stop-label={@stop_label}
987
        class={[
988
          "relative z-10 size-8 justify-center rounded-full p-0 transition-all duration-300",
989
          "bg-primary text-primary-foreground hover:bg-primary/80 hover:text-primary-foreground",
990
          "group-data-[recording=true]/speech:bg-destructive",
991
          "group-data-[recording=true]/speech:text-white",
992
          "group-data-[recording=true]/speech:hover:bg-destructive/80",
993
          @class
994
        ]}
995
        {@rest}
996
      >
997
        <UI.icon
998
          name="spin"
999
          class="hidden size-4 animate-spin group-data-[processing=true]/speech:block"
1000
        />
1001
        <UI.icon
1002
          name="stop"
1003
          class={[
1004
            "hidden size-4 group-data-[recording=true]/speech:block",
1005
            "group-data-[processing=true]/speech:hidden"
1006
          ]}
1007
        />
1008
        <UI.icon
1009
          name="mic"
1010
          class={[
1011
            "size-4 group-data-[recording=true]/speech:hidden",
1012
            "group-data-[processing=true]/speech:hidden"
1013
          ]}
1014
        />
1015
      </UI.button>
1016
    </div>
1017
    <script :type={Phoenix.LiveView.ColocatedHook} name=".SpeechInput">
1018
      const detectMode = () => {
1019
        if (typeof window === "undefined") return "none"
1020
        if ("SpeechRecognition" in window || "webkitSpeechRecognition" in window) {
1021
          return "speech-recognition"
1022
        }
1023
        if ("MediaRecorder" in window && "mediaDevices" in navigator) return "media-recorder"
1024
        return "none"
1025
      }
1026
1027
      export default {
1028
        mounted() {
1029
          this.button = this.el.querySelector("button")
1030
          this.mode = detectMode()
1031
          this.el.dataset.mode = this.mode
1032
          this.chunks = []
1033
1034
          const audioInput = () =>
1035
            this.el.dataset.audioInput ? document.getElementById(this.el.dataset.audioInput) : null
1036
1037
          if (this.mode === "none" || (this.mode === "media-recorder" && !audioInput())) {
1038
            this.button.disabled = true
1039
            return
1040
          }
1041
1042
          this.setRecording = (recording) => {
1043
            this.el.dataset.recording = recording ? "true" : "false"
1044
            this.button.setAttribute(
1045
              "aria-label",
1046
              recording ? this.button.dataset.stopLabel : this.button.dataset.label
1047
            )
1048
          }
1049
1050
          if (this.mode === "speech-recognition") {
1051
            const Recognition = window.SpeechRecognition || window.webkitSpeechRecognition
1052
            this.recognition = new Recognition()
1053
            this.recognition.continuous = true
1054
            this.recognition.interimResults = true
1055
            this.recognition.lang = this.el.dataset.lang
1056
1057
            this.recognition.addEventListener("start", () => this.setRecording(true))
1058
            this.recognition.addEventListener("end", () => this.setRecording(false))
1059
            this.recognition.addEventListener("error", () => this.setRecording(false))
1060
            this.recognition.addEventListener("result", (event) => {
1061
              let text = ""
1062
              for (let i = event.resultIndex; i < event.results.length; i += 1) {
1063
                const result = event.results[i]
1064
                if (result.isFinal) text += result[0]?.transcript ?? ""
1065
              }
1066
              if (text) this.pushEvent(this.el.dataset.transcriptEvent, { text })
1067
            })
1068
          }
1069
1070
          this.startRecorder = async () => {
1071
            try {
1072
              this.stream = await navigator.mediaDevices.getUserMedia({ audio: true })
1073
            } catch {
1074
              this.setRecording(false)
1075
              return
1076
            }
1077
            this.chunks = []
1078
            this.recorder = new MediaRecorder(this.stream)
1079
            this.recorder.addEventListener("dataavailable", (event) => {
1080
              if (event.data.size > 0) this.chunks.push(event.data)
1081
            })
1082
            this.recorder.addEventListener("stop", () => {
1083
              for (const track of this.stream.getTracks()) track.stop()
1084
              this.stream = null
1085
              const blob = new Blob(this.chunks, { type: "audio/webm" })
1086
              const input = audioInput()
1087
              if (blob.size === 0 || !input) return
1088
              this.el.dataset.processing = "true"
1089
              const transfer = new DataTransfer()
1090
              transfer.items.add(new File([blob], "speech.webm", { type: "audio/webm" }))
1091
              input.files = transfer.files
1092
              input.dispatchEvent(new Event("input", { bubbles: true }))
1093
              input.dispatchEvent(new Event("change", { bubbles: true }))
1094
              this.el.dataset.processing = "false"
1095
            })
1096
            this.recorder.start()
1097
            this.setRecording(true)
1098
          }
1099
1100
          this.onClick = () => {
1101
            const recording = this.el.dataset.recording === "true"
1102
            if (this.mode === "speech-recognition") {
1103
              if (recording) { this.recognition.stop() } else { this.recognition.start() }
1104
              return
1105
            }
1106
            if (recording) {
1107
              if (this.recorder && this.recorder.state === "recording") this.recorder.stop()
1108
              this.setRecording(false)
1109
            } else {
1110
              this.startRecorder()
1111
            }
1112
          }
1113
1114
          this.button.addEventListener("click", this.onClick)
1115
        },
1116
1117
        destroyed() {
1118
          if (this.button && this.onClick) this.button.removeEventListener("click", this.onClick)
1119
          if (this.recognition) this.recognition.stop()
1120
          if (this.recorder && this.recorder.state === "recording") this.recorder.stop()
1121
          if (this.stream) { for (const track of this.stream.getTracks()) track.stop() }
1122
        },
1123
      }
1124
    </script>
1125
    """
1126
  end
1127
1128
  # ── Mic selector ──────────────────────────────────────────────────────────
1129
1130
  @doc """
1131
  Chooses the microphone.
1132
1133
  The device list only exists on the client — `enumerateDevices` returns
1134
  nothing useful until microphone permission has been granted, and the source
1135
  requests it when its popover opens. The same sequence is a colocated hook
1136
  here, filling a native `<select>`, so the element carries
1137
  `phx-update="ignore"`: its options are the hook's, and a LiveView patch must
1138
  not discard them.
1139
1140
  Devices the server already knows can still be passed as `mic_selector_item/1`
1141
  children; the hook replaces them once the browser answers.
1142
  """
1143
  attr :id, :string, required: true
1144
  attr :name, :string, default: nil
1145
  attr :label, :string, default: "Microphone"
1146
  attr :placeholder, :string, default: "Select microphone..."
1147
  attr :class, :any, default: nil
1148
  attr :rest, :global, include: ~w(disabled form required)
1149
  slot :inner_block
1150
1151
  def mic_selector(assigns) do
1152
    ~H"""
1153
    <select
1154
      id={@id}
1155
      name={@name}
1156
      aria-label={@label}
1157
      phx-hook=".MicSelector"
1158
      phx-update="ignore"
1159
      data-placeholder={@placeholder}
1160
      class={[
1161
        "h-8 min-w-0 max-w-56 truncate rounded-md border border-input bg-transparent px-2",
1162
        "text-left text-muted-foreground text-sm shadow-none transition-colors",
1163
        "hover:bg-muted hover:text-foreground",
1164
        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
1165
        @class
1166
      ]}
1167
      {@rest}
1168
    >
1169
      <option value="">{@placeholder}</option>
1170
      {render_slot(@inner_block)}
1171
    </select>
1172
    <script :type={Phoenix.LiveView.ColocatedHook} name=".MicSelector">
1173
      // The source strips the trailing hardware id out of a device label and
1174
      // shows it dimmed beside the name. An <option> holds text and nothing
1175
      // else, so the two parts are joined rather than styled apart.
1176
      const deviceIdPattern = /\s*\(([0-9a-f]{4}:[0-9a-f]{4})\)$/i
1177
1178
      export default {
1179
        async mounted() {
1180
          if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
1181
            this.el.disabled = true
1182
            return
1183
          }
1184
1185
          this.load = async () => {
1186
            let devices = []
1187
            try {
1188
              devices = await navigator.mediaDevices.enumerateDevices()
1189
            } catch {
1190
              return
1191
            }
1192
            const inputs = devices.filter((device) => device.kind === "audioinput" && device.label)
1193
            if (inputs.length === 0) return
1194
1195
            const selected = this.el.value
1196
            this.el.replaceChildren()
1197
            this.el.append(new Option(this.el.dataset.placeholder, ""))
1198
            for (const device of inputs) {
1199
              const matches = device.label.match(deviceIdPattern)
1200
              const label = matches
1201
                ? `${device.label.replace(deviceIdPattern, "")} (${matches[1]})`
1202
                : device.label
1203
              this.el.append(new Option(label, device.deviceId))
1204
            }
1205
            if (selected) this.el.value = selected
1206
          }
1207
1208
          this.onDeviceChange = () => this.load()
1209
          navigator.mediaDevices.addEventListener("devicechange", this.onDeviceChange)
1210
          await this.load()
1211
        },
1212
1213
        destroyed() {
1214
          if (this.onDeviceChange) {
1215
            navigator.mediaDevices.removeEventListener("devicechange", this.onDeviceChange)
1216
          }
1217
        },
1218
      }
1219
    </script>
1220
    """
1221
  end
1222
1223
  @doc "One microphone the server already knows about."
1224
  attr :value, :string, required: true
1225
  attr :selected, :boolean, default: false
1226
  attr :class, :any, default: nil
1227
  attr :rest, :global
1228
  slot :inner_block, required: true
1229
1230
  def mic_selector_item(assigns) do
1231
    ~H"""
1232
    <option value={@value} selected={@selected} class={@class} {@rest}>
1233
      {render_slot(@inner_block)}
1234
    </option>
1235
    """
1236
  end
1237
1238
  # ── Model selector, the searchable panel ──────────────────────────────────
1239
1240
  @doc """
1241
  The full model picker: a searchable list in a bounded disclosure.
1242
1243
  The source is a `cmdk` command palette inside a Radix dialog. Neither is
1244
  vendored, so this is `OpenAgentsWeb.UI.menu/1` — a native `popover` — with
1245
  the filtering in a colocated hook that matches each item's `data-value` and
1246
  reveals `model_selector_empty/1` when nothing survives.
1247
  """
1248
  attr :id, :string, required: true
1249
  attr :label, :string, default: "Select a model"
1250
  attr :class, :any, default: nil
1251
  attr :rest, :global
1252
  slot :inner_block, required: true
1253
1254
  def model_selector(assigns) do
1255
    ~H"""
1256
    <UI.menu
1257
      id={@id}
1258
      label={@label}
1259
      class={["w-80 max-w-[90vw] overflow-hidden p-0", @class]}
1260
      phx-hook=".ModelSelectorFilter"
1261
      {@rest}
1262
    >
1263
      {render_slot(@inner_block)}
1264
    </UI.menu>
1265
    <script :type={Phoenix.LiveView.ColocatedHook} name=".ModelSelectorFilter">
1266
      export default {
1267
        mounted() {
1268
          this.input = this.el.querySelector("[data-slot='model-selector-input']")
1269
          if (!this.input) return
1270
1271
          this.filter = () => {
1272
            const query = this.input.value.trim().toLowerCase()
1273
            let matched = 0
1274
            for (const item of this.el.querySelectorAll("[data-slot='model-selector-item']")) {
1275
              const value = (item.dataset.value || item.textContent || "").toLowerCase()
1276
              const hit = query === "" || value.includes(query)
1277
              item.hidden = !hit
1278
              if (hit) matched += 1
1279
            }
1280
            for (const group of this.el.querySelectorAll("[data-slot='model-selector-group']")) {
1281
              const items = [...group.querySelectorAll("[data-slot='model-selector-item']")]
1282
              group.hidden = items.length > 0 && items.every((item) => item.hidden)
1283
            }
1284
            for (const empty of this.el.querySelectorAll("[data-slot='model-selector-empty']")) {
1285
              empty.hidden = matched > 0
1286
            }
1287
          }
1288
1289
          this.input.addEventListener("input", this.filter)
1290
          this.el.addEventListener("toggle", this.filter)
1291
          this.filter()
1292
        },
1293
1294
        destroyed() {
1295
          if (this.input) this.input.removeEventListener("input", this.filter)
1296
          this.el.removeEventListener("toggle", this.filter)
1297
        },
1298
      }
1299
    </script>
1300
    """
1301
  end
1302
1303
  @doc "Opens the model selector."
1304
  attr :id, :string, default: nil
1305
  attr :panel, :string, required: true, doc: "the `model_selector/1` id"
1306
  attr :class, :any, default: nil
1307
  attr :rest, :global, include: ~w(disabled)
1308
  slot :inner_block, required: true
1309
1310
  def model_selector_trigger(assigns) do
1311
    ~H"""
1312
    <.prompt_input_button
1313
      id={@id}
1314
      size={:sm}
1315
      popovertarget={@panel}
1316
      popovertargetaction="toggle"
1317
      class={@class}
1318
      {@rest}
1319
    >
1320
      {render_slot(@inner_block)}
1321
      <UI.icon name="chevron-up-down" class="size-4 shrink-0 text-muted-foreground" />
1322
    </.prompt_input_button>
1323
    """
1324
  end
1325
1326
  @doc "The panel's search field."
1327
  attr :id, :string, default: nil
1328
  attr :placeholder, :string, default: "Search models..."
1329
  attr :class, :any, default: nil
1330
  attr :rest, :global
1331
1332
  def model_selector_input(assigns) do
1333
    ~H"""
1334
    <div class="flex items-center gap-2 border-b border-border px-3">
1335
      <UI.icon name="magnifying-glass-search" class="size-4 shrink-0 text-muted-foreground" />
1336
      <input
1337
        id={@id}
1338
        type="text"
1339
        role="combobox"
1340
        aria-expanded="true"
1341
        aria-label={@placeholder}
1342
        autocomplete="off"
1343
        placeholder={@placeholder}
1344
        data-slot="model-selector-input"
1345
        class={[
1346
          "h-auto flex-1 border-0 bg-transparent py-3.5 text-sm outline-none",
1347
          "placeholder:text-muted-foreground",
1348
          @class
1349
        ]}
1350
        {@rest}
1351
      />
1352
    </div>
1353
    """
1354
  end
1355
1356
  @doc "The scrolling list of models."
1357
  attr :id, :string, default: nil
1358
  attr :class, :any, default: nil
1359
  attr :rest, :global
1360
  slot :inner_block, required: true
1361
1362
  def model_selector_list(assigns) do
1363
    ~H"""
1364
    <div
1365
      id={@id}
1366
      role="listbox"
1367
      class={["max-h-72 scroll-py-1 overflow-y-auto overflow-x-hidden p-1", @class]}
1368
      {@rest}
1369
    >
1370
      {render_slot(@inner_block)}
1371
    </div>
1372
    """
1373
  end
1374
1375
  @doc "Shown when the search matches nothing."
1376
  attr :id, :string, default: nil
1377
  attr :class, :any, default: nil
1378
  attr :rest, :global
1379
  slot :inner_block
1380
1381
  def model_selector_empty(assigns) do
1382
    ~H"""
1383
    <div
1384
      id={@id}
1385
      data-slot="model-selector-empty"
1386
      class={["py-6 text-center text-muted-foreground text-sm", @class]}
1387
      {@rest}
1388
    >
1389
      <%= if @inner_block != [] do %>
1390
        {render_slot(@inner_block)}
1391
      <% else %>
1392
        No models found.
1393
      <% end %>
1394
    </div>
1395
    """
1396
  end
1397
1398
  @doc "A named run of models, such as one provider's."
1399
  attr :id, :string, default: nil
1400
  attr :heading, :string, default: nil
1401
  attr :class, :any, default: nil
1402
  attr :rest, :global
1403
  slot :inner_block, required: true
1404
1405
  def model_selector_group(assigns) do
1406
    ~H"""
1407
    <div
1408
      id={@id}
1409
      role="group"
1410
      aria-label={@heading}
1411
      data-slot="model-selector-group"
1412
      class={["overflow-hidden p-1 text-foreground", @class]}
1413
      {@rest}
1414
    >
1415
      <div :if={@heading} class="px-2 py-1.5 font-medium text-muted-foreground text-xs">
1416
        {@heading}
1417
      </div>
1418
      {render_slot(@inner_block)}
1419
    </div>
1420
    """
1421
  end
1422
1423
  @doc """
1424
  One model.
1425
1426
  `value` is what the filter hook matches against, so give it whatever a reader
1427
  would type: the model's name, its provider, or both.
1428
  """
1429
  attr :id, :string, default: nil
1430
  attr :value, :string, required: true
1431
  attr :selected, :boolean, default: false
1432
  attr :class, :any, default: nil
1433
  attr :rest, :global, include: ~w(disabled form name)
1434
  slot :inner_block, required: true
1435
1436
  def model_selector_item(assigns) do
1437
    ~H"""
1438
    <button
1439
      id={@id}
1440
      type="button"
1441
      role="option"
1442
      aria-selected={to_string(@selected)}
1443
      data-slot="model-selector-item"
1444
      data-value={@value}
1445
      class={[command_item(), @class]}
1446
      {@rest}
1447
    >
1448
      {render_slot(@inner_block)}
1449
    </button>
1450
    """
1451
  end
1452
1453
  @doc "A model's name, filling the row between its logo and its shortcut."
1454
  attr :class, :any, default: nil
1455
  attr :rest, :global
1456
  slot :inner_block, required: true
1457
1458
  def model_selector_name(assigns) do
1459
    ~H"""
1460
    <span class={["flex-1 truncate text-left", @class]} {@rest}>{render_slot(@inner_block)}</span>
1461
    """
1462
  end
1463
1464
  @doc "A keyboard shortcut, stated at the trailing edge of a row."
1465
  attr :class, :any, default: nil
1466
  attr :rest, :global
1467
  slot :inner_block, required: true
1468
1469
  def model_selector_shortcut(assigns) do
1470
    ~H"""
1471
    <span class={["ml-auto text-muted-foreground text-xs tracking-widest", @class]} {@rest}>
1472
      {render_slot(@inner_block)}
1473
    </span>
1474
    """
1475
  end
1476
1477
  @doc "A rule between groups."
1478
  attr :class, :any, default: nil
1479
  attr :rest, :global
1480
1481
  def model_selector_separator(assigns) do
1482
    ~H"""
1483
    <div role="separator" class={["-mx-1 h-px bg-border", @class]} {@rest}></div>
1484
    """
1485
  end
1486
1487
  @doc """
1488
  A provider's mark.
1489
1490
  The source builds the URL from a provider name against `models.dev`. This
1491
  takes `src` instead: a component in this app does not decide which host the
1492
  page fetches from. `dark:invert` is dropped for the reason in the module
1493
  note — the variant never matches here.
1494
  """
1495
  attr :src, :string, required: true
1496
  attr :provider, :string, required: true, doc: "used for the alternative text"
1497
  attr :class, :any, default: nil
1498
  attr :rest, :global
1499
1500
  def model_selector_logo(assigns) do
1501
    ~H"""
1502
    <img
1503
      src={@src}
1504
      alt={"#{@provider} logo"}
1505
      width="12"
1506
      height="12"
1507
      class={["size-3", @class]}
1508
      {@rest}
1509
    />
1510
    """
1511
  end
1512
1513
  @doc "Overlapping provider marks, for a model several providers serve."
1514
  attr :class, :any, default: nil
1515
  attr :rest, :global
1516
  slot :inner_block, required: true
1517
1518
  def model_selector_logo_group(assigns) do
1519
    ~H"""
1520
    <div
1521
      class={[
1522
        "flex shrink-0 items-center -space-x-1",
1523
        "[&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1",
1524
        @class
1525
      ]}
1526
      {@rest}
1527
    >
1528
      {render_slot(@inner_block)}
1529
    </div>
1530
    """
1531
  end
1532
1533
  # ── Queue ─────────────────────────────────────────────────────────────────
1534
1535
  @doc "Messages waiting to be sent, held below the composer."
1536
  attr :id, :string, default: nil
1537
  attr :class, :any, default: nil
1538
  attr :rest, :global
1539
  slot :inner_block, required: true
1540
1541
  def queue(assigns) do
1542
    ~H"""
1543
    <div
1544
      id={@id}
1545
      data-slot="queue"
1546
      class={[
1547
        "flex flex-col gap-2 rounded-xl border border-border bg-background px-3 pt-2 pb-2 shadow-xs",
1548
        @class
1549
      ]}
1550
      {@rest}
1551
    >
1552
      {render_slot(@inner_block)}
1553
    </div>
1554
    """
1555
  end
1556
1557
  @doc """
1558
  A collapsible run of queued items.
1559
1560
  A native `<details>`, so the disclosure works before any JavaScript loads.
1561
  The chevron in `queue_section_label/1` rotates off this element's `open`
1562
  attribute rather than off Radix's `data-state`.
1563
  """
1564
  attr :id, :string, default: nil
1565
  attr :open, :boolean, default: true
1566
  attr :class, :any, default: nil
1567
  attr :rest, :global
1568
  slot :inner_block, required: true
1569
1570
  def queue_section(assigns) do
1571
    ~H"""
1572
    <details id={@id} open={@open} class={["group/queue-section", @class]} {@rest}>
1573
      {render_slot(@inner_block)}
1574
    </details>
1575
    """
1576
  end
1577
1578
  @doc "The section's header, which opens and closes it."
1579
  attr :class, :any, default: nil
1580
  attr :rest, :global
1581
  slot :inner_block, required: true
1582
1583
  def queue_section_trigger(assigns) do
1584
    ~H"""
1585
    <summary
1586
      class={[
1587
        "group flex w-full cursor-pointer list-none items-center justify-between rounded-md",
1588
        "bg-muted/40 px-3 py-2 text-left font-medium text-muted-foreground text-sm",
1589
        "transition-colors hover:bg-muted [&::-webkit-details-marker]:hidden",
1590
        @class
1591
      ]}
1592
      {@rest}
1593
    >
1594
      {render_slot(@inner_block)}
1595
    </summary>
1596
    """
1597
  end
1598
1599
  @doc "The section header's own content: a chevron, a count, and a word."
1600
  attr :label, :string, required: true
1601
  attr :count, :integer, default: nil
1602
  attr :class, :any, default: nil
1603
  attr :rest, :global
1604
  slot :icon
1605
1606
  def queue_section_label(assigns) do
1607
    ~H"""
1608
    <span class={["flex items-center gap-2", @class]} {@rest}>
1609
      <UI.icon
1610
        name="chevron-down"
1611
        class="size-4 -rotate-90 transition-transform group-open/queue-section:rotate-0"
1612
      />
1613
      {render_slot(@icon)}
1614
      <span>{[@count, @label] |> Enum.reject(&is_nil/1) |> Enum.join(" ")}</span>
1615
    </span>
1616
    """
1617
  end
1618
1619
  @doc "What the section reveals."
1620
  attr :class, :any, default: nil
1621
  attr :rest, :global
1622
  slot :inner_block, required: true
1623
1624
  def queue_section_content(assigns) do
1625
    ~H"""
1626
    <div class={@class} {@rest}>{render_slot(@inner_block)}</div>
1627
    """
1628
  end
1629
1630
  @doc "The bounded scrolling list of queued items."
1631
  attr :id, :string, default: nil
1632
  attr :class, :any, default: nil
1633
  attr :rest, :global
1634
  slot :inner_block, required: true
1635
1636
  def queue_list(assigns) do
1637
    ~H"""
1638
    <div id={@id} class={["-mb-1 mt-2 overflow-y-auto", @class]} {@rest}>
1639
      <div class="max-h-40 pr-4">
1640
        <ul>{render_slot(@inner_block)}</ul>
1641
      </div>
1642
    </div>
1643
    """
1644
  end
1645
1646
  @doc "One queued message."
1647
  attr :id, :string, default: nil
1648
  attr :class, :any, default: nil
1649
  attr :rest, :global
1650
  slot :inner_block, required: true
1651
1652
  def queue_item(assigns) do
1653
    ~H"""
1654
    <li
1655
      id={@id}
1656
      class={[
1657
        "group/queue-item flex flex-col gap-1 rounded-md px-3 py-1 text-sm",
1658
        "transition-colors hover:bg-muted",
1659
        @class
1660
      ]}
1661
      {@rest}
1662
    >
1663
      {render_slot(@inner_block)}
1664
    </li>
1665
    """
1666
  end
1667
1668
  @doc "The dot that says whether a queued item has been sent."
1669
  attr :completed, :boolean, default: false
1670
  attr :class, :any, default: nil
1671
  attr :rest, :global
1672
1673
  def queue_item_indicator(assigns) do
1674
    ~H"""
1675
    <span
1676
      aria-hidden="true"
1677
      class={[
1678
        "mt-0.5 inline-block size-2.5 rounded-full border",
1679
        if(@completed,
1680
          do: "border-muted-foreground/20 bg-muted-foreground/10",
1681
          else: "border-muted-foreground/50"
1682
        ),
1683
        @class
1684
      ]}
1685
      {@rest}
1686
    ></span>
1687
    """
1688
  end
1689
1690
  @doc "The queued message's text."
1691
  attr :completed, :boolean, default: false
1692
  attr :class, :any, default: nil
1693
  attr :rest, :global
1694
  slot :inner_block, required: true
1695
1696
  def queue_item_content(assigns) do
1697
    ~H"""
1698
    <span
1699
      class={[
1700
        "line-clamp-1 grow break-words",
1701
        if(@completed, do: "text-muted-foreground/50 line-through", else: "text-muted-foreground"),
1702
        @class
1703
      ]}
1704
      {@rest}
1705
    >
1706
      {render_slot(@inner_block)}
1707
    </span>
1708
    """
1709
  end
1710
1711
  @doc "Supporting text under a queued message."
1712
  attr :completed, :boolean, default: false
1713
  attr :class, :any, default: nil
1714
  attr :rest, :global
1715
  slot :inner_block, required: true
1716
1717
  def queue_item_description(assigns) do
1718
    ~H"""
1719
    <div
1720
      class={[
1721
        "ml-6 text-xs",
1722
        if(@completed, do: "text-muted-foreground/40 line-through", else: "text-muted-foreground"),
1723
        @class
1724
      ]}
1725
      {@rest}
1726
    >
1727
      {render_slot(@inner_block)}
1728
    </div>
1729
    """
1730
  end
1731
1732
  @doc "Controls on a queued item."
1733
  attr :class, :any, default: nil
1734
  attr :rest, :global
1735
  slot :inner_block, required: true
1736
1737
  def queue_item_actions(assigns) do
1738
    ~H"""
1739
    <div class={["flex gap-1", @class]} {@rest}>{render_slot(@inner_block)}</div>
1740
    """
1741
  end
1742
1743
  @doc """
1744
  One control on a queued item.
1745
1746
  Revealed on hover in the source. `focus-visible` reveals it as well, because
1747
  a hover-only reveal is a control the keyboard can reach but never see.
1748
  """
1749
  attr :id, :string, default: nil
1750
  attr :label, :string, required: true
1751
  attr :class, :any, default: nil
1752
  attr :rest, :global, include: ~w(disabled form name value)
1753
  slot :inner_block, required: true
1754
1755
  def queue_item_action(assigns) do
1756
    ~H"""
1757
    <UI.button
1758
      id={@id}
1759
      type="button"
1760
      variant={:ghost}
1761
      aria-label={@label}
1762
      class={[
1763
        "size-auto rounded p-1 text-muted-foreground opacity-0 transition-opacity",
1764
        "hover:bg-muted-foreground/10 hover:text-foreground",
1765
        "group-hover/queue-item:opacity-100 focus-visible:opacity-100",
1766
        @class
1767
      ]}
1768
      {@rest}
1769
    >
1770
      {render_slot(@inner_block)}
1771
    </UI.button>
1772
    """
1773
  end
1774
1775
  @doc "The strip of things attached to a queued message."
1776
  attr :class, :any, default: nil
1777
  attr :rest, :global
1778
  slot :inner_block, required: true
1779
1780
  def queue_item_attachment(assigns) do
1781
    ~H"""
1782
    <div class={["mt-1 flex flex-wrap gap-2", @class]} {@rest}>{render_slot(@inner_block)}</div>
1783
    """
1784
  end
1785
1786
  @doc "An image attached to a queued message."
1787
  attr :src, :string, required: true
1788
  attr :alt, :string, default: ""
1789
  attr :class, :any, default: nil
1790
  attr :rest, :global
1791
1792
  def queue_item_image(assigns) do
1793
    ~H"""
1794
    <img
1795
      src={@src}
1796
      alt={@alt}
1797
      width="32"
1798
      height="32"
1799
      class={["h-8 w-8 rounded border object-cover", @class]}
1800
      {@rest}
1801
    />
1802
    """
1803
  end
1804
1805
  @doc "A file attached to a queued message."
1806
  attr :class, :any, default: nil
1807
  attr :rest, :global
1808
  slot :inner_block, required: true
1809
1810
  def queue_item_file(assigns) do
1811
    ~H"""
1812
    <span
1813
      class={["flex items-center gap-1 rounded border bg-muted px-2 py-1 text-xs", @class]}
1814
      {@rest}
1815
    >
1816
      <UI.icon name="paperclip" class="size-3" />
1817
      <span class="max-w-[100px] truncate">{render_slot(@inner_block)}</span>
1818
    </span>
1819
    """
1820
  end
1821
1822
  @doc """
1823
  What stands in for the queue when nothing is waiting.
1824
1825
  AI Elements has no such part — its queue simply renders no items. An empty
1826
  region with no words leaves a reader unsure whether anything was queued at
1827
  all, so this states the condition, following `attachment_empty/1`.
1828
  """
1829
  attr :id, :string, default: nil
1830
  attr :class, :any, default: nil
1831
  attr :rest, :global
1832
  slot :inner_block
1833
1834
  def queue_empty(assigns) do
1835
    ~H"""
1836
    <div
1837
      id={@id}
1838
      data-slot="queue-empty"
1839
      class={["flex items-center justify-center px-3 py-4 text-muted-foreground text-sm", @class]}
1840
      {@rest}
1841
    >
1842
      <%= if @inner_block != [] do %>
1843
        {render_slot(@inner_block)}
1844
      <% else %>
1845
        Nothing queued
1846
      <% end %>
1847
    </div>
1848
    """
1849
  end
1850
1851
  # ── Private ───────────────────────────────────────────────────────────────
1852
1853
  defp button_size_class(:xs) do
1854
    "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 " <>
1855
      "[&>svg:not([class*='size-'])]:size-3.5"
1856
  end
1857
1858
  defp button_size_class(:sm), do: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5"
1859
1860
  defp button_size_class(:icon_xs),
1861
    do: "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0"
1862
1863
  defp button_size_class(:icon_sm), do: "size-8 p-0 has-[>svg]:p-0"
1864
1865
  defp media_icon(:image), do: "image-square"
1866
  defp media_icon(:video), do: "video"
1867
  defp media_icon(:audio), do: "music"
1868
  defp media_icon(:document), do: "file-document"
1869
  defp media_icon(:source), do: "globe"
1870
  defp media_icon(_unknown), do: "paperclip"
1871
1872
  defp input_group, do: @input_group
1873
  defp addon, do: @addon
1874
  defp button_base, do: @button_base
1875
  defp command_item, do: @command_item
1876
end
test/openagents_web/components/ai/prompt_input_test.exs added +605

@@ -0,0 +1,605 @@

1
defmodule OpenAgentsWeb.AI.PromptInputTest do
2
  @moduledoc """
3
  What the composer must keep true after a port from React.
4
5
  These check the three things a screenshot cannot: that the four submit
6
  statuses each render a different control with a different accessible name,
7
  that the textarea is actually bound to the form field rather than to a loose
8
  string, and that every list has words for the case where it is empty. The
9
  class assertions are deliberately narrow — one or two utilities per element,
10
  the ones that carry the geometry AI Elements was ported for — so that
11
  restyling does not break the suite but losing the layout does.
12
  """
13
14
  use ExUnit.Case, async: true
15
16
  import Phoenix.LiveViewTest, only: [render_component: 2]
17
18
  alias OpenAgentsWeb.AI.PromptInput
19
20
  defp query(html, selector) do
21
    html
22
    |> LazyHTML.from_fragment()
23
    |> LazyHTML.query(selector)
24
  end
25
26
  defp count(html, selector) do
27
    html |> query(selector) |> LazyHTML.to_tree() |> length()
28
  end
29
30
  defp attribute(html, selector, name) do
31
    html |> query(selector) |> LazyHTML.attribute(name) |> List.first()
32
  end
33
34
  defp text(html, selector) do
35
    html |> query(selector) |> LazyHTML.text() |> String.trim()
36
  end
37
38
  # `render_component/2` has no sugar for slots, so a slot is a one-element
39
  # list holding a function that returns already-safe markup. That lets one
40
  # component's rendered output be nested inside another's.
41
  defp slot(markup, name \\ :inner_block) do
42
    [%{__slot__: name, inner_block: fn _changed, _arg -> {:safe, markup} end}]
43
  end
44
45
  defp form, do: Phoenix.Component.to_form(%{"message" => "Hello there"}, as: :chat)
46
47
  describe "prompt_input/1" do
48
    test "renders a form driven by the given to_form assign" do
49
      html =
50
        render_component(&PromptInput.prompt_input/1,
51
          id: "composer",
52
          for: form(),
53
          inner_block: slot("<span>body</span>")
54
        )
55
56
      assert count(html, "form#composer") == 1
57
      assert attribute(html, "form#composer", "phx-hook") =~ ".PromptInput"
58
      assert attribute(html, "form#composer", "data-file-input") == "composer-files"
59
      assert attribute(html, "form#composer", "data-submit-on-enter") == "true"
60
      assert attribute(html, "form#composer", "data-dragging") == "false"
61
    end
62
63
    test "carries the hidden file input the drop and paste affordances write to" do
64
      html =
65
        render_component(&PromptInput.prompt_input/1,
66
          id: "composer",
67
          for: form(),
68
          accept: "image/*",
69
          inner_block: slot("<span>body</span>")
70
        )
71
72
      assert attribute(html, "input#composer-files", "type") == "file"
73
      assert attribute(html, "input#composer-files", "accept") == "image/*"
74
      assert attribute(html, "input#composer-files", "aria-label") == "Upload files"
75
      assert attribute(html, "input#composer-files", "class") == "hidden"
76
    end
77
78
    test "wraps its children in the input group that carries the focus ring" do
79
      html =
80
        render_component(&PromptInput.prompt_input/1,
81
          id: "composer",
82
          for: form(),
83
          inner_block: slot("<span>body</span>")
84
        )
85
86
      group = attribute(html, "[data-slot=input-group]", "class")
87
      assert group =~ "group/input-group"
88
      assert group =~ "has-[>textarea]:h-auto"
89
      assert group =~ "has-[[data-slot=input-group-control]:focus-visible]:border-ring"
90
      assert group =~ "overflow-hidden"
91
    end
92
93
    test "publishes the backspace event only when the caller asks for one" do
94
      without =
95
        render_component(&PromptInput.prompt_input/1,
96
          id: "composer",
97
          for: form(),
98
          inner_block: slot("<span>body</span>")
99
        )
100
101
      assert attribute(without, "form#composer", "data-backspace-event") == nil
102
103
      with_event =
104
        render_component(&PromptInput.prompt_input/1,
105
          id: "composer",
106
          for: form(),
107
          backspace_event: "remove_last_attachment",
108
          inner_block: slot("<span>body</span>")
109
        )
110
111
      assert attribute(with_event, "form#composer", "data-backspace-event") ==
112
               "remove_last_attachment"
113
    end
114
115
    test "shows the drop overlay markup only when the slot is given" do
116
      html =
117
        render_component(&PromptInput.prompt_input/1,
118
          id: "composer",
119
          for: form(),
120
          inner_block: slot("<span>body</span>"),
121
          drop_overlay: slot("Drop files here", :drop_overlay)
122
        )
123
124
      assert text(html, "[aria-hidden=true]") == "Drop files here"
125
126
      assert attribute(html, "[aria-hidden=true]", "class") =~
127
               "group-data-[dragging=true]/prompt-input:flex"
128
    end
129
  end
130
131
  describe "prompt_input_textarea/1" do
132
    test "takes its id, name, and value from a form field" do
133
      html = render_component(&PromptInput.prompt_input_textarea/1, field: form()[:message])
134
135
      assert attribute(html, "textarea", "id") == "chat_message"
136
      assert attribute(html, "textarea", "name") == "chat[message]"
137
      assert text(html, "textarea") == "Hello there"
138
    end
139
140
    test "is the input group's control and flattens the vendored textarea chrome" do
141
      html = render_component(&PromptInput.prompt_input_textarea/1, field: form()[:message])
142
143
      assert attribute(html, "textarea", "data-slot") == "input-group-control"
144
145
      class = attribute(html, "textarea", "class")
146
      assert class =~ "textarea"
147
      assert class =~ "field-sizing-content"
148
      assert class =~ "max-h-48"
149
      assert class =~ "min-h-16"
150
      assert class =~ "border-0"
151
      assert class =~ "bg-transparent"
152
      assert class =~ "shadow-none"
153
    end
154
155
    test "keeps AI Elements' placeholder and accepts an explicit id" do
156
      html =
157
        render_component(&PromptInput.prompt_input_textarea/1, field: form()[:message], id: "ask")
158
159
      assert attribute(html, "textarea", "id") == "ask"
160
      assert attribute(html, "textarea", "placeholder") == "What would you like to know?"
161
    end
162
163
    test "works without a form field, for a caller holding its own value" do
164
      html =
165
        render_component(&PromptInput.prompt_input_textarea/1,
166
          id: "ask",
167
          name: "message",
168
          value: "raw"
169
        )
170
171
      assert attribute(html, "textarea", "name") == "message"
172
      assert text(html, "textarea") == "raw"
173
    end
174
  end
175
176
  describe "prompt_input_submit/1" do
177
    test "ready offers the enter glyph and submits the form" do
178
      html = render_component(&PromptInput.prompt_input_submit/1, id: "send", status: :ready)
179
180
      assert attribute(html, "#send", "type") == "submit"
181
      assert attribute(html, "#send", "aria-label") == "Submit"
182
      assert attribute(html, "#send", "data-status") == "ready"
183
      assert count(html, "[data-icon=arrow-curved-left]") == 1
184
    end
185
186
    test "submitted spins and renames itself Stop" do
187
      html = render_component(&PromptInput.prompt_input_submit/1, id: "send", status: :submitted)
188
189
      assert attribute(html, "#send", "aria-label") == "Stop"
190
      assert attribute(html, "#send", "data-status") == "submitted"
191
      assert attribute(html, "[data-icon=spin]", "class") =~ "animate-spin"
192
    end
193
194
    test "streaming offers the stop glyph" do
195
      html = render_component(&PromptInput.prompt_input_submit/1, id: "send", status: :streaming)
196
197
      assert attribute(html, "#send", "aria-label") == "Stop"
198
      assert attribute(html, "#send", "data-status") == "streaming"
199
      assert count(html, "[data-icon=stop]") == 1
200
    end
201
202
    test "error offers the cross and still submits, so a retry is one press" do
203
      html = render_component(&PromptInput.prompt_input_submit/1, id: "send", status: :error)
204
205
      assert attribute(html, "#send", "type") == "submit"
206
      assert attribute(html, "#send", "aria-label") == "Submit"
207
      assert attribute(html, "#send", "data-status") == "error"
208
      assert count(html, "[data-icon=x]") == 1
209
    end
210
211
    test "stops being a submit button once a stop action exists" do
212
      html =
213
        render_component(&PromptInput.prompt_input_submit/1,
214
          id: "send",
215
          status: :streaming,
216
          on_stop: "stop_turn"
217
        )
218
219
      assert attribute(html, "#send", "type") == "button"
220
      assert attribute(html, "#send", "phx-click") == "stop_turn"
221
    end
222
223
    test "a stop action does nothing while the composer is ready" do
224
      html =
225
        render_component(&PromptInput.prompt_input_submit/1,
226
          id: "send",
227
          status: :ready,
228
          on_stop: "stop_turn"
229
        )
230
231
      assert attribute(html, "#send", "type") == "submit"
232
      assert attribute(html, "#send", "phx-click") == nil
233
    end
234
  end
235
236
  describe "toolbar parts" do
237
    test "the header sits above the control and the footer below it" do
238
      header = render_component(&PromptInput.prompt_input_header/1, inner_block: slot("chips"))
239
      footer = render_component(&PromptInput.prompt_input_footer/1, inner_block: slot("tools"))
240
241
      assert attribute(header, "[data-slot=input-group-addon]", "data-align") == "block-end"
242
      assert attribute(header, "[data-slot=input-group-addon]", "class") =~ "order-first"
243
      assert attribute(footer, "[data-slot=input-group-addon]", "class") =~ "justify-between"
244
      refute attribute(footer, "[data-slot=input-group-addon]", "class") =~ "order-first"
245
    end
246
247
    test "the toolbar is the footer under the name AI Elements uses" do
248
      toolbar = render_component(&PromptInput.prompt_input_toolbar/1, inner_block: slot("tools"))
249
250
      assert attribute(toolbar, "[data-slot=input-group-addon]", "class") =~ "justify-between"
251
    end
252
253
    test "tools hold a tight run of controls" do
254
      html = render_component(&PromptInput.prompt_input_tools/1, inner_block: slot("controls"))
255
256
      assert attribute(html, "div", "class") =~ "flex min-w-0 items-center gap-1"
257
    end
258
  end
259
260
  describe "prompt_input_button/1" do
261
    test "defaults to the ghost icon control the composer chrome is made of" do
262
      html = render_component(&PromptInput.prompt_input_button/1, inner_block: slot("x"))
263
264
      assert attribute(html, "button", "data-variant") == "ghost"
265
      assert attribute(html, "button", "type") == "button"
266
      assert attribute(html, "button", "class") =~ "size-8"
267
    end
268
269
    test "the tooltip becomes a title, because there is no tooltip primitive here" do
270
      html =
271
        render_component(&PromptInput.prompt_input_button/1,
272
          tooltip: "Add files",
273
          size: :sm,
274
          inner_block: slot("x")
275
        )
276
277
      assert attribute(html, "button", "title") == "Add files"
278
      assert attribute(html, "button", "class") =~ "h-8"
279
    end
280
  end
281
282
  describe "the action menu" do
283
    test "the trigger points at the menu it opens" do
284
      html =
285
        render_component(&PromptInput.prompt_input_action_menu_trigger/1,
286
          menu: "composer-actions"
287
        )
288
289
      assert attribute(html, "button", "popovertarget") == "composer-actions"
290
      assert attribute(html, "button", "aria-label") == "Open composer actions"
291
      assert count(html, "[data-icon=plus]") == 1
292
    end
293
294
    test "the menu is a native popover with a name" do
295
      html =
296
        render_component(&PromptInput.prompt_input_action_menu/1,
297
          id: "composer-actions",
298
          inner_block: slot("items")
299
        )
300
301
      assert attribute(html, "#composer-actions", "popover") == "auto"
302
      assert attribute(html, "#composer-actions", "role") == "menu"
303
      assert attribute(html, "#composer-actions", "aria-label") == "Composer actions"
304
    end
305
306
    test "adding attachments is a label bound to the composer's file input" do
307
      html = render_component(&PromptInput.prompt_input_action_add_attachments/1, for: "composer")
308
309
      assert attribute(html, "label", "for") == "composer-files"
310
      assert attribute(html, "label", "role") == "menuitem"
311
      assert text(html, "label") == "Add photos or files"
312
    end
313
314
    test "taking a screenshot stays a button, because capture needs a gesture" do
315
      html = render_component(&PromptInput.prompt_input_action_add_screenshot/1, [])
316
317
      assert attribute(html, "button", "role") == "menuitem"
318
      assert count(html, "[data-icon=desktop]") == 1
319
    end
320
  end
321
322
  describe "prompt_input_model_select/1" do
323
    test "is one native select rather than four Radix parts" do
324
      html =
325
        render_component(&PromptInput.prompt_input_model_select/1,
326
          id: "model",
327
          name: "chat[model]",
328
          inner_block: slot(~s(<option value="a">A</option>))
329
        )
330
331
      assert attribute(html, "select#model", "name") == "chat[model]"
332
      assert attribute(html, "select#model", "aria-label") == "Model"
333
      assert attribute(html, "select#model", "class") =~ "hover:bg-muted"
334
      refute attribute(html, "select#model", "class") =~ "hover:bg-accent"
335
    end
336
337
    test "an item is an option that can be preselected" do
338
      html =
339
        render_component(&PromptInput.prompt_input_model_select_item/1,
340
          value: "sonnet",
341
          selected: true,
342
          inner_block: slot("Sonnet")
343
        )
344
345
      assert attribute(html, "option", "value") == "sonnet"
346
      assert attribute(html, "option", "selected") == ""
347
      assert text(html, "option") == "Sonnet"
348
    end
349
  end
350
351
  describe "attachments" do
352
    test "a populated list holds one item per attachment" do
353
      items =
354
        render_component(&PromptInput.attachment/1,
355
          id: "attachment-1",
356
          inner_block: slot("one")
357
        ) <>
358
          render_component(&PromptInput.attachment/1,
359
            id: "attachment-2",
360
            inner_block: slot("two")
361
          )
362
363
      html = render_component(&PromptInput.attachments/1, id: "files", inner_block: slot(items))
364
365
      assert count(html, "[data-slot=attachment]") == 2
366
      assert attribute(html, "#files", "data-variant") == "grid"
367
      assert attribute(html, "#files", "class") =~ "ml-auto w-fit"
368
    end
369
370
    test "an empty list says so rather than rendering an unexplained gap" do
371
      html = render_component(&PromptInput.attachment_empty/1, id: "files-empty")
372
373
      assert text(html, "#files-empty") == "No attachments"
374
      assert attribute(html, "#files-empty", "class") =~ "text-muted-foreground"
375
    end
376
377
    test "the list variant stacks and the grid variant wraps" do
378
      list = render_component(&PromptInput.attachments/1, variant: :list, inner_block: slot(""))
379
      grid = render_component(&PromptInput.attachments/1, variant: :grid, inner_block: slot(""))
380
381
      assert attribute(list, "[data-slot=attachments]", "class") =~ "flex-col gap-2"
382
      assert attribute(grid, "[data-slot=attachments]", "class") =~ "flex-wrap gap-2"
383
    end
384
385
    test "the preview shows an image when it has one and a glyph when it does not" do
386
      with_image =
387
        render_component(&PromptInput.attachment_preview/1,
388
          media_category: :image,
389
          src: "/uploads/cat.png",
390
          filename: "cat.png"
391
        )
392
393
      assert attribute(with_image, "img", "src") == "/uploads/cat.png"
394
      assert attribute(with_image, "img", "alt") == "cat.png"
395
396
      without_image = render_component(&PromptInput.attachment_preview/1, media_category: :audio)
397
      assert count(without_image, "[data-icon=music]") == 1
398
    end
399
400
    test "the name is hidden in the grid variant, where the thumbnail is the item" do
401
      inline =
402
        render_component(&PromptInput.attachment_info/1,
403
          label: "notes.pdf",
404
          media_type: "application/pdf"
405
        )
406
407
      grid = render_component(&PromptInput.attachment_info/1, label: "notes.pdf", variant: :grid)
408
409
      assert text(inline, "[data-slot=attachment-info]") =~ "notes.pdf"
410
      assert text(inline, "[data-slot=attachment-info]") =~ "application/pdf"
411
      assert count(grid, "[data-slot=attachment-info]") == 0
412
    end
413
414
    test "remove is named for assistive technology even while hidden from a pointer" do
415
      html =
416
        render_component(&PromptInput.attachment_remove/1, id: "drop-1", label: "Remove cat.png")
417
418
      assert attribute(html, "#drop-1", "aria-label") == "Remove cat.png"
419
      assert text(html, ".sr-only") == "Remove cat.png"
420
      assert attribute(html, "#drop-1", "class") =~ "group-hover/attachment:opacity-100"
421
      assert attribute(html, "#drop-1", "class") =~ "focus-visible:opacity-100"
422
    end
423
  end
424
425
  describe "speech_input/1" do
426
    test "starts silent, names its action, and carries the capture hook" do
427
      html =
428
        render_component(&PromptInput.speech_input/1, id: "mic", transcript_event: "transcribed")
429
430
      assert attribute(html, "#mic", "phx-hook") =~ ".SpeechInput"
431
      assert attribute(html, "#mic", "data-recording") == "false"
432
      assert attribute(html, "#mic", "data-transcript-event") == "transcribed"
433
      assert attribute(html, "#mic-button", "aria-label") == "Start voice input"
434
      assert count(html, "[data-icon=mic]") == 1
435
    end
436
437
    test "draws the three staggered rings the source animates while recording" do
438
      html =
439
        render_component(&PromptInput.speech_input/1, id: "mic", transcript_event: "transcribed")
440
441
      rings = query(html, ".animate-ping") |> LazyHTML.to_tree()
442
      assert length(rings) == 3
443
444
      assert attribute(html, ".animate-ping", "class") =~
445
               "group-data-[recording=true]/speech:block"
446
    end
447
  end
448
449
  describe "mic_selector/1" do
450
    test "hands its options to the hook and tells LiveView not to touch them" do
451
      html = render_component(&PromptInput.mic_selector/1, id: "mic-device")
452
453
      assert attribute(html, "#mic-device", "phx-hook") =~ ".MicSelector"
454
      assert attribute(html, "#mic-device", "phx-update") == "ignore"
455
      assert attribute(html, "#mic-device", "aria-label") == "Microphone"
456
      assert text(html, "#mic-device option") == "Select microphone..."
457
    end
458
459
    test "renders devices the server already knows" do
460
      html =
461
        render_component(&PromptInput.mic_selector_item/1,
462
          value: "device-1",
463
          inner_block: slot("Built-in microphone")
464
        )
465
466
      assert attribute(html, "option", "value") == "device-1"
467
      assert text(html, "option") == "Built-in microphone"
468
    end
469
  end
470
471
  describe "model_selector/1" do
472
    test "the panel is a named popover carrying the filter hook" do
473
      html = render_component(&PromptInput.model_selector/1, id: "models", inner_block: slot("x"))
474
475
      assert attribute(html, "#models", "popover") == "auto"
476
      assert attribute(html, "#models", "phx-hook") =~ ".ModelSelectorFilter"
477
      assert attribute(html, "#models", "aria-label") == "Select a model"
478
    end
479
480
    test "the search field is what the hook reads" do
481
      html = render_component(&PromptInput.model_selector_input/1, id: "model-search")
482
483
      assert attribute(html, "#model-search", "data-slot") == "model-selector-input"
484
      assert attribute(html, "#model-search", "role") == "combobox"
485
      assert attribute(html, "#model-search", "autocomplete") == "off"
486
    end
487
488
    test "an item exposes the string the filter matches against" do
489
      html =
490
        render_component(&PromptInput.model_selector_item/1,
491
          id: "model-sonnet",
492
          value: "anthropic claude sonnet",
493
          selected: true,
494
          inner_block: slot("Claude Sonnet")
495
        )
496
497
      assert attribute(html, "#model-sonnet", "role") == "option"
498
      assert attribute(html, "#model-sonnet", "data-value") == "anthropic claude sonnet"
499
      assert attribute(html, "#model-sonnet", "aria-selected") == "true"
500
      assert attribute(html, "#model-sonnet", "data-slot") == "model-selector-item"
501
    end
502
503
    test "the empty state is addressable, because the hook reveals it" do
504
      html = render_component(&PromptInput.model_selector_empty/1, id: "models-empty")
505
506
      assert attribute(html, "#models-empty", "data-slot") == "model-selector-empty"
507
      assert text(html, "#models-empty") == "No models found."
508
    end
509
510
    test "a logo takes its source from the caller rather than a hardcoded host" do
511
      html =
512
        render_component(&PromptInput.model_selector_logo/1,
513
          src: "/logos/anthropic.svg",
514
          provider: "Anthropic"
515
        )
516
517
      assert attribute(html, "img", "src") == "/logos/anthropic.svg"
518
      assert attribute(html, "img", "alt") == "Anthropic logo"
519
      refute attribute(html, "img", "class") =~ "dark:invert"
520
    end
521
  end
522
523
  describe "queue" do
524
    test "a populated queue lists one row per waiting message" do
525
      rows =
526
        render_component(&PromptInput.queue_item/1, id: "queued-1", inner_block: slot("one")) <>
527
          render_component(&PromptInput.queue_item/1, id: "queued-2", inner_block: slot("two"))
528
529
      list = render_component(&PromptInput.queue_list/1, id: "queued", inner_block: slot(rows))
530
      html = render_component(&PromptInput.queue/1, id: "queue", inner_block: slot(list))
531
532
      assert count(html, "#queued li") == 2
533
      assert attribute(html, "#queue", "class") =~ "rounded-xl"
534
    end
535
536
    test "an empty queue says nothing is waiting" do
537
      html = render_component(&PromptInput.queue_empty/1, id: "queue-empty")
538
539
      assert attribute(html, "#queue-empty", "data-slot") == "queue-empty"
540
      assert text(html, "#queue-empty") == "Nothing queued"
541
    end
542
543
    test "an empty queue accepts the caller's own words" do
544
      html =
545
        render_component(&PromptInput.queue_empty/1,
546
          id: "queue-empty",
547
          inner_block: slot("Send a message to start the queue")
548
        )
549
550
      assert text(html, "#queue-empty") == "Send a message to start the queue"
551
    end
552
553
    test "a section is a details element, so it opens without JavaScript" do
554
      section =
555
        render_component(&PromptInput.queue_section/1,
556
          id: "queued-section",
557
          inner_block: slot("x")
558
        )
559
560
      assert attribute(section, "details#queued-section", "open") == ""
561
      assert attribute(section, "details#queued-section", "class") =~ "group/queue-section"
562
    end
563
564
    test "the section label states the count beside the word" do
565
      html = render_component(&PromptInput.queue_section_label/1, label: "queued", count: 3)
566
567
      assert text(html, "span span") == "3 queued"
568
569
      assert attribute(html, "[data-icon=chevron-down]", "class") =~
570
               "group-open/queue-section:rotate-0"
571
    end
572
573
    test "a completed item strikes its own text through rather than only dimming it" do
574
      done =
575
        render_component(&PromptInput.queue_item_content/1,
576
          completed: true,
577
          inner_block: slot("x")
578
        )
579
580
      todo = render_component(&PromptInput.queue_item_content/1, inner_block: slot("x"))
581
582
      assert attribute(done, "span", "class") =~ "line-through"
583
      refute attribute(todo, "span", "class") =~ "line-through"
584
    end
585
586
    test "an item action is named and reachable by keyboard" do
587
      html =
588
        render_component(&PromptInput.queue_item_action/1,
589
          id: "queued-1-remove",
590
          label: "Remove from queue",
591
          inner_block: slot("x")
592
        )
593
594
      assert attribute(html, "#queued-1-remove", "aria-label") == "Remove from queue"
595
      assert attribute(html, "#queued-1-remove", "class") =~ "focus-visible:opacity-100"
596
    end
597
598
    test "an attached file names itself beside the clip" do
599
      html = render_component(&PromptInput.queue_item_file/1, inner_block: slot("notes.pdf"))
600
601
      assert count(html, "[data-icon=paperclip]") == 1
602
      assert text(html, ".truncate") == "notes.pdf"
603
    end
604
  end
605
end

This page updates live while a promote is in flight · changelog