Show the agent's thinking and work, without React

00801da6bd3a · AtlantisPleb · · parent 1502d9d52179

Show the agent's thinking and work, without React

Ports six AI Elements components -- reasoning, chain-of-thought, tool, task,
plan, and checkpoint -- into one HEEx module. Together they answer the one
question a reader has mid-turn: what is the agent doing now, and what did it
already do. Their Tailwind is carried across as written, because the harvested
utility strings are the point of the port; only tokens this theme names
differently, and utilities this bundle does not ship, are substituted, each
with a comment at the call site saying so.

Radix Collapsible backs five of the six upstream. All five become
`<details>`/`<summary>` here rather than a JS toggle. The browser then supplies
the disclosure semantics, the implicit `aria-expanded`, and keyboard operation
for nothing, and "auto-open while streaming, collapse when done" stays a
server decision -- it is written through the `open` attribute, so the LiveView
that already knows whether a turn is streaming is the one that decides, and no
client state library is involved.

React compound components become sibling function components, with the state
React kept in context passed as explicit attributes. `plan/1` is the exception
and takes slots: its footer must stay visible while its body collapses, and
`<details>` hides every child that is not its `<summary>`, so only the parent
can place a child outside the collapsing region.

Three substitutions are worth calling out. tailwindcss-animate is not in this
bundle, so the enter/exit utilities (`animate-in`, `slide-in-from-top-2`,
`fade-out-0`) are dropped and disclosure is instant. `not-prose` needs the
typography plugin, which is also absent. And `<Shimmer>` belongs to a file
outside this batch, so `animate-pulse` stands in for the streaming label.

Tool state badges use `UI.badge/1` variants rather than a neutral badge with a
coloured glyph, which is this product's rule: semantic colour reinforces the
words rather than sitting beside them. Markdown goes through
`OpenAgents.Markdown.to_html/2` where upstream renders Streamdown, and every
glyph goes through `UI.icon/1` against the vendored Apps SDK set.

Nothing calls these yet. The catalog entries and the chat rewiring land after
all four AI Elements batches are in.

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/reasoning.ex
  • added test/openagents_web/components/ai/reasoning_test.exs

Diff

2 files changed, +1335 -0

lib/openagents_web/components/ai/reasoning.ex added +848

@@ -0,0 +1,848 @@

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
test/openagents_web/components/ai/reasoning_test.exs added +487

@@ -0,0 +1,487 @@

1
defmodule OpenAgentsWeb.AI.ReasoningTest do
2
  @moduledoc """
3
  The parts of the AI Elements port that a screenshot cannot check.
4
5
  Three things can silently regress here. A `<details>` port loses the disclosure
6
  itself if the `open` attribute stops being written, and the surface then looks
7
  fine while never opening. A tool state is a word plus a colour, and dropping
8
  either leaves the reader guessing. And the utility strings are the point of the
9
  port, so the classes AI Elements carries are asserted rather than assumed.
10
  """
11
12
  use ExUnit.Case, async: true
13
14
  import Phoenix.LiveViewTest, only: [render_component: 2]
15
16
  alias OpenAgentsWeb.AI.Reasoning
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 text(html, selector) do
26
    html
27
    |> LazyHTML.from_fragment()
28
    |> LazyHTML.query(selector)
29
    |> LazyHTML.text()
30
  end
31
32
  # `render_component/2` wants slots as the entry list the HEEx compiler would
33
  # have built, not as a bare function.
34
  defp slot(name \\ :inner_block, content) when is_binary(content) do
35
    [%{__slot__: name, inner_block: fn _assigns, _args -> content end}]
36
  end
37
38
  describe "reasoning/1" do
39
    test "the server writes the open state, so a streaming block renders open" do
40
      html =
41
        render_component(&Reasoning.reasoning/1,
42
          id: "r-open",
43
          open: true,
44
          inner_block: slot("thinking")
45
        )
46
47
      assert [{"details", attrs, _}] = query(html, "#r-open")
48
      assert {"open", _} = List.keyfind(attrs, "open", 0)
49
    end
50
51
    test "a settled block renders closed" do
52
      html =
53
        render_component(&Reasoning.reasoning/1,
54
          id: "r-closed",
55
          inner_block: slot("thought")
56
        )
57
58
      assert [{"details", attrs, _}] = query(html, "#r-closed")
59
      refute List.keyfind(attrs, "open", 0)
60
    end
61
62
    test "carries the AI Elements spacing and the group hook the chevron needs" do
63
      html = render_component(&Reasoning.reasoning/1, id: "r", inner_block: slot(""))
64
65
      assert [{"details", attrs, _}] = query(html, "#r")
66
      assert {"class", class} = List.keyfind(attrs, "class", 0)
67
      assert class =~ "group"
68
      assert class =~ "mb-4"
69
    end
70
  end
71
72
  describe "reasoning_trigger/1" do
73
    test "shows the streaming label while the model is still thinking" do
74
      html = render_component(&Reasoning.reasoning_trigger/1, id: "t", streaming: true)
75
76
      assert text(html, "#t") =~ "Thinking..."
77
      assert [{"p", attrs, _}] = query(html, "#t p")
78
      assert {"class", class} = List.keyfind(attrs, "class", 0)
79
      assert class =~ "animate-pulse"
80
    end
81
82
    test "shows the elapsed duration once thinking has finished" do
83
      html = render_component(&Reasoning.reasoning_trigger/1, id: "t", duration: 7)
84
85
      assert text(html, "#t") =~ "Thought for 7 seconds"
86
    end
87
88
    test "falls back to a vague duration when none was measured" do
89
      html = render_component(&Reasoning.reasoning_trigger/1, id: "t")
90
91
      assert text(html, "#t") =~ "Thought for a few seconds"
92
    end
93
94
    test "is a summary whose chevron flips with the parent details" do
95
      html = render_component(&Reasoning.reasoning_trigger/1, id: "t")
96
97
      assert [{"summary", _, _}] = query(html, "summary#t")
98
      assert [{"svg", attrs, _}] = query(html, ~s(#t svg[data-icon="chevron-down"]))
99
      assert {"class", class} = List.keyfind(attrs, "class", 0)
100
      assert class =~ "group-open:rotate-180"
101
    end
102
103
    test "the glyphs beside the words announce nothing" do
104
      html = render_component(&Reasoning.reasoning_trigger/1, id: "t")
105
106
      for {"svg", attrs, _} <- query(html, "#t svg") do
107
        assert {"aria-hidden", "true"} = List.keyfind(attrs, "aria-hidden", 0)
108
      end
109
    end
110
  end
111
112
  describe "reasoning_content/1" do
113
    test "renders the reasoning as markdown rather than escaped text" do
114
      html =
115
        render_component(&Reasoning.reasoning_content/1,
116
          id: "rc",
117
          text: "A **bold** thought."
118
        )
119
120
      assert [_] = query(html, "#rc strong")
121
      assert [{"div", attrs, _}] = query(html, "#rc")
122
      assert {"class", class} = List.keyfind(attrs, "class", 0)
123
      assert class =~ "text-muted-foreground"
124
    end
125
126
    test "completes unbalanced markup while the text is still arriving" do
127
      html =
128
        render_component(&Reasoning.reasoning_content/1,
129
          id: "rc",
130
          text: "A **bold",
131
          streaming: true
132
        )
133
134
      assert [_] = query(html, "#rc strong")
135
    end
136
  end
137
138
  describe "tool/1 states" do
139
    defp tool_header(state) do
140
      render_component(&Reasoning.tool_header/1,
141
        id: "th",
142
        type: "tool-getWeather",
143
        state: state
144
      )
145
    end
146
147
    test "input-streaming reads as pending and stays neutral" do
148
      html = tool_header("input-streaming")
149
150
      assert text(html, "#th") =~ "Pending"
151
      assert [{"span", attrs, _}] = query(html, "#th .badge")
152
      assert {"data-variant", "dim"} = List.keyfind(attrs, "data-variant", 0)
153
    end
154
155
    test "input-available reads as running and pulses" do
156
      html = tool_header("input-available")
157
158
      assert text(html, "#th") =~ "Running"
159
      assert [{"svg", attrs, _}] = query(html, ~s(#th svg[data-icon="clock"]))
160
      assert {"class", class} = List.keyfind(attrs, "class", 0)
161
      assert class =~ "animate-pulse"
162
    end
163
164
    test "output-available reads as completed and carries the success colour" do
165
      html = tool_header("output-available")
166
167
      assert text(html, "#th") =~ "Completed"
168
      assert [{"span", attrs, _}] = query(html, "#th .badge")
169
      assert {"data-variant", "success"} = List.keyfind(attrs, "data-variant", 0)
170
      assert [_] = query(html, ~s(#th svg[data-icon="check-circle"]))
171
    end
172
173
    test "output-error reads as an error and carries the danger colour" do
174
      html = tool_header("output-error")
175
176
      assert text(html, "#th") =~ "Error"
177
      assert [{"span", attrs, _}] = query(html, "#th .badge")
178
      assert {"data-variant", "danger"} = List.keyfind(attrs, "data-variant", 0)
179
      assert [_] = query(html, ~s(#th svg[data-icon="x-circle"]))
180
    end
181
182
    test "the tool name comes from the part type" do
183
      assert text(tool_header("input-available"), "#th") =~ "getWeather"
184
    end
185
186
    test "a dynamic tool names itself" do
187
      html =
188
        render_component(&Reasoning.tool_header/1,
189
          id: "th",
190
          type: "dynamic-tool",
191
          tool_name: "search_docs",
192
          state: "input-available"
193
        )
194
195
      assert text(html, "#th") =~ "search_docs"
196
    end
197
198
    test "an explicit title wins over the derived name" do
199
      html =
200
        render_component(&Reasoning.tool_header/1,
201
          id: "th",
202
          title: "Check the weather",
203
          type: "tool-getWeather",
204
          state: "output-available"
205
        )
206
207
      assert text(html, "#th") =~ "Check the weather"
208
      refute text(html, "#th") =~ "getWeather"
209
    end
210
  end
211
212
  describe "tool_output/1" do
213
    test "renders nothing when the tool has neither returned nor failed" do
214
      assert String.trim(render_component(&Reasoning.tool_output/1, id: "to")) == ""
215
    end
216
217
    test "labels a result and gives it the quiet surface" do
218
      html = render_component(&Reasoning.tool_output/1, id: "to", output: ~s({"c": 12}))
219
220
      assert text(html, "#to h4") =~ "Result"
221
      assert text(html, "#to code") =~ ~s({"c": 12})
222
      assert [{"div", attrs, _}] = query(html, "#to > div")
223
      assert {"class", class} = List.keyfind(attrs, "class", 0)
224
      assert class =~ "bg-muted/50"
225
    end
226
227
    test "labels an error and gives it the destructive surface" do
228
      html = render_component(&Reasoning.tool_output/1, id: "to", error_text: "timed out")
229
230
      assert text(html, "#to h4") =~ "Error"
231
      assert text(html, "#to") =~ "timed out"
232
      assert [{"div", attrs, _}] = query(html, "#to > div")
233
      assert {"class", class} = List.keyfind(attrs, "class", 0)
234
      assert class =~ "bg-destructive/10"
235
      assert class =~ "text-destructive"
236
    end
237
  end
238
239
  describe "tool_input/1" do
240
    test "labels the parameters and prints them" do
241
      html = render_component(&Reasoning.tool_input/1, id: "ti", input: ~s({"city": "Oslo"}))
242
243
      assert text(html, "#ti h4") =~ "Parameters"
244
      assert text(html, "#ti code") =~ "Oslo"
245
    end
246
  end
247
248
  describe "task/1" do
249
    test "opens by default, as upstream does" do
250
      html = render_component(&Reasoning.task/1, id: "tk", inner_block: slot(""))
251
252
      assert [{"details", attrs, _}] = query(html, "#tk")
253
      assert {"open", _} = List.keyfind(attrs, "open", 0)
254
    end
255
256
    test "a task with items rules them off against the left edge" do
257
      html =
258
        render_component(&Reasoning.task_content/1,
259
          id: "tc",
260
          inner_block: slot("Read lib/app.ex")
261
        )
262
263
      assert text(html, "#tc") =~ "Read lib/app.ex"
264
      assert [{"div", attrs, _}] = query(html, "#tc > div")
265
      assert {"class", class} = List.keyfind(attrs, "class", 0)
266
      assert class =~ "border-l-2"
267
      assert class =~ "border-muted"
268
      assert class =~ "pl-4"
269
    end
270
271
    test "a task with no items still renders its rule, and nothing else" do
272
      html =
273
        render_component(&Reasoning.task_content/1, id: "tc", inner_block: slot(""))
274
275
      assert String.trim(text(html, "#tc")) == ""
276
      assert [{"div", _, _}] = query(html, "#tc > div")
277
    end
278
279
    test "an item is quiet body text" do
280
      html =
281
        render_component(&Reasoning.task_item/1,
282
          id: "ti-1",
283
          inner_block: slot("Read lib/app.ex")
284
        )
285
286
      assert text(html, "#ti-1") =~ "Read lib/app.ex"
287
      assert [{"div", attrs, _}] = query(html, "#ti-1")
288
      assert {"class", class} = List.keyfind(attrs, "class", 0)
289
      assert class =~ "text-muted-foreground"
290
      assert class =~ "text-sm"
291
    end
292
293
    test "the trigger states what the task is" do
294
      html = render_component(&Reasoning.task_trigger/1, id: "tt", title: "Searching the repo")
295
296
      assert [{"summary", _, _}] = query(html, "summary#tt")
297
      assert text(html, "#tt") =~ "Searching the repo"
298
    end
299
300
    test "a file in a task item is a chip on the secondary surface" do
301
      html =
302
        render_component(&Reasoning.task_item_file/1,
303
          id: "tf",
304
          inner_block: slot("app.ex")
305
        )
306
307
      assert [{"div", attrs, _}] = query(html, "#tf")
308
      assert {"class", class} = List.keyfind(attrs, "class", 0)
309
      assert class =~ "bg-secondary"
310
      assert class =~ "inline-flex"
311
    end
312
  end
313
314
  describe "chain_of_thought/1" do
315
    test "one details element carries both the header and the content" do
316
      html =
317
        render_component(&Reasoning.chain_of_thought/1,
318
          id: "cot",
319
          open: true,
320
          inner_block: slot("")
321
        )
322
323
      assert [{"details", attrs, _}] = query(html, "#cot")
324
      assert {"open", _} = List.keyfind(attrs, "open", 0)
325
    end
326
327
    test "the header names the trace by default" do
328
      html = render_component(&Reasoning.chain_of_thought_header/1, id: "coth")
329
330
      assert text(html, "#coth") =~ "Chain of thought"
331
    end
332
333
    test "a step dims according to its status" do
334
      for {status, expected} <- [
335
            {:active, "text-foreground"},
336
            {:complete, "text-muted-foreground"},
337
            {:pending, "text-muted-foreground/50"}
338
          ] do
339
        html =
340
          render_component(&Reasoning.chain_of_thought_step/1,
341
            id: "step",
342
            label: "Reading the spec",
343
            status: status
344
          )
345
346
        assert [{"div", attrs, _}] = query(html, "#step")
347
        assert {"class", class} = List.keyfind(attrs, "class", 0)
348
        assert class =~ expected
349
      end
350
    end
351
352
    test "a step shows its description when it has one" do
353
      html =
354
        render_component(&Reasoning.chain_of_thought_step/1,
355
          id: "step",
356
          label: "Reading the spec",
357
          description: "docs/spec.md"
358
        )
359
360
      assert text(html, "#step") =~ "docs/spec.md"
361
    end
362
363
    test "a search result is a quiet badge" do
364
      html =
365
        render_component(&Reasoning.chain_of_thought_search_result/1,
366
          id: "sr",
367
          inner_block: slot("openagents.com")
368
        )
369
370
      assert [{"span", attrs, _}] = query(html, "#sr")
371
      assert {"class", class} = List.keyfind(attrs, "class", 0)
372
      assert class =~ "badge"
373
      assert {"data-variant", "dim"} = List.keyfind(attrs, "data-variant", 0)
374
    end
375
376
    test "an image shows its caption when it has one" do
377
      html =
378
        render_component(&Reasoning.chain_of_thought_image/1,
379
          id: "img",
380
          caption: "The rendered chart",
381
          inner_block: slot("")
382
        )
383
384
      assert text(html, "#img p") =~ "The rendered chart"
385
    end
386
  end
387
388
  describe "plan/1" do
389
    defp plan(overrides) do
390
      render_component(
391
        &Reasoning.plan/1,
392
        Keyword.merge(
393
          [
394
            id: "plan",
395
            header: slot(:header, "Ship the parser"),
396
            inner_block: slot("step one")
397
          ],
398
          overrides
399
        )
400
      )
401
    end
402
403
    test "the header is the disclosure control and the body collapses with it" do
404
      html = plan([])
405
406
      assert [{"details", _, _}] = query(html, "#plan details")
407
      assert [{"summary", _, _}] = query(html, ~s(#plan summary[data-slot="plan-header"]))
408
      assert text(html, ~s(#plan [data-slot="plan-content"])) =~ "step one"
409
    end
410
411
    test "the footer sits outside the collapsing region so it survives closing" do
412
      html =
413
        plan(
414
          open: false,
415
          footer: slot(:footer, "Approve")
416
        )
417
418
      assert [] == query(html, ~s(#plan details [data-slot="plan-footer"]))
419
      assert text(html, ~s(#plan > [data-slot="plan-footer"])) =~ "Approve"
420
    end
421
422
    test "the title and description shimmer only while the plan is streaming" do
423
      streaming =
424
        render_component(&Reasoning.plan_title/1,
425
          id: "pt",
426
          streaming: true,
427
          inner_block: slot("Ship it")
428
        )
429
430
      settled =
431
        render_component(&Reasoning.plan_title/1,
432
          id: "pt",
433
          inner_block: slot("Ship it")
434
        )
435
436
      assert [{"h3", streaming_attrs, _}] = query(streaming, "#pt")
437
      assert [{"h3", settled_attrs, _}] = query(settled, "#pt")
438
      assert {"class", streaming_class} = List.keyfind(streaming_attrs, "class", 0)
439
      assert {"class", settled_class} = List.keyfind(settled_attrs, "class", 0)
440
      assert streaming_class =~ "animate-pulse"
441
      refute settled_class =~ "animate-pulse"
442
    end
443
444
    test "the trigger keeps the screen-reader wording for what it does" do
445
      html = render_component(&Reasoning.plan_trigger/1, id: "ptr")
446
447
      assert text(html, "#ptr .sr-only") =~ "Toggle plan"
448
    end
449
  end
450
451
  describe "checkpoint/1" do
452
    test "runs a rule out from its controls" do
453
      html =
454
        render_component(&Reasoning.checkpoint/1,
455
          id: "cp",
456
          inner_block: slot("Restore")
457
        )
458
459
      assert text(html, "#cp") =~ "Restore"
460
      assert [{"hr", attrs, _}] = query(html, "#cp hr")
461
      assert {"class", class} = List.keyfind(attrs, "class", 0)
462
      assert class =~ "flex-1"
463
      assert class =~ "bg-border"
464
    end
465
466
    test "the mark is a bookmark glyph that announces nothing" do
467
      html = render_component(&Reasoning.checkpoint_icon/1, id: "cpi")
468
469
      assert [{"svg", attrs, _}] = query(html, "#cpi")
470
      assert {"data-icon", "saved-xs"} = List.keyfind(attrs, "data-icon", 0)
471
      assert {"aria-hidden", "true"} = List.keyfind(attrs, "aria-hidden", 0)
472
    end
473
474
    test "a trigger turns its tooltip into a native title" do
475
      html =
476
        render_component(&Reasoning.checkpoint_trigger/1,
477
          id: "cpt",
478
          tooltip: "Restore this checkpoint",
479
          inner_block: slot("Restore")
480
        )
481
482
      assert [{"button", attrs, _}] = query(html, "#cpt")
483
      assert {"title", "Restore this checkpoint"} = List.keyfind(attrs, "title", 0)
484
      assert {"data-variant", "ghost"} = List.keyfind(attrs, "data-variant", 0)
485
    end
486
  end
487
end

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