|
1
|
+ |
defmodule OpenAgentsWeb.AI.Reasoning do
|
|
2
|
+ |
@moduledoc """
|
|
3
|
+ |
The agent's visible thinking and work, ported from Vercel's AI Elements.
|
|
4
|
+ |
|
|
5
|
+ |
Six AI Elements files land here as one module: `reasoning`, `chain-of-thought`,
|
|
6
|
+ |
`tool`, `task`, `plan`, and `checkpoint`. They belong together because they
|
|
7
|
+ |
answer one question for the reader — what is the agent doing right now, and
|
|
8
|
+ |
what did it already do. Their Tailwind classes are carried across as written
|
|
9
|
+ |
so the harvested surface still reads as AI Elements; only tokens our theme
|
|
10
|
+ |
names differently, and utilities our bundle does not ship, are substituted.
|
|
11
|
+ |
Each substitution carries a comment at its call site.
|
|
12
|
+ |
|
|
13
|
+ |
## Compound components become siblings, except `plan/1`
|
|
14
|
+ |
|
|
15
|
+ |
React expresses these as compound components sharing a context. HEEx has no
|
|
16
|
+ |
context, so five of the six become sibling function components that a caller
|
|
17
|
+ |
nests by hand, with the state React kept in context passed as explicit
|
|
18
|
+ |
attributes (`streaming`, `duration`, `state`). That keeps the AI Elements call
|
|
19
|
+ |
shape and makes the state the LiveView owns visible at the call site.
|
|
20
|
+ |
|
|
21
|
+ |
`plan/1` is the exception and takes slots. Its footer must stay visible while
|
|
22
|
+ |
its content collapses, and a `<details>` element hides every child that is not
|
|
23
|
+ |
its `<summary>`. Only the parent can place a child outside the collapsing
|
|
24
|
+ |
region, so the parent has to know about the parts.
|
|
25
|
+ |
|
|
26
|
+ |
## Collapsing without React
|
|
27
|
+ |
|
|
28
|
+ |
Radix `Collapsible` backs `reasoning`, `tool`, `task`, `chain-of-thought`, and
|
|
29
|
+ |
`plan` upstream. All five use `<details>`/`<summary>` here — one mechanism for
|
|
30
|
+ |
the whole batch. `<details>` needs no JavaScript, no client state library, and
|
|
31
|
+ |
no hook; the browser supplies the disclosure semantics, the implicit
|
|
32
|
+ |
`aria-expanded`, and keyboard operation. The server writes the initial state
|
|
33
|
+ |
through the `open` attribute, so "auto-open while streaming, collapse when
|
|
34
|
+ |
done" stays a LiveView decision: pass `open={@streaming}` and re-render.
|
|
35
|
+ |
|
|
36
|
+ |
Two consequences are worth knowing. A reader who toggles a `<details>` owns it
|
|
37
|
+ |
until the next server render of that attribute, exactly as an uncontrolled
|
|
38
|
+ |
Radix collapsible behaves. And `data-[state=open]` from the source becomes the
|
|
39
|
+ |
`open` variant, since `<details>` carries a real `open` attribute.
|
|
40
|
+ |
|
|
41
|
+ |
## Markdown
|
|
42
|
+ |
|
|
43
|
+ |
`reasoning_content/1` renders through `OpenAgents.Markdown.to_html/2` where
|
|
44
|
+ |
AI Elements renders `<Streamdown>`. Pass `streaming` while the text is still
|
|
45
|
+ |
arriving so the renderer completes unbalanced markup.
|
|
46
|
+ |
"""
|
|
47
|
+ |
use OpenAgentsWeb, :html
|
|
48
|
+ |
|
|
49
|
+ |
@tool_states ~w(
|
|
50
|
+ |
input-streaming input-available output-available output-error
|
|
51
|
+ |
approval-requested approval-responded output-denied
|
|
52
|
+ |
)
|
|
53
|
+ |
|
|
54
|
+ |
# Shared summary treatment. `<summary>` is `display: list-item` by default,
|
|
55
|
+ |
# which paints a disclosure triangle; `flex` removes it in Chrome and Firefox
|
|
56
|
+ |
# and the WebKit pseudo-element rule removes it in Safari. A function rather
|
|
57
|
+ |
# than a module attribute, because `@name` inside `~H` reads an assign.
|
|
58
|
+ |
defp summary_class, do: "cursor-pointer list-none [&::-webkit-details-marker]:hidden"
|
|
59
|
+ |
|
|
60
|
+ |
@doc """
|
|
61
|
+ |
A collapsible block of model reasoning.
|
|
62
|
+ |
|
|
63
|
+ |
Wraps `reasoning_trigger/1` and `reasoning_content/1`. Give `open` from the
|
|
64
|
+ |
LiveView: AI Elements opens itself while `isStreaming` is true and closes a
|
|
65
|
+ |
second after it turns false, which is state a server render already holds.
|
|
66
|
+ |
|
|
67
|
+ |
<.reasoning id="turn-7-reasoning" open={@streaming}>
|
|
68
|
+ |
<.reasoning_trigger streaming={@streaming} duration={@thought_seconds} />
|
|
69
|
+ |
<.reasoning_content text={@reasoning_text} streaming={@streaming} />
|
|
70
|
+ |
</.reasoning>
|
|
71
|
+ |
"""
|
|
72
|
+ |
attr :id, :string, default: nil
|
|
73
|
+ |
attr :open, :boolean, default: false
|
|
74
|
+ |
attr :class, :any, default: nil
|
|
75
|
+ |
attr :rest, :global
|
|
76
|
+ |
slot :inner_block, required: true
|
|
77
|
+ |
|
|
78
|
+ |
def reasoning(assigns) do
|
|
79
|
+ |
~H"""
|
|
80
|
+ |
<%!-- Source: "not-prose mb-4". `not-prose` needs the typography plugin,
|
|
81
|
+ |
which this bundle does not load, so it is dropped. `group` is added so the
|
|
82
|
+ |
trigger's chevron can key off the parent's open state. --%>
|
|
83
|
+ |
<details id={@id} class={["group mb-4", @class]} open={@open} {@rest}>
|
|
84
|
+ |
{render_slot(@inner_block)}
|
|
85
|
+ |
</details>
|
|
86
|
+ |
"""
|
|
87
|
+ |
end
|
|
88
|
+ |
|
|
89
|
+ |
@doc """
|
|
90
|
+ |
The reasoning disclosure control and its elapsed-duration label.
|
|
91
|
+ |
|
|
92
|
+ |
With no `inner_block`, renders the AI Elements default: a brain glyph, the
|
|
93
|
+ |
thinking message, and a chevron that flips when the block opens. The message
|
|
94
|
+ |
reads "Thinking..." while `streaming`, then "Thought for N seconds" once
|
|
95
|
+ |
`duration` is known, and "Thought for a few seconds" when it is not.
|
|
96
|
+ |
"""
|
|
97
|
+ |
attr :id, :string, default: nil
|
|
98
|
+ |
attr :streaming, :boolean, default: false
|
|
99
|
+ |
attr :duration, :integer, default: nil
|
|
100
|
+ |
attr :class, :any, default: nil
|
|
101
|
+ |
attr :rest, :global
|
|
102
|
+ |
slot :inner_block
|
|
103
|
+ |
|
|
104
|
+ |
def reasoning_trigger(assigns) do
|
|
105
|
+ |
~H"""
|
|
106
|
+ |
<summary
|
|
107
|
+ |
id={@id}
|
|
108
|
+ |
class={[
|
|
109
|
+ |
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
|
110
|
+ |
summary_class(),
|
|
111
|
+ |
@class
|
|
112
|
+ |
]}
|
|
113
|
+ |
{@rest}
|
|
114
|
+ |
>
|
|
115
|
+ |
<%= if @inner_block != [] do %>
|
|
116
|
+ |
{render_slot(@inner_block)}
|
|
117
|
+ |
<% else %>
|
|
118
|
+ |
<%!-- lucide BrainIcon -> Apps SDK `brain`. --%>
|
|
119
|
+ |
<.icon name="brain" class="size-4" />
|
|
120
|
+ |
<%!-- Source wraps the streaming label in <Shimmer>, a masked gradient
|
|
121
|
+ |
sweep from an AI Elements file outside this batch. `animate-pulse` is the
|
|
122
|
+ |
nearest utility this bundle ships. --%>
|
|
123
|
+ |
<p class={["m-0", (@streaming || @duration == 0) && "animate-pulse"]}>
|
|
124
|
+ |
{thinking_message(@streaming, @duration)}
|
|
125
|
+ |
</p>
|
|
126
|
+ |
<%!-- lucide ChevronDownIcon -> Apps SDK `chevron-down`. `data-[state=open]`
|
|
127
|
+ |
becomes the `open` variant, which `<details>` supplies natively. --%>
|
|
128
|
+ |
<.icon
|
|
129
|
+ |
name="chevron-down"
|
|
130
|
+ |
class="size-4 transition-transform rotate-0 group-open:rotate-180"
|
|
131
|
+ |
/>
|
|
132
|
+ |
<% end %>
|
|
133
|
+ |
</summary>
|
|
134
|
+ |
"""
|
|
135
|
+ |
end
|
|
136
|
+ |
|
|
137
|
+ |
defp thinking_message(streaming, duration) when streaming or duration == 0, do: "Thinking..."
|
|
138
|
+ |
defp thinking_message(_streaming, nil), do: "Thought for a few seconds"
|
|
139
|
+ |
defp thinking_message(_streaming, duration), do: "Thought for #{duration} seconds"
|
|
140
|
+ |
|
|
141
|
+ |
@doc """
|
|
142
|
+ |
The reasoning text, rendered as markdown.
|
|
143
|
+ |
|
|
144
|
+ |
Pass `streaming` while the text is still arriving so partial markup completes.
|
|
145
|
+ |
"""
|
|
146
|
+ |
attr :id, :string, default: nil
|
|
147
|
+ |
attr :text, :string, required: true
|
|
148
|
+ |
attr :streaming, :boolean, default: false
|
|
149
|
+ |
attr :class, :any, default: nil
|
|
150
|
+ |
attr :rest, :global
|
|
151
|
+ |
|
|
152
|
+ |
def reasoning_content(assigns) do
|
|
153
|
+ |
~H"""
|
|
154
|
+ |
<%!-- Source adds enter/exit animation utilities from tailwindcss-animate
|
|
155
|
+ |
(`data-[state=open]:animate-in`, `slide-in-from-top-2`, `fade-out-0`). That
|
|
156
|
+ |
plugin is not in this bundle, so the animation classes are dropped and the
|
|
157
|
+ |
disclosure is instant. --%>
|
|
158
|
+ |
<div
|
|
159
|
+ |
id={@id}
|
|
160
|
+ |
class={["mt-4 text-sm text-muted-foreground outline-none", @class]}
|
|
161
|
+ |
{@rest}
|
|
162
|
+ |
>
|
|
163
|
+ |
{OpenAgents.Markdown.to_html(@text, streaming: @streaming)}
|
|
164
|
+ |
</div>
|
|
165
|
+ |
"""
|
|
166
|
+ |
end
|
|
167
|
+ |
|
|
168
|
+ |
@doc """
|
|
169
|
+ |
A collapsible chain-of-thought trace.
|
|
170
|
+ |
|
|
171
|
+ |
Upstream this is a plain `<div>` holding two separate Radix collapsibles that
|
|
172
|
+ |
share one context, so the header and the content can be siblings in the DOM.
|
|
173
|
+ |
A single `<details>` expresses the same thing with one element, so this port
|
|
174
|
+ |
collapses the pair: put `chain_of_thought_header/1` and
|
|
175
|
+ |
`chain_of_thought_content/1` directly inside.
|
|
176
|
+ |
"""
|
|
177
|
+ |
attr :id, :string, default: nil
|
|
178
|
+ |
attr :open, :boolean, default: false
|
|
179
|
+ |
attr :class, :any, default: nil
|
|
180
|
+ |
attr :rest, :global
|
|
181
|
+ |
slot :inner_block, required: true
|
|
182
|
+ |
|
|
183
|
+ |
def chain_of_thought(assigns) do
|
|
184
|
+ |
~H"""
|
|
185
|
+ |
<%!-- Source: "not-prose w-full space-y-4". `not-prose` dropped (no
|
|
186
|
+ |
typography plugin); `group` added for the chevron. --%>
|
|
187
|
+ |
<details id={@id} class={["group w-full space-y-4", @class]} open={@open} {@rest}>
|
|
188
|
+ |
{render_slot(@inner_block)}
|
|
189
|
+ |
</details>
|
|
190
|
+ |
"""
|
|
191
|
+ |
end
|
|
192
|
+ |
|
|
193
|
+ |
@doc """
|
|
194
|
+ |
The chain-of-thought disclosure control. Defaults to the label "Chain of thought".
|
|
195
|
+ |
"""
|
|
196
|
+ |
attr :id, :string, default: nil
|
|
197
|
+ |
attr :class, :any, default: nil
|
|
198
|
+ |
attr :rest, :global
|
|
199
|
+ |
slot :inner_block
|
|
200
|
+ |
|
|
201
|
+ |
def chain_of_thought_header(assigns) do
|
|
202
|
+ |
~H"""
|
|
203
|
+ |
<summary
|
|
204
|
+ |
id={@id}
|
|
205
|
+ |
class={[
|
|
206
|
+ |
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
|
207
|
+ |
summary_class(),
|
|
208
|
+ |
@class
|
|
209
|
+ |
]}
|
|
210
|
+ |
{@rest}
|
|
211
|
+ |
>
|
|
212
|
+ |
<.icon name="brain" class="size-4" />
|
|
213
|
+ |
<span class="flex-1 text-left">
|
|
214
|
+ |
<%= if @inner_block != [] do %>
|
|
215
|
+ |
{render_slot(@inner_block)}
|
|
216
|
+ |
<% else %>
|
|
217
|
+ |
Chain of thought
|
|
218
|
+ |
<% end %>
|
|
219
|
+ |
</span>
|
|
220
|
+ |
<.icon name="chevron-down" class="size-4 transition-transform rotate-0 group-open:rotate-180" />
|
|
221
|
+ |
</summary>
|
|
222
|
+ |
"""
|
|
223
|
+ |
end
|
|
224
|
+ |
|
|
225
|
+ |
@doc """
|
|
226
|
+ |
The body of a chain-of-thought trace. Holds `chain_of_thought_step/1` elements.
|
|
227
|
+ |
"""
|
|
228
|
+ |
attr :id, :string, default: nil
|
|
229
|
+ |
attr :class, :any, default: nil
|
|
230
|
+ |
attr :rest, :global
|
|
231
|
+ |
slot :inner_block, required: true
|
|
232
|
+ |
|
|
233
|
+ |
def chain_of_thought_content(assigns) do
|
|
234
|
+ |
~H"""
|
|
235
|
+ |
<%!-- Enter/exit animation utilities dropped: see reasoning_content/1. --%>
|
|
236
|
+ |
<div id={@id} class={["mt-2 space-y-3 text-popover-foreground outline-none", @class]} {@rest}>
|
|
237
|
+ |
{render_slot(@inner_block)}
|
|
238
|
+ |
</div>
|
|
239
|
+ |
"""
|
|
240
|
+ |
end
|
|
241
|
+ |
|
|
242
|
+ |
@doc """
|
|
243
|
+ |
One step in a chain of thought.
|
|
244
|
+ |
|
|
245
|
+ |
`status` dims the step: `:active` is the step being worked, `:complete` is
|
|
246
|
+ |
behind it, and `:pending` is ahead of it.
|
|
247
|
+ |
"""
|
|
248
|
+ |
attr :id, :string, default: nil
|
|
249
|
+ |
attr :icon, :string, default: "dot"
|
|
250
|
+ |
attr :label, :string, required: true
|
|
251
|
+ |
attr :description, :string, default: nil
|
|
252
|
+ |
attr :status, :atom, values: [:complete, :active, :pending], default: :complete
|
|
253
|
+ |
attr :class, :any, default: nil
|
|
254
|
+ |
attr :rest, :global
|
|
255
|
+ |
slot :inner_block
|
|
256
|
+ |
|
|
257
|
+ |
def chain_of_thought_step(assigns) do
|
|
258
|
+ |
~H"""
|
|
259
|
+ |
<%!-- Source also carries "fade-in-0 slide-in-from-top-2 animate-in"; dropped
|
|
260
|
+ |
with the rest of the tailwindcss-animate utilities. --%>
|
|
261
|
+ |
<div id={@id} class={["flex gap-2 text-sm", step_status_class(@status), @class]} {@rest}>
|
|
262
|
+ |
<div class="relative mt-0.5">
|
|
263
|
+ |
<.icon name={@icon} class="size-4" />
|
|
264
|
+ |
<div class="absolute top-7 bottom-0 left-1/2 -mx-px w-px bg-border"></div>
|
|
265
|
+ |
</div>
|
|
266
|
+ |
<div class="flex-1 space-y-2 overflow-hidden">
|
|
267
|
+ |
<div>{@label}</div>
|
|
268
|
+ |
<div :if={@description} class="text-muted-foreground text-xs">{@description}</div>
|
|
269
|
+ |
{render_slot(@inner_block)}
|
|
270
|
+ |
</div>
|
|
271
|
+ |
</div>
|
|
272
|
+ |
"""
|
|
273
|
+ |
end
|
|
274
|
+ |
|
|
275
|
+ |
defp step_status_class(:active), do: "text-foreground"
|
|
276
|
+ |
defp step_status_class(:complete), do: "text-muted-foreground"
|
|
277
|
+ |
defp step_status_class(:pending), do: "text-muted-foreground/50"
|
|
278
|
+ |
|
|
279
|
+ |
@doc """
|
|
280
|
+ |
A row of search results found during a step.
|
|
281
|
+ |
"""
|
|
282
|
+ |
attr :id, :string, default: nil
|
|
283
|
+ |
attr :class, :any, default: nil
|
|
284
|
+ |
attr :rest, :global
|
|
285
|
+ |
slot :inner_block, required: true
|
|
286
|
+ |
|
|
287
|
+ |
def chain_of_thought_search_results(assigns) do
|
|
288
|
+ |
~H"""
|
|
289
|
+ |
<div id={@id} class={["flex flex-wrap items-center gap-2", @class]} {@rest}>
|
|
290
|
+ |
{render_slot(@inner_block)}
|
|
291
|
+ |
</div>
|
|
292
|
+ |
"""
|
|
293
|
+ |
end
|
|
294
|
+ |
|
|
295
|
+ |
@doc """
|
|
296
|
+ |
One search result inside `chain_of_thought_search_results/1`.
|
|
297
|
+ |
"""
|
|
298
|
+ |
attr :id, :string, default: nil
|
|
299
|
+ |
attr :class, :any, default: nil
|
|
300
|
+ |
attr :rest, :global
|
|
301
|
+ |
slot :inner_block, required: true
|
|
302
|
+ |
|
|
303
|
+ |
def chain_of_thought_search_result(assigns) do
|
|
304
|
+ |
~H"""
|
|
305
|
+ |
<%!-- Source uses shadcn Badge variant="secondary"; `UI.badge/1` is the
|
|
306
|
+ |
equivalent primitive here and `:dim` is its quiet variant. --%>
|
|
307
|
+ |
<.badge id={@id} variant={:dim} class={["gap-1 px-2 py-0.5 font-normal text-xs", @class]} {@rest}>
|
|
308
|
+ |
{render_slot(@inner_block)}
|
|
309
|
+ |
</.badge>
|
|
310
|
+ |
"""
|
|
311
|
+ |
end
|
|
312
|
+ |
|
|
313
|
+ |
@doc """
|
|
314
|
+ |
An image produced during a step, with an optional caption.
|
|
315
|
+ |
"""
|
|
316
|
+ |
attr :id, :string, default: nil
|
|
317
|
+ |
attr :caption, :string, default: nil
|
|
318
|
+ |
attr :class, :any, default: nil
|
|
319
|
+ |
attr :rest, :global
|
|
320
|
+ |
slot :inner_block, required: true
|
|
321
|
+ |
|
|
322
|
+ |
def chain_of_thought_image(assigns) do
|
|
323
|
+ |
~H"""
|
|
324
|
+ |
<div id={@id} class={["mt-2 space-y-2", @class]} {@rest}>
|
|
325
|
+ |
<div class="relative flex max-h-[22rem] items-center justify-center overflow-hidden rounded-lg bg-muted p-3">
|
|
326
|
+ |
{render_slot(@inner_block)}
|
|
327
|
+ |
</div>
|
|
328
|
+ |
<p :if={@caption} class="text-muted-foreground text-xs">{@caption}</p>
|
|
329
|
+ |
</div>
|
|
330
|
+ |
"""
|
|
331
|
+ |
end
|
|
332
|
+ |
|
|
333
|
+ |
@doc """
|
|
334
|
+ |
A collapsible record of one tool call.
|
|
335
|
+ |
|
|
336
|
+ |
Wraps `tool_header/1`, then `tool_content/1` holding `tool_input/1` and
|
|
337
|
+ |
`tool_output/1`.
|
|
338
|
+ |
"""
|
|
339
|
+ |
attr :id, :string, default: nil
|
|
340
|
+ |
attr :open, :boolean, default: false
|
|
341
|
+ |
attr :class, :any, default: nil
|
|
342
|
+ |
attr :rest, :global
|
|
343
|
+ |
slot :inner_block, required: true
|
|
344
|
+ |
|
|
345
|
+ |
def tool(assigns) do
|
|
346
|
+ |
~H"""
|
|
347
|
+ |
<%!-- Source: "group not-prose mb-4 w-full rounded-md border". `not-prose`
|
|
348
|
+ |
dropped (no typography plugin). --%>
|
|
349
|
+ |
<details id={@id} class={["group mb-4 w-full rounded-md border", @class]} open={@open} {@rest}>
|
|
350
|
+ |
{render_slot(@inner_block)}
|
|
351
|
+ |
</details>
|
|
352
|
+ |
"""
|
|
353
|
+ |
end
|
|
354
|
+ |
|
|
355
|
+ |
@doc """
|
|
356
|
+ |
The tool disclosure control: the tool's name and its current state.
|
|
357
|
+ |
|
|
358
|
+ |
`type` is the AI SDK part type. `tool-getWeather` displays as `getWeather`;
|
|
359
|
+ |
`dynamic-tool` displays `tool_name`. `title` overrides both.
|
|
360
|
+ |
"""
|
|
361
|
+ |
attr :id, :string, default: nil
|
|
362
|
+ |
attr :title, :string, default: nil
|
|
363
|
+ |
attr :type, :string, required: true
|
|
364
|
+ |
attr :tool_name, :string, default: nil
|
|
365
|
+ |
attr :state, :string, values: @tool_states, required: true
|
|
366
|
+ |
attr :class, :any, default: nil
|
|
367
|
+ |
attr :rest, :global
|
|
368
|
+ |
|
|
369
|
+ |
def tool_header(assigns) do
|
|
370
|
+ |
~H"""
|
|
371
|
+ |
<summary
|
|
372
|
+ |
id={@id}
|
|
373
|
+ |
class={["flex w-full items-center justify-between gap-4 p-3", summary_class(), @class]}
|
|
374
|
+ |
{@rest}
|
|
375
|
+ |
>
|
|
376
|
+ |
<div class="flex items-center gap-2">
|
|
377
|
+ |
<%!-- lucide WrenchIcon -> Apps SDK `tools`, which is a wrench. --%>
|
|
378
|
+ |
<.icon name="tools" class="size-4 text-muted-foreground" />
|
|
379
|
+ |
<span class="font-medium text-sm">{tool_display_name(@title, @type, @tool_name)}</span>
|
|
380
|
+ |
<.tool_status_badge state={@state} />
|
|
381
|
+ |
</div>
|
|
382
|
+ |
<.icon
|
|
383
|
+ |
name="chevron-down"
|
|
384
|
+ |
class="size-4 text-muted-foreground transition-transform group-open:rotate-180"
|
|
385
|
+ |
/>
|
|
386
|
+ |
</summary>
|
|
387
|
+ |
"""
|
|
388
|
+ |
end
|
|
389
|
+ |
|
|
390
|
+ |
defp tool_display_name(title, _type, _tool_name) when is_binary(title), do: title
|
|
391
|
+ |
defp tool_display_name(_title, "dynamic-tool", tool_name), do: tool_name
|
|
392
|
+ |
|
|
393
|
+ |
defp tool_display_name(_title, type, _tool_name) do
|
|
394
|
+ |
type |> String.split("-") |> Enum.drop(1) |> Enum.join("-")
|
|
395
|
+ |
end
|
|
396
|
+ |
|
|
397
|
+ |
@doc """
|
|
398
|
+ |
The state of a tool call, as a labelled badge.
|
|
399
|
+ |
|
|
400
|
+ |
Upstream this is a neutral badge whose glyph carries the colour. `UI.badge/1`
|
|
401
|
+ |
colours the whole badge instead, which is this product's rule — semantic
|
|
402
|
+ |
colour reinforces the words rather than sitting beside them — so the glyph
|
|
403
|
+ |
inherits `currentColor` and the per-icon colour classes are dropped.
|
|
404
|
+ |
"""
|
|
405
|
+ |
attr :id, :string, default: nil
|
|
406
|
+ |
attr :state, :string, values: @tool_states, required: true
|
|
407
|
+ |
attr :class, :any, default: nil
|
|
408
|
+ |
attr :rest, :global
|
|
409
|
+ |
|
|
410
|
+ |
def tool_status_badge(assigns) do
|
|
411
|
+ |
~H"""
|
|
412
|
+ |
<.badge
|
|
413
|
+ |
id={@id}
|
|
414
|
+ |
variant={tool_state_variant(@state)}
|
|
415
|
+ |
class={["gap-1.5 rounded-full text-xs", @class]}
|
|
416
|
+ |
{@rest}
|
|
417
|
+ |
>
|
|
418
|
+ |
<.icon
|
|
419
|
+ |
name={tool_state_icon(@state)}
|
|
420
|
+ |
class={["size-4", @state == "input-available" && "animate-pulse"]}
|
|
421
|
+ |
/>
|
|
422
|
+ |
{tool_state_label(@state)}
|
|
423
|
+ |
</.badge>
|
|
424
|
+ |
"""
|
|
425
|
+ |
end
|
|
426
|
+ |
|
|
427
|
+ |
defp tool_state_label("approval-requested"), do: "Awaiting approval"
|
|
428
|
+ |
defp tool_state_label("approval-responded"), do: "Responded"
|
|
429
|
+ |
defp tool_state_label("input-available"), do: "Running"
|
|
430
|
+ |
defp tool_state_label("input-streaming"), do: "Pending"
|
|
431
|
+ |
defp tool_state_label("output-available"), do: "Completed"
|
|
432
|
+ |
defp tool_state_label("output-denied"), do: "Denied"
|
|
433
|
+ |
defp tool_state_label("output-error"), do: "Error"
|
|
434
|
+ |
|
|
435
|
+ |
# lucide ClockIcon -> `clock`, CircleIcon -> `empty-circle`,
|
|
436
|
+ |
# CheckCircleIcon -> `check-circle`, XCircleIcon -> `x-circle`.
|
|
437
|
+ |
defp tool_state_icon("approval-requested"), do: "clock"
|
|
438
|
+ |
defp tool_state_icon("approval-responded"), do: "check-circle"
|
|
439
|
+ |
defp tool_state_icon("input-available"), do: "clock"
|
|
440
|
+ |
defp tool_state_icon("input-streaming"), do: "empty-circle"
|
|
441
|
+ |
defp tool_state_icon("output-available"), do: "check-circle"
|
|
442
|
+ |
defp tool_state_icon("output-denied"), do: "x-circle"
|
|
443
|
+ |
defp tool_state_icon("output-error"), do: "x-circle"
|
|
444
|
+ |
|
|
445
|
+ |
defp tool_state_variant("approval-requested"), do: :warning
|
|
446
|
+ |
defp tool_state_variant("approval-responded"), do: :info
|
|
447
|
+ |
defp tool_state_variant("input-available"), do: :info
|
|
448
|
+ |
defp tool_state_variant("input-streaming"), do: :dim
|
|
449
|
+ |
defp tool_state_variant("output-available"), do: :success
|
|
450
|
+ |
defp tool_state_variant("output-denied"), do: :warning
|
|
451
|
+ |
defp tool_state_variant("output-error"), do: :danger
|
|
452
|
+ |
|
|
453
|
+ |
@doc """
|
|
454
|
+ |
The body of a tool call. Holds `tool_input/1` and `tool_output/1`.
|
|
455
|
+ |
"""
|
|
456
|
+ |
attr :id, :string, default: nil
|
|
457
|
+ |
attr :class, :any, default: nil
|
|
458
|
+ |
attr :rest, :global
|
|
459
|
+ |
slot :inner_block, required: true
|
|
460
|
+ |
|
|
461
|
+ |
def tool_content(assigns) do
|
|
462
|
+ |
~H"""
|
|
463
|
+ |
<%!-- Enter/exit animation utilities dropped: see reasoning_content/1. --%>
|
|
464
|
+ |
<div id={@id} class={["space-y-4 p-4 text-popover-foreground outline-none", @class]} {@rest}>
|
|
465
|
+ |
{render_slot(@inner_block)}
|
|
466
|
+ |
</div>
|
|
467
|
+ |
"""
|
|
468
|
+ |
end
|
|
469
|
+ |
|
|
470
|
+ |
@doc """
|
|
471
|
+ |
The parameters a tool was called with, as formatted JSON.
|
|
472
|
+ |
"""
|
|
473
|
+ |
attr :id, :string, default: nil
|
|
474
|
+ |
attr :input, :string, required: true
|
|
475
|
+ |
attr :class, :any, default: nil
|
|
476
|
+ |
attr :rest, :global
|
|
477
|
+ |
|
|
478
|
+ |
def tool_input(assigns) do
|
|
479
|
+ |
~H"""
|
|
480
|
+ |
<div id={@id} class={["space-y-2 overflow-hidden", @class]} {@rest}>
|
|
481
|
+ |
<h4 class="font-medium text-muted-foreground text-xs uppercase tracking-wide">Parameters</h4>
|
|
482
|
+ |
<div class="rounded-md bg-muted/50">
|
|
483
|
+ |
<.ai_code_block code={@input} />
|
|
484
|
+ |
</div>
|
|
485
|
+ |
</div>
|
|
486
|
+ |
"""
|
|
487
|
+ |
end
|
|
488
|
+ |
|
|
489
|
+ |
@doc """
|
|
490
|
+ |
What a tool returned, or the error it raised.
|
|
491
|
+ |
|
|
492
|
+ |
Renders nothing when neither `output` nor `error_text` is set, matching the
|
|
493
|
+ |
upstream early return.
|
|
494
|
+ |
"""
|
|
495
|
+ |
attr :id, :string, default: nil
|
|
496
|
+ |
attr :output, :string, default: nil
|
|
497
|
+ |
attr :error_text, :string, default: nil
|
|
498
|
+ |
attr :class, :any, default: nil
|
|
499
|
+ |
attr :rest, :global
|
|
500
|
+ |
|
|
501
|
+ |
def tool_output(assigns) do
|
|
502
|
+ |
~H"""
|
|
503
|
+ |
<div :if={@output || @error_text} id={@id} class={["space-y-2", @class]} {@rest}>
|
|
504
|
+ |
<h4 class="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
|
505
|
+ |
{if @error_text, do: "Error", else: "Result"}
|
|
506
|
+ |
</h4>
|
|
507
|
+ |
<div class={[
|
|
508
|
+ |
"overflow-x-auto rounded-md text-xs [&_table]:w-full",
|
|
509
|
+ |
if(@error_text,
|
|
510
|
+ |
do: "bg-destructive/10 text-destructive",
|
|
511
|
+ |
else: "bg-muted/50 text-foreground"
|
|
512
|
+ |
)
|
|
513
|
+ |
]}>
|
|
514
|
+ |
<div :if={@error_text}>{@error_text}</div>
|
|
515
|
+ |
<.ai_code_block :if={@output} code={@output} />
|
|
516
|
+ |
</div>
|
|
517
|
+ |
</div>
|
|
518
|
+ |
"""
|
|
519
|
+ |
end
|
|
520
|
+ |
|
|
521
|
+ |
# AI Elements renders tool payloads through its own `CodeBlock`, which is not
|
|
522
|
+ |
# in this batch. Until that port lands, a preformatted block carries the same
|
|
523
|
+ |
# shape without claiming syntax highlighting it does not do.
|
|
524
|
+ |
attr :code, :string, required: true
|
|
525
|
+ |
|
|
526
|
+ |
defp ai_code_block(assigns) do
|
|
527
|
+ |
~H"""
|
|
528
|
+ |
<pre class="overflow-x-auto p-4 font-mono text-xs"><code>{@code}</code></pre>
|
|
529
|
+ |
"""
|
|
530
|
+ |
end
|
|
531
|
+ |
|
|
532
|
+ |
@doc """
|
|
533
|
+ |
A collapsible record of one piece of agent work.
|
|
534
|
+ |
|
|
535
|
+ |
Open by default, as upstream. Wraps `task_trigger/1` and `task_content/1`.
|
|
536
|
+ |
"""
|
|
537
|
+ |
attr :id, :string, default: nil
|
|
538
|
+ |
attr :open, :boolean, default: true
|
|
539
|
+ |
attr :class, :any, default: nil
|
|
540
|
+ |
attr :rest, :global
|
|
541
|
+ |
slot :inner_block, required: true
|
|
542
|
+ |
|
|
543
|
+ |
def task(assigns) do
|
|
544
|
+ |
~H"""
|
|
545
|
+ |
<details id={@id} class={["group", @class]} open={@open} {@rest}>
|
|
546
|
+ |
{render_slot(@inner_block)}
|
|
547
|
+ |
</details>
|
|
548
|
+ |
"""
|
|
549
|
+ |
end
|
|
550
|
+ |
|
|
551
|
+ |
@doc """
|
|
552
|
+ |
The task disclosure control, showing what the task is.
|
|
553
|
+ |
"""
|
|
554
|
+ |
attr :id, :string, default: nil
|
|
555
|
+ |
attr :title, :string, required: true
|
|
556
|
+ |
attr :class, :any, default: nil
|
|
557
|
+ |
attr :rest, :global
|
|
558
|
+ |
slot :inner_block
|
|
559
|
+ |
|
|
560
|
+ |
def task_trigger(assigns) do
|
|
561
|
+ |
~H"""
|
|
562
|
+ |
<%!-- Upstream renders `CollapsibleTrigger asChild` around a <div>, so the
|
|
563
|
+ |
trigger element is the child. `<summary>` is already the trigger, so the
|
|
564
|
+ |
wrapper and its child merge into one element. --%>
|
|
565
|
+ |
<summary
|
|
566
|
+ |
id={@id}
|
|
567
|
+ |
class={[
|
|
568
|
+ |
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
|
569
|
+ |
summary_class(),
|
|
570
|
+ |
@class
|
|
571
|
+ |
]}
|
|
572
|
+ |
{@rest}
|
|
573
|
+ |
>
|
|
574
|
+ |
<%= if @inner_block != [] do %>
|
|
575
|
+ |
{render_slot(@inner_block)}
|
|
576
|
+ |
<% else %>
|
|
577
|
+ |
<%!-- lucide SearchIcon -> Apps SDK `search`. --%>
|
|
578
|
+ |
<.icon name="search" class="size-4" />
|
|
579
|
+ |
<p class="m-0 text-sm">{@title}</p>
|
|
580
|
+ |
<.icon name="chevron-down" class="size-4 transition-transform group-open:rotate-180" />
|
|
581
|
+ |
<% end %>
|
|
582
|
+ |
</summary>
|
|
583
|
+ |
"""
|
|
584
|
+ |
end
|
|
585
|
+ |
|
|
586
|
+ |
@doc """
|
|
587
|
+ |
The list of things a task did. Holds `task_item/1` elements.
|
|
588
|
+ |
"""
|
|
589
|
+ |
attr :id, :string, default: nil
|
|
590
|
+ |
attr :class, :any, default: nil
|
|
591
|
+ |
attr :rest, :global
|
|
592
|
+ |
slot :inner_block, required: true
|
|
593
|
+ |
|
|
594
|
+ |
def task_content(assigns) do
|
|
595
|
+ |
~H"""
|
|
596
|
+ |
<%!-- Enter/exit animation utilities dropped: see reasoning_content/1. --%>
|
|
597
|
+ |
<div id={@id} class={["text-popover-foreground outline-none", @class]} {@rest}>
|
|
598
|
+ |
<div class="mt-4 space-y-2 border-muted border-l-2 pl-4">
|
|
599
|
+ |
{render_slot(@inner_block)}
|
|
600
|
+ |
</div>
|
|
601
|
+ |
</div>
|
|
602
|
+ |
"""
|
|
603
|
+ |
end
|
|
604
|
+ |
|
|
605
|
+ |
@doc """
|
|
606
|
+ |
One line of task work.
|
|
607
|
+ |
"""
|
|
608
|
+ |
attr :id, :string, default: nil
|
|
609
|
+ |
attr :class, :any, default: nil
|
|
610
|
+ |
attr :rest, :global
|
|
611
|
+ |
slot :inner_block, required: true
|
|
612
|
+ |
|
|
613
|
+ |
def task_item(assigns) do
|
|
614
|
+ |
~H"""
|
|
615
|
+ |
<div id={@id} class={["text-muted-foreground text-sm", @class]} {@rest}>
|
|
616
|
+ |
{render_slot(@inner_block)}
|
|
617
|
+ |
</div>
|
|
618
|
+ |
"""
|
|
619
|
+ |
end
|
|
620
|
+ |
|
|
621
|
+ |
@doc """
|
|
622
|
+ |
A file named inside a task item.
|
|
623
|
+ |
"""
|
|
624
|
+ |
attr :id, :string, default: nil
|
|
625
|
+ |
attr :class, :any, default: nil
|
|
626
|
+ |
attr :rest, :global
|
|
627
|
+ |
slot :inner_block, required: true
|
|
628
|
+ |
|
|
629
|
+ |
def task_item_file(assigns) do
|
|
630
|
+ |
~H"""
|
|
631
|
+ |
<div
|
|
632
|
+ |
id={@id}
|
|
633
|
+ |
class={[
|
|
634
|
+ |
"inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-foreground text-xs",
|
|
635
|
+ |
@class
|
|
636
|
+ |
]}
|
|
637
|
+ |
{@rest}
|
|
638
|
+ |
>
|
|
639
|
+ |
{render_slot(@inner_block)}
|
|
640
|
+ |
</div>
|
|
641
|
+ |
"""
|
|
642
|
+ |
end
|
|
643
|
+ |
|
|
644
|
+ |
@doc """
|
|
645
|
+ |
A plan the agent intends to follow, on a card whose body collapses.
|
|
646
|
+ |
|
|
647
|
+ |
This is the one part of the batch that takes slots rather than siblings. Its
|
|
648
|
+ |
footer stays visible while its body collapses, and `<details>` hides every
|
|
649
|
+ |
child but the `<summary>`, so the parent has to place the footer outside the
|
|
650
|
+ |
collapsing region.
|
|
651
|
+ |
|
|
652
|
+ |
<.plan id="build-plan" open streaming={@streaming}>
|
|
653
|
+ |
<:header>
|
|
654
|
+ |
<div>
|
|
655
|
+ |
<.plan_title streaming={@streaming}>Ship the parser</.plan_title>
|
|
656
|
+ |
<.plan_description streaming={@streaming}>Four steps.</.plan_description>
|
|
657
|
+ |
</div>
|
|
658
|
+ |
<.plan_trigger />
|
|
659
|
+ |
</:header>
|
|
660
|
+ |
<.task id="plan-step-1">...</.task>
|
|
661
|
+ |
<:footer><.button variant={:primary}>Approve</.button></:footer>
|
|
662
|
+ |
</.plan>
|
|
663
|
+ |
"""
|
|
664
|
+ |
attr :id, :string, default: nil
|
|
665
|
+ |
attr :open, :boolean, default: true
|
|
666
|
+ |
attr :streaming, :boolean, default: false
|
|
667
|
+ |
attr :class, :any, default: nil
|
|
668
|
+ |
attr :rest, :global
|
|
669
|
+ |
slot :header, required: true
|
|
670
|
+ |
slot :footer
|
|
671
|
+ |
slot :inner_block, required: true
|
|
672
|
+ |
|
|
673
|
+ |
def plan(assigns) do
|
|
674
|
+ |
~H"""
|
|
675
|
+ |
<.card id={@id} class={["shadow-none", @class]} {@rest}>
|
|
676
|
+ |
<details class="group" open={@open}>
|
|
677
|
+ |
<summary
|
|
678
|
+ |
class={["flex items-start justify-between gap-2 px-6 py-4", summary_class()]}
|
|
679
|
+ |
data-slot="plan-header"
|
|
680
|
+ |
>
|
|
681
|
+ |
{render_slot(@header)}
|
|
682
|
+ |
</summary>
|
|
683
|
+ |
<div class="px-6 pb-4" data-slot="plan-content">{render_slot(@inner_block)}</div>
|
|
684
|
+ |
</details>
|
|
685
|
+ |
<div :if={@footer != []} class="flex items-center gap-2 px-6 pb-4" data-slot="plan-footer">
|
|
686
|
+ |
{render_slot(@footer)}
|
|
687
|
+ |
</div>
|
|
688
|
+ |
</.card>
|
|
689
|
+ |
"""
|
|
690
|
+ |
end
|
|
691
|
+ |
|
|
692
|
+ |
@doc """
|
|
693
|
+ |
The name of a plan. Shimmers while `streaming`.
|
|
694
|
+ |
"""
|
|
695
|
+ |
attr :id, :string, default: nil
|
|
696
|
+ |
attr :streaming, :boolean, default: false
|
|
697
|
+ |
attr :class, :any, default: nil
|
|
698
|
+ |
attr :rest, :global
|
|
699
|
+ |
slot :inner_block, required: true
|
|
700
|
+ |
|
|
701
|
+ |
def plan_title(assigns) do
|
|
702
|
+ |
~H"""
|
|
703
|
+ |
<%!-- shadcn CardTitle is "leading-none font-semibold". <Shimmer> is not in
|
|
704
|
+ |
this batch; `animate-pulse` stands in, as in reasoning_trigger/1. --%>
|
|
705
|
+ |
<h3
|
|
706
|
+ |
id={@id}
|
|
707
|
+ |
class={["leading-none font-semibold", @streaming && "animate-pulse", @class]}
|
|
708
|
+ |
data-slot="plan-title"
|
|
709
|
+ |
{@rest}
|
|
710
|
+ |
>
|
|
711
|
+ |
{render_slot(@inner_block)}
|
|
712
|
+ |
</h3>
|
|
713
|
+ |
"""
|
|
714
|
+ |
end
|
|
715
|
+ |
|
|
716
|
+ |
@doc """
|
|
717
|
+ |
What a plan is for. Shimmers while `streaming`.
|
|
718
|
+ |
"""
|
|
719
|
+ |
attr :id, :string, default: nil
|
|
720
|
+ |
attr :streaming, :boolean, default: false
|
|
721
|
+ |
attr :class, :any, default: nil
|
|
722
|
+ |
attr :rest, :global
|
|
723
|
+ |
slot :inner_block, required: true
|
|
724
|
+ |
|
|
725
|
+ |
def plan_description(assigns) do
|
|
726
|
+ |
~H"""
|
|
727
|
+ |
<%!-- shadcn CardDescription is "text-muted-foreground text-sm". --%>
|
|
728
|
+ |
<p
|
|
729
|
+ |
id={@id}
|
|
730
|
+ |
class={[
|
|
731
|
+ |
"text-balance text-muted-foreground text-sm",
|
|
732
|
+ |
@streaming && "animate-pulse",
|
|
733
|
+ |
@class
|
|
734
|
+ |
]}
|
|
735
|
+ |
data-slot="plan-description"
|
|
736
|
+ |
{@rest}
|
|
737
|
+ |
>
|
|
738
|
+ |
{render_slot(@inner_block)}
|
|
739
|
+ |
</p>
|
|
740
|
+ |
"""
|
|
741
|
+ |
end
|
|
742
|
+ |
|
|
743
|
+ |
@doc """
|
|
744
|
+ |
A control that sits in a plan header, opposite the title.
|
|
745
|
+ |
"""
|
|
746
|
+ |
attr :id, :string, default: nil
|
|
747
|
+ |
attr :class, :any, default: nil
|
|
748
|
+ |
attr :rest, :global
|
|
749
|
+ |
slot :inner_block, required: true
|
|
750
|
+ |
|
|
751
|
+ |
def plan_action(assigns) do
|
|
752
|
+ |
~H"""
|
|
753
|
+ |
<div id={@id} class={["ml-auto", @class]} data-slot="plan-action" {@rest}>
|
|
754
|
+ |
{render_slot(@inner_block)}
|
|
755
|
+ |
</div>
|
|
756
|
+ |
"""
|
|
757
|
+ |
end
|
|
758
|
+ |
|
|
759
|
+ |
@doc """
|
|
760
|
+ |
The affordance that shows a plan header can be opened and closed.
|
|
761
|
+ |
|
|
762
|
+ |
Upstream this is a ghost icon button and the Radix trigger. Here the whole
|
|
763
|
+ |
`<summary>` is the control, so a nested button would be a second interactive
|
|
764
|
+ |
element inside it; this renders the glyph only. The screen-reader text is kept
|
|
765
|
+ |
so the summary still says what activating it does.
|
|
766
|
+ |
"""
|
|
767
|
+ |
attr :id, :string, default: nil
|
|
768
|
+ |
attr :class, :any, default: nil
|
|
769
|
+ |
attr :rest, :global
|
|
770
|
+ |
|
|
771
|
+ |
def plan_trigger(assigns) do
|
|
772
|
+ |
~H"""
|
|
773
|
+ |
<span
|
|
774
|
+ |
id={@id}
|
|
775
|
+ |
class={[
|
|
776
|
+ |
"inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground",
|
|
777
|
+ |
@class
|
|
778
|
+ |
]}
|
|
779
|
+ |
data-slot="plan-trigger"
|
|
780
|
+ |
{@rest}
|
|
781
|
+ |
>
|
|
782
|
+ |
<%!-- lucide ChevronsUpDownIcon -> Apps SDK `chevron-up-down`. --%>
|
|
783
|
+ |
<.icon name="chevron-up-down" class="size-4" />
|
|
784
|
+ |
<span class="sr-only">Toggle plan</span>
|
|
785
|
+ |
</span>
|
|
786
|
+ |
"""
|
|
787
|
+ |
end
|
|
788
|
+ |
|
|
789
|
+ |
@doc """
|
|
790
|
+ |
A marked point in a conversation that can be returned to.
|
|
791
|
+ |
|
|
792
|
+ |
The trailing rule fills whatever width the controls leave.
|
|
793
|
+ |
"""
|
|
794
|
+ |
attr :id, :string, default: nil
|
|
795
|
+ |
attr :class, :any, default: nil
|
|
796
|
+ |
attr :rest, :global
|
|
797
|
+ |
slot :inner_block, required: true
|
|
798
|
+ |
|
|
799
|
+ |
def checkpoint(assigns) do
|
|
800
|
+ |
~H"""
|
|
801
|
+ |
<div
|
|
802
|
+ |
id={@id}
|
|
803
|
+ |
class={["flex items-center gap-0.5 overflow-hidden text-muted-foreground", @class]}
|
|
804
|
+ |
{@rest}
|
|
805
|
+ |
>
|
|
806
|
+ |
{render_slot(@inner_block)}
|
|
807
|
+ |
<%!-- shadcn Separator, which is decorative and announces nothing. --%>
|
|
808
|
+ |
<hr class="h-px flex-1 border-0 bg-border" />
|
|
809
|
+ |
</div>
|
|
810
|
+ |
"""
|
|
811
|
+ |
end
|
|
812
|
+ |
|
|
813
|
+ |
@doc """
|
|
814
|
+ |
The glyph that marks a checkpoint.
|
|
815
|
+ |
"""
|
|
816
|
+ |
attr :id, :string, default: nil
|
|
817
|
+ |
attr :name, :string, default: "saved-xs"
|
|
818
|
+ |
attr :class, :any, default: nil
|
|
819
|
+ |
attr :rest, :global
|
|
820
|
+ |
|
|
821
|
+ |
def checkpoint_icon(assigns) do
|
|
822
|
+ |
~H"""
|
|
823
|
+ |
<%!-- lucide BookmarkIcon -> Apps SDK `saved-xs`, the vendored bookmark. --%>
|
|
824
|
+ |
<.icon id={@id} name={@name} class={["size-4 shrink-0", @class]} {@rest} />
|
|
825
|
+ |
"""
|
|
826
|
+ |
end
|
|
827
|
+ |
|
|
828
|
+ |
@doc """
|
|
829
|
+ |
A control on a checkpoint, such as restoring it.
|
|
830
|
+ |
|
|
831
|
+ |
Upstream wraps the button in a Radix tooltip. Radix is not available, so
|
|
832
|
+ |
`tooltip` becomes the native `title` attribute: same text, no JavaScript, and
|
|
833
|
+ |
it still reaches assistive technology.
|
|
834
|
+ |
"""
|
|
835
|
+ |
attr :id, :string, default: nil
|
|
836
|
+ |
attr :tooltip, :string, default: nil
|
|
837
|
+ |
attr :class, :any, default: nil
|
|
838
|
+ |
attr :rest, :global
|
|
839
|
+ |
slot :inner_block, required: true
|
|
840
|
+ |
|
|
841
|
+ |
def checkpoint_trigger(assigns) do
|
|
842
|
+ |
~H"""
|
|
843
|
+ |
<.button id={@id} variant={:ghost} size={:sm} title={@tooltip} class={@class} {@rest}>
|
|
844
|
+ |
{render_slot(@inner_block)}
|
|
845
|
+ |
</.button>
|
|
846
|
+ |
"""
|
|
847
|
+ |
end
|
|
848
|
+ |
end
|