Harvest AI Elements' transcript surface into HEEx

2b810a38ebfb · AtlantisPleb · · parent 00801da6bd3a

Harvest AI Elements' transcript surface into HEEx

The chat transcript is the surface this product spends the most pixels on and
the one with the least shared vocabulary: `.chat-transcript`, `.message-row`,
and `.message-bubble` in `openagents.css` are three hand-authored rules that
only `chat_live.ex` can use. Vercel's AI Elements already solved the same
shapes against the same shadcn token set this product aliases, so the Tailwind
comes across rather than being reinvented.

`OpenAgentsWeb.AI.Conversation` ports `conversation.tsx`, `message.tsx`,
`shimmer.tsx`, `suggestion.tsx`, `toolbar.tsx`, `controls.tsx`, and
`persona.tsx` at AI Elements `6a9d5b1` as fifteen function components. No CSS
is added: every utility resolves against the tokens Basecoat's `@theme` already
maps, and the four that did not are substituted in place with the reason stated
in the component's doc. `border` becomes `border border-border`, because
Tailwind v4 resolves a bare `border` to `currentcolor` and this product does
not carry shadcn's global `* { border-color: var(--border) }`. `dark:` and
`is-user:dark` go, because Tailwind's `dark:` follows the operating-system
preference while this product selects its palette with `data-theme`, so keeping
them would paint the wrong surface exactly when a reader had overridden the OS.
`size="icon"` becomes `size-9 p-0`, because `UI.button/1` deliberately exposes
no icon size.

What could not travel says so in the moduledoc rather than arriving half-built.
`use-stick-to-bottom` becomes one colocated hook that pins the viewport while
the reader is already at the bottom and toggles `hidden` on the scroll button;
it writes no DOM content, so `phx-update="ignore"` is deliberately absent —
setting it would freeze the transcript the hook exists to follow. Radix
tooltips become `title` plus the source's own `sr-only` span. Rive's WebGL
persona becomes a name, an avatar, and a state marker, with `thinking` and
`asleep` joining the nearest meaning `status_indicator/1` already carries
rather than introducing two colours. The shimmer keeps AI Elements' gradient
machinery exactly and breathes with `animate-pulse` instead of sweeping,
because the sweep needs a `@keyframes` rule and three other agents are in this
stylesheet.

Nothing renders these yet; the catalogue entries and the chat rewiring land
once the other three 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/conversation.ex
  • added test/openagents_web/components/ai/conversation_test.exs

Diff

2 files changed, +1013 -0

lib/openagents_web/components/ai/conversation.ex added +651

@@ -0,0 +1,651 @@

1
defmodule OpenAgentsWeb.AI.Conversation do
2
  @moduledoc """
3
  The transcript surface: the scroller, the messages inside it, and the small
4
  chrome that sits beside them.
5
6
  Ported from Vercel's AI Elements (MIT, © 2025 Vercel), specifically
7
  `conversation.tsx`, `message.tsx`, `shimmer.tsx`, `suggestion.tsx`,
8
  `toolbar.tsx`, `controls.tsx`, and `persona.tsx` at `6a9d5b1`. The Tailwind
9
  classes are the point of the port and are carried across verbatim wherever
10
  they resolve against this product's tokens; the React machinery underneath
11
  them is not. Every substitution is named in the doc of the component that
12
  makes it, so a later reader can tell a deliberate swap from a typo.
13
14
  ## What became what
15
16
  Compound React components become sibling function components — `message/1`
17
  and `message_content/1` rather than one component with a slot per position —
18
  because the caller decides the order of avatar, content, and actions, and a
19
  slot per position would only rename that freedom while costing the ability to
20
  drop a part entirely.
21
22
  `conversation_scroll_button/1` is the exception, and is both: it stays a
23
  public component so it can be placed and tested on its own, and
24
  `conversation/1` renders one by default. The button is positioned against the
25
  conversation's own box, so it has to sit *outside* the scrolling viewport;
26
  leaving that placement to the caller would put it inside and let it scroll
27
  away, which is the one thing the control exists to prevent.
28
29
  ## No React
30
31
  `use-stick-to-bottom` becomes one colocated hook. It listens for scroll,
32
  watches the content box for growth, pins the viewport to the bottom while the
33
  reader is already there, and toggles the `hidden` utility on the scroll
34
  button. It never writes DOM content, so `phx-update="ignore"` is deliberately
35
  absent: setting it would freeze the transcript the hook exists to follow.
36
37
  Rive drives `Persona` in the source — a WebGL2 canvas playing a remote `.riv`
38
  state machine. Neither the runtime nor the asset is available here, so
39
  `persona/1` is a name, an avatar, and a state marker instead.
40
41
  ## Markdown
42
43
  Where AI Elements renders `<Streamdown>`, `message_content/1` calls
44
  `OpenAgents.Markdown.to_html/2`, passing `streaming: true` while the caller
45
  says text is still arriving, so the same guards that protect the chat
46
  transcript protect this one.
47
  """
48
49
  use Phoenix.Component
50
51
  alias OpenAgents.Markdown
52
  alias OpenAgentsWeb.UI
53
54
  @doc """
55
  The transcript scroller.
56
57
  Two boxes, as in the source: the outer one is the positioning context and
58
  clips (`overflow-y-hidden`), the inner one scrolls. That split is what lets
59
  `conversation_scroll_button/1` hold still at the bottom edge while the
60
  transcript moves behind it.
61
62
  `role="log"` is kept from the source, so assistive technology announces
63
  arriving turns rather than re-reading the whole thread.
64
  """
65
  attr :id, :string, required: true
66
  attr :class, :any, default: nil
67
68
  attr :scroll_button, :boolean,
69
    default: true,
70
    doc: "render the built-in scroll-to-bottom control outside the scrolling viewport"
71
72
  attr :scroll_button_label, :string, default: "Scroll to the newest message"
73
  attr :rest, :global
74
  slot :inner_block, required: true
75
76
  def conversation(assigns) do
77
    ~H"""
78
    <div
79
      id={@id}
80
      class={["relative flex-1 overflow-y-hidden", @class]}
81
      role="log"
82
      phx-hook=".StickToBottom"
83
      {@rest}
84
    >
85
      <div id={"#{@id}-viewport"} class="h-full overflow-y-auto" data-conversation-viewport="true">
86
        {render_slot(@inner_block)}
87
      </div>
88
      <.conversation_scroll_button
89
        :if={@scroll_button}
90
        id={"#{@id}-scroll-button"}
91
        label={@scroll_button_label}
92
      />
93
      <script :type={Phoenix.LiveView.ColocatedHook} name=".StickToBottom">
94
        const THRESHOLD = 24
95
96
        export default {
97
          mounted() {
98
            this.viewport = this.el.querySelector("[data-conversation-viewport]")
99
            if (!this.viewport) return
100
101
            this.button = this.el.querySelector("[data-conversation-scroll-button]")
102
            this.pinned = true
103
104
            this.onScroll = () => {
105
              this.pinned = this.atBottom()
106
              this.sync()
107
            }
108
            this.viewport.addEventListener("scroll", this.onScroll, { passive: true })
109
110
            if (this.button) {
111
              this.onClick = () => {
112
                this.pinned = true
113
                this.stick("smooth")
114
              }
115
              this.button.addEventListener("click", this.onClick)
116
            }
117
118
            if (window.ResizeObserver) {
119
              this.observer = new ResizeObserver(() => this.stick("smooth"))
120
              for (const child of this.viewport.children) {
121
                this.observer.observe(child)
122
              }
123
            }
124
125
            this.stick("auto")
126
          },
127
128
          updated() {
129
            this.stick("smooth")
130
          },
131
132
          destroyed() {
133
            if (this.viewport && this.onScroll) {
134
              this.viewport.removeEventListener("scroll", this.onScroll)
135
            }
136
            if (this.button && this.onClick) {
137
              this.button.removeEventListener("click", this.onClick)
138
            }
139
            if (this.observer) {
140
              this.observer.disconnect()
141
            }
142
          },
143
144
          atBottom() {
145
            const { scrollHeight, scrollTop, clientHeight } = this.viewport
146
            return scrollHeight - scrollTop - clientHeight <= THRESHOLD
147
          },
148
149
          stick(behavior) {
150
            if (this.pinned) {
151
              this.viewport.scrollTo({ top: this.viewport.scrollHeight, behavior })
152
            }
153
            this.sync()
154
          },
155
156
          sync() {
157
            const atBottom = this.atBottom()
158
            this.el.dataset.atBottom = String(atBottom)
159
            if (this.button) {
160
              this.button.classList.toggle("hidden", atBottom)
161
            }
162
          }
163
        }
164
      </script>
165
    </div>
166
    """
167
  end
168
169
  @doc "The column of turns inside `conversation/1`."
170
  attr :id, :string, default: nil
171
  attr :class, :any, default: nil
172
  attr :rest, :global
173
  slot :inner_block, required: true
174
175
  def conversation_content(assigns) do
176
    ~H"""
177
    <div id={@id} class={["flex flex-col gap-8 p-4", @class]} {@rest}>
178
      {render_slot(@inner_block)}
179
    </div>
180
    """
181
  end
182
183
  @doc """
184
  What stands in for a transcript that has not started.
185
186
  The source takes either `children` or the title, description, and icon triple.
187
  Both branches survive: give the slot content and it replaces the default
188
  heading entirely.
189
  """
190
  attr :id, :string, default: nil
191
  attr :title, :string, default: "No messages yet"
192
  attr :description, :string, default: "Start a conversation to see messages here"
193
  attr :icon, :string, default: nil, doc: "a name for `OpenAgentsWeb.UI.icon/1`"
194
  attr :class, :any, default: nil
195
  attr :rest, :global
196
  slot :inner_block
197
198
  def conversation_empty_state(assigns) do
199
    assigns = assign(assigns, :custom?, assigns.inner_block != [])
200
201
    ~H"""
202
    <div
203
      id={@id}
204
      class={["flex size-full flex-col items-center justify-center gap-3 p-8 text-center", @class]}
205
      {@rest}
206
    >
207
      {render_slot(@inner_block)}
208
      <div :if={!@custom? && @icon} class="text-muted-foreground">
209
        <UI.icon name={@icon} class="size-6" />
210
      </div>
211
      <div :if={!@custom?} class="space-y-1">
212
        <h3 class="font-medium text-sm">{@title}</h3>
213
        <p :if={@description} class="text-muted-foreground text-sm">{@description}</p>
214
      </div>
215
    </div>
216
    """
217
  end
218
219
  @doc """
220
  The control that returns the reader to the newest turn.
221
222
  Hidden until the colocated hook in `conversation/1` reports that the viewport
223
  has moved off the bottom. It starts hidden because a freshly rendered
224
  transcript is already pinned there.
225
226
  Two substitutions. It drops `dark:bg-background dark:hover:bg-muted`, which in
227
  AI Elements repaints an outline button for a dark page: this product's
228
  `outline` variant already resolves against whichever of the two palettes is
229
  active, and Tailwind's `dark:` variant follows the operating-system preference
230
  rather than this product's `data-theme`, so keeping the pair would paint the
231
  wrong surface exactly when a reader had overridden that preference. It also
232
  spells `size="icon"` as `size-9 p-0`, because `OpenAgentsWeb.UI.button/1`
233
  deliberately exposes no icon size.
234
  """
235
  attr :id, :string, required: true
236
  attr :label, :string, default: "Scroll to the newest message"
237
  attr :class, :any, default: nil
238
  attr :rest, :global
239
240
  def conversation_scroll_button(assigns) do
241
    ~H"""
242
    <UI.button
243
      id={@id}
244
      variant={:outline}
245
      class={[
246
        "hidden absolute bottom-4 left-[50%] size-9 translate-x-[-50%] rounded-full p-0",
247
        @class
248
      ]}
249
      aria-label={@label}
250
      data-conversation-scroll-button="true"
251
      {@rest}
252
    >
253
      <UI.icon name="arrow-down" class="size-4" />
254
    </UI.button>
255
    """
256
  end
257
258
  @doc """
259
  One turn.
260
261
  `from` lands as the `is-user` or `is-assistant` marker class the source uses,
262
  which is what every `group-[.is-user]:` rule inside `message_content/1` reads.
263
  The branch navigation around it is not ported: it is React state with no
264
  markup of its own beyond two chevron buttons.
265
  """
266
  attr :id, :string, default: nil
267
  attr :from, :string, values: ~w(user assistant system), required: true
268
  attr :class, :any, default: nil
269
  attr :rest, :global
270
  slot :inner_block, required: true
271
272
  def message(assigns) do
273
    ~H"""
274
    <div
275
      id={@id}
276
      class={[
277
        "group flex w-full max-w-[95%] flex-col gap-2",
278
        if(@from == "user", do: "is-user ml-auto justify-end", else: "is-assistant"),
279
        @class
280
      ]}
281
      data-from={@from}
282
      {@rest}
283
    >
284
      {render_slot(@inner_block)}
285
    </div>
286
    """
287
  end
288
289
  @doc """
290
  The body of one turn.
291
292
  Given `text`, it renders Markdown through `OpenAgents.Markdown.to_html/2` and
293
  wraps it in the class the source puts on `MessageResponse`, so the first and
294
  last blocks add no margin inside the bubble. Pass `streaming` while the turn
295
  is still arriving. Given a slot instead, it renders that; both may be present,
296
  and the Markdown comes first.
297
298
  The source's `is-user:dark` is dropped. It is a project-local Tailwind variant
299
  that flips a user bubble to the dark palette by adding shadcn's `.dark` class,
300
  and this product selects its palette with `data-theme` on the root element
301
  rather than with a class on an arbitrary box.
302
  """
303
  attr :id, :string, default: nil
304
  attr :text, :string, default: nil
305
  attr :streaming, :boolean, default: false
306
  attr :class, :any, default: nil
307
  attr :rest, :global
308
  slot :inner_block
309
310
  def message_content(assigns) do
311
    ~H"""
312
    <div
313
      id={@id}
314
      class={[
315
        "flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
316
        "group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary",
317
        "group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
318
        "group-[.is-assistant]:text-foreground",
319
        @class
320
      ]}
321
      {@rest}
322
    >
323
      <div :if={@text} class="size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
324
        {Markdown.to_html(@text, streaming: @streaming)}
325
      </div>
326
      {render_slot(@inner_block)}
327
    </div>
328
    """
329
  end
330
331
  @doc """
332
  The face beside a turn.
333
334
  Recovered from the source's earlier `Message` (AI Elements `d5f1159^`), the
335
  last revision that carried an avatar. Built on `OpenAgentsWeb.UI.avatar/1`
336
  rather than a second avatar implementation; the `size-8 ring-1 ring-border`
337
  treatment is the source's, and `size-8` outranks the primitive's own geometry
338
  because Tailwind utilities land in a later cascade layer than Basecoat
339
  components.
340
  """
341
  attr :id, :string, default: nil
342
  attr :src, :string, default: nil
343
  attr :name, :string, default: nil
344
  attr :alt, :string, default: ""
345
  attr :class, :any, default: nil
346
  attr :rest, :global
347
348
  def message_avatar(assigns) do
349
    assigns = assign(assigns, :fallback, String.slice(assigns.name || "ME", 0, 2))
350
351
    ~H"""
352
    <UI.avatar
353
      id={@id}
354
      src={@src}
355
      alt={@alt}
356
      fallback={@fallback}
357
      class={["size-8 ring-1 ring-border", @class]}
358
      {@rest}
359
    />
360
    """
361
  end
362
363
  @doc "The row of controls under a turn."
364
  attr :id, :string, default: nil
365
  attr :class, :any, default: nil
366
  attr :rest, :global
367
  slot :inner_block, required: true
368
369
  def message_actions(assigns) do
370
    ~H"""
371
    <div id={@id} class={["flex items-center gap-1", @class]} {@rest}>
372
      {render_slot(@inner_block)}
373
    </div>
374
    """
375
  end
376
377
  @doc """
378
  One control under a turn.
379
380
  The source wraps the button in a Radix tooltip. There is no tooltip primitive
381
  in `OpenAgentsWeb.UI`, and adding one would mean adding CSS, so the hint
382
  reaches a pointer through the native `title` attribute and assistive
383
  technology through the source's own `sr-only` span. `size="icon-sm"` becomes
384
  `size={:xs}`, because `OpenAgentsWeb.UI.button/1` exposes no icon size.
385
  """
386
  attr :id, :string, default: nil
387
  attr :tooltip, :string, default: nil
388
  attr :label, :string, default: nil
389
390
  attr :variant, :atom,
391
    values: [:primary, :secondary, :outline, :ghost, :destructive],
392
    default: :ghost
393
394
  attr :size, :atom, values: [:default, :xs, :sm, :lg], default: :xs
395
  attr :class, :any, default: nil
396
  attr :rest, :global
397
  slot :inner_block, required: true
398
399
  def message_action(assigns) do
400
    assigns = assign(assigns, :name, assigns.label || assigns.tooltip)
401
402
    ~H"""
403
    <UI.button
404
      id={@id}
405
      variant={@variant}
406
      size={@size}
407
      class={@class}
408
      title={@tooltip}
409
      aria-label={@name}
410
      {@rest}
411
    >
412
      {render_slot(@inner_block)}
413
      <span :if={@name} class="sr-only">{@name}</span>
414
    </UI.button>
415
    """
416
  end
417
418
  @doc """
419
  Text that reads as still arriving.
420
421
  The source animates `background-position` across a 250%-wide gradient with
422
  `motion/react`, which needs a `@keyframes` rule this port may not add. The
423
  gradient machinery is kept exactly — clipped to the glyphs, muted base, bright
424
  band — and held at the centre, and the life comes from Tailwind's own
425
  `animate-pulse`. The band sweeps in AI Elements and breathes here; restoring
426
  the sweep is one `@keyframes` rule in the stylesheet.
427
428
  The highlight also moves from `--color-background` to `--color-foreground`.
429
  AI Elements is a light-first surface where the page ground is the brightest
430
  value available; on this product's dark default the same token is nearly
431
  black, so the band would darken the text instead of lighting it.
432
433
  `spread` is scaled by the length of the text, as in the source, so a long line
434
  gets a proportionally wider band.
435
  """
436
  attr :id, :string, default: nil
437
  attr :text, :string, required: true
438
  attr :tag, :string, default: "p"
439
  attr :spread, :integer, default: 2
440
  attr :class, :any, default: nil
441
  attr :rest, :global
442
443
  def shimmer(assigns) do
444
    assigns = assign(assigns, :dynamic_spread, String.length(assigns.text) * assigns.spread)
445
446
    ~H"""
447
    <.dynamic_tag
448
      tag_name={@tag}
449
      id={@id}
450
      class={[
451
        "relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
452
        "[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-foreground),#0000_calc(50%+var(--spread)))]",
453
        "[background-repeat:no-repeat,padding-box] [background-position:50%_center]",
454
        "[background-image:var(--bg),linear-gradient(var(--color-muted-foreground),var(--color-muted-foreground))]",
455
        "animate-pulse motion-reduce:animate-none",
456
        @class
457
      ]}
458
      style={"--spread: #{@dynamic_spread}px"}
459
      data-shimmer="true"
460
      {@rest}
461
    >
462
      {@text}
463
    </.dynamic_tag>
464
    """
465
  end
466
467
  @doc """
468
  A horizontal rail of openers.
469
470
  The source's Radix `ScrollArea` is a plain overflow container here. It exists
471
  to carry a scrollbar the source then hides with `<ScrollBar className="hidden">`,
472
  which native `overflow-x-auto` already does wherever overlay scrollbars are
473
  the platform default. As in the source, `class` lands on the inner row rather
474
  than on the scroller, so a caller can change the gap without breaking the
475
  clip.
476
  """
477
  attr :id, :string, default: nil
478
  attr :class, :any, default: nil
479
  attr :rest, :global
480
  slot :inner_block, required: true
481
482
  def suggestions(assigns) do
483
    ~H"""
484
    <div id={@id} class="w-full overflow-x-auto whitespace-nowrap" {@rest}>
485
      <div class={["flex w-max flex-nowrap items-center gap-2", @class]}>
486
        {render_slot(@inner_block)}
487
      </div>
488
    </div>
489
    """
490
  end
491
492
  @doc """
493
  One opener.
494
495
  The source hands the suggestion text back through `onClick`. Here the caller
496
  attaches `phx-click` and `phx-value-*` through the global passthrough, which
497
  is the same contract without a closure. The text also lands on `value`, so a
498
  form submission carries it without a second attribute.
499
  """
500
  attr :id, :string, default: nil
501
  attr :suggestion, :string, required: true
502
  attr :variant, :atom, values: [:primary, :secondary, :outline, :ghost], default: :outline
503
  attr :size, :atom, values: [:default, :xs, :sm, :lg], default: :sm
504
  attr :class, :any, default: nil
505
  attr :rest, :global
506
  slot :inner_block
507
508
  def suggestion(assigns) do
509
    ~H"""
510
    <UI.button
511
      id={@id}
512
      variant={@variant}
513
      size={@size}
514
      class={["cursor-pointer rounded-full px-4", @class]}
515
      value={@suggestion}
516
      {@rest}
517
    >
518
      <%= if @inner_block == [] do %>
519
        {@suggestion}
520
      <% else %>
521
        {render_slot(@inner_block)}
522
      <% end %>
523
    </UI.button>
524
    """
525
  end
526
527
  @doc """
528
  The small floating bar of controls that belongs to one object.
529
530
  In the source this is `@xyflow/react`'s `NodeToolbar`, which positions itself
531
  below the node it names. There is no React Flow canvas here, so what survives
532
  is the bar; the caller decides where it sits.
533
534
  `border` becomes `border border-border`. Tailwind v4 resolves a bare `border`
535
  to `currentcolor`, and this product does not carry shadcn's global
536
  `* { border-color: var(--border) }` rule, so the unqualified class would draw
537
  the edge in the text colour.
538
  """
539
  attr :id, :string, default: nil
540
  attr :label, :string, default: nil
541
  attr :class, :any, default: nil
542
  attr :rest, :global
543
  slot :inner_block, required: true
544
545
  def toolbar(assigns) do
546
    ~H"""
547
    <div
548
      id={@id}
549
      class={["flex items-center gap-1 rounded-sm border border-border bg-background p-1.5", @class]}
550
      role={@label && "toolbar"}
551
      aria-label={@label}
552
      {@rest}
553
    >
554
      {render_slot(@inner_block)}
555
    </div>
556
    """
557
  end
558
559
  @doc """
560
  The canvas control cluster.
561
562
  `@xyflow/react`'s `Controls` supplies the zoom and fit buttons in the source,
563
  so the wrapper is all that is portable and the buttons come from the slot. The
564
  child rules carry the intent and stay: whatever buttons land inside read as
565
  one segmented cluster rather than as separate controls.
566
567
  `border` becomes `border border-border` for the reason given on `toolbar/1`.
568
  """
569
  attr :id, :string, default: nil
570
  attr :label, :string, default: nil
571
  attr :class, :any, default: nil
572
  attr :rest, :global
573
  slot :inner_block, required: true
574
575
  def controls(assigns) do
576
    ~H"""
577
    <div
578
      id={@id}
579
      class={[
580
        "gap-px overflow-hidden rounded-md border border-border bg-card p-1 shadow-none!",
581
        "[&>button]:rounded-md [&>button]:border-none! [&>button]:bg-transparent!",
582
        "[&>button]:hover:bg-secondary!",
583
        @class
584
      ]}
585
      role={@label && "group"}
586
      aria-label={@label}
587
      {@rest}
588
    >
589
      {render_slot(@inner_block)}
590
    </div>
591
    """
592
  end
593
594
  @persona_states %{
595
    "idle" => {"idle", "Idle"},
596
    "listening" => {"listening", "Listening"},
597
    "thinking" => {"running", "Thinking"},
598
    "speaking" => {"speaking", "Speaking"},
599
    "asleep" => {"ended", "Asleep"}
600
  }
601
602
  @doc """
603
  Who is on the other side of the conversation, and what they are doing.
604
605
  The source is a Rive WebGL2 canvas playing one of six remote `.riv` files,
606
  switched by a state machine and recoloured from the page theme. None of that
607
  travels: the runtime is a React package, the assets sit on a third-party
608
  origin, and a WebGL context per persona is a cost this surface has not earned.
609
  What is portable is the contract — a fixed set of states, one presence marker,
610
  `size-16 shrink-0` for the mark — so this is a name, an avatar, and a state.
611
612
  Two of the five states have no marker of their own in
613
  `OpenAgentsWeb.UI.status_indicator/1`, so they join the nearest existing
614
  meaning rather than introduce a colour: `thinking` reads as `running`
615
  (activity, blue) and `asleep` as `ended` (a resting fact, grey).
616
617
  The marker is decorative because the word beside it says the same thing, which
618
  is the rule `status_indicator/1` documents.
619
  """
620
  attr :id, :string, default: nil
621
  attr :name, :string, required: true
622
  attr :state, :string, values: ~w(idle listening thinking speaking asleep), default: "idle"
623
  attr :status_label, :string, default: nil
624
  attr :src, :string, default: nil
625
  attr :fallback, :string, default: nil
626
  attr :class, :any, default: nil
627
  attr :rest, :global
628
629
  def persona(assigns) do
630
    {marker, default_label} = Map.fetch!(@persona_states, assigns.state)
631
632
    assigns =
633
      assigns
634
      |> assign(:marker, marker)
635
      |> assign(:label, assigns.status_label || default_label)
636
      |> assign(:initials, assigns.fallback || String.slice(assigns.name, 0, 2))
637
638
    ~H"""
639
    <div id={@id} class={["flex items-center gap-3", @class]} data-state={@state} {@rest}>
640
      <UI.avatar src={@src} alt="" fallback={@initials} class="size-16 shrink-0" />
641
      <div class="flex flex-col gap-1">
642
        <span class="font-medium text-sm">{@name}</span>
643
        <span class="flex items-center gap-2 text-muted-foreground text-sm">
644
          <UI.status_indicator state={@marker} label={@label} decorative />
645
          {@label}
646
        </span>
647
      </div>
648
    </div>
649
    """
650
  end
651
end
test/openagents_web/components/ai/conversation_test.exs added +362

@@ -0,0 +1,362 @@

1
defmodule OpenAgentsWeb.AI.ConversationTest do
2
  @moduledoc """
3
  What the port has to keep true, and what a screenshot cannot check.
4
5
  Three things break silently. A conditional that renders both branches at once
6
  looks fine until a caller supplies the override and gets two headings. A
7
  Tailwind class that this product's tokens do not define renders as nothing —
8
  no error, no colour. And the difference between streaming and settled Markdown
9
  is one unclosed asterisk, which reads as a typo rather than as a bug.
10
  """
11
12
  use ExUnit.Case, async: true
13
14
  import Phoenix.LiveViewTest, only: [render_component: 2]
15
16
  alias OpenAgentsWeb.AI.Conversation
17
18
  defp query(html, selector) do
19
    html
20
    |> LazyHTML.from_fragment()
21
    |> LazyHTML.query(selector)
22
    |> LazyHTML.to_tree()
23
  end
24
25
  defp slot(content) do
26
    [%{__slot__: :inner_block, inner_block: fn _changed, _argument -> content end}]
27
  end
28
29
  describe "conversation/1" do
30
    test "announces arriving turns and scrolls in a box of its own" do
31
      html =
32
        render_component(&Conversation.conversation/1,
33
          id: "chat",
34
          inner_block: slot("a turn")
35
        )
36
37
      assert query(html, "#chat[role=log]") != []
38
      assert query(html, "#chat[phx-hook$='.StickToBottom']") != []
39
      assert query(html, "#chat-viewport[data-conversation-viewport]") != []
40
      assert html =~ "overflow-y-hidden"
41
      assert html =~ "overflow-y-auto"
42
    end
43
44
    test "carries its own scroll button, outside the scrolling viewport" do
45
      html =
46
        render_component(&Conversation.conversation/1,
47
          id: "chat",
48
          inner_block: slot("a turn")
49
        )
50
51
      assert query(html, "#chat > #chat-scroll-button") != []
52
      assert query(html, "#chat-viewport #chat-scroll-button") == []
53
    end
54
55
    test "omits the scroll button when the caller places its own" do
56
      html =
57
        render_component(&Conversation.conversation/1,
58
          id: "chat",
59
          scroll_button: false,
60
          inner_block: slot("a turn")
61
        )
62
63
      assert query(html, "#chat-scroll-button") == []
64
      assert query(html, "#chat-viewport") != []
65
    end
66
  end
67
68
  describe "conversation_content/1" do
69
    test "stacks turns in one column" do
70
      html =
71
        render_component(&Conversation.conversation_content/1,
72
          id: "turns",
73
          inner_block: slot("a turn")
74
        )
75
76
      assert query(html, "#turns") != []
77
      assert html =~ "flex flex-col gap-8 p-4"
78
    end
79
  end
80
81
  describe "conversation_empty_state/1" do
82
    test "explains what would appear here" do
83
      html = render_component(&Conversation.conversation_empty_state/1, id: "blank")
84
85
      assert [{"h3", _, ["No messages yet"]}] = query(html, "#blank h3")
86
      assert [{"p", _, ["Start a conversation to see messages here"]}] = query(html, "#blank p")
87
    end
88
89
    test "draws a glyph only when one is named" do
90
      without = render_component(&Conversation.conversation_empty_state/1, id: "blank")
91
92
      with_icon =
93
        render_component(&Conversation.conversation_empty_state/1, id: "blank", icon: "chat")
94
95
      assert query(without, "#blank svg") == []
96
      assert query(with_icon, "#blank svg") != []
97
    end
98
99
    test "a caller's own content replaces the heading rather than joining it" do
100
      html =
101
        render_component(&Conversation.conversation_empty_state/1,
102
          id: "blank",
103
          inner_block: slot("Nothing to see")
104
        )
105
106
      assert query(html, "#blank h3") == []
107
      assert query(html, "#blank p") == []
108
      assert html =~ "Nothing to see"
109
    end
110
  end
111
112
  describe "conversation_scroll_button/1" do
113
    test "starts hidden, names itself, and points down" do
114
      html = render_component(&Conversation.conversation_scroll_button/1, id: "back")
115
116
      assert [{"button", attributes, _}] = query(html, "#back")
117
      assert {"aria-label", "Scroll to the newest message"} in attributes
118
      assert {"data-conversation-scroll-button", "true"} in attributes
119
      assert {"data-variant", "outline"} in attributes
120
121
      class = Enum.find_value(attributes, fn {name, value} -> name == "class" && value end)
122
      assert class =~ "hidden"
123
      assert class =~ "rounded-full"
124
125
      assert query(html, "#back svg[data-icon=arrow-down]") != []
126
    end
127
  end
128
129
  describe "message/1" do
130
    test "a user turn carries the marker its content styles read" do
131
      html =
132
        render_component(&Conversation.message/1,
133
          id: "m1",
134
          from: "user",
135
          inner_block: slot("hello")
136
        )
137
138
      assert query(html, "#m1.is-user") != []
139
      assert query(html, "#m1.is-assistant") == []
140
      assert query(html, "#m1[data-from=user]") != []
141
    end
142
143
    test "every other role reads as the assistant side" do
144
      html =
145
        render_component(&Conversation.message/1,
146
          id: "m2",
147
          from: "assistant",
148
          inner_block: slot("hello")
149
        )
150
151
      assert query(html, "#m2.is-assistant") != []
152
      assert query(html, "#m2.is-user") == []
153
    end
154
  end
155
156
  describe "message_content/1" do
157
    test "the user bubble is painted by the group, not by the content" do
158
      html = render_component(&Conversation.message_content/1, id: "b", inner_block: slot("hi"))
159
160
      assert html =~ "group-[.is-user]:bg-secondary"
161
      assert html =~ "group-[.is-user]:rounded-lg"
162
      assert html =~ "group-[.is-assistant]:text-foreground"
163
    end
164
165
    test "renders Markdown rather than the characters that spell it" do
166
      html = render_component(&Conversation.message_content/1, id: "b", text: "**bold**")
167
168
      assert [{"strong", _, ["bold"]}] = query(html, "#b strong")
169
    end
170
171
    test "a settled turn keeps an unclosed marker literal" do
172
      html = render_component(&Conversation.message_content/1, id: "b", text: "**bold")
173
174
      assert query(html, "#b strong") == []
175
      assert html =~ "**bold"
176
    end
177
178
    test "a streaming turn closes what has not arrived yet" do
179
      html =
180
        render_component(&Conversation.message_content/1,
181
          id: "b",
182
          text: "**bold",
183
          streaming: true
184
        )
185
186
      assert [{"strong", _, ["bold"]}] = query(html, "#b strong")
187
    end
188
  end
189
190
  describe "message_avatar/1" do
191
    test "falls back to two letters of the name" do
192
      html = render_component(&Conversation.message_avatar/1, id: "who", name: "Sonnet")
193
194
      assert query(html, "#who img") == []
195
      assert html =~ "So"
196
      assert html =~ "size-8"
197
      assert html =~ "ring-border"
198
    end
199
200
    test "shows the image when there is one" do
201
      html =
202
        render_component(&Conversation.message_avatar/1,
203
          id: "who",
204
          name: "Sonnet",
205
          src: "/images/sonnet.png"
206
        )
207
208
      assert query(html, "#who img[src='/images/sonnet.png']") != []
209
    end
210
  end
211
212
  describe "message_actions/1 and message_action/1" do
213
    test "the row is a row" do
214
      html = render_component(&Conversation.message_actions/1, id: "acts", inner_block: slot("x"))
215
216
      assert html =~ "flex items-center gap-1"
217
    end
218
219
    test "an icon-only action names itself twice: once for a pointer, once aloud" do
220
      html =
221
        render_component(&Conversation.message_action/1,
222
          id: "copy",
223
          tooltip: "Copy",
224
          inner_block: slot("")
225
        )
226
227
      assert [{"button", attributes, _}] = query(html, "#copy")
228
      assert {"title", "Copy"} in attributes
229
      assert {"aria-label", "Copy"} in attributes
230
      assert {"data-variant", "ghost"} in attributes
231
      assert [{"span", _, ["Copy"]}] = query(html, "#copy span.sr-only")
232
    end
233
234
    test "an action with nothing to say carries no empty name" do
235
      html = render_component(&Conversation.message_action/1, id: "bare", inner_block: slot("x"))
236
237
      assert query(html, "#bare span.sr-only") == []
238
    end
239
  end
240
241
  describe "shimmer/1" do
242
    test "clips a moving band to the glyphs and scales it to the line" do
243
      html = render_component(&Conversation.shimmer/1, id: "s", text: "Thinking")
244
245
      assert [{"p", attributes, [text]}] = query(html, "#s")
246
      assert String.trim(text) == "Thinking"
247
      assert {"style", "--spread: 16px"} in attributes
248
249
      class = Enum.find_value(attributes, fn {name, value} -> name == "class" && value end)
250
      assert class =~ "bg-clip-text"
251
      assert class =~ "text-transparent"
252
      assert class =~ "animate-pulse"
253
      assert class =~ "motion-reduce:animate-none"
254
      assert class =~ "var(--color-muted-foreground)"
255
    end
256
257
    test "takes the element the caller asks for" do
258
      html = render_component(&Conversation.shimmer/1, id: "s", tag: "span", text: "Hi")
259
260
      assert query(html, "span#s") != []
261
      assert query(html, "p#s") == []
262
    end
263
  end
264
265
  describe "suggestions/1 and suggestion/1" do
266
    test "the rail clips sideways and never wraps" do
267
      html = render_component(&Conversation.suggestions/1, id: "rail", inner_block: slot("x"))
268
269
      assert html =~ "overflow-x-auto"
270
      assert html =~ "flex w-max flex-nowrap"
271
    end
272
273
    test "an opener says its own text and carries it as a value" do
274
      html = render_component(&Conversation.suggestion/1, id: "s1", suggestion: "Explain this")
275
276
      assert [{"button", attributes, _}] = query(html, "#s1")
277
      assert {"value", "Explain this"} in attributes
278
      assert {"data-variant", "outline"} in attributes
279
      assert html =~ "Explain this"
280
      assert html =~ "rounded-full"
281
    end
282
283
    test "a caller's own label wins over the text" do
284
      html =
285
        render_component(&Conversation.suggestion/1,
286
          id: "s1",
287
          suggestion: "Explain this",
288
          inner_block: slot("Explain")
289
        )
290
291
      assert [{"button", _, children}] = query(html, "#s1")
292
      assert IO.iodata_to_binary(children) =~ "Explain"
293
      refute IO.iodata_to_binary(children) =~ "Explain this"
294
    end
295
  end
296
297
  describe "toolbar/1 and controls/1" do
298
    test "the bar draws its edge from the border token, not from the text colour" do
299
      html = render_component(&Conversation.toolbar/1, id: "bar", inner_block: slot("x"))
300
301
      assert html =~ "border border-border"
302
      assert html =~ "bg-background"
303
    end
304
305
    test "the bar is only a toolbar when it has a name to announce" do
306
      named =
307
        render_component(&Conversation.toolbar/1,
308
          id: "bar",
309
          label: "Node",
310
          inner_block: slot("x")
311
        )
312
313
      bare = render_component(&Conversation.toolbar/1, id: "bar", inner_block: slot("x"))
314
315
      assert query(named, "#bar[role=toolbar][aria-label=Node]") != []
316
      assert query(bare, "#bar[role]") == []
317
    end
318
319
    test "the cluster flattens whatever buttons land in it" do
320
      html = render_component(&Conversation.controls/1, id: "zoom", inner_block: slot("x"))
321
322
      assert html =~ "border border-border"
323
      assert html =~ "bg-card"
324
      assert html =~ "[&amp;&gt;button]:bg-transparent!"
325
    end
326
  end
327
328
  describe "persona/1" do
329
    test "a state with no marker of its own joins the nearest meaning" do
330
      thinking =
331
        render_component(&Conversation.persona/1, id: "p", name: "Sarah", state: "thinking")
332
333
      asleep = render_component(&Conversation.persona/1, id: "p", name: "Sarah", state: "asleep")
334
335
      assert query(thinking, "#p[data-state=thinking] .status-indicator[data-state=running]") !=
336
               []
337
338
      assert query(asleep, "#p[data-state=asleep] .status-indicator[data-state=ended]") != []
339
    end
340
341
    test "the marker stays quiet because the word beside it already says it" do
342
      html = render_component(&Conversation.persona/1, id: "p", name: "Sarah", state: "listening")
343
344
      assert query(html, "#p .status-indicator[aria-hidden=true]") != []
345
      assert html =~ "Listening"
346
      assert html =~ "size-16 shrink-0"
347
    end
348
349
    test "a caller's own status wording wins over the default" do
350
      html =
351
        render_component(&Conversation.persona/1,
352
          id: "p",
353
          name: "Sarah",
354
          state: "speaking",
355
          status_label: "Reading the diff aloud"
356
        )
357
358
      assert html =~ "Reading the diff aloud"
359
      refute html =~ "Speaking"
360
    end
361
  end
362
end

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