Put the AI Elements ports on the components page

3c40cf64b921 · AtlantisPleb · · parent 0b55af9a0c24

Put the AI Elements ports on the components page

Four modules landed in the last four commits and nothing renders them, so the
component library still says this app has no transcript, no reasoning surface,
no composer, and no evidence components. This registers all four and gives each
family a page that composes it the way a caller composes it.

The four modules hold about 110 public function components between them, and
one catalog entry per function would produce an index rather than a catalog: a
sidebar of `tool_header`, `tool_status_badge`, `tool_content`, `tool_input`, and
`tool_output` tells a reader nothing they could not get from the module doc, and
none of those five is legible on its own. So the unit here is the family. The
head a caller reaches for gets the entry -- `tool`, `reasoning`, `message`,
`prompt_input`, `sources` -- and its parts are excluded in
`documented_modules/0` naming the family that demonstrates them, the same way
`graph_defs/1` and `graph_surface/1` already are. Thirty-one entries, sixty-six
exclusions, and the contract test still fails the moment a new component appears
in any of the four modules.

They are four sections rather than one because four sections of six to ten sit
inside the range the page already has (Forge has one, OpenAgents UI has thirty)
while one section of thirty-one would be the second largest thing on the page
and would bury the boundary between four modules that answer four different
questions. The sidebar needed no structural change: its sections are `<details>`
and only the one holding the current page opens.

Each page shows the states the family exists to keep apart, because that is what
a catalog is for. The tool page walks all seven tool states rather than one
successful call. The composer page shows all four submit statuses side by side,
which is where you can see that streaming is a stop button and not a disabled
send. Reasoning shows open and closed. Attachments shows all three layout
variants and the empty state. Confirmation shows requested, approved, and
denied, which is the whole argument for the component: it keeps showing its
answer. The sample content is this product -- an SCV run reverting its own
patch, a question about where admission happens -- so the prose wraps the way it
will in use.

One component needed a fix to be demoable. `inline_citation/1` called
`render_slot/1` unconditionally on each `:source` entry, and the slot's three
attributes already carry everything a source has, so the ordinary call
`<:source url=... title=... />` is self-closing and raised `BadFunctionError`.
It now asks before rendering, with a regression test.

Two pieces of prose were made true again rather than left behind: the catalog
moduledoc claimed `OpenAgentsWeb.UI` was the only product component module, and
the index page named only three modules. Both now describe the surface modules
beside it and the family-per-entry rule, which `docs/component-library.md` also
records so the next port follows it.

Two contract tests are new. Every catalog icon must be a glyph that is actually
vendored, because the sidebar renders it on every page and a typo would be a
runtime error across the whole library; and every exclusion must name a function
that still exists, because a stale exclusion silently uncatalogues whatever
takes that name next.

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

  • modified docs/component-library.md
  • modified lib/openagents_web/component_catalog.ex
  • modified lib/openagents_web/components/ai/evidence.ex
  • modified lib/openagents_web/live/components_live.ex
  • modified test/openagents_web/component_catalog_test.exs
  • modified test/openagents_web/components/ai/evidence_test.exs
  • modified test/openagents_web/live/components_live_test.exs

Diff

7 files changed, +1956 -9

docs/component-library.md modified +24 -1

@@ -40,9 +40,32 @@ and synchronizes it across tabs.

40 40
41 41
## Specialized components
42 42
43
`OpenAgentsWeb.Components.RepoHeader.repo_header/1` is the only catalogued
43
`OpenAgentsWeb.Components.RepoHeader.repo_header/1` is the catalogued
44 44
forge-specific component. Surface-specific components can live in a focused
45 45
module when they encode real domain composition rather than a generic control.
46
`OpenAgentsWeb.UI.Graph`, `OpenAgentsWeb.UI.Landing`, and
47
`OpenAgentsWeb.UI.Circle` are the other surface modules.
48
49
## AI Elements
50
51
Four modules under `OpenAgentsWeb.AI` are ported from Vercel's AI Elements
52
(MIT). Each moduledoc names the substitutions the port made, so a later reader
53
can tell a deliberate swap from a typo:
54
55
| Module | What it covers |
56
| --- | --- |
57
| `AI.Conversation` | Transcript scroller, turns, suggestions, toolbar, persona |
58
| `AI.Reasoning` | Reasoning, chain of thought, tool calls, tasks, plans, checkpoints |
59
| `AI.PromptInput` | Composer, action menu, attachments, speech, model selectors, queue |
60
| `AI.Evidence` | Code blocks, terminals, sources, citations, artifacts, questions |
61
62
These four modules are catalogued **one entry per family**, not one per
63
function: `reasoning/1` and the trigger and body it wraps are one catalog
64
entry, because they are one thing a caller reaches for and the parts are only
65
legible inside the composition. Register the family head and exclude its parts
66
in `ComponentCatalog.documented_modules/0`, naming the family that demonstrates
67
each part. Around 110 components across the four modules would otherwise
68
produce an index rather than a catalog.
46 69
47 70
## Extension rules
48 71
lib/openagents_web/component_catalog.ex modified +393 -5

@@ -6,10 +6,19 @@ defmodule OpenAgentsWeb.ComponentCatalog do

6 6
  function component in `OpenAgentsWeb.UI` and the documented layout modules to
7 7
  appear here, so the catalog cannot drift behind the supported API.
8 8
9
  `OpenAgentsWeb.UI` is the only product component module. It combines the
10
  vendored Basecoat structure with the OpenAgents style pack. Icon demos use the
11
  preferred vendored Apps SDK set; see `docs/ICONS.md` for the exceptional
12
  Heroicons fallback policy and current fallback inventory.
9
  `OpenAgentsWeb.UI` is the general product component module: it combines the
10
  vendored Basecoat structure with the OpenAgents style pack, and new reusable
11
  primitives start there. Surface modules beside it — the SCV graph, the landing
12
  page, the issue tracker, and the four AI Elements ports under
13
  `OpenAgentsWeb.AI` — hold composition that is specific to one surface. Icon
14
  demos use the preferred vendored Apps SDK set; see `docs/ICONS.md` for the
15
  exceptional Heroicons fallback policy and current fallback inventory.
16
17
  The `OpenAgentsWeb.AI` modules are catalogued one entry per family rather than
18
  one per function. `reasoning/1` and the trigger and body it wraps are one
19
  entry, because they are one thing a caller reaches for, and the parts are only
20
  legible inside the composition. The parts are excluded in
21
  `documented_modules/0` with the family that demonstrates them.
13 22
  """
14 23
15 24
  @sections [

@@ -617,6 +626,246 @@ defmodule OpenAgentsWeb.ComponentCatalog do

617 626
          summary: "Repository breadcrumb and tab bar with issue counts."
618 627
        }
619 628
      ]
629
    },
630
    %{
631
      title: "AI conversation",
632
      items: [
633
        %{
634
          slug: "ai-conversation",
635
          title: "Conversation",
636
          icon: "chat",
637
          source: "OpenAgentsWeb.AI.Conversation.conversation/1",
638
          summary:
639
            "The transcript scroller, pinned to the newest turn until the reader leaves it."
640
        },
641
        %{
642
          slug: "ai-message",
643
          title: "Message",
644
          icon: "comment",
645
          source: "OpenAgentsWeb.AI.Conversation.message/1",
646
          summary: "One turn: a face, a Markdown body, and the controls under it."
647
        },
648
        %{
649
          slug: "ai-shimmer",
650
          title: "Shimmer",
651
          icon: "sparkle",
652
          source: "OpenAgentsWeb.AI.Conversation.shimmer/1",
653
          summary: "A highlight travelling through a line, so waiting reads as work."
654
        },
655
        %{
656
          slug: "ai-suggestions",
657
          title: "Suggestions",
658
          icon: "lightbulb",
659
          source: "OpenAgentsWeb.AI.Conversation.suggestions/1",
660
          summary:
661
            "Openers the reader can send unedited, on a row that scrolls rather than wraps."
662
        },
663
        %{
664
          slug: "ai-toolbar",
665
          title: "Conversation toolbar",
666
          icon: "settings-slider",
667
          source: "OpenAgentsWeb.AI.Conversation.toolbar/1",
668
          summary: "The bar that floats over a transcript, named so the keyboard can reach it."
669
        },
670
        %{
671
          slug: "ai-controls",
672
          title: "Conversation controls",
673
          icon: "tools",
674
          source: "OpenAgentsWeb.AI.Conversation.controls/1",
675
          summary: "Grouped actions that stay legible over whatever they sit on."
676
        },
677
        %{
678
          slug: "ai-persona",
679
          title: "Persona",
680
          icon: "agent",
681
          source: "OpenAgentsWeb.AI.Conversation.persona/1",
682
          summary: "Who is answering, and what they are doing right now."
683
        }
684
      ]
685
    },
686
    %{
687
      title: "AI reasoning",
688
      items: [
689
        %{
690
          slug: "ai-reasoning",
691
          title: "Reasoning",
692
          icon: "brain",
693
          source: "OpenAgentsWeb.AI.Reasoning.reasoning/1",
694
          summary:
695
            "Model thinking on native disclosure: open while it streams, closed once it lands."
696
        },
697
        %{
698
          slug: "ai-chain-of-thought",
699
          title: "Chain of thought",
700
          icon: "nodes",
701
          source: "OpenAgentsWeb.AI.Reasoning.chain_of_thought/1",
702
          summary: "Steps down a rail, each one complete, active, or still pending."
703
        },
704
        %{
705
          slug: "ai-tool",
706
          title: "Tool call",
707
          icon: "settings-wrench",
708
          source: "OpenAgentsWeb.AI.Reasoning.tool/1",
709
          summary: "One call and its states, from streaming input to a denied result."
710
        },
711
        %{
712
          slug: "ai-task",
713
          title: "Task",
714
          icon: "tasks",
715
          source: "OpenAgentsWeb.AI.Reasoning.task/1",
716
          summary: "A piece of finished work and the files it touched."
717
        },
718
        %{
719
          slug: "ai-plan",
720
          title: "Plan",
721
          icon: "notebook-check",
722
          source: "OpenAgentsWeb.AI.Reasoning.plan/1",
723
          summary: "Intended steps on a card whose body collapses while its footer stays."
724
        },
725
        %{
726
          slug: "ai-checkpoint",
727
          title: "Checkpoint",
728
          icon: "flag",
729
          source: "OpenAgentsWeb.AI.Reasoning.checkpoint/1",
730
          summary: "A point the run can be restored to, marked in the transcript."
731
        }
732
      ]
733
    },
734
    %{
735
      title: "AI composer",
736
      items: [
737
        %{
738
          slug: "ai-prompt-input",
739
          title: "Prompt input",
740
          icon: "chat-compose",
741
          source: "OpenAgentsWeb.AI.PromptInput.prompt_input/1",
742
          summary: "The composer: a Phoenix form, a growing textarea, and a toolbar under it."
743
        },
744
        %{
745
          slug: "ai-prompt-input-action-menu",
746
          title: "Composer action menu",
747
          icon: "plus-circle",
748
          source: "OpenAgentsWeb.AI.PromptInput.prompt_input_action_menu/1",
749
          summary: "Attach and capture actions on a native popover, with no script."
750
        },
751
        %{
752
          slug: "ai-prompt-input-model-select",
753
          title: "Composer model select",
754
          icon: "dropdown",
755
          source: "OpenAgentsWeb.AI.PromptInput.prompt_input_model_select/1",
756
          summary: "A real select for the model, so the composer form carries the choice."
757
        },
758
        %{
759
          slug: "ai-attachments",
760
          title: "Attachments",
761
          icon: "paperclip",
762
          source: "OpenAgentsWeb.AI.PromptInput.attachments/1",
763
          summary: "Staged files as a grid, an inline row, or a list, each with an empty state."
764
        },
765
        %{
766
          slug: "ai-speech-input",
767
          title: "Speech input",
768
          icon: "mic",
769
          source: "OpenAgentsWeb.AI.PromptInput.speech_input/1",
770
          summary: "Push-to-talk whose whole visual state is CSS keyed off data attributes."
771
        },
772
        %{
773
          slug: "ai-mic-selector",
774
          title: "Microphone selector",
775
          icon: "voice",
776
          source: "OpenAgentsWeb.AI.PromptInput.mic_selector/1",
777
          summary: "Which device is listening, filled in from the browser after mount."
778
        },
779
        %{
780
          slug: "ai-model-selector",
781
          title: "Model selector",
782
          icon: "search",
783
          source: "OpenAgentsWeb.AI.PromptInput.model_selector/1",
784
          summary: "A searchable model palette on a popover, filtered in the browser."
785
        },
786
        %{
787
          slug: "ai-queue",
788
          title: "Queue",
789
          icon: "stack",
790
          source: "OpenAgentsWeb.AI.PromptInput.queue/1",
791
          summary: "Turns waiting to be sent, in sections that collapse."
792
        }
793
      ]
794
    },
795
    %{
796
      title: "AI evidence",
797
      items: [
798
        %{
799
          slug: "ai-code-block",
800
          title: "Code block",
801
          icon: "square-code",
802
          source: "OpenAgentsWeb.AI.Evidence.code_block/1",
803
          summary: "Filename, language, actions, and line numbers that cannot be selected."
804
        },
805
        %{
806
          slug: "ai-snippet",
807
          title: "Snippet",
808
          icon: "clipboard-copy",
809
          source: "OpenAgentsWeb.AI.Evidence.snippet/1",
810
          summary: "One command in a read-only field, beside the control that copies it."
811
        },
812
        %{
813
          slug: "ai-terminal",
814
          title: "Terminal",
815
          icon: "terminal",
816
          source: "OpenAgentsWeb.AI.Evidence.terminal/1",
817
          summary: "Command output on a fixed dark ground, with a caret while it runs."
818
        },
819
        %{
820
          slug: "ai-sources",
821
          title: "Sources",
822
          icon: "book-open",
823
          source: "OpenAgentsWeb.AI.Evidence.sources/1",
824
          summary: "What an answer was drawn from: counted first, listed on request."
825
        },
826
        %{
827
          slug: "ai-inline-citation",
828
          title: "Inline citation",
829
          icon: "quote",
830
          source: "OpenAgentsWeb.AI.Evidence.inline_citation/1",
831
          summary: "A hostname chip mid-sentence that opens onto the sources behind it."
832
        },
833
        %{
834
          slug: "ai-context",
835
          title: "Context meter",
836
          icon: "usage",
837
          source: "OpenAgentsWeb.AI.Evidence.context/1",
838
          summary: "How much of the window a turn spent, stated as a number and as an arc."
839
        },
840
        %{
841
          slug: "ai-artifact",
842
          title: "Artifact",
843
          icon: "document",
844
          source: "OpenAgentsWeb.AI.Evidence.artifact/1",
845
          summary: "Something the model produced, framed with the actions that act on it."
846
        },
847
        %{
848
          slug: "ai-confirmation",
849
          title: "Confirmation",
850
          icon: "shield-check",
851
          source: "OpenAgentsWeb.AI.Evidence.confirmation/1",
852
          summary: "An ask before an irreversible step, which keeps showing its answer."
853
        },
854
        %{
855
          slug: "ai-question",
856
          title: "Question",
857
          icon: "question-mark-circle",
858
          source: "OpenAgentsWeb.AI.Evidence.question/1",
859
          summary: "Choices as real radios or checkboxes, plus room to say something else."
860
        },
861
        %{
862
          slug: "ai-image",
863
          title: "Image",
864
          icon: "file-image",
865
          source: "OpenAgentsWeb.AI.Evidence.image/1",
866
          summary: "A generated image in a frame that cannot push the page around."
867
        }
868
      ]
620 869
    }
621 870
  ]
622 871

@@ -645,7 +894,146 @@ defmodule OpenAgentsWeb.ComponentCatalog do

645 894
      OpenAgentsWeb.UI.Graph => [:graph_defs, :graph_surface],
646 895
      OpenAgentsWeb.Components.RepoHeader => [],
647 896
      OpenAgentsWeb.UI.Landing => [],
648
      OpenAgentsWeb.UI.Circle => []
897
      OpenAgentsWeb.UI.Circle => [],
898
      # The AI Elements ports are catalogued one entry per family, not one per
899
      # function. A family is a head a caller reaches for and the parts it is
900
      # composed from; the parts are excluded here and demoed inside the head's
901
      # page, where the composition is the thing worth reading.
902
      OpenAgentsWeb.AI.Conversation => [
903
        # The scroller's own parts: the column of turns, the placeholder it
904
        # shows when there are none, and the pinned control. All three are
905
        # demoed through `conversation/1`.
906
        :conversation_content,
907
        :conversation_empty_state,
908
        :conversation_scroll_button,
909
        # A turn is assembled from these four in the order the caller chooses,
910
        # so they are demoed through `message/1`.
911
        :message_content,
912
        :message_avatar,
913
        :message_actions,
914
        :message_action,
915
        # One opener out of the row; demoed through `suggestions/1`.
916
        :suggestion
917
      ],
918
      OpenAgentsWeb.AI.Reasoning => [
919
        # The disclosure summary and the streamed body; demoed through
920
        # `reasoning/1`, which is where the open and closed states read.
921
        :reasoning_trigger,
922
        :reasoning_content,
923
        # The header, body, steps, search results, and figures of a chain;
924
        # demoed through `chain_of_thought/1`.
925
        :chain_of_thought_header,
926
        :chain_of_thought_content,
927
        :chain_of_thought_step,
928
        :chain_of_thought_search_results,
929
        :chain_of_thought_search_result,
930
        :chain_of_thought_image,
931
        # A call's header, state badge, body, arguments, and result. The state
932
        # badge carries the taxonomy, so all seven states are demoed through
933
        # `tool/1` rather than on a page of their own.
934
        :tool_header,
935
        :tool_status_badge,
936
        :tool_content,
937
        :tool_input,
938
        :tool_output,
939
        # The summary, body, entries, and file chips of one task; demoed
940
        # through `task/1`.
941
        :task_trigger,
942
        :task_content,
943
        :task_item,
944
        :task_item_file,
945
        # A plan's name, prose, actions, and disclosure control. `plan/1` takes
946
        # slots, so these only make sense inside it and are demoed there.
947
        :plan_title,
948
        :plan_description,
949
        :plan_action,
950
        :plan_trigger,
951
        # The glyph and the restore control inside a checkpoint; demoed through
952
        # `checkpoint/1`.
953
        :checkpoint_icon,
954
        :checkpoint_trigger
955
      ],
956
      OpenAgentsWeb.AI.PromptInput => [
957
        # The composer's shell: the input group, the textarea bound to the
958
        # form field, the two bands around it, and the toolbar that holds the
959
        # tools and the submit control. All demoed through `prompt_input/1`,
960
        # which is the only place their geometry is legible.
961
        :prompt_input_body,
962
        :prompt_input_textarea,
963
        :prompt_input_header,
964
        :prompt_input_footer,
965
        :prompt_input_toolbar,
966
        :prompt_input_tools,
967
        :prompt_input_button,
968
        :prompt_input_submit,
969
        # The popover, its list, its rows, and the two actions that ship with
970
        # it; demoed through `prompt_input_action_menu/1`.
971
        :prompt_input_action_menu_trigger,
972
        :prompt_input_action_menu_content,
973
        :prompt_input_action_menu_item,
974
        :prompt_input_action_add_attachments,
975
        :prompt_input_action_add_screenshot,
976
        # One option in the composer's model select; demoed through
977
        # `prompt_input_model_select/1`.
978
        :prompt_input_model_select_item,
979
        # One staged file and its parts. The three layout variants are what
980
        # distinguish them, so they are demoed together through
981
        # `attachments/1`.
982
        :attachment,
983
        :attachment_preview,
984
        :attachment_info,
985
        :attachment_remove,
986
        :attachment_empty,
987
        # One device in the microphone list; demoed through `mic_selector/1`.
988
        :mic_selector_item,
989
        # The trigger, the search field, and the list, groups, rows, and
990
        # trimmings of the model palette; demoed through `model_selector/1`,
991
        # where the filtering hook is what makes them worth looking at.
992
        :model_selector_trigger,
993
        :model_selector_input,
994
        :model_selector_list,
995
        :model_selector_empty,
996
        :model_selector_group,
997
        :model_selector_item,
998
        :model_selector_name,
999
        :model_selector_shortcut,
1000
        :model_selector_separator,
1001
        :model_selector_logo,
1002
        :model_selector_logo_group,
1003
        # A queue's sections, rows, and the attachments hanging off a row; all
1004
        # demoed through `queue/1`.
1005
        :queue_section,
1006
        :queue_section_trigger,
1007
        :queue_section_label,
1008
        :queue_section_content,
1009
        :queue_list,
1010
        :queue_item,
1011
        :queue_item_indicator,
1012
        :queue_item_content,
1013
        :queue_item_description,
1014
        :queue_item_actions,
1015
        :queue_item_action,
1016
        :queue_item_attachment,
1017
        :queue_item_image,
1018
        :queue_item_file,
1019
        :queue_empty
1020
      ],
1021
      OpenAgentsWeb.AI.Evidence => [
1022
        # One line of terminal output; demoed through `terminal/1`, which owns
1023
        # the ground it is legible against.
1024
        :terminal_line,
1025
        # One source in the list; demoed through `sources/1`.
1026
        :source,
1027
        # The contents of a citation's card; demoed through
1028
        # `inline_citation/1`, since the card only exists inside it.
1029
        :inline_citation_source,
1030
        :inline_citation_quote,
1031
        # A control in an artifact's header; demoed through `artifact/1`.
1032
        :artifact_action,
1033
        # The approve and deny controls; demoed through `confirmation/1`,
1034
        # which is what decides whether they are still shown.
1035
        :confirmation_action
1036
      ]
649 1037
    }
650 1038
  end
651 1039
end
lib/openagents_web/components/ai/evidence.ex modified +5 -1

@@ -388,7 +388,11 @@ defmodule OpenAgentsWeb.AI.Evidence do

388 388
              url={source[:url]}
389 389
              description={source[:description]}
390 390
            >
391
              {render_slot(source)}
391
              <%!-- The slot's three attributes already say everything a source
392
                    has, so `<:source url=... title=... />` is the ordinary call
393
                    and carries no content. `render_slot/1` on an entry whose
394
                    `inner_block` is nil raises, so ask before rendering. --%>
395
              {source[:inner_block] && render_slot(source)}
392 396
            </.inline_citation_source>
393 397
          </span>
394 398
        </span>
lib/openagents_web/live/components_live.ex modified +1458 -2

@@ -11,6 +11,10 @@ defmodule OpenAgentsWeb.ComponentsLive do

11 11
12 12
  use OpenAgentsWeb, :live_view
13 13
14
  alias OpenAgentsWeb.AI.Conversation
15
  alias OpenAgentsWeb.AI.Evidence
16
  alias OpenAgentsWeb.AI.PromptInput
17
  alias OpenAgentsWeb.AI.Reasoning
14 18
  alias OpenAgentsWeb.ComponentCatalog
15 19
  alias OpenAgentsWeb.UI.Circle
16 20
  alias OpenAgentsWeb.UI.Graph

@@ -190,6 +194,90 @@ defmodule OpenAgentsWeb.ComponentsLive do

190 194
    }
191 195
  ]
192 196
197
  # One call per tool state. `tool_status_badge/1` is the taxonomy, so the page
198
  # that demonstrates a tool call has to walk all seven; a single successful
199
  # call would document the one state nobody needs help reading.
200
  @demo_tool_calls [
201
    %{
202
      state: "input-streaming",
203
      name: "grep",
204
      input: ~s({"pattern": "capability_manifest", "path": "lib/openage),
205
      output: nil,
206
      error: nil
207
    },
208
    %{
209
      state: "input-available",
210
      name: "read",
211
      input: ~s({"path": "lib/openagents/cloud/placement.ex", "offset": 1, "limit": 120}),
212
      output: nil,
213
      error: nil
214
    },
215
    %{
216
      state: "output-available",
217
      name: "bash",
218
      input: ~s({"command": "mix test test/openagents/forge/targets_test.exs --seed 0"}),
219
      output: "Finished in 2.4 seconds\n1416 tests, 0 failures, 14 excluded",
220
      error: nil
221
    },
222
    %{
223
      state: "output-error",
224
      name: "bash",
225
      input: ~s({"command": "mix precommit"}),
226
      output: nil,
227
      error: "** (Mix) Formatter would change lib/openagents_web/live/chat_live.ex"
228
    },
229
    %{
230
      state: "approval-requested",
231
      name: "write",
232
      input: ~s({"path": "lib/openagents/forge/targets.ex", "bytes": 4213}),
233
      output: nil,
234
      error: nil
235
    },
236
    %{
237
      state: "approval-responded",
238
      name: "write",
239
      input: ~s({"path": "lib/openagents/forge/targets.ex", "bytes": 4213}),
240
      output: "Approved by mason. 3 hunks written.",
241
      error: nil
242
    },
243
    %{
244
      state: "output-denied",
245
      name: "bash",
246
      input: ~s({"command": "git push openagents HEAD:main"}),
247
      output: nil,
248
      error: "Denied by mason: this run is not allowed to push."
249
    }
250
  ]
251
252
  # Real Elixir rather than a fragment, so the line-number gutter and the
253
  # horizontal overflow are both exercised the way they will be in use.
254
  @demo_code """
255
  def admit(%Run{} = run, fleet) do
256
    with :ok <- Capability.fresh?(run.manifest),
257
         {:ok, node} <- Placement.choose(fleet, run.requirements) do
258
      {:ok, %{run | node: node, admitted_at: DateTime.utc_now()}}
259
    else
260
      {:error, :manifest_expired} = error -> error
261
      {:error, reason} -> {:error, {:unplaceable, reason}}
262
    end
263
  end
264
  """
265
266
  @demo_terminal_output """
267
  $ mix test test/openagents/forge/targets_test.exs --seed 0
268
  Running ExUnit with seed: 0, max_cases: 20
269
270
  ....................
271
272
  Finished in 2.4 seconds (0.9s async, 1.5s sync)
273
  20 tests, 0 failures
274
275
  $ mix test test/openagents/forge/targets_test.exs --seed 1
276
  Running ExUnit with seed: 1, max_cases: 20
277
278
  ......F.............
279
  """
280
193 281
  # Everyone `git shortlog -sn` names on this repository, most commits first.
194 282
  @demo_repo_contributors [%{name: "AtlantisPleb"}, %{name: "Christopher David"}]
195 283

@@ -358,9 +446,17 @@ defmodule OpenAgentsWeb.ComponentsLive do

358 446
        as: :demo
359 447
      )
360 448
449
    # The composer is a real `to_form/2` assign, because that is the only way
450
    # `prompt_input_textarea/1` can be bound to a field rather than a string.
451
    composer_form =
452
      to_form(%{"message" => "Which module decides whether an SCV run is admitted?"},
453
        as: :composer
454
      )
455
361 456
    {:ok,
362 457
     socket
363 458
     |> assign(:form, form)
459
     |> assign(:composer_form, composer_form)
364 460
     |> assign(:rows, @sample_rows)
365 461
     |> assign(:openagents_icons, @openagents_icons)
366 462
     |> assign(:demo_user, @demo_user)

@@ -407,15 +503,24 @@ defmodule OpenAgentsWeb.ComponentsLive do

407 503
    {:noreply, put_flash(socket, :info, "Demo form submitted. Nothing was saved.")}
408 504
  end
409 505
506
  # `speech_input/1` requires the name of an event to push recognized text to.
507
  # Without a matching clause the catalog page would crash the first time
508
  # someone spoke into it, which is a worse demo than no demo.
509
  def handle_event("demo_transcript", %{"text" => text}, socket) do
510
    {:noreply, assign(socket, :composer_form, to_form(%{"message" => text}, as: :composer))}
511
  end
512
410 513
  @impl true
411 514
  def render(%{live_action: :index} = assigns) do
412 515
    ~H"""
413 516
    <div id="components-index">
414 517
      <h1 class="text-3xl font-semibold mb-4">Component library</h1>
415 518
      <p class="text-muted-foreground mb-8 text-pretty max-w-[68ch]">
416
        Live examples of every supported function component in <code>OpenAgentsWeb.UI</code>, <code>OpenAgentsWeb.Layouts</code>, and the
519
        Live examples of every supported function component in <code>OpenAgentsWeb.UI</code>, <code>OpenAgentsWeb.Layouts</code>, the surface modules beside them, and the
417 520
        repository header. A test asserts this page covers every public component
418
        in those modules.
521
        in those modules. The <code>OpenAgentsWeb.AI</code>
522
        sections are catalogued one entry per family, so a page there shows a
523
        whole composition rather than one of its parts.
419 524
        Planned GitHub-shaped components are listed in <code>docs/component-library.md</code>.
420 525
      </p>
421 526

@@ -460,6 +565,7 @@ defmodule OpenAgentsWeb.ComponentsLive do

460 565
461 566
  attr :item, :map, required: true
462 567
  attr :form, :any, default: nil
568
  attr :composer_form, :any, default: nil
463 569
  attr :rows, :list, default: []
464 570
  attr :openagents_icons, :list, default: []
465 571
  attr :demo_user, :map, default: nil

@@ -2629,6 +2735,1356 @@ defmodule OpenAgentsWeb.ComponentsLive do

2629 2735
    """
2630 2736
  end
2631 2737
2738
  # ── AI conversation ───────────────────────────────────────────────────────
2739
  # Ported from Vercel's AI Elements. Each page composes a whole family the way
2740
  # a caller composes it, and shows the states the family exists to keep apart:
2741
  # a tool call has seven, and a demo of one of them documents nothing.
2742
2743
  defp component_demo(%{item: %{slug: "ai-conversation"}} = assigns) do
2744
    ~H"""
2745
    <div class="space-y-3">
2746
      <p class="text-sm text-base-content/60">
2747
        Two boxes: the outer one positions and clips, the inner one scrolls. That split is
2748
        what lets the scroll control hold still at the bottom edge while the transcript
2749
        moves behind it. A colocated hook keeps the viewport pinned while the reader is
2750
        already at the bottom, and lets go the moment they scroll up.
2751
      </p>
2752
      <Conversation.conversation
2753
        id="demo-conversation"
2754
        class="h-72 rounded-lg border border-border"
2755
      >
2756
        <Conversation.conversation_content class="space-y-6 p-4">
2757
          <Conversation.message from="system">
2758
            <Conversation.message_content text="Run `scv.13` opened on `OpenAgentsInc/openagents.com`." />
2759
          </Conversation.message>
2760
          <Conversation.message from="user">
2761
            <Conversation.message_content text="Why did scv-13 revert its own patch?" />
2762
          </Conversation.message>
2763
          <Conversation.message from="assistant">
2764
            <Conversation.message_avatar name="Sarah" />
2765
            <Conversation.message_content text="`targets_test.exs` stayed red after the third attempt, so the run hit the revert rule in `SELF-EDIT-001` and put the worktree back where it found it." />
2766
          </Conversation.message>
2767
          <Conversation.message from="user">
2768
            <Conversation.message_content text="Show me the third attempt." />
2769
          </Conversation.message>
2770
          <Conversation.message from="assistant">
2771
            <Conversation.message_avatar name="Sarah" />
2772
            <Conversation.message_content
2773
              text="Reading the transcript for attempt three"
2774
              streaming
2775
            />
2776
          </Conversation.message>
2777
        </Conversation.conversation_content>
2778
      </Conversation.conversation>
2779
      <p class="text-sm text-base-content/60">
2780
        With nothing in it the same scroller carries a placeholder rather than an empty
2781
        box, and the scroll control is left off, because there is nowhere to go.
2782
      </p>
2783
      <Conversation.conversation
2784
        id="demo-conversation-empty"
2785
        scroll_button={false}
2786
        class="h-44 rounded-lg border border-border"
2787
      >
2788
        <Conversation.conversation_content class="grid h-full place-items-center p-4">
2789
          <Conversation.conversation_empty_state
2790
            icon="chat"
2791
            title="No messages yet"
2792
            description="Ask about a repository, or start an SCV run."
2793
          />
2794
        </Conversation.conversation_content>
2795
      </Conversation.conversation>
2796
    </div>
2797
    """
2798
  end
2799
2800
  defp component_demo(%{item: %{slug: "ai-message"}} = assigns) do
2801
    ~H"""
2802
    <div class="space-y-3">
2803
      <p class="text-sm text-base-content/60">
2804
        A turn is a row of siblings the caller orders, not one component with a slot per
2805
        position: an assistant turn wants a face, a body, and controls; a user turn wants
2806
        only a body, and dropping a part should not mean passing an empty slot. The <code>from</code>
2807
        attribute lands as the marker class every rule inside the body reads, which is
2808
        what moves a user turn to the right and gives it a bubble.
2809
      </p>
2810
      <div class="space-y-6">
2811
        <Conversation.message id="demo-message-user" from="user">
2812
          <Conversation.message_content text="Which module decides whether an SCV run is admitted?" />
2813
        </Conversation.message>
2814
2815
        <Conversation.message id="demo-message-assistant" from="assistant">
2816
          <Conversation.message_avatar name="Sarah" />
2817
          <Conversation.message_content text="`OpenAgents.Forge.Targets` scores the candidates and `Placement` admits one. The refusal path is the interesting half: an expired capability manifest is refused before scoring, so it never reaches the queue." />
2818
          <Conversation.message_actions>
2819
            <Conversation.message_action label="Copy">
2820
              <UI.icon name="copy" class="size-4" />
2821
            </Conversation.message_action>
2822
            <Conversation.message_action label="Regenerate">
2823
              <UI.icon name="regenerate" class="size-4" />
2824
            </Conversation.message_action>
2825
            <Conversation.message_action label="Good answer">
2826
              <UI.icon name="thumb-up" class="size-4" />
2827
            </Conversation.message_action>
2828
            <Conversation.message_action label="Bad answer">
2829
              <UI.icon name="thumb-down" class="size-4" />
2830
            </Conversation.message_action>
2831
          </Conversation.message_actions>
2832
        </Conversation.message>
2833
2834
        <Conversation.message id="demo-message-streaming" from="assistant">
2835
          <Conversation.message_avatar name="Sarah" />
2836
          <Conversation.message_content
2837
            text="Checking whether `Placement.admit/2` is still"
2838
            streaming
2839
          />
2840
        </Conversation.message>
2841
2842
        <Conversation.message id="demo-message-system" from="system">
2843
          <Conversation.message_content text="Budget window exhausted. The run resumes at the next window." />
2844
        </Conversation.message>
2845
      </div>
2846
      <p class="text-sm text-base-content/60">
2847
        The streaming turn is the one worth reading twice. Its text ends mid-sentence and
2848
        still renders, because <code>streaming</code>
2849
        tells the Markdown renderer to close what the model has not closed yet.
2850
      </p>
2851
    </div>
2852
    """
2853
  end
2854
2855
  defp component_demo(%{item: %{slug: "ai-shimmer"}} = assigns) do
2856
    ~H"""
2857
    <div class="space-y-3">
2858
      <p class="text-sm text-base-content/60">
2859
        A highlight travelling through the text rather than a spinner beside it: the words
2860
        stay readable the whole time, so waiting says what it is waiting for. <code>spread</code>
2861
        widens the band, which is how a long line and a short one can take the same time
2862
        to sweep.
2863
      </p>
2864
      <div class="space-y-3">
2865
        <Conversation.shimmer id="demo-shimmer-short" text="Thinking" />
2866
        <Conversation.shimmer
2867
          id="demo-shimmer-line"
2868
          text="Reading lib/openagents/forge/targets.ex"
2869
        />
2870
        <Conversation.shimmer
2871
          id="demo-shimmer-wide"
2872
          text="Planning the change across four files"
2873
          spread={4}
2874
        />
2875
        <Conversation.shimmer
2876
          id="demo-shimmer-heading"
2877
          text="Working"
2878
          tag="h3"
2879
          class="text-lg font-medium"
2880
        />
2881
      </div>
2882
    </div>
2883
    """
2884
  end
2885
2886
  defp component_demo(%{item: %{slug: "ai-suggestions"}} = assigns) do
2887
    ~H"""
2888
    <div class="space-y-3">
2889
      <p class="text-sm text-base-content/60">
2890
        Openers a reader can send unedited. The row scrolls sideways rather than wrapping,
2891
        because a wrapped set changes height as it changes length, and a control that
2892
        moves the composer down the page every time the model finishes is worse than one
2893
        the reader has to scroll.
2894
      </p>
2895
      <Conversation.suggestions id="demo-suggestions">
2896
        <Conversation.suggestion suggestion="What changed in this repository today?" />
2897
        <Conversation.suggestion suggestion="Why did the last SCV run refuse?" />
2898
        <Conversation.suggestion suggestion="Open an issue for the flaky targets test" />
2899
        <Conversation.suggestion suggestion="Summarise the staging deploy" variant={:secondary} />
2900
        <Conversation.suggestion suggestion="Show me the diff" variant={:ghost} size={:xs} />
2901
      </Conversation.suggestions>
2902
    </div>
2903
    """
2904
  end
2905
2906
  defp component_demo(%{item: %{slug: "ai-toolbar"}} = assigns) do
2907
    ~H"""
2908
    <div class="space-y-3">
2909
      <p class="text-sm text-base-content/60">
2910
        The bar that floats over a transcript. It takes a <code>label</code>
2911
        because a bar of icon buttons with no name is a set of unexplained glyphs to
2912
        anyone not looking at it.
2913
      </p>
2914
      <div class="relative h-40 rounded-lg border border-border bg-muted/30">
2915
        <Conversation.toolbar id="demo-toolbar" label="Transcript actions">
2916
          <Conversation.message_action label="Search this run">
2917
            <UI.icon name="search" class="size-4" />
2918
          </Conversation.message_action>
2919
          <Conversation.message_action label="Export transcript">
2920
            <UI.icon name="download" class="size-4" />
2921
          </Conversation.message_action>
2922
          <Conversation.message_action label="Share run">
2923
            <UI.icon name="share" class="size-4" />
2924
          </Conversation.message_action>
2925
        </Conversation.toolbar>
2926
      </div>
2927
    </div>
2928
    """
2929
  end
2930
2931
  defp component_demo(%{item: %{slug: "ai-controls"}} = assigns) do
2932
    ~H"""
2933
    <div class="space-y-3">
2934
      <p class="text-sm text-base-content/60">
2935
        The same idea one level down: a group of actions that has to stay legible over
2936
        whatever it sits on. Here it sits over the transcript ground rather than the page
2937
        ground, which is the case the treatment exists for.
2938
      </p>
2939
      <div class="relative h-40 rounded-lg border border-border bg-muted/30 p-4">
2940
        <Conversation.controls id="demo-controls" label="Run controls">
2941
          <UI.button variant={:ghost} size={:sm}>
2942
            <UI.icon name="pause" class="size-4" /> Pause
2943
          </UI.button>
2944
          <UI.button variant={:ghost} size={:sm}>
2945
            <UI.icon name="stop" class="size-4" /> Stop
2946
          </UI.button>
2947
          <UI.button variant={:ghost} size={:sm}>
2948
            <UI.icon name="arrow-rotate-cw" class="size-4" /> Retry
2949
          </UI.button>
2950
        </Conversation.controls>
2951
      </div>
2952
    </div>
2953
    """
2954
  end
2955
2956
  defp component_demo(%{item: %{slug: "ai-persona"}} = assigns) do
2957
    ~H"""
2958
    <div class="space-y-3">
2959
      <p class="text-sm text-base-content/60">
2960
        Upstream this is a WebGL canvas playing one of six remote animations. What
2961
        survived the port is the contract: a fixed set of states, one presence marker, and
2962
        a name. Two states borrow a marker rather than introduce a colour — <code>thinking</code>
2963
        reads as running and <code>asleep</code>
2964
        as ended — and the marker is decorative, because the word beside it already says
2965
        it.
2966
      </p>
2967
      <div class="grid gap-4 sm:grid-cols-2">
2968
        <Conversation.persona id="demo-persona-idle" name="Sarah" state="idle" />
2969
        <Conversation.persona id="demo-persona-listening" name="Sarah" state="listening" />
2970
        <Conversation.persona id="demo-persona-thinking" name="Sarah" state="thinking" />
2971
        <Conversation.persona id="demo-persona-speaking" name="Sarah" state="speaking" />
2972
        <Conversation.persona id="demo-persona-asleep" name="Sarah" state="asleep" />
2973
        <Conversation.persona
2974
          id="demo-persona-labelled"
2975
          name="scv-13"
2976
          state="thinking"
2977
          status_label="Reverting the third attempt"
2978
        />
2979
      </div>
2980
    </div>
2981
    """
2982
  end
2983
2984
  # ── AI reasoning ──────────────────────────────────────────────────────────
2985
2986
  defp component_demo(%{item: %{slug: "ai-reasoning"}} = assigns) do
2987
    ~H"""
2988
    <div class="space-y-4">
2989
      <p class="text-sm text-base-content/60">
2990
        A native <code>details</code>, so the disclosure needs no script and carries its
2991
        own <code>aria-expanded</code>. The server writes the initial state, which keeps
2992
        "open while it streams, closed once it lands" a LiveView decision: pass
2993
        <code phx-no-curly-interpolation>open={@streaming}</code>
2994
        and re-render. A reader who toggles it owns it until the next render of that
2995
        attribute.
2996
      </p>
2997
      <Reasoning.reasoning id="demo-reasoning-open" open>
2998
        <Reasoning.reasoning_trigger streaming />
2999
        <Reasoning.reasoning_content
3000
          text="The question is about admission, not scoring. `Targets` ranks candidates, but the refusal happens earlier"
3001
          streaming
3002
        />
3003
      </Reasoning.reasoning>
3004
      <Reasoning.reasoning id="demo-reasoning-closed">
3005
        <Reasoning.reasoning_trigger duration={12} />
3006
        <Reasoning.reasoning_content text="The question is about admission, not scoring. `Targets` ranks candidates, but the refusal happens earlier, in the capability check, so an expired manifest never reaches the queue." />
3007
      </Reasoning.reasoning>
3008
      <p class="text-sm text-base-content/60">
3009
        Open above, closed below. The trigger says the same thing either way, and it is
3010
        the only part that changes when the stream ends: the pulsing "Thinking…" becomes
3011
        a duration.
3012
      </p>
3013
    </div>
3014
    """
3015
  end
3016
3017
  defp component_demo(%{item: %{slug: "ai-chain-of-thought"}} = assigns) do
3018
    ~H"""
3019
    <div class="space-y-3">
3020
      <p class="text-sm text-base-content/60">
3021
        Steps down a rail. <code>status</code>
3022
        is the whole design: the step being worked reads at full strength, the ones behind
3023
        it are muted, and the ones ahead are dimmed further, so the reader's eye lands on
3024
        the present without an animation asking it to.
3025
      </p>
3026
      <Reasoning.chain_of_thought id="demo-cot" open>
3027
        <Reasoning.chain_of_thought_header>
3028
          Answering a question about this repository
3029
        </Reasoning.chain_of_thought_header>
3030
        <Reasoning.chain_of_thought_content>
3031
          <Reasoning.chain_of_thought_step
3032
            icon="search"
3033
            label="Search for the admission path"
3034
            description="Ranked by how often each file names a capability manifest."
3035
            status={:complete}
3036
          >
3037
            <Reasoning.chain_of_thought_search_results>
3038
              <Reasoning.chain_of_thought_search_result>
3039
                lib/openagents/forge/targets.ex
3040
              </Reasoning.chain_of_thought_search_result>
3041
              <Reasoning.chain_of_thought_search_result>
3042
                lib/openagents/cloud/placement.ex
3043
              </Reasoning.chain_of_thought_search_result>
3044
              <Reasoning.chain_of_thought_search_result>
3045
                docs/cloud/INVARIANTS.md
3046
              </Reasoning.chain_of_thought_search_result>
3047
            </Reasoning.chain_of_thought_search_results>
3048
          </Reasoning.chain_of_thought_step>
3049
          <Reasoning.chain_of_thought_step
3050
            icon="document"
3051
            label="Read Placement.admit/2"
3052
            description="The refusal is ahead of the scoring, not inside it."
3053
            status={:complete}
3054
          />
3055
          <Reasoning.chain_of_thought_step
3056
            icon="chart"
3057
            label="Check the last twenty runs for the same refusal"
3058
            status={:active}
3059
          >
3060
            <Reasoning.chain_of_thought_image caption="Refusals by reason, last twenty runs">
3061
              <img src={~p"/images/logo.svg"} alt="" class="h-24 w-auto opacity-60" />
3062
            </Reasoning.chain_of_thought_image>
3063
          </Reasoning.chain_of_thought_step>
3064
          <Reasoning.chain_of_thought_step
3065
            icon="edit-pencil"
3066
            label="Write the answer"
3067
            status={:pending}
3068
          />
3069
        </Reasoning.chain_of_thought_content>
3070
      </Reasoning.chain_of_thought>
3071
    </div>
3072
    """
3073
  end
3074
3075
  defp component_demo(%{item: %{slug: "ai-tool"}} = assigns) do
3076
    assigns = assign(assigns, :tool_calls, @demo_tool_calls)
3077
3078
    ~H"""
3079
    <div class="space-y-4">
3080
      <p class="text-sm text-base-content/60">
3081
        Seven states, all of them here, because the badge is the taxonomy and one happy
3082
        call documents none of it. Four describe a call's own progress — arguments still
3083
        arriving, arguments complete, a result, a failure — and three describe a call that
3084
        needed a person: waiting on approval, answered, and refused.
3085
      </p>
3086
      <Reasoning.tool :for={call <- @tool_calls} id={"demo-tool-#{call.state}"} open>
3087
        <Reasoning.tool_header type={"tool-" <> call.name} tool_name={call.name} state={call.state} />
3088
        <Reasoning.tool_content>
3089
          <Reasoning.tool_input input={call.input} />
3090
          <Reasoning.tool_output
3091
            :if={call.output || call.error}
3092
            output={call.output}
3093
            error_text={call.error}
3094
          />
3095
        </Reasoning.tool_content>
3096
      </Reasoning.tool>
3097
      <p class="text-sm text-base-content/60">
3098
        Arguments and results are preformatted text, not highlighted code: the code-block
3099
        port is <.link
3100
          patch={~p"/components/ai-code-block"}
3101
          class="underline underline-offset-2 hover:no-underline"
3102
        >a separate component</.link>, and claiming syntax highlighting that is not
3103
        happening would be worse than plain text.
3104
      </p>
3105
    </div>
3106
    """
3107
  end
3108
3109
  defp component_demo(%{item: %{slug: "ai-task"}} = assigns) do
3110
    ~H"""
3111
    <div class="space-y-4">
3112
      <p class="text-sm text-base-content/60">
3113
        What one piece of work did, and which files it touched. Open by default, as
3114
        upstream: a task is written into the transcript once it is finished, so the first
3115
        thing a reader wants is its contents, not its title.
3116
      </p>
3117
      <Reasoning.task id="demo-task-open" open>
3118
        <Reasoning.task_trigger title="Fixed the flaky targets test" />
3119
        <Reasoning.task_content>
3120
          <Reasoning.task_item>
3121
            Read
3122
            <Reasoning.task_item_file>
3123
              test/openagents/forge/targets_test.exs
3124
            </Reasoning.task_item_file>
3125
          </Reasoning.task_item>
3126
          <Reasoning.task_item>
3127
            Found the ordering assumption in
3128
            <Reasoning.task_item_file>lib/openagents/forge/targets.ex</Reasoning.task_item_file>
3129
          </Reasoning.task_item>
3130
          <Reasoning.task_item>
3131
            Sorted the candidates by identifier before scoring
3132
          </Reasoning.task_item>
3133
          <Reasoning.task_item>
3134
            Ran <code>mix test --seed 0</code>: 1416 passed, 14 excluded
3135
          </Reasoning.task_item>
3136
        </Reasoning.task_content>
3137
      </Reasoning.task>
3138
      <Reasoning.task id="demo-task-closed" open={false}>
3139
        <Reasoning.task_trigger title="Checked the staging fleet before starting" />
3140
        <Reasoning.task_content>
3141
          <Reasoning.task_item>
3142
            Read
3143
            <Reasoning.task_item_file>infra/staging/outputs.tf</Reasoning.task_item_file>
3144
          </Reasoning.task_item>
3145
        </Reasoning.task_content>
3146
      </Reasoning.task>
3147
      <p class="text-sm text-base-content/60">
3148
        The second one is closed, which is what a long run needs: everything the agent did
3149
        stays in the transcript, and only the task under discussion stays open.
3150
      </p>
3151
    </div>
3152
    """
3153
  end
3154
3155
  defp component_demo(%{item: %{slug: "ai-plan"}} = assigns) do
3156
    ~H"""
3157
    <div class="space-y-3">
3158
      <p class="text-sm text-base-content/60">
3159
        The one part of this batch that takes slots rather than siblings. A <code>details</code>
3160
        hides every child but its summary, and this footer has to stay visible while the
3161
        body collapses — approving a plan you cannot see is the one thing to prevent — so
3162
        the parent places the footer outside the collapsing region.
3163
      </p>
3164
      <Reasoning.plan id="demo-plan" open>
3165
        <:header>
3166
          <div>
3167
            <Reasoning.plan_title>Make the targets test deterministic</Reasoning.plan_title>
3168
            <Reasoning.plan_description>
3169
              Four steps, two of them in files this run has already read.
3170
            </Reasoning.plan_description>
3171
          </div>
3172
          <Reasoning.plan_trigger />
3173
        </:header>
3174
        <Reasoning.task id="demo-plan-step-1" open={false}>
3175
          <Reasoning.task_trigger title="Sort candidates by identifier before scoring" />
3176
          <Reasoning.task_content>
3177
            <Reasoning.task_item>
3178
              <Reasoning.task_item_file>lib/openagents/forge/targets.ex</Reasoning.task_item_file>
3179
            </Reasoning.task_item>
3180
          </Reasoning.task_content>
3181
        </Reasoning.task>
3182
        <Reasoning.task id="demo-plan-step-2" open={false}>
3183
          <Reasoning.task_trigger title="Assert the order, not just the membership" />
3184
          <Reasoning.task_content>
3185
            <Reasoning.task_item>
3186
              <Reasoning.task_item_file>
3187
                test/openagents/forge/targets_test.exs
3188
              </Reasoning.task_item_file>
3189
            </Reasoning.task_item>
3190
          </Reasoning.task_content>
3191
        </Reasoning.task>
3192
        <Reasoning.task id="demo-plan-step-3" open={false}>
3193
          <Reasoning.task_trigger title="Run the suite at three seeds" />
3194
          <Reasoning.task_content>
3195
            <Reasoning.task_item>mix test --seed 0, --seed 1, --seed 2</Reasoning.task_item>
3196
          </Reasoning.task_content>
3197
        </Reasoning.task>
3198
        <:footer>
3199
          <Reasoning.plan_action>
3200
            <UI.button variant={:primary} size={:sm}>Approve</UI.button>
3201
          </Reasoning.plan_action>
3202
          <Reasoning.plan_action>
3203
            <UI.button variant={:outline} size={:sm}>Ask for changes</UI.button>
3204
          </Reasoning.plan_action>
3205
        </:footer>
3206
      </Reasoning.plan>
3207
      <p class="text-sm text-base-content/60">
3208
        Collapse it with the control in its header. The two actions stay where they are.
3209
      </p>
3210
    </div>
3211
    """
3212
  end
3213
3214
  defp component_demo(%{item: %{slug: "ai-checkpoint"}} = assigns) do
3215
    ~H"""
3216
    <div class="space-y-3">
3217
      <p class="text-sm text-base-content/60">
3218
        A point the run can be returned to, drawn as a rule across the transcript so it
3219
        reads as a division rather than another message. The rule fills whatever width the
3220
        controls leave, which is why the label and the action can be any length.
3221
      </p>
3222
      <div class="space-y-4">
3223
        <Reasoning.checkpoint id="demo-checkpoint-plain">
3224
          <Reasoning.checkpoint_icon />
3225
          <span class="px-2 text-xs">Before the rebase onto main</span>
3226
        </Reasoning.checkpoint>
3227
        <Reasoning.checkpoint id="demo-checkpoint-restore">
3228
          <Reasoning.checkpoint_icon />
3229
          <span class="px-2 text-xs">After attempt two</span>
3230
          <Reasoning.checkpoint_trigger tooltip="Restore the worktree to this point">
3231
            Restore
3232
          </Reasoning.checkpoint_trigger>
3233
        </Reasoning.checkpoint>
3234
        <Reasoning.checkpoint id="demo-checkpoint-current">
3235
          <Reasoning.checkpoint_icon name="saved-filled-xs" />
3236
          <span class="px-2 text-xs text-foreground">Current</span>
3237
        </Reasoning.checkpoint>
3238
      </div>
3239
      <p class="text-sm text-base-content/60">
3240
        Upstream the restore control carries a Radix tooltip. There is no tooltip
3241
        primitive here, so <code>tooltip</code>
3242
        becomes the native <code>title</code>: same text, no script, still announced.
3243
      </p>
3244
    </div>
3245
    """
3246
  end
3247
3248
  # ── AI composer ───────────────────────────────────────────────────────────
3249
3250
  defp component_demo(%{item: %{slug: "ai-prompt-input"}} = assigns) do
3251
    ~H"""
3252
    <div class="space-y-4">
3253
      <p class="text-sm text-base-content/60">
3254
        A real Phoenix form around a real input group. The textarea is bound to a <code>Phoenix.HTML.FormField</code>, not to a loose string, so the composer submits
3255
        the way every other form in this app does. Everything else — the growing height,
3256
        Enter to submit, dropped and pasted files — is one colocated hook, because none of
3257
        it can be expressed in markup.
3258
      </p>
3259
      <PromptInput.prompt_input
3260
        id="demo-prompt-input"
3261
        for={@composer_form}
3262
        accept="image/*,text/*"
3263
        phx-submit="save"
3264
      >
3265
        <:drop_overlay>Drop files to attach them</:drop_overlay>
3266
        <PromptInput.prompt_input_body>
3267
          <PromptInput.prompt_input_textarea
3268
            field={@composer_form[:message]}
3269
            placeholder="Ask about this repository, or start a run"
3270
          />
3271
        </PromptInput.prompt_input_body>
3272
        <PromptInput.prompt_input_toolbar>
3273
          <PromptInput.prompt_input_tools>
3274
            <PromptInput.prompt_input_action_menu_trigger menu="demo-prompt-input-actions" />
3275
            <PromptInput.prompt_input_button tooltip="Search the web">
3276
              <UI.icon name="globe" class="size-4" />
3277
            </PromptInput.prompt_input_button>
3278
            <PromptInput.prompt_input_model_select
3279
              name="composer[model]"
3280
              value="claude-opus-5"
3281
            >
3282
              <PromptInput.prompt_input_model_select_item value="claude-opus-5" selected>
3283
                Claude Opus 5
3284
              </PromptInput.prompt_input_model_select_item>
3285
              <PromptInput.prompt_input_model_select_item value="claude-sonnet-4">
3286
                Claude Sonnet 4
3287
              </PromptInput.prompt_input_model_select_item>
3288
            </PromptInput.prompt_input_model_select>
3289
          </PromptInput.prompt_input_tools>
3290
          <PromptInput.prompt_input_submit id="demo-prompt-input-submit" status={:ready} />
3291
        </PromptInput.prompt_input_toolbar>
3292
      </PromptInput.prompt_input>
3293
      <PromptInput.prompt_input_action_menu id="demo-prompt-input-actions">
3294
        <PromptInput.prompt_input_action_menu_content>
3295
          <PromptInput.prompt_input_action_add_attachments for="demo-prompt-input" />
3296
          <PromptInput.prompt_input_action_add_screenshot />
3297
        </PromptInput.prompt_input_action_menu_content>
3298
      </PromptInput.prompt_input_action_menu>
3299
3300
      <p class="text-sm text-base-content/60">
3301
        The submit control is four controls, not one control with four colours. Each
3302
        status draws a different glyph and carries a different accessible name, and the
3303
        streaming one is a stop button rather than a disabled send — which is the only
3304
        state where the reader has something to do.
3305
      </p>
3306
      <div class="flex flex-wrap items-center gap-6">
3307
        <div :for={status <- [:ready, :submitted, :streaming, :error]} class="space-y-2">
3308
          <PromptInput.prompt_input_submit id={"demo-submit-#{status}"} status={status} />
3309
          <p class="text-xs text-base-content/60">{status}</p>
3310
        </div>
3311
      </div>
3312
3313
      <p class="text-sm text-base-content/60">
3314
        With a header and a footer, the same group carries staged attachments above the
3315
        text and a note below it, and the input group grows rather than scrolling.
3316
      </p>
3317
      <PromptInput.prompt_input id="demo-prompt-input-full" for={@composer_form} phx-submit="save">
3318
        <PromptInput.prompt_input_header>
3319
          <PromptInput.attachments variant={:inline}>
3320
            <PromptInput.attachment variant={:inline}>
3321
              <PromptInput.attachment_preview
3322
                variant={:inline}
3323
                media_category={:source}
3324
                filename="targets.ex"
3325
              />
3326
              <PromptInput.attachment_info variant={:inline} label="targets.ex" />
3327
              <PromptInput.attachment_remove variant={:inline} label="Remove targets.ex" />
3328
            </PromptInput.attachment>
3329
          </PromptInput.attachments>
3330
        </PromptInput.prompt_input_header>
3331
        <PromptInput.prompt_input_body>
3332
          <PromptInput.prompt_input_textarea
3333
            id="demo-prompt-input-full-textarea"
3334
            field={@composer_form[:message]}
3335
          />
3336
        </PromptInput.prompt_input_body>
3337
        <PromptInput.prompt_input_footer>
3338
          This run can read the repository. It cannot push.
3339
        </PromptInput.prompt_input_footer>
3340
        <PromptInput.prompt_input_toolbar>
3341
          <PromptInput.prompt_input_tools>
3342
            <PromptInput.speech_input
3343
              id="demo-prompt-input-speech"
3344
              transcript_event="demo_transcript"
3345
            />
3346
          </PromptInput.prompt_input_tools>
3347
          <PromptInput.prompt_input_submit id="demo-prompt-input-full-submit" status={:streaming} />
3348
        </PromptInput.prompt_input_toolbar>
3349
      </PromptInput.prompt_input>
3350
    </div>
3351
    """
3352
  end
3353
3354
  defp component_demo(%{item: %{slug: "ai-prompt-input-action-menu"}} = assigns) do
3355
    ~H"""
3356
    <div class="space-y-3">
3357
      <p class="text-sm text-base-content/60">
3358
        Upstream this is a Radix dropdown. Here it is the native popover API: click out to
3359
        dismiss,
3360
        <UI.kbd>Esc</UI.kbd>
3361
        to close, the trigger as the anchor, and no script. The two actions that ship with
3362
        it are the two the composer always has — reach the file input, and capture the
3363
        screen.
3364
      </p>
3365
      <div class="flex items-center gap-3">
3366
        <PromptInput.prompt_input_action_menu_trigger menu="demo-action-menu" />
3367
        <span class="text-sm text-base-content/60">Open the menu</span>
3368
      </div>
3369
      <PromptInput.prompt_input_action_menu id="demo-action-menu" label="Composer actions">
3370
        <PromptInput.prompt_input_action_menu_content>
3371
          <PromptInput.prompt_input_action_add_attachments for="demo-prompt-input" />
3372
          <PromptInput.prompt_input_action_add_screenshot />
3373
          <PromptInput.prompt_input_action_menu_item>
3374
            <UI.icon name="folder" class="mr-2 size-4" /> Add a repository
3375
          </PromptInput.prompt_input_action_menu_item>
3376
        </PromptInput.prompt_input_action_menu_content>
3377
      </PromptInput.prompt_input_action_menu>
3378
      <p class="text-sm text-base-content/60">
3379
        The attach action names the composer it belongs to rather than holding a reference
3380
        to it, because the file input it clicks is rendered by that composer.
3381
      </p>
3382
    </div>
3383
    """
3384
  end
3385
3386
  defp component_demo(%{item: %{slug: "ai-prompt-input-model-select"}} = assigns) do
3387
    ~H"""
3388
    <div class="space-y-3">
3389
      <p class="text-sm text-base-content/60">
3390
        A real <code>select</code>, which is the point: the model is part of what the
3391
        composer submits, so it should arrive in the same params as the text rather than
3392
        in client state the server has to be told about. The trigger is styled down to the
3393
        toolbar's height so it sits in the row rather than on it.
3394
      </p>
3395
      <div class="flex flex-wrap items-center gap-4">
3396
        <PromptInput.prompt_input_model_select name="demo[model]" value="claude-opus-5">
3397
          <PromptInput.prompt_input_model_select_item value="claude-opus-5" selected>
3398
            Claude Opus 5
3399
          </PromptInput.prompt_input_model_select_item>
3400
          <PromptInput.prompt_input_model_select_item value="claude-sonnet-4">
3401
            Claude Sonnet 4
3402
          </PromptInput.prompt_input_model_select_item>
3403
          <PromptInput.prompt_input_model_select_item value="claude-haiku-4">
3404
            Claude Haiku 4
3405
          </PromptInput.prompt_input_model_select_item>
3406
        </PromptInput.prompt_input_model_select>
3407
3408
        <PromptInput.prompt_input_model_select
3409
          id="demo-model-select-disabled"
3410
          name="demo[locked_model]"
3411
          value="claude-opus-5"
3412
          label="Model, fixed by the run"
3413
          disabled
3414
        >
3415
          <PromptInput.prompt_input_model_select_item value="claude-opus-5" selected>
3416
            Claude Opus 5
3417
          </PromptInput.prompt_input_model_select_item>
3418
        </PromptInput.prompt_input_model_select>
3419
      </div>
3420
      <p class="text-sm text-base-content/60">
3421
        The second one is fixed, which is what a resumed run needs: changing the model
3422
        mid-run would make the transcript describe two different agents.
3423
      </p>
3424
    </div>
3425
    """
3426
  end
3427
3428
  defp component_demo(%{item: %{slug: "ai-attachments"}} = assigns) do
3429
    ~H"""
3430
    <div class="space-y-6">
3431
      <p class="text-sm text-base-content/60">
3432
        Three layouts for the same file. <code>:grid</code>
3433
        is the thumbnail wall above the composer, <code>:inline</code>
3434
        is the chip row that fits inside it, and <code>:list</code>
3435
        is the full-width row a review surface wants. The preview falls back to the glyph
3436
        for the file's category, which is how an audio file stays distinguishable from a
3437
        document with no thumbnail to show.
3438
      </p>
3439
3440
      <div class="space-y-2">
3441
        <p class="text-xs uppercase tracking-wide text-base-content/60">Grid</p>
3442
        <PromptInput.attachments id="demo-attachments-grid" variant={:grid}>
3443
          <PromptInput.attachment variant={:grid}>
3444
            <PromptInput.attachment_preview
3445
              variant={:grid}
3446
              media_category={:image}
3447
              src={~p"/images/logo.svg"}
3448
              filename="graph.svg"
3449
            />
3450
            <PromptInput.attachment_remove variant={:grid} label="Remove graph.svg" />
3451
          </PromptInput.attachment>
3452
          <PromptInput.attachment variant={:grid}>
3453
            <PromptInput.attachment_preview
3454
              variant={:grid}
3455
              media_category={:document}
3456
              filename="receipt.pdf"
3457
            />
3458
            <PromptInput.attachment_remove variant={:grid} label="Remove receipt.pdf" />
3459
          </PromptInput.attachment>
3460
          <PromptInput.attachment variant={:grid}>
3461
            <PromptInput.attachment_preview
3462
              variant={:grid}
3463
              media_category={:audio}
3464
              filename="standup.m4a"
3465
            />
3466
            <PromptInput.attachment_remove variant={:grid} label="Remove standup.m4a" />
3467
          </PromptInput.attachment>
3468
        </PromptInput.attachments>
3469
      </div>
3470
3471
      <div class="space-y-2">
3472
        <p class="text-xs uppercase tracking-wide text-base-content/60">Inline</p>
3473
        <PromptInput.attachments id="demo-attachments-inline" variant={:inline}>
3474
          <PromptInput.attachment variant={:inline}>
3475
            <PromptInput.attachment_preview
3476
              variant={:inline}
3477
              media_category={:source}
3478
              filename="targets.ex"
3479
            />
3480
            <PromptInput.attachment_info variant={:inline} label="targets.ex" />
3481
            <PromptInput.attachment_remove variant={:inline} label="Remove targets.ex" />
3482
          </PromptInput.attachment>
3483
          <PromptInput.attachment variant={:inline}>
3484
            <PromptInput.attachment_preview
3485
              variant={:inline}
3486
              media_category={:video}
3487
              filename="repro.mp4"
3488
            />
3489
            <PromptInput.attachment_info variant={:inline} label="repro.mp4" />
3490
            <PromptInput.attachment_remove variant={:inline} label="Remove repro.mp4" />
3491
          </PromptInput.attachment>
3492
        </PromptInput.attachments>
3493
      </div>
3494
3495
      <div class="space-y-2">
3496
        <p class="text-xs uppercase tracking-wide text-base-content/60">List</p>
3497
        <PromptInput.attachments id="demo-attachments-list" variant={:list}>
3498
          <PromptInput.attachment variant={:list}>
3499
            <PromptInput.attachment_preview
3500
              variant={:list}
3501
              media_category={:source}
3502
              filename="placement.ex"
3503
            />
3504
            <PromptInput.attachment_info
3505
              variant={:list}
3506
              label="lib/openagents/cloud/placement.ex"
3507
              media_type="text/x-elixir"
3508
            />
3509
            <PromptInput.attachment_remove variant={:list} label="Remove placement.ex" />
3510
          </PromptInput.attachment>
3511
          <PromptInput.attachment variant={:list}>
3512
            <PromptInput.attachment_preview
3513
              variant={:list}
3514
              media_category={:unknown}
3515
              filename="fleet.tfstate"
3516
            />
3517
            <PromptInput.attachment_info
3518
              variant={:list}
3519
              label="infra/staging/fleet.tfstate"
3520
              media_type="application/json"
3521
            />
3522
            <PromptInput.attachment_remove variant={:list} label="Remove fleet.tfstate" />
3523
          </PromptInput.attachment>
3524
        </PromptInput.attachments>
3525
      </div>
3526
3527
      <div class="space-y-2">
3528
        <p class="text-xs uppercase tracking-wide text-base-content/60">Empty</p>
3529
        <PromptInput.attachment_empty id="demo-attachments-empty" />
3530
      </div>
3531
    </div>
3532
    """
3533
  end
3534
3535
  defp component_demo(%{item: %{slug: "ai-speech-input"}} = assigns) do
3536
    ~H"""
3537
    <div class="space-y-3">
3538
      <p class="text-sm text-base-content/60">
3539
        Push-to-talk. The hook detects the Web Speech API, falls back to <code>MediaRecorder</code>, and disables itself when neither exists — then publishes
3540
        what it found on the wrapper, so the whole treatment, including the three
3541
        staggered rings while recording, is CSS keyed off data attributes rather than a
3542
        re-render.
3543
      </p>
3544
      <div class="flex flex-wrap items-center gap-8">
3545
        <PromptInput.speech_input id="demo-speech" transcript_event="demo_transcript" />
3546
        <PromptInput.speech_input
3547
          id="demo-speech-disabled"
3548
          transcript_event="demo_transcript"
3549
          disabled
3550
        />
3551
      </div>
3552
      <p class="text-sm text-base-content/60">
3553
        Recognized text arrives as an ordinary LiveView event. Recorded audio cannot
3554
        travel in one, so in the fallback the hook writes the blob onto a file input the
3555
        caller names, and disables itself when there is no such input to write to.
3556
      </p>
3557
    </div>
3558
    """
3559
  end
3560
3561
  defp component_demo(%{item: %{slug: "ai-mic-selector"}} = assigns) do
3562
    ~H"""
3563
    <div class="space-y-3">
3564
      <p class="text-sm text-base-content/60">
3565
        A native <code>select</code>
3566
        the browser fills in after mount, which is why it carries <code>phx-update="ignore"</code>: the device list is knowledge the client has and
3567
        the server does not, and a re-render must not throw it away. Until permission is
3568
        granted the list is the placeholder alone, which is honest — the browser refuses
3569
        to name devices it has not been allowed to.
3570
      </p>
3571
      <div class="flex flex-wrap items-center gap-4">
3572
        <PromptInput.mic_selector id="demo-mic-selector" name="demo[mic]" />
3573
        <PromptInput.mic_selector id="demo-mic-selector-seeded" name="demo[seeded_mic]">
3574
          <PromptInput.mic_selector_item value="default" selected>
3575
            MacBook Pro Microphone
3576
          </PromptInput.mic_selector_item>
3577
          <PromptInput.mic_selector_item value="usb-1">
3578
            Shure MV7 (14ed:1012)
3579
          </PromptInput.mic_selector_item>
3580
        </PromptInput.mic_selector>
3581
      </div>
3582
      <p class="text-sm text-base-content/60">
3583
        The second one is seeded from the server so the shape is visible here. In use, the
3584
        hook replaces whatever it finds.
3585
      </p>
3586
    </div>
3587
    """
3588
  end
3589
3590
  defp component_demo(%{item: %{slug: "ai-model-selector"}} = assigns) do
3591
    ~H"""
3592
    <div class="space-y-3">
3593
      <p class="text-sm text-base-content/60">
3594
        The composer's select is for two models. This is for thirty: a native popover
3595
        holding a search field, grouped rows, and shortcuts. Filtering runs in the browser
3596
        against text already on the page, so typing costs no round trip and an empty
3597
        result has words rather than a blank panel.
3598
      </p>
3599
      <div class="flex items-center gap-3">
3600
        <PromptInput.model_selector_trigger panel="demo-model-selector">
3601
          <PromptInput.model_selector_logo src={~p"/images/logo.svg"} provider="OpenAgents" />
3602
          <PromptInput.model_selector_name>Claude Opus 5</PromptInput.model_selector_name>
3603
        </PromptInput.model_selector_trigger>
3604
        <span class="text-sm text-base-content/60">Open, then type to filter</span>
3605
      </div>
3606
      <PromptInput.model_selector id="demo-model-selector" label="Select a model">
3607
        <PromptInput.model_selector_input placeholder="Search models..." />
3608
        <PromptInput.model_selector_list>
3609
          <PromptInput.model_selector_group heading="Anthropic">
3610
            <PromptInput.model_selector_item value="Claude Opus 5" selected>
3611
              <PromptInput.model_selector_name>Claude Opus 5</PromptInput.model_selector_name>
3612
              <PromptInput.model_selector_shortcut>⌘1</PromptInput.model_selector_shortcut>
3613
            </PromptInput.model_selector_item>
3614
            <PromptInput.model_selector_item value="Claude Sonnet 4">
3615
              <PromptInput.model_selector_name>Claude Sonnet 4</PromptInput.model_selector_name>
3616
              <PromptInput.model_selector_shortcut>⌘2</PromptInput.model_selector_shortcut>
3617
            </PromptInput.model_selector_item>
3618
            <PromptInput.model_selector_item value="Claude Haiku 4">
3619
              <PromptInput.model_selector_name>Claude Haiku 4</PromptInput.model_selector_name>
3620
            </PromptInput.model_selector_item>
3621
          </PromptInput.model_selector_group>
3622
          <PromptInput.model_selector_separator />
3623
          <PromptInput.model_selector_group heading="On this fleet">
3624
            <PromptInput.model_selector_item value="Psion 1B pretrained">
3625
              <PromptInput.model_selector_name>Psion 1B</PromptInput.model_selector_name>
3626
            </PromptInput.model_selector_item>
3627
          </PromptInput.model_selector_group>
3628
          <PromptInput.model_selector_empty />
3629
        </PromptInput.model_selector_list>
3630
      </PromptInput.model_selector>
3631
      <div class="flex items-center gap-3">
3632
        <span class="text-sm text-base-content/60">Several providers behind one row:</span>
3633
        <PromptInput.model_selector_logo_group>
3634
          <PromptInput.model_selector_logo src={~p"/images/logo.svg"} provider="OpenAgents" />
3635
          <PromptInput.model_selector_logo src={~p"/images/logo.svg"} provider="Psionic" />
3636
        </PromptInput.model_selector_logo_group>
3637
      </div>
3638
    </div>
3639
    """
3640
  end
3641
3642
  defp component_demo(%{item: %{slug: "ai-queue"}} = assigns) do
3643
    ~H"""
3644
    <div class="space-y-3">
3645
      <p class="text-sm text-base-content/60">
3646
        Turns the reader has already written but not yet sent. Each section is a native <code>details</code>, so the chevron rotates off the element's own
3647
        <code>open</code>
3648
        attribute and the disclosure works before any JavaScript loads.
3649
      </p>
3650
      <PromptInput.queue id="demo-queue">
3651
        <PromptInput.queue_section open>
3652
          <PromptInput.queue_section_trigger>
3653
            <PromptInput.queue_section_label label="queued" count={2}>
3654
              <:icon><UI.icon name="clock" class="size-4" /></:icon>
3655
            </PromptInput.queue_section_label>
3656
          </PromptInput.queue_section_trigger>
3657
          <PromptInput.queue_section_content>
3658
            <PromptInput.queue_list>
3659
              <PromptInput.queue_item>
3660
                <PromptInput.queue_item_indicator />
3661
                <PromptInput.queue_item_content>
3662
                  Run the suite at three seeds and report the flaky ones
3663
                  <PromptInput.queue_item_description>
3664
                    Sends after the current turn
3665
                  </PromptInput.queue_item_description>
3666
                </PromptInput.queue_item_content>
3667
                <PromptInput.queue_item_actions>
3668
                  <PromptInput.queue_item_action label="Remove from the queue">
3669
                    <UI.icon name="x" class="size-4" />
3670
                  </PromptInput.queue_item_action>
3671
                </PromptInput.queue_item_actions>
3672
              </PromptInput.queue_item>
3673
              <PromptInput.queue_item>
3674
                <PromptInput.queue_item_indicator />
3675
                <PromptInput.queue_item_content>
3676
                  Open an issue for whatever stays red
3677
                  <PromptInput.queue_item_attachment>
3678
                    <PromptInput.queue_item_file>targets_test.exs</PromptInput.queue_item_file>
3679
                    <PromptInput.queue_item_image src={~p"/images/logo.svg"} alt="" />
3680
                  </PromptInput.queue_item_attachment>
3681
                </PromptInput.queue_item_content>
3682
              </PromptInput.queue_item>
3683
            </PromptInput.queue_list>
3684
          </PromptInput.queue_section_content>
3685
        </PromptInput.queue_section>
3686
3687
        <PromptInput.queue_section open={false}>
3688
          <PromptInput.queue_section_trigger>
3689
            <PromptInput.queue_section_label label="sent" count={1}>
3690
              <:icon><UI.icon name="check" class="size-4" /></:icon>
3691
            </PromptInput.queue_section_label>
3692
          </PromptInput.queue_section_trigger>
3693
          <PromptInput.queue_section_content>
3694
            <PromptInput.queue_list>
3695
              <PromptInput.queue_item>
3696
                <PromptInput.queue_item_indicator completed />
3697
                <PromptInput.queue_item_content completed>
3698
                  Which module decides whether an SCV run is admitted?
3699
                </PromptInput.queue_item_content>
3700
              </PromptInput.queue_item>
3701
            </PromptInput.queue_list>
3702
          </PromptInput.queue_section_content>
3703
        </PromptInput.queue_section>
3704
      </PromptInput.queue>
3705
3706
      <p class="text-sm text-base-content/60">With nothing waiting:</p>
3707
      <PromptInput.queue id="demo-queue-empty">
3708
        <PromptInput.queue_empty />
3709
      </PromptInput.queue>
3710
    </div>
3711
    """
3712
  end
3713
3714
  # ── AI evidence ───────────────────────────────────────────────────────────
3715
3716
  defp component_demo(%{item: %{slug: "ai-code-block"}} = assigns) do
3717
    assigns = assign(assigns, :code, @demo_code)
3718
3719
    ~H"""
3720
    <div class="space-y-4">
3721
      <p class="text-sm text-base-content/60">
3722
        Chrome, line numbers, and a copy affordance, but no syntax highlighting: the
3723
        source tokenizes with Shiki in the browser, which is a second rendering engine
3724
        this page has not earned. The numbers come from a CSS counter, so selecting the
3725
        block copies the code without them.
3726
      </p>
3727
      <Evidence.code_block
3728
        id="demo-code-block"
3729
        code={@code}
3730
        language="elixir"
3731
        filename="lib/openagents/cloud/placement.ex"
3732
        show_line_numbers
3733
      >
3734
        <:actions>
3735
          <UI.text_button>Open in the repository</UI.text_button>
3736
        </:actions>
3737
      </Evidence.code_block>
3738
      <p class="text-sm text-base-content/60">
3739
        Without a filename, a language, or actions, the header holds only the copy
3740
        control, and the numbers can be left off for a fragment nobody is going to cite by
3741
        line.
3742
      </p>
3743
      <Evidence.code_block id="demo-code-block-bare" code={@code} />
3744
    </div>
3745
    """
3746
  end
3747
3748
  defp component_demo(%{item: %{slug: "ai-snippet"}} = assigns) do
3749
    ~H"""
3750
    <div class="space-y-3">
3751
      <p class="text-sm text-base-content/60">
3752
        One command in a read-only field rather than in a paragraph, so a reader can
3753
        select it, tab to it, and copy it without selecting the prose around it. The
3754
        prefix is decorative: it says "this is a shell command" without becoming part of
3755
        what gets copied.
3756
      </p>
3757
      <div class="space-y-3">
3758
        <Evidence.snippet id="demo-snippet-shell" code="mix precommit" prefix="$" />
3759
        <Evidence.snippet
3760
          id="demo-snippet-long"
3761
          code="git fetch openagents && git rebase openagents/main && git push openagents HEAD:main"
3762
          prefix="$"
3763
          label="Push to the forge"
3764
        />
3765
        <Evidence.snippet
3766
          id="demo-snippet-plain"
3767
          code="OPENAGENTS_RELEASE_VSN=0.3.0"
3768
          copy={false}
3769
          label="Environment variable"
3770
        />
3771
      </div>
3772
    </div>
3773
    """
3774
  end
3775
3776
  defp component_demo(%{item: %{slug: "ai-terminal"}} = assigns) do
3777
    assigns = assign(assigns, :output, @demo_terminal_output)
3778
3779
    ~H"""
3780
    <div class="space-y-4">
3781
      <p class="text-sm text-base-content/60">
3782
        A fixed dark ground rather than a themed one, because terminal output means the
3783
        colours the program chose, and repainting them by palette would make the same run
3784
        look like two different runs. Escape sequences are not parsed: strip them before
3785
        passing the output, or the reader sees them.
3786
      </p>
3787
      <Evidence.terminal
3788
        id="demo-terminal-streaming"
3789
        title="scv-13 — mix test"
3790
        output={@output}
3791
        status="Running"
3792
        streaming
3793
      >
3794
        <:actions>
3795
          <UI.text_button>Stop</UI.text_button>
3796
        </:actions>
3797
      </Evidence.terminal>
3798
      <p class="text-sm text-base-content/60">
3799
        Composed by hand instead, one line at a time, when the prompt matters as much as
3800
        the output.
3801
      </p>
3802
      <Evidence.terminal id="demo-terminal-lines" title="scv-13 — worktree">
3803
        <Evidence.terminal_line>git status --short</Evidence.terminal_line>
3804
        <Evidence.terminal_line prompt=" ">M lib/openagents/forge/targets.ex</Evidence.terminal_line>
3805
        <Evidence.terminal_line prompt=" ">
3806
          M test/openagents/forge/targets_test.exs
3807
        </Evidence.terminal_line>
3808
        <Evidence.terminal_line>git stash list</Evidence.terminal_line>
3809
      </Evidence.terminal>
3810
    </div>
3811
    """
3812
  end
3813
3814
  defp component_demo(%{item: %{slug: "ai-sources"}} = assigns) do
3815
    ~H"""
3816
    <div class="space-y-3">
3817
      <p class="text-sm text-base-content/60">
3818
        The count comes first and the list only on request, which is the right default for
3819
        something a reader checks rather than reads: they want to know an answer was
3820
        grounded before they want to know in what. A native <code>details</code>, so it needs no script.
3821
      </p>
3822
      <Evidence.sources id="demo-sources" count={4}>
3823
        <Evidence.source
3824
          href="https://openagents.com/OpenAgentsInc/openagents.com"
3825
          title="lib/openagents/cloud/placement.ex"
3826
        />
3827
        <Evidence.source
3828
          href="https://openagents.com/OpenAgentsInc/openagents.com"
3829
          title="docs/cloud/INVARIANTS.md"
3830
        />
3831
        <Evidence.source href="https://hexdocs.pm/phoenix_live_view" title="Phoenix.LiveView" />
3832
        <Evidence.source href="https://openagents.com/OpenAgentsInc/openagents.com" />
3833
      </Evidence.sources>
3834
      <p class="text-sm text-base-content/60">
3835
        The last one has no title, so it falls back to the address. A source that cannot
3836
        be named is still a source, and hiding it would overstate how much of the answer
3837
        is accounted for.
3838
      </p>
3839
      <Evidence.sources id="demo-sources-open" count={1} open>
3840
        <Evidence.source
3841
          href="https://openagents.com/OpenAgentsInc/openagents.com"
3842
          title="AGENTS.md"
3843
        />
3844
      </Evidence.sources>
3845
    </div>
3846
    """
3847
  end
3848
3849
  defp component_demo(%{item: %{slug: "ai-inline-citation"}} = assigns) do
3850
    ~H"""
3851
    <div class="space-y-3">
3852
      <p class="text-sm text-base-content/60">
3853
        A hostname chip mid-sentence, so the reader can see what a claim rests on without
3854
        leaving the line. The card opens on hover and on focus within, which is what makes
3855
        it reachable from the keyboard; the source paginates its sources in a carousel,
3856
        and they are simply listed here, because a citation is scoped to one claim and the
3857
        count stays small.
3858
      </p>
3859
      <p class="max-w-[68ch] text-sm leading-relaxed">
3860
        An expired capability manifest is refused before scoring <Evidence.inline_citation id="demo-citation">
3861
          rather than ranked last
3862
          <:source
3863
            url="https://openagents.com/OpenAgentsInc/openagents.com/blob/main/docs/cloud/INVARIANTS.md"
3864
            title="Cloud invariants"
3865
            description="Admission checks capability freshness ahead of placement."
3866
          />
3867
          <:source
3868
            url="https://openagents.com/OpenAgentsInc/openagents.com/blob/main/lib/openagents/cloud/placement.ex"
3869
            title="Placement.admit/2"
3870
          />
3871
        </Evidence.inline_citation>, so it never reaches the queue at all.
3872
      </p>
3873
      <p class="max-w-[68ch] text-sm leading-relaxed">
3874
        The quote treatment carries the sentence a source actually said: <Evidence.inline_citation id="demo-citation-quote">
3875
          the refusal is a policy decision
3876
          <:source url="https://openagents.com/OpenAgentsInc/openagents.com" title="AGENTS.md" />
3877
        </Evidence.inline_citation>.
3878
      </p>
3879
      <Evidence.inline_citation_quote>
3880
        Treat invariant changes as policy changes.
3881
      </Evidence.inline_citation_quote>
3882
    </div>
3883
    """
3884
  end
3885
3886
  defp component_demo(%{item: %{slug: "ai-context"}} = assigns) do
3887
    ~H"""
3888
    <div class="space-y-3">
3889
      <p class="text-sm text-base-content/60">
3890
        How much of the window a turn spent, said twice: as a percentage and as an arc, so
3891
        the meter survives greyscale and a screen reader alike. The ring is a masked conic
3892
        gradient rather than a drawn arc, because inline SVG is not allowed in a product
3893
        surface here. Costs are attributes — there is no pricing table in this app, so a
3894
        caller that knows the price passes it.
3895
      </p>
3896
      <div class="flex flex-wrap items-center gap-6">
3897
        <Evidence.context
3898
          id="demo-context"
3899
          used_tokens={128_400}
3900
          max_tokens={1_000_000}
3901
          input_tokens={96_000}
3902
          output_tokens={12_400}
3903
          reasoning_tokens={14_000}
3904
          cached_tokens={6_000}
3905
          input_cost={0.29}
3906
          output_cost={0.19}
3907
          reasoning_cost={0.21}
3908
          cached_cost={0.01}
3909
          total_cost={0.70}
3910
        />
3911
        <Evidence.context id="demo-context-high" used_tokens={870_000} max_tokens={1_000_000} />
3912
        <Evidence.context id="demo-context-over" used_tokens={1_120_000} max_tokens={1_000_000} />
3913
      </div>
3914
      <p class="text-sm text-base-content/60">
3915
        The third one is over budget. A bar that silently pins at full hides the one state
3916
        worth seeing, so it clamps its width, turns to the danger tone, and says so in an
3917
        attribute a test can assert on.
3918
      </p>
3919
    </div>
3920
    """
3921
  end
3922
3923
  defp component_demo(%{item: %{slug: "ai-artifact"}} = assigns) do
3924
    assigns = assign(assigns, :code, @demo_code)
3925
3926
    ~H"""
3927
    <div class="space-y-3">
3928
      <p class="text-sm text-base-content/60">
3929
        Something the model produced, framed so the actions that act on it sit beside its
3930
        name rather than under its contents. The body is whatever the artifact is — a
3931
        patch, a document, a chart — and scrolls on its own, so a long artifact does not
3932
        push the rest of the turn off the page.
3933
      </p>
3934
      <Evidence.artifact
3935
        id="demo-artifact"
3936
        title="targets.ex"
3937
        description="Sorts candidates by identifier before scoring"
3938
        class="h-72"
3939
      >
3940
        <:actions>
3941
          <Evidence.artifact_action icon="copy" label="Copy the patch" />
3942
          <Evidence.artifact_action icon="download" label="Download the patch" />
3943
          <Evidence.artifact_action
3944
            icon="external-link"
3945
            label="Open in the repository"
3946
            tooltip="Open lib/openagents/forge/targets.ex"
3947
          />
3948
        </:actions>
3949
        <Evidence.code_block id="demo-artifact-code" code={@code} language="elixir" copy={false} />
3950
      </Evidence.artifact>
3951
    </div>
3952
    """
3953
  end
3954
3955
  defp component_demo(%{item: %{slug: "ai-confirmation"}} = assigns) do
3956
    ~H"""
3957
    <div class="space-y-4">
3958
      <p class="text-sm text-base-content/60">
3959
        An ask before a step that cannot be taken back, and — the part that matters — it
3960
        keeps showing its answer afterwards. A confirmation that vanishes once decided
3961
        leaves a transcript that cannot say who allowed what, which is exactly what a
3962
        transcript is for.
3963
      </p>
3964
      <Evidence.confirmation
3965
        id="demo-confirmation-requested"
3966
        state={:requested}
3967
        title="Push scv-13's branch to the forge and open a pull request?"
3968
      >
3969
        <:actions>
3970
          <Evidence.confirmation_action variant={:primary}>Approve</Evidence.confirmation_action>
3971
          <Evidence.confirmation_action variant={:outline}>Deny</Evidence.confirmation_action>
3972
        </:actions>
3973
      </Evidence.confirmation>
3974
      <Evidence.confirmation
3975
        id="demo-confirmation-approved"
3976
        state={:approved}
3977
        title="Push scv-13's branch to the forge and open a pull request?"
3978
        reason="Approved by mason. The suite was green at three seeds."
3979
      />
3980
      <Evidence.confirmation
3981
        id="demo-confirmation-denied"
3982
        state={:denied}
3983
        title="Force-push over the staging branch?"
3984
        reason="Denied by mason. Another run owns that branch."
3985
      />
3986
      <p class="text-sm text-base-content/60">
3987
        The decided ones carry no controls, because there is nothing left to decide, and
3988
        the reason is where the transcript earns its keep.
3989
      </p>
3990
    </div>
3991
    """
3992
  end
3993
3994
  defp component_demo(%{item: %{slug: "ai-question"}} = assigns) do
3995
    ~H"""
3996
    <div class="space-y-4">
3997
      <p class="text-sm text-base-content/60">
3998
        Both halves matter. A model that can only offer choices asks the wrong question
3999
        sooner or later, and one that only offers a text box makes the reader redo work it
4000
        has already done. The choices are real radio and checkbox inputs styled as chips,
4001
        so selection, keyboard behaviour, and submission are the browser's job.
4002
      </p>
4003
      <Evidence.question
4004
        id="demo-question-single"
4005
        prompt="Which branch should scv-13 work on?"
4006
        description="The repository has three branches with recent commits."
4007
        name="branch"
4008
        selection_mode={:single}
4009
        selected={["main"]}
4010
        text_label="Something else?"
4011
        placeholder="Name a branch"
4012
        submit_label="Use this branch"
4013
      >
4014
        <:option value="main">main</:option>
4015
        <:option value="codex/repository-cli-docs">codex/repository-cli-docs</:option>
4016
        <:option value="feat/ai-elements-catalog">feat/ai-elements-catalog</:option>
4017
      </Evidence.question>
4018
4019
      <Evidence.question
4020
        id="demo-question-multiple"
4021
        prompt="Which surfaces should the run touch?"
4022
        name="surface"
4023
        selection_mode={:multiple}
4024
        selected={["catalog", "tests"]}
4025
        text="Leave the CSS alone; another run owns it."
4026
        submit_label="Start the run"
4027
      >
4028
        <:option value="catalog">Component catalog</:option>
4029
        <:option value="demos">Demo pages</:option>
4030
        <:option value="tests">Tests</:option>
4031
        <:option value="css">Stylesheet</:option>
4032
      </Evidence.question>
4033
4034
      <Evidence.question
4035
        id="demo-question-answered"
4036
        prompt="Which branch should scv-13 work on?"
4037
        name="answered_branch"
4038
        selected={["main"]}
4039
        disabled
4040
        submit_label="Answered"
4041
      >
4042
        <:option value="main">main</:option>
4043
        <:option value="codex/repository-cli-docs">codex/repository-cli-docs</:option>
4044
      </Evidence.question>
4045
      <p class="text-sm text-base-content/60">
4046
        The third one is answered and disabled, which is the state the source could not
4047
        reach: its submit control disabled itself from React state, so here the server
4048
        decides, and an answered question stays legible in the transcript.
4049
      </p>
4050
    </div>
4051
    """
4052
  end
4053
4054
  defp component_demo(%{item: %{slug: "ai-image"}} = assigns) do
4055
    ~H"""
4056
    <div class="space-y-3">
4057
      <p class="text-sm text-base-content/60">
4058
        A generated image cannot travel through Markdown here: the sanitizer's allowlist
4059
        has no <code>img</code>
4060
        in it, so an image written into a model's prose is dropped before it reaches the
4061
        page. It arrives as attributes instead — either a source or the base64 and media
4062
        type a model returns, which become a data URI.
4063
      </p>
4064
      <div class="flex flex-wrap items-start gap-6">
4065
        <Evidence.image
4066
          id="demo-image"
4067
          src={~p"/images/logo.svg"}
4068
          alt="The OpenAgents mark"
4069
          class="max-w-48"
4070
        />
4071
        <Evidence.image
4072
          id="demo-image-wide"
4073
          src={~p"/images/og-card-default.png"}
4074
          alt="The default social card for openagents.com"
4075
          class="max-w-md"
4076
        />
4077
      </div>
4078
      <p class="text-sm text-base-content/60">
4079
        <code>alt</code>
4080
        is required rather than defaulted. A generated image is exactly the case where
4081
        nothing nearby says what it shows, so an empty alternative would make the whole
4082
        message disappear for a reader who cannot see it.
4083
      </p>
4084
    </div>
4085
    """
4086
  end
4087
2632 4088
  # The breadcrumb names the section a component lives in, so the trail matches
2633 4089
  # the sidebar the reader navigated through.
2634 4090
  defp section_title_for(slug) do
test/openagents_web/component_catalog_test.exs modified +18

@@ -30,6 +30,24 @@ defmodule OpenAgentsWeb.ComponentCatalogTest do

30 30
    end
31 31
  end
32 32
33
  test "every catalog entry names a glyph the icon component can render" do
34
    for item <- ComponentCatalog.items() do
35
      assert OpenAgentsWeb.Icons.exists?(item.icon),
36
             "#{item.slug} names the icon #{inspect(item.icon)}, which is not vendored. " <>
37
               "The sidebar renders it on every component page, so a typo here is a " <>
38
               "runtime error on the whole library."
39
    end
40
  end
41
42
  test "every exclusion names a function component that still exists" do
43
    # A stale exclusion is worse than none: the entry it once described is
44
    # gone, and the next component to take that name is silently uncatalogued.
45
    for {module, excluded} <- ComponentCatalog.documented_modules(), name <- excluded do
46
      assert name in function_components(module),
47
             "#{inspect(module)} excludes #{inspect(name)}, which it no longer defines"
48
    end
49
  end
50
33 51
  test "the catalog covers every public function component in the documented modules" do
34 52
    for {module, excluded} <- ComponentCatalog.documented_modules() do
35 53
      catalogued =
test/openagents_web/components/ai/evidence_test.exs modified +23

@@ -205,6 +205,29 @@ defmodule OpenAgentsWeb.AI.EvidenceTest do

205 205
      assert text(html, "#ic-one button") == "example.com"
206 206
    end
207 207
208
    test "a source written with attributes alone still renders" do
209
      # `<:source url=... title=... />` is the ordinary call: the slot's three
210
      # attributes already say everything a source has, so the natural markup is
211
      # self-closing and carries no inner block. That used to raise.
212
      html =
213
        render_component(&Evidence.inline_citation/1,
214
          id: "ic-bare",
215
          inner_block: [block(:inner_block, "the claim")],
216
          source: [
217
            %{
218
              __slot__: :source,
219
              inner_block: nil,
220
              url: "https://example.com/a",
221
              title: "A",
222
              description: "Why it is cited"
223
            }
224
          ]
225
        )
226
227
      assert text(html, "#ic-bare button") == "example.com"
228
      assert html =~ "Why it is cited"
229
    end
230
208 231
    test "the chip points at the card it opens" do
209 232
      html =
210 233
        render_component(&Evidence.inline_citation/1,
test/openagents_web/live/components_live_test.exs modified +35

@@ -92,6 +92,41 @@ defmodule OpenAgentsWeb.ComponentsLiveTest do

92 92
    assert has_element?(view, ".repo-tabs__tab .repo-tabs__count", "12")
93 93
  end
94 94
95
  test "the tool page walks every tool state", %{conn: conn} do
96
    # The status badge is the taxonomy this component exists for. A page that
97
    # showed one successful call would document the state nobody needs help
98
    # reading and hide the six that matter.
99
    {:ok, view, _html} = live(conn, ~p"/components/ai-tool")
100
101
    for state <- ~w(
102
          input-streaming input-available output-available output-error
103
          approval-requested approval-responded output-denied
104
        ) do
105
      assert has_element?(view, "#demo-tool-#{state}"),
106
             "the tool page does not show the #{state} state"
107
    end
108
  end
109
110
  test "the composer page shows all four submit statuses", %{conn: conn} do
111
    {:ok, view, _html} = live(conn, ~p"/components/ai-prompt-input")
112
113
    for status <- ~w(ready submitted streaming error) do
114
      assert has_element?(view, "#demo-submit-#{status}"),
115
             "the composer page does not show the #{status} submit control"
116
    end
117
118
    # The streaming control is a stop button, not a disabled send: it is the one
119
    # status where the reader still has something to do.
120
    assert has_element?(view, ~s{#demo-submit-streaming[aria-label="Stop"]})
121
  end
122
123
  test "the reasoning page shows the block both open and closed", %{conn: conn} do
124
    {:ok, view, _html} = live(conn, ~p"/components/ai-reasoning")
125
126
    assert has_element?(view, "details#demo-reasoning-open[open]")
127
    refute has_element?(view, "details#demo-reasoning-closed[open]")
128
  end
129
95 130
  test "the catalog exposes no nonfunctional theme control", %{conn: conn} do
96 131
    {:ok, view, _html} = live(conn, ~p"/components")
97 132

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