|
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
|