From 7602e12e03794681473ba8538fbdec6c79093bef Mon Sep 17 00:00:00 2001 From: Matt de Courcelle Date: Wed, 27 May 2026 08:49:29 -0500 Subject: [PATCH 1/4] Add canonical tool_call_card widget Co-Authored-By: Codex --- lib/ash_ui/rendering/iur_adapter.ex | 56 ++++ lib/ash_ui/rendering/live_ui_adapter.ex | 72 +++++ packages/live_ui/lib/live_ui/renderer.ex | 61 +++++ packages/live_ui/lib/live_ui/widgets.ex | 8 + .../lib/live_ui/widgets/row_and_artifact.ex | 12 + .../lib/live_ui/widgets/tool_call_card.ex | 186 +++++++++++++ .../live_ui/widgets/tool_call_card_test.exs | 192 +++++++++++++ .../unified_iur/lib/unified_iur/validate.ex | 253 ++++++++++++++++++ .../lib/unified_iur/widgets/components.ex | 186 ++++++++++++- .../test/unified_iur/validate_test.exs | 116 ++++++++ .../unified_iur/widgets/components_test.exs | 201 +++++++++++++- .../dsl/entities/widget_components.ex | 20 ++ .../lib/unified_ui/widget_components.ex | 7 + .../widget_components_catalog_test.exs | 8 +- .../ash_ui/phase_31_package_boundary_test.exs | 7 +- test/ash_ui/rendering/iur_adapter_test.exs | 71 +++++ .../ash_ui/rendering/live_ui_adapter_test.exs | 42 +++ 17 files changed, 1494 insertions(+), 4 deletions(-) create mode 100644 packages/live_ui/lib/live_ui/widgets/row_and_artifact.ex create mode 100644 packages/live_ui/lib/live_ui/widgets/tool_call_card.ex create mode 100644 packages/live_ui/test/live_ui/widgets/tool_call_card_test.exs diff --git a/lib/ash_ui/rendering/iur_adapter.ex b/lib/ash_ui/rendering/iur_adapter.ex index 543b1f5c..5bbb3651 100644 --- a/lib/ash_ui/rendering/iur_adapter.ex +++ b/lib/ash_ui/rendering/iur_adapter.ex @@ -363,6 +363,13 @@ defmodule AshUI.Rendering.IURAdapter do |> Map.fetch!(:attributes) end + defp base_attributes(:tool_call_card, props) do + props + |> tool_call_card_opts() + |> IURComponents.tool_call_card() + |> Map.fetch!(:attributes) + end + defp base_attributes(:pipeline_stepper_horizontal = kind, props) do component_attributes( kind, @@ -1075,6 +1082,47 @@ defmodule AshUI.Rendering.IURAdapter do |> compact_map() end + defp tool_call_card_opts(props) do + tool_call = props |> fetch(:tool_call, %{}) |> normalize_map() + + %{ + id: first_present(props, [:_element_id, :id]), + tool_name: + first_present(tool_call, [:tool_name]) || + first_present(props, [:tool_name, :name, :label]), + tool_kind: + normalize_existing_atom( + first_present(tool_call, [:tool_kind]) || first_present(props, [:tool_kind]) || :other + ), + target: first_present(tool_call, [:target]) || first_present(props, [:target, :path]), + summary: + first_present(tool_call, [:summary]) || first_present(props, [:summary, :description]), + status: + normalize_existing_atom( + first_present(tool_call, [:status]) || first_present(props, [:status]) || :pending + ), + args: tool_call_args_value(tool_call, props), + expanded?: + boolean_present(tool_call, [:expanded?], boolean_present(props, [:expanded?], false)), + actor_handle: + first_present(tool_call, [:actor_handle]) || first_present(props, [:actor_handle]), + started_at: first_present(tool_call, [:started_at]) || first_present(props, [:started_at]), + duration_ms: + first_present(tool_call, [:duration_ms]) || first_present(props, [:duration_ms]), + approval_event_id: + first_present(tool_call, [:approval_event_id]) || + first_present(props, [:approval_event_id]), + paired_result_event_id: + first_present(tool_call, [:paired_result_event_id]) || + first_present(props, [:paired_result_event_id]), + expand_intent: first_present(props, [:expand_intent]) || :expand_toggled, + expand_interaction: fetch(props, :expand_interaction), + interactions: fetch(props, :interactions), + interaction: fetch(props, :interaction) + } + |> compact_map() + end + defp composer_query_preview_opts(props) do preview = props |> fetch(:query_preview, %{}) |> normalize_map() @@ -1362,6 +1410,14 @@ defmodule AshUI.Rendering.IURAdapter do normalize_map(value) end + defp tool_call_args_value(tool_call, props) do + cond do + not is_nil(first_present(tool_call, [:args])) -> first_present(tool_call, [:args]) + not is_nil(fetch(props, :args)) -> fetch(props, :args) + true -> %{} + end + end + defp normalize_redline_segments(segments) when is_list(segments) do Enum.map(segments, fn segment -> segment diff --git a/lib/ash_ui/rendering/live_ui_adapter.ex b/lib/ash_ui/rendering/live_ui_adapter.ex index b4d87f61..d3498b5a 100644 --- a/lib/ash_ui/rendering/live_ui_adapter.ex +++ b/lib/ash_ui/rendering/live_ui_adapter.ex @@ -880,6 +880,78 @@ defmodule AshUI.Rendering.LiveUIAdapter do """ end + defp generate_heex(%{"type" => "tool_call_card"} = iur, _opts) do + props = iur["props"] || %{} + tool_call = props |> prop("tool_call", %{}) |> normalize_item() + + tool_name = + escaped_text_prop( + tool_call, + "tool_name", + escaped_text_prop(props, ["tool_name", "name"], "Tool") + ) + + tool_kind = + escaped_text_prop(tool_call, "tool_kind", escaped_text_prop(props, "tool_kind", "other")) + + target = escaped_text_prop(tool_call, "target", escaped_text_prop(props, "target", "")) + summary = escaped_text_prop(tool_call, "summary", escaped_text_prop(props, "summary", "")) + status = escaped_text_prop(tool_call, "status", escaped_text_prop(props, "status", "pending")) + expanded? = truthy_prop(tool_call, "expanded?", truthy_prop(props, "expanded?", false)) + args = prop(tool_call, "args", prop(props, "args", %{})) + result = prop(tool_call, "tool_result_summary", prop(props, "tool_result_summary")) + + args_html = + if expanded? do + ~s(

Args

#{html_escape(inspect(args, pretty: true, limit: :infinity, printable_limit: :infinity))}
) + else + "" + end + + result_html = + case result do + nil -> + "" + + result -> + result = normalize_item(result) + event_id = escaped_text_prop(result, ["event_id", "result_event_id"], "") + result_status = escaped_text_prop(result, "status", "") + compact_output = escaped_text_prop(result, ["compact_output", "summary"], "") + diff_summary = escaped_text_prop(result, "diff_summary") + error? = truthy_prop(result, "error?", truthy_prop(result, "error", false)) + + """ +
+
+ #{event_id} + #{result_status} +
+

#{compact_output}

+ #{if diff_summary, do: "

#{diff_summary}

", else: ""} + #{if error?, do: "

Error

", else: ""} +
+ """ + end + + """ +
+
+ +
+

#{tool_name}

+

#{target}

+
+ #{status} +
+

#{summary}

+ + #{args_html} + #{result_html} +
+ """ + end + defp generate_heex(%{"type" => "pipeline_stepper_horizontal"} = iur, _opts) do props = iur["props"] || %{} steps = prop(props, "steps", []) diff --git a/packages/live_ui/lib/live_ui/renderer.ex b/packages/live_ui/lib/live_ui/renderer.ex index 63e9abf2..2f465a1d 100644 --- a/packages/live_ui/lib/live_ui/renderer.ex +++ b/packages/live_ui/lib/live_ui/renderer.ex @@ -76,6 +76,7 @@ defmodule LiveUi.Renderer do :supervision_tree_viewer, :table, :thread_card, + :tool_call_card, :tabs, :text, :text_input, @@ -647,6 +648,49 @@ defmodule LiveUi.Renderer do """ end + # NOTE: `:tool_call_card` is a row/artifact component and member of + # `@component_kinds`; keep this native clause before the generic component + # fallback so expansion and paired-result rendering use the dedicated widget. + def render(%{element: %Element{kind: :tool_call_card}} = assigns) do + tool_call = get_in(assigns.element.attributes, [:tool_call]) || %{} + + assigns = + assigns + |> assign(:tool_call, tool_call) + |> assign(:tool_call_args, tool_call_args(tool_call)) + |> assign(:tool_result_summary, tool_result_summary(assigns.element)) + |> assign( + :expand_attrs, + interaction_event_attrs(assigns.element, Map.get(assigns, :event_target)) + ) + |> assign(:style_attrs, style_rest(assigns.element)) + + ~H""" + + """ + end + # NOTE: `:segmented_button_group` is a member of `@component_kinds` through the # `:form_control_and_composer` family, so the generic fallback below would # shadow any later `:segmented_button_group` clause. Keep this specific clause @@ -1967,6 +2011,23 @@ defmodule LiveUi.Renderer do |> Enum.reject(&is_nil/1) end + defp tool_result_summary(%Element{} = element) do + element + |> child_elements(:tool_result_summary) + |> List.first() + |> case do + %Element{attributes: attributes} -> Map.get(attributes, :tool_result_summary) + _other -> nil + end + end + + defp tool_call_args(tool_call) do + case map_value(tool_call, :args, %{}) do + args when is_map(args) -> args + _other -> %{} + end + end + defp overlay_children(%Element{} = element) do element.children |> Enum.reject(&(&1.slot == :base)) diff --git a/packages/live_ui/lib/live_ui/widgets.ex b/packages/live_ui/lib/live_ui/widgets.ex index b5f8a893..0e8cc9c0 100644 --- a/packages/live_ui/lib/live_ui/widgets.ex +++ b/packages/live_ui/lib/live_ui/widgets.ex @@ -15,6 +15,7 @@ defmodule LiveUi.Widgets do | :display | :content_identity_and_disclosure | :form_control_and_composer + | :row_and_artifact | :composition_behavior | :layer_shell_and_callout | :workflow_progress_and_status @@ -35,6 +36,7 @@ defmodule LiveUi.Widgets do :display, :content_identity_and_disclosure, :form_control_and_composer, + :row_and_artifact, :composition_behavior, :layer_shell_and_callout, :workflow_progress_and_status @@ -51,6 +53,7 @@ defmodule LiveUi.Widgets do display_modules() ++ content_identity_and_disclosure_modules() ++ form_control_and_composer_modules() ++ + row_and_artifact_modules() ++ composition_behavior_modules() ++ layer_shell_and_callout_modules() ++ workflow_progress_and_status_modules() @@ -101,6 +104,11 @@ defmodule LiveUi.Widgets do LiveUi.Widgets.FormControlAndComposer.modules() end + @spec row_and_artifact_modules() :: [widget_module()] + def row_and_artifact_modules do + LiveUi.Widgets.RowAndArtifact.modules() + end + @spec composition_behavior_modules() :: [widget_module()] def composition_behavior_modules do LiveUi.Widgets.CompositionBehavior.modules() diff --git a/packages/live_ui/lib/live_ui/widgets/row_and_artifact.ex b/packages/live_ui/lib/live_ui/widgets/row_and_artifact.ex new file mode 100644 index 00000000..420be29f --- /dev/null +++ b/packages/live_ui/lib/live_ui/widgets/row_and_artifact.ex @@ -0,0 +1,12 @@ +defmodule LiveUi.Widgets.RowAndArtifact do + @moduledoc """ + Reference surface for row and artifact widgets. + """ + + @modules [ + LiveUi.Widgets.ToolCallCard + ] + + @spec modules() :: [module()] + def modules, do: @modules +end diff --git a/packages/live_ui/lib/live_ui/widgets/tool_call_card.ex b/packages/live_ui/lib/live_ui/widgets/tool_call_card.ex new file mode 100644 index 00000000..f11fc9e2 --- /dev/null +++ b/packages/live_ui/lib/live_ui/widgets/tool_call_card.ex @@ -0,0 +1,186 @@ +defmodule LiveUi.Widgets.ToolCallCard do + @moduledoc """ + Native tool-call card widget. + + Renders one assistant tool call in a conversation timeline with canonical + expansion state and an optional paired result summary child. + """ + + use LiveUi.Component, + family: :row_and_artifact, + name: :tool_call_card, + slots: [], + events: [:expand_toggled] + + LiveUi.Component.common_attrs() + attr(:tool_name, :string, required: true) + attr(:tool_kind, :atom, required: true) + attr(:target, :string, required: true) + attr(:summary, :string, required: true) + attr(:status, :atom, required: true) + attr(:args, :map, default: %{}) + attr(:expanded?, :boolean, default: false) + attr(:actor_handle, :string, default: nil) + attr(:started_at, :any, default: nil) + attr(:duration_ms, :integer, default: nil) + attr(:approval_event_id, :string, default: nil) + attr(:paired_result_event_id, :string, default: nil) + attr(:tool_result_summary, :map, default: nil) + attr(:expand_attrs, :any, default: []) + + @impl true + def render(assigns) do + ~H""" +
+
+ +
+

<%= @tool_name %>

+

<%= @target %>

+
+ + <%= status_label(@status) %> + +
+ +

<%= @summary %>

+ +
+ <%= if @actor_handle do %> + <%= @actor_handle %> + <% end %> + <%= if @started_at do %> + + <% end %> + <%= if @duration_ms do %> + <%= duration_label(@duration_ms) %> + <% end %> +
+ + + + <%= if @expanded? do %> +
+

Args

+
<%= format_args(@args) %>
+
+ <% end %> + + <%= if @tool_result_summary do %> +
+
+ + <%= result_value(@tool_result_summary, :event_id) %> + + + <%= status_label(result_value(@tool_result_summary, :status)) %> + +
+

+ <%= result_value(@tool_result_summary, :compact_output) %> +

+ <%= if result_value(@tool_result_summary, :diff_summary) do %> +

+ <%= result_value(@tool_result_summary, :diff_summary) %> +

+ <% end %> + <%= if result_error?(@tool_result_summary) do %> +

Error

+ <% end %> +
+ <% end %> +
+ """ + end + + defp tool_call_card_class(extra_class, status, expanded?) do + [ + "live-ui-tool-call-card", + "live-ui-tool-call-card--#{status}", + expanded? && "is-expanded", + extra_class + ] + end + + defp glyph_for_kind(:read), do: "R" + defp glyph_for_kind(:edit), do: "E" + defp glyph_for_kind(:write), do: "W" + defp glyph_for_kind(:bash), do: "$" + defp glyph_for_kind(:multiedit), do: "M" + defp glyph_for_kind(:other), do: "?" + defp glyph_for_kind(_other), do: "?" + + defp status_class(nil), do: nil + defp status_class(status), do: "is-status-#{status}" + + defp status_label(nil), do: "" + + defp status_label(status) do + status + |> to_string() + |> String.replace("_", " ") + end + + defp timestamp_iso8601(%DateTime{} = dt), do: DateTime.to_iso8601(dt) + defp timestamp_iso8601(%NaiveDateTime{} = ndt), do: NaiveDateTime.to_iso8601(ndt) + defp timestamp_iso8601(value) when is_binary(value), do: value + defp timestamp_iso8601(_other), do: nil + + defp timestamp_label(value) when is_binary(value), do: value + defp timestamp_label(value), do: timestamp_iso8601(value) || "" + + defp duration_label(duration_ms) when is_integer(duration_ms) and duration_ms < 1_000 do + "#{duration_ms}ms" + end + + defp duration_label(duration_ms) when is_integer(duration_ms) do + seconds = Float.round(duration_ms / 1_000, 1) + "#{seconds}s" + end + + defp duration_label(_duration_ms), do: "" + + defp format_args(args) when is_map(args) do + inspect(args, pretty: true, limit: :infinity, printable_limit: :infinity) + end + + defp format_args(_args), do: "%{}" + + defp expand_button_attrs(attrs) when attrs in [nil, [], %{}], + do: %{:"phx-click" => "expand_toggled"} + + defp expand_button_attrs(attrs), do: attrs + + defp result_value(nil, _key), do: nil + + defp result_value(result, key) when is_map(result) do + Map.get(result, key, Map.get(result, to_string(key))) + end + + defp result_error?(result) do + result_value(result, :error?) == true or result_value(result, :error) == true + end +end diff --git a/packages/live_ui/test/live_ui/widgets/tool_call_card_test.exs b/packages/live_ui/test/live_ui/widgets/tool_call_card_test.exs new file mode 100644 index 00000000..217bd293 --- /dev/null +++ b/packages/live_ui/test/live_ui/widgets/tool_call_card_test.exs @@ -0,0 +1,192 @@ +defmodule LiveUi.Widgets.ToolCallCardTest do + use ExUnit.Case, async: true + + import Phoenix.LiveViewTest + + alias LiveUi.Component + alias UnifiedIUR.Widgets.Components + + describe "tool_call_card widget metadata" do + test "registers as a row_and_artifact widget with expand_toggled event" do + metadata = Component.metadata(LiveUi.Widgets.ToolCallCard) + + assert metadata.mountable? + assert metadata.component_module == LiveUi.Widgets.ToolCallCard.Component + assert metadata.family == :row_and_artifact + assert metadata.name == :tool_call_card + assert :expand_toggled in metadata.events + end + + test "is present in row_and_artifact aggregation" do + assert LiveUi.Widgets.ToolCallCard in LiveUi.Widgets.RowAndArtifact.modules() + assert LiveUi.Widgets.ToolCallCard in LiveUi.Widgets.row_and_artifact_modules() + end + end + + describe "tool_call_card component rendering" do + test "renders the canonical root hooks and header content" do + html = + render_component( + &LiveUi.Widgets.ToolCallCard.component/1, + base_assigns() + ) + + assert html =~ ~s(data-live-ui-widget="tool-call-card") + assert html =~ ~s(data-tool-kind="bash") + assert html =~ ~s(data-status="pending") + assert html =~ "Bash" + assert html =~ "mix test" + assert html =~ "Run focused tests." + assert html =~ "live-ui-tool-call-card__header" + assert html =~ "live-ui-tool-call-card__status-badge" + end + + test "renders status variants as data and BEM hooks" do + for status <- [:pending, :approved, :denied, :complete, :failed] do + html = + render_component( + &LiveUi.Widgets.ToolCallCard.component/1, + base_assigns(%{id: "tool-#{status}", status: status}) + ) + + assert html =~ ~s(data-status="#{status}") + assert html =~ "live-ui-tool-call-card--#{status}" + assert html =~ "is-status-#{status}" + end + end + + test "keeps args collapsed until expanded" do + collapsed = + render_component( + &LiveUi.Widgets.ToolCallCard.component/1, + base_assigns(%{id: "tool-collapsed", expanded?: false}) + ) + + expanded = + render_component( + &LiveUi.Widgets.ToolCallCard.component/1, + base_assigns(%{id: "tool-expanded", expanded?: true}) + ) + + refute collapsed =~ "live-ui-tool-call-card__args" + assert collapsed =~ ~s(aria-expanded="false") + assert expanded =~ "live-ui-tool-call-card__args" + assert expanded =~ "path: "test/live_ui" + assert expanded =~ ~s(aria-expanded="true") + end + + test "renders expand button with fallback event attrs" do + html = + render_component( + &LiveUi.Widgets.ToolCallCard.component/1, + base_assigns(%{id: "tool-expand"}) + ) + + assert html =~ "live-ui-tool-call-card__expand-toggle" + assert html =~ ~s(aria-label="Toggle tool call Bash details") + assert html =~ ~s(phx-click="expand_toggled") + end + + test "renders optional result summary child data" do + html = + render_component( + &LiveUi.Widgets.ToolCallCard.component/1, + base_assigns(%{ + id: "tool-result", + status: :complete, + tool_result_summary: %{ + event_id: "result-1", + status: :complete, + compact_output: "All tests passed.", + diff_summary: "No source diff.", + error?: false + } + }) + ) + + assert html =~ "live-ui-tool-call-card__result" + assert html =~ "result-1" + assert html =~ "All tests passed." + assert html =~ "No source diff." + refute html =~ "live-ui-tool-call-card__result-error" + end + end + + describe "renderer dispatch" do + test "tool_call_card kind is in supported_kinds" do + assert :tool_call_card in LiveUi.Renderer.supported_kinds() + end + + test "renders via dedicated renderer clause with canonical interaction attrs" do + element = + Components.tool_call_card( + id: "tool-call-r1", + tool_name: "Bash", + tool_kind: :bash, + target: "mix test", + summary: "Run focused tests.", + status: :pending, + args: %{cmd: "mix test"} + ) + + html = + render_component(&LiveUi.Renderer.render/1, %{ + element: element, + event_target: "#runtime-host" + }) + + assert html =~ ~s(data-live-ui-widget="tool-call-card") + assert html =~ ~s(phx-click="canonical_interaction") + assert html =~ ~s(phx-target="#runtime-host") + assert html =~ ~s(phx-value-widget="tool_call_card") + assert html =~ ~s(phx-value-element_id="tool-call-r1") + refute html =~ ~s(data-live-ui-component-kind="tool_call_card") + refute html =~ ~s(data-live-ui-unsupported-native-component) + end + + test "renderer propagates expanded state and paired result child" do + element = + Components.tool_call_card( + id: "tool-call-r2", + tool_name: "Read", + tool_kind: :read, + target: "lib/ash_ui.ex", + summary: "Read the file.", + status: :complete, + args: %{path: "lib/ash_ui.ex"}, + expanded?: true, + paired_result_event_id: "tool-result-r2", + tool_result_summary: %{ + event_id: "tool-result-r2", + status: :complete, + compact_output: "Loaded file.", + error?: false + } + ) + + html = render_component(&LiveUi.Renderer.render/1, %{element: element}) + + assert html =~ ~s(aria-expanded="true") + assert html =~ "live-ui-tool-call-card__args" + assert html =~ "Loaded file." + assert html =~ "tool-result-r2" + refute html =~ "Unsupported canonical kind" + end + end + + defp base_assigns(overrides \\ %{}) do + Map.merge( + %{ + id: "tool-card", + tool_name: "Bash", + tool_kind: :bash, + target: "mix test", + summary: "Run focused tests.", + status: :pending, + args: %{cmd: "mix test", path: "test/live_ui/widgets/tool_call_card_test.exs"}, + expanded?: false + }, + overrides + ) + end +end diff --git a/packages/unified_iur/lib/unified_iur/validate.ex b/packages/unified_iur/lib/unified_iur/validate.ex index f720b821..7a3b394b 100644 --- a/packages/unified_iur/lib/unified_iur/validate.ex +++ b/packages/unified_iur/lib/unified_iur/validate.ex @@ -124,6 +124,16 @@ defmodule UnifiedIUR.Validate do construct_family: :widget_components, guidance: "Represent artifact counts as a list of maps with key, value, and optional label." }, + invalid_tool_call_card: %{ + construct_family: :widget_components, + guidance: + "Represent tool_call_card with required tool identity, status, target, summary, args, and renderer-independent expansion intent." + }, + invalid_tool_result_summary: %{ + construct_family: :widget_components, + guidance: + "Represent tool_result_summary as the single paired result child with event id, status, compact output, and optional diff or error marker." + }, invalid_rail_contract: %{ construct_family: :widget_components, guidance: @@ -208,6 +218,8 @@ defmodule UnifiedIUR.Validate do @redline_states [:keep, :insert, :delete, :accepted, :rejected] @artifact_kinds [:pr, :doc, :spec, :file, :grain, :generic] @artifact_badge_tones [:positive, :warning, :danger, :info, :neutral] + @tool_call_kinds [:read, :edit, :write, :bash, :multiedit, :other] + @tool_call_statuses [:pending, :approved, :denied, :complete, :failed] @rail_sides [:right] @query_preview_states [:loading, :ready, :empty, :error] @propose_new_doc_statuses [:pending, :accepted, :rejected, :archived] @@ -641,6 +653,26 @@ defmodule UnifiedIUR.Validate do ) end + defp validate_component_contracts(%Element{ + kind: :tool_call_card, + attributes: attributes, + children: children + }) do + tool_call = Map.get(attributes, :tool_call, %{}) + + [] + |> Kernel.++(validate_tool_call_shape(tool_call)) + |> Kernel.++( + validate_tool_call_result_children(children, fetch(tool_call, :paired_result_event_id)) + ) + end + + defp validate_component_contracts(%Element{kind: :tool_result_summary, attributes: attributes}) do + attributes + |> Map.get(:tool_result_summary, %{}) + |> validate_tool_result_summary_shape([:attributes, :tool_result_summary]) + end + defp validate_component_contracts(%Element{kind: :meter_thin, attributes: attributes}) do meter = Map.get(attributes, :meter, %{}) current = fetch(meter, :current) @@ -729,6 +761,227 @@ defmodule UnifiedIUR.Validate do defp validate_component_contracts(_element), do: [] + defp validate_tool_call_shape(tool_call) when is_map(tool_call) do + [] + |> maybe_add( + not non_blank_string?(fetch(tool_call, :tool_name)), + Error.new( + :invalid_tool_call_card, + "tool_call_card requires tool_name as a non-blank string", + path: [:attributes, :tool_call, :tool_name], + details: %{tool_name: inspect(fetch(tool_call, :tool_name))} + ) + ) + |> maybe_add( + fetch(tool_call, :tool_kind) not in @tool_call_kinds, + Error.new( + :invalid_tool_call_card, + "tool_call_card tool_kind must be one of #{inspect(@tool_call_kinds)}", + path: [:attributes, :tool_call, :tool_kind], + details: %{tool_kind: inspect(fetch(tool_call, :tool_kind))} + ) + ) + |> maybe_add( + not non_blank_string?(fetch(tool_call, :target)), + Error.new( + :invalid_tool_call_card, + "tool_call_card requires target as a non-blank string", + path: [:attributes, :tool_call, :target], + details: %{target: inspect(fetch(tool_call, :target))} + ) + ) + |> maybe_add( + not is_binary(fetch(tool_call, :summary)), + Error.new( + :invalid_tool_call_card, + "tool_call_card requires summary as a string", + path: [:attributes, :tool_call, :summary], + details: %{summary: inspect(fetch(tool_call, :summary))} + ) + ) + |> maybe_add( + fetch(tool_call, :status) not in @tool_call_statuses, + Error.new( + :invalid_tool_call_card, + "tool_call_card status must be one of #{inspect(@tool_call_statuses)}", + path: [:attributes, :tool_call, :status], + details: %{status: inspect(fetch(tool_call, :status))} + ) + ) + |> maybe_add( + not is_map(fetch(tool_call, :args)), + Error.new( + :invalid_tool_call_card, + "tool_call_card args must be a map", + path: [:attributes, :tool_call, :args], + details: %{args: inspect(fetch(tool_call, :args))} + ) + ) + |> maybe_add( + has_key?(tool_call, :expanded?) and not is_boolean(fetch(tool_call, :expanded?)), + Error.new( + :invalid_tool_call_card, + "tool_call_card expanded? must be a boolean", + path: [:attributes, :tool_call, :expanded?], + details: %{expanded?: inspect(fetch(tool_call, :expanded?))} + ) + ) + |> maybe_add( + has_key?(tool_call, :duration_ms) and + not non_negative_integer?(fetch(tool_call, :duration_ms)), + Error.new( + :invalid_tool_call_card, + "tool_call_card duration_ms must be a non-negative integer", + path: [:attributes, :tool_call, :duration_ms], + details: %{duration_ms: inspect(fetch(tool_call, :duration_ms))} + ) + ) + end + + defp validate_tool_call_shape(_tool_call) do + [ + Error.new( + :invalid_tool_call_card, + "tool_call_card attributes.tool_call must be a map", + path: [:attributes, :tool_call] + ) + ] + end + + defp validate_tool_call_result_children(children, paired_result_event_id) + when is_list(children) do + present_children = + children + |> Enum.with_index() + |> Enum.filter(fn + {%Child{element: %Element{}}, _index} -> true + _other -> false + end) + + result_children = + Enum.filter(present_children, fn {%Child{} = child, _index} -> + tool_result_summary_child?(child) + end) + + non_result_errors = + present_children + |> Enum.reject(fn {%Child{} = child, _index} -> tool_result_summary_child?(child) end) + |> Enum.map(fn {_child, index} -> + Error.new( + :invalid_tool_result_summary, + "tool_call_card accepts only a tool_result_summary child", + path: [:children, index] + ) + end) + + count_errors = + maybe_add( + [], + length(result_children) > 1, + Error.new( + :invalid_tool_result_summary, + "tool_call_card accepts at most one tool_result_summary child", + path: [:children], + details: %{count: length(result_children)} + ) + ) + + child_errors = + result_children + |> Enum.take(1) + |> Enum.flat_map(fn {%Child{element: element}, index} -> + summary = Map.get(element.attributes, :tool_result_summary, %{}) + event_id = tool_result_event_id(summary) + + validate_tool_result_summary_shape(summary, [ + :children, + index, + :attributes, + :tool_result_summary + ]) ++ + maybe_add( + [], + non_blank_string?(paired_result_event_id) and event_id != paired_result_event_id, + Error.new( + :invalid_tool_result_summary, + "tool_result_summary event_id must match paired_result_event_id", + path: [:children, index, :attributes, :tool_result_summary, :event_id], + details: %{ + event_id: inspect(event_id), + paired_result_event_id: paired_result_event_id + } + ) + ) + end) + + non_result_errors ++ count_errors ++ child_errors + end + + defp validate_tool_call_result_children(_children, _paired_result_event_id), do: [] + + defp tool_result_summary_child?(%Child{ + slot: slot, + element: %Element{kind: :tool_result_summary} + }) + when slot in [:tool_result_summary, "tool_result_summary"], + do: true + + defp tool_result_summary_child?(_child), do: false + + defp validate_tool_result_summary_shape(summary, path) when is_map(summary) do + [] + |> maybe_add( + not non_blank_string?(tool_result_event_id(summary)), + Error.new( + :invalid_tool_result_summary, + "tool_result_summary requires event_id as a non-blank string", + path: path ++ [:event_id], + details: %{event_id: inspect(tool_result_event_id(summary))} + ) + ) + |> maybe_add( + fetch(summary, :status) not in @tool_call_statuses, + Error.new( + :invalid_tool_result_summary, + "tool_result_summary status must be one of #{inspect(@tool_call_statuses)}", + path: path ++ [:status], + details: %{status: inspect(fetch(summary, :status))} + ) + ) + |> maybe_add( + not is_binary(fetch(summary, :compact_output)), + Error.new( + :invalid_tool_result_summary, + "tool_result_summary requires compact_output as a string", + path: path ++ [:compact_output], + details: %{compact_output: inspect(fetch(summary, :compact_output))} + ) + ) + |> maybe_add( + has_key?(summary, :error?) and not is_boolean(fetch(summary, :error?)), + Error.new( + :invalid_tool_result_summary, + "tool_result_summary error? must be a boolean", + path: path ++ [:error?], + details: %{error?: inspect(fetch(summary, :error?))} + ) + ) + end + + defp validate_tool_result_summary_shape(_summary, path) do + [ + Error.new( + :invalid_tool_result_summary, + "tool_result_summary attributes must be a map", + path: path + ) + ] + end + + defp tool_result_event_id(summary) do + fetch(summary, :event_id, fetch(summary, :result_event_id)) + end + defp validate_subject_shape(subject) when is_map(subject) do [] |> maybe_add( diff --git a/packages/unified_iur/lib/unified_iur/widgets/components.ex b/packages/unified_iur/lib/unified_iur/widgets/components.ex index a577db0d..0c70ed7b 100644 --- a/packages/unified_iur/lib/unified_iur/widgets/components.ex +++ b/packages/unified_iur/lib/unified_iur/widgets/components.ex @@ -44,7 +44,8 @@ defmodule UnifiedIUR.Widgets.Components do @row_artifact_kinds [ :list_item_multi_column, :artifact_row, - :thread_card + :thread_card, + :tool_call_card ] @artifact_kinds [ @@ -81,6 +82,8 @@ defmodule UnifiedIUR.Widgets.Components do @query_preview_states [:loading, :ready, :empty, :error] @propose_new_doc_statuses [:pending, :accepted, :rejected, :archived] + @tool_call_kinds [:read, :edit, :write, :bash, :multiedit, :other] + @tool_call_statuses [:pending, :approved, :denied, :complete, :failed] @redline_code_kinds [ :redline_inline, @@ -402,6 +405,79 @@ defmodule UnifiedIUR.Widgets.Components do ) end + @spec tool_call_card(opts()) :: Element.t() + def tool_call_card(opts \\ []) do + opts = normalize_opts(opts) + + tool_name = option(opts, :tool_name) + tool_kind = option(opts, :tool_kind) + target = option(opts, :target) + summary = option(opts, :summary) + status = option(opts, :status) + args = option(opts, :args) + expanded? = option(opts, :expanded?, false) || false + duration_ms = option(opts, :duration_ms) + paired_result_event_id = option(opts, :paired_result_event_id) + tool_result_summary = normalize_tool_result_summary_child!(opts, paired_result_event_id) + + unless non_blank_string?(tool_name) do + raise ArgumentError, "tool_call_card requires a non-blank :tool_name string" + end + + unless tool_kind in @tool_call_kinds do + raise ArgumentError, "tool_call_card :tool_kind must be one of #{inspect(@tool_call_kinds)}" + end + + unless non_blank_string?(target) do + raise ArgumentError, "tool_call_card requires a non-blank :target string" + end + + unless is_binary(summary) do + raise ArgumentError, "tool_call_card requires a :summary string" + end + + unless status in @tool_call_statuses do + raise ArgumentError, "tool_call_card :status must be one of #{inspect(@tool_call_statuses)}" + end + + unless is_map(args) do + raise ArgumentError, "tool_call_card :args must be a map" + end + + unless is_boolean(expanded?) do + raise ArgumentError, "tool_call_card :expanded? must be a boolean" + end + + unless is_nil(duration_ms) or non_negative_integer?(duration_ms) do + raise ArgumentError, "tool_call_card :duration_ms must be a non-negative integer" + end + + opts = put_tool_call_expand_interaction(opts, tool_name) + + build_component( + :tool_call_card, + :row_and_artifact, + %{ + tool_call: + %{ + tool_name: tool_name, + tool_kind: tool_kind, + target: target, + summary: summary, + status: status, + args: Map.new(args), + expanded?: expanded? + } + |> maybe_put(:actor_handle, option(opts, :actor_handle)) + |> maybe_put(:started_at, option(opts, :started_at)) + |> maybe_put(:duration_ms, duration_ms) + |> maybe_put(:approval_event_id, option(opts, :approval_event_id)) + |> maybe_put(:paired_result_event_id, paired_result_event_id) + }, + Map.put(opts, :children, List.wrap(tool_result_summary)) + ) + end + @spec pipeline_stepper_horizontal([keyword() | map()], opts()) :: Element.t() def pipeline_stepper_horizontal(steps, opts \\ []) when is_list(steps) do opts = normalize_opts(opts) @@ -1375,6 +1451,32 @@ defmodule UnifiedIUR.Widgets.Components do end end + defp put_tool_call_expand_interaction(opts, tool_name) do + cond do + explicit_interactions?(opts) -> + opts + + true -> + Map.put(opts, :interactions, [tool_call_expand_interaction(opts, tool_name)]) + end + end + + defp tool_call_expand_interaction(opts, tool_name) do + case option(opts, :expand_interaction) do + nil -> + Interaction.command( + intent: option(opts, :expand_intent, :expand_toggled), + element_id: option(opts, :id), + entity: tool_name, + command: :expand_toggled, + value: option(opts, :target) + ) + + interaction -> + Interaction.new(interaction) + end + end + defp put_query_preview_interactions(opts, composer_id, query) do cond do Map.has_key?(opts, :interactions) or Map.has_key?(opts, "interactions") or @@ -1707,6 +1809,86 @@ defmodule UnifiedIUR.Widgets.Components do end end + defp normalize_tool_result_summary_child!(opts, paired_result_event_id) do + summaries = tool_result_summary_inputs(opts) + + case summaries do + [] -> + nil + + [summary] -> + summary = normalize_tool_result_summary!(summary) + event_id = Map.fetch!(summary, :event_id) + + if non_blank_string?(paired_result_event_id) and event_id != paired_result_event_id do + raise ArgumentError, + "tool_call_card :tool_result_summary event_id must match :paired_result_event_id" + end + + {:tool_result_summary, + Element.new(:widget, :tool_result_summary, + id: event_id, + attributes: %{tool_result_summary: summary} + )} + + _more -> + raise ArgumentError, "tool_call_card accepts at most one :tool_result_summary child" + end + end + + defp tool_result_summary_inputs(opts) do + cond do + not is_nil(option(opts, :tool_result_summaries)) -> + option(opts, :tool_result_summaries) + |> List.wrap() + + is_nil(option(opts, :tool_result_summary)) -> + [] + + keyword_list?(option(opts, :tool_result_summary)) -> + [option(opts, :tool_result_summary)] + + is_list(option(opts, :tool_result_summary)) -> + option(opts, :tool_result_summary) + + true -> + [option(opts, :tool_result_summary)] + end + end + + defp normalize_tool_result_summary!(summary) when is_map(summary) or is_list(summary) do + summary = normalize_map(summary) + event_id = option(summary, :event_id, option(summary, :result_event_id)) + status = option(summary, :status) + compact_output = option(summary, :compact_output, option(summary, :summary)) + + unless non_blank_string?(event_id) do + raise ArgumentError, "tool_result_summary requires a non-blank :event_id string" + end + + unless status in @tool_call_statuses do + raise ArgumentError, + "tool_result_summary :status must be one of #{inspect(@tool_call_statuses)}" + end + + unless is_binary(compact_output) do + raise ArgumentError, "tool_result_summary requires a :compact_output string" + end + + %{ + event_id: event_id, + status: status, + compact_output: compact_output + } + |> maybe_put(:diff_summary, option(summary, :diff_summary)) + |> maybe_put(:error?, option(summary, :error?, option(summary, :error))) + end + + defp normalize_tool_result_summary!(_summary) do + raise ArgumentError, "tool_result_summary must be a map" + end + + defp normalize_query_preview_state!(state) when state in @query_preview_states, do: state defp normalize_query_preview_state!(state) when is_binary(state) do @@ -1842,8 +2024,10 @@ defmodule UnifiedIUR.Widgets.Components do defp empty_map_to_nil(value), do: value defp non_empty_string?(value), do: is_binary(value) and byte_size(value) > 0 + defp non_blank_string?(value), do: is_binary(value) and String.trim(value) != "" defp non_negative_integer?(value), do: is_integer(value) and value >= 0 defp positive_integer?(value), do: is_integer(value) and value > 0 + defp keyword_list?(value), do: is_list(value) and Keyword.keyword?(value) defp normalized_confidence?(value) when is_integer(value) or is_float(value) do value >= 0.0 and value <= 1.0 diff --git a/packages/unified_iur/test/unified_iur/validate_test.exs b/packages/unified_iur/test/unified_iur/validate_test.exs index 35845630..eb17f7ed 100644 --- a/packages/unified_iur/test/unified_iur/validate_test.exs +++ b/packages/unified_iur/test/unified_iur/validate_test.exs @@ -350,4 +350,120 @@ defmodule UnifiedIUR.ValidateTest do :invalid_artifact_count ] end + + test "recognizes and validates canonical tool_call_card vocabulary" do + valid_tool_call = + Components.tool_call_card( + id: "tool-call-1", + tool_name: "Bash", + tool_kind: :bash, + target: "mix test", + summary: "Run the focused test suite.", + status: :complete, + args: %{cmd: "mix test"}, + paired_result_event_id: "tool-result-1", + tool_result_summary: %{ + event_id: "tool-result-1", + status: :complete, + compact_output: "Tests passed.", + error?: false + } + ) + + assert :tool_call_card in Components.kinds() + assert :ok = Validate.element(valid_tool_call) + end + + test "rejects malformed raw tool_call_card payloads with structured diagnostics" do + invalid_tool_call = + Element.new(:widget, :tool_call_card, + attributes: %{ + component: %{family: :row_and_artifact, kind: :tool_call_card}, + tool_call: %{ + tool_name: " ", + tool_kind: :delete, + target: "", + summary: nil, + status: :running, + args: [] + } + } + ) + + assert {:error, errors} = Validate.element(invalid_tool_call) + assert Enum.all?(errors, &(&1.code == :invalid_tool_call_card)) + end + + test "rejects invalid tool_result_summary children" do + duplicate_results = + Element.new(:widget, :tool_call_card, + attributes: valid_tool_call_attrs("result-1"), + children: [ + {:tool_result_summary, tool_result_summary_element("result-1")}, + {:tool_result_summary, tool_result_summary_element("result-2")} + ] + ) + + missing_event_id = + Element.new(:widget, :tool_call_card, + attributes: valid_tool_call_attrs(nil), + children: [ + {:tool_result_summary, + Element.new(:widget, :tool_result_summary, + attributes: %{ + tool_result_summary: %{status: :complete, compact_output: "ok"} + } + )} + ] + ) + + mismatched_pair = + Element.new(:widget, :tool_call_card, + attributes: valid_tool_call_attrs("expected-result"), + children: [ + {:tool_result_summary, tool_result_summary_element("actual-result")} + ] + ) + + assert {:error, duplicate_errors} = Validate.element(duplicate_results) + assert Enum.any?(duplicate_errors, &(&1.code == :invalid_tool_result_summary)) + + assert {:error, missing_errors} = Validate.element(missing_event_id) + assert Enum.any?(missing_errors, &(&1.code == :invalid_tool_result_summary)) + + assert {:error, mismatch_errors} = Validate.element(mismatched_pair) + assert Enum.any?(mismatch_errors, &(&1.code == :invalid_tool_result_summary)) + end + + defp valid_tool_call_attrs(paired_result_event_id) do + %{ + component: %{family: :row_and_artifact, kind: :tool_call_card}, + tool_call: + %{ + tool_name: "Read", + tool_kind: :read, + target: "lib/ash_ui.ex", + summary: "Read the file.", + status: :complete, + args: %{}, + expanded?: false + } + |> maybe_put(:paired_result_event_id, paired_result_event_id) + } + end + + defp tool_result_summary_element(event_id) do + Element.new(:widget, :tool_result_summary, + attributes: %{ + tool_result_summary: %{ + event_id: event_id, + status: :complete, + compact_output: "ok" + } + } + ) + end + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) end diff --git a/packages/unified_iur/test/unified_iur/widgets/components_test.exs b/packages/unified_iur/test/unified_iur/widgets/components_test.exs index 0cc3c987..a3990013 100644 --- a/packages/unified_iur/test/unified_iur/widgets/components_test.exs +++ b/packages/unified_iur/test/unified_iur/widgets/components_test.exs @@ -27,7 +27,8 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do assert Components.row_artifact_kinds() == [ :list_item_multi_column, :artifact_row, - :thread_card + :thread_card, + :tool_call_card ] assert Components.artifact_kinds() == [:pr, :doc, :spec, :file, :grain, :generic] @@ -176,6 +177,28 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do progress_pct: 0.4 ) + tool_call = + Components.tool_call_card( + id: "tool-call-read", + tool_name: "Read", + tool_kind: :read, + target: "lib/ash_ui.ex", + summary: "Read the package entrypoint.", + status: :complete, + args: %{path: "lib/ash_ui.ex"}, + expanded?: true, + actor_handle: "@codex", + duration_ms: 42, + paired_result_event_id: "event-result-1", + tool_result_summary: %{ + event_id: "event-result-1", + status: :complete, + compact_output: "Loaded 120 lines.", + diff_summary: "No changes.", + error?: false + } + ) + assert segmented.attributes.selection == %{ presentation: :segmented_button_group, multiple?: false, @@ -268,6 +291,45 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do ] assert [%Interaction{family: :open, intent: :open_thread}] = thread.attributes.interactions + + assert tool_call.attributes.component == %{ + family: :row_and_artifact, + kind: :tool_call_card + } + + assert tool_call.attributes.tool_call == %{ + tool_name: "Read", + tool_kind: :read, + target: "lib/ash_ui.ex", + summary: "Read the package entrypoint.", + status: :complete, + args: %{path: "lib/ash_ui.ex"}, + expanded?: true, + actor_handle: "@codex", + duration_ms: 42, + paired_result_event_id: "event-result-1" + } + + assert [%Interaction{family: :command, intent: :expand_toggled}] = + tool_call.attributes.interactions + + assert [ + %{ + slot: :tool_result_summary, + element: %Element{ + kind: :tool_result_summary, + attributes: %{ + tool_result_summary: %{ + event_id: "event-result-1", + status: :complete, + compact_output: "Loaded 120 lines.", + diff_summary: "No changes.", + error?: false + } + } + } + } + ] = tool_call.children end test "validates canonical thread card identity and progress" do @@ -302,6 +364,143 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do end end + describe "tool_call_card payload validation" do + test "rejects missing required fields" do + assert_raise ArgumentError, ~r/non-blank :tool_name/, fn -> + Components.tool_call_card( + tool_kind: :read, + target: "lib/ash_ui.ex", + summary: "Read file", + status: :pending, + args: %{} + ) + end + + assert_raise ArgumentError, ~r/requires a :summary string/, fn -> + Components.tool_call_card( + tool_name: "Read", + tool_kind: :read, + target: "lib/ash_ui.ex", + status: :pending, + args: %{} + ) + end + end + + test "rejects blank tool_name and target" do + assert_raise ArgumentError, ~r/non-blank :tool_name/, fn -> + Components.tool_call_card( + tool_name: " ", + tool_kind: :read, + target: "lib/ash_ui.ex", + summary: "Read file", + status: :pending, + args: %{} + ) + end + + assert_raise ArgumentError, ~r/non-blank :target/, fn -> + Components.tool_call_card( + tool_name: "Read", + tool_kind: :read, + target: " ", + summary: "Read file", + status: :pending, + args: %{} + ) + end + end + + test "rejects non-map args" do + assert_raise ArgumentError, ~r/:args must be a map/, fn -> + Components.tool_call_card( + tool_name: "Read", + tool_kind: :read, + target: "lib/ash_ui.ex", + summary: "Read file", + status: :pending, + args: [path: "lib/ash_ui.ex"] + ) + end + end + + test "rejects unknown tool_kind" do + assert_raise ArgumentError, ~r/:tool_kind must be one of/, fn -> + Components.tool_call_card( + tool_name: "Read", + tool_kind: :delete, + target: "lib/ash_ui.ex", + summary: "Read file", + status: :pending, + args: %{} + ) + end + end + + test "rejects unknown status" do + assert_raise ArgumentError, ~r/:status must be one of/, fn -> + Components.tool_call_card( + tool_name: "Read", + tool_kind: :read, + target: "lib/ash_ui.ex", + summary: "Read file", + status: :running, + args: %{} + ) + end + end + + test "rejects more than one tool_result_summary" do + assert_raise ArgumentError, ~r/at most one :tool_result_summary/, fn -> + Components.tool_call_card( + tool_name: "Read", + tool_kind: :read, + target: "lib/ash_ui.ex", + summary: "Read file", + status: :complete, + args: %{}, + tool_result_summary: [ + %{event_id: "result-1", status: :complete, compact_output: "ok"}, + %{event_id: "result-2", status: :complete, compact_output: "ok"} + ] + ) + end + end + + test "rejects tool_result_summary without an event id" do + assert_raise ArgumentError, ~r/non-blank :event_id/, fn -> + Components.tool_call_card( + tool_name: "Read", + tool_kind: :read, + target: "lib/ash_ui.ex", + summary: "Read file", + status: :complete, + args: %{}, + tool_result_summary: %{status: :complete, compact_output: "ok"} + ) + end + end + + test "rejects mismatched paired result event id" do + assert_raise ArgumentError, ~r/event_id must match :paired_result_event_id/, fn -> + Components.tool_call_card( + tool_name: "Read", + tool_kind: :read, + target: "lib/ash_ui.ex", + summary: "Read file", + status: :complete, + args: %{}, + paired_result_event_id: "result-expected", + tool_result_summary: %{ + event_id: "result-actual", + status: :complete, + compact_output: "ok" + } + ) + end + end + end + test "validates canonical collection picker shape" do assert_raise ArgumentError, ~r/non-empty :picker_id/, fn -> Components.collection_picker(items: []) diff --git a/packages/unified_ui/lib/unified_ui/dsl/entities/widget_components.ex b/packages/unified_ui/lib/unified_ui/dsl/entities/widget_components.ex index 1944218b..212b6c4c 100644 --- a/packages/unified_ui/lib/unified_ui/dsl/entities/widget_components.ex +++ b/packages/unified_ui/lib/unified_ui/dsl/entities/widget_components.ex @@ -15,6 +15,8 @@ defmodule UnifiedUi.Dsl.Entities.WidgetComponents do @presence_states [:active, :away, :offline, :focus, :do_not_disturb] @artifact_kinds [:pr, :doc, :spec, :file, :grain, :generic] @propose_new_doc_statuses [:pending, :accepted, :rejected, :archived] + @tool_call_kinds [:read, :edit, :write, :bash, :multiedit, :other] + @tool_call_statuses [:pending, :approved, :denied, :complete, :failed] @spec entities() :: [Spark.Dsl.Entity.t()] def entities do @@ -131,6 +133,24 @@ defmodule UnifiedUi.Dsl.Entities.WidgetComponents do last_activity_at: [type: :any, required: false], open_intent: [type: :any, required: false], summary: [type: :string, required: false] + ), + leaf( + :tool_call_card, + @row_artifact_family, + tool_name: [type: :string, required: true], + tool_kind: [type: {:in, @tool_call_kinds}, required: true], + target: [type: :string, required: true], + summary: [type: :string, required: true], + status: [type: {:in, @tool_call_statuses}, required: true], + args: [type: :any, required: true], + expanded?: [type: :boolean, required: false, default: false], + actor_handle: [type: :string, required: false], + started_at: [type: :any, required: false], + duration_ms: [type: :integer, required: false], + approval_event_id: [type: :string, required: false], + paired_result_event_id: [type: :string, required: false], + tool_result_summary: [type: :any, required: false], + expand_intent: [type: :atom, required: false] ) ] end diff --git a/packages/unified_ui/lib/unified_ui/widget_components.ex b/packages/unified_ui/lib/unified_ui/widget_components.ex index ffda14a6..98d61d58 100644 --- a/packages/unified_ui/lib/unified_ui/widget_components.ex +++ b/packages/unified_ui/lib/unified_ui/widget_components.ex @@ -106,6 +106,13 @@ defmodule UnifiedUi.WidgetComponents do "Conversation thread preview artifact with participants, seed quote, progress, and canonical open interaction.", aliases: [] }, + %{ + kind: :tool_call_card, + family: :row_and_artifact, + summary: + "Assistant tool-call artifact with status, target, args, expansion state, and optional paired result summary.", + aliases: [] + }, %{ kind: :sticky_frosted_header, family: :layer_shell_and_callout, diff --git a/packages/unified_ui/test/unified_ui/widget_components_catalog_test.exs b/packages/unified_ui/test/unified_ui/widget_components_catalog_test.exs index b9b42081..ad2e0d00 100644 --- a/packages/unified_ui/test/unified_ui/widget_components_catalog_test.exs +++ b/packages/unified_ui/test/unified_ui/widget_components_catalog_test.exs @@ -29,7 +29,12 @@ defmodule UnifiedUi.WidgetComponentsCatalogTest do :collection_picker, :mode_nav ], - row_and_artifact: [:list_item_multi_column, :artifact_row, :thread_card], + row_and_artifact: [ + :list_item_multi_column, + :artifact_row, + :thread_card, + :tool_call_card + ], workflow_progress_and_status: [ :pipeline_stepper_horizontal, :segmented_progress_bar, @@ -71,6 +76,7 @@ defmodule UnifiedUi.WidgetComponentsCatalogTest do assert :propose_new_doc_card in kinds assert :collection_picker in kinds assert :workflow_progress_status_card in kinds + assert :tool_call_card in kinds end test "canonical name lookup accepts AshUi aliases with diagnostics" do diff --git a/test/ash_ui/phase_31_package_boundary_test.exs b/test/ash_ui/phase_31_package_boundary_test.exs index 9b9c622e..fd7d40bb 100644 --- a/test/ash_ui/phase_31_package_boundary_test.exs +++ b/test/ash_ui/phase_31_package_boundary_test.exs @@ -45,7 +45,12 @@ defmodule AshUI.Phase31PackageBoundaryTest do :mode_nav ] - assert families.row_and_artifact == [:list_item_multi_column, :artifact_row, :thread_card] + assert families.row_and_artifact == [ + :list_item_multi_column, + :artifact_row, + :thread_card, + :tool_call_card + ] assert families.workflow_progress_and_status == [ :pipeline_stepper_horizontal, diff --git a/test/ash_ui/rendering/iur_adapter_test.exs b/test/ash_ui/rendering/iur_adapter_test.exs index 264602c2..b746439c 100644 --- a/test/ash_ui/rendering/iur_adapter_test.exs +++ b/test/ash_ui/rendering/iur_adapter_test.exs @@ -368,6 +368,50 @@ defmodule AshUI.Rendering.IURAdapterTest do assert :ok = UnifiedIUR.Validate.element(child.element) end + test "routes tool_call_card kind through row_and_artifact family with canonical expand interaction" do + ash_iur = + IUR.new(:screen, + id: "tool-call-card-screen", + name: "tool_call_card_screen", + attributes: %{}, + children: [ + IUR.new(:tool_call_card, + id: "tool-call-1", + props: %{ + "tool_name" => "Bash", + "tool_kind" => "bash", + "target" => "mix test", + "summary" => "Run the focused suite.", + "status" => "pending", + "args" => %{"cmd" => "mix test"}, + "expanded?" => false + } + ) + ] + ) + + assert {:ok, canonical} = IURAdapter.to_canonical(ash_iur) + [child] = canonical.children + assert child.element.kind == :tool_call_card + assert child.element.type == :widget + assert child.element.attributes.component.family == :row_and_artifact + + assert child.element.attributes.tool_call == %{ + tool_name: "Bash", + tool_kind: :bash, + target: "mix test", + summary: "Run the focused suite.", + status: :pending, + args: %{cmd: "mix test"}, + expanded?: false + } + + assert [%UnifiedIUR.Interaction{family: :command, intent: :expand_toggled}] = + child.element.attributes.interactions + + assert :ok = UnifiedIUR.Validate.element(child.element) + end + test "returns structured conversion errors for invalid propose_new_doc_card payloads" do ash_iur = IUR.new(:screen, @@ -393,6 +437,33 @@ defmodule AshUI.Rendering.IURAdapterTest do assert error.message =~ "propose_new_doc_card :status must be one of" end + test "returns structured conversion errors for invalid tool_call_card payloads" do + ash_iur = + IUR.new(:screen, + id: "tool-call-card-screen-invalid", + name: "tool_call_card_screen", + attributes: %{}, + children: [ + IUR.new(:tool_call_card, + id: "tool-call-invalid", + props: %{ + "tool_name" => "Bash", + "tool_kind" => "bash", + "target" => "mix test", + "summary" => "Run tests", + "status" => "pending", + "args" => ["cmd", "mix test"] + } + ) + ] + ) + + assert {:error, {:conversion_failed, %ArgumentError{} = error}} = + IURAdapter.to_canonical(ash_iur) + + assert error.message =~ "tool_call_card :args must be a map" + end + test "routes workflow_progress_status_card kind through workflow_progress_and_status family" do ash_iur = IUR.new(:screen, diff --git a/test/ash_ui/rendering/live_ui_adapter_test.exs b/test/ash_ui/rendering/live_ui_adapter_test.exs index 0cbd6cb1..e380df8d 100644 --- a/test/ash_ui/rendering/live_ui_adapter_test.exs +++ b/test/ash_ui/rendering/live_ui_adapter_test.exs @@ -1241,6 +1241,48 @@ defmodule AshUI.Rendering.LiveUIAdapterTest do end end + describe "tool_call_card adapter dispatch" do + test "generates dedicated tool_call_card fallback markup" do + iur = %{ + "type" => "tool_call_card", + "id" => "tool-call-card-adapter-test", + "props" => %{ + "tool_call" => %{ + "tool_name" => "Bash", + "tool_kind" => "bash", + "target" => "mix test", + "summary" => "Run the focused suite.", + "status" => "complete", + "args" => %{"cmd" => "mix test"}, + "expanded?" => true, + "tool_result_summary" => %{ + "event_id" => "result-1", + "status" => "complete", + "compact_output" => "Tests passed.", + "diff_summary" => "No source diff.", + "error?" => false + } + } + }, + "children" => [], + "metadata" => %{} + } + + {:ok, heex} = LiveUIAdapter.render(iur, force_fallback: true) + + assert heex =~ "ash-tool-call-card" + assert heex =~ ~s(data-live-ui-widget="tool-call-card") + assert heex =~ ~s(data-tool-kind="bash") + assert heex =~ ~s(data-status="complete") + assert heex =~ "Bash" + assert heex =~ "mix test" + assert heex =~ "Run the focused suite." + assert heex =~ "ash-tool-call-card__args" + assert heex =~ "Tests passed." + assert heex =~ "No source diff." + end + end + describe "composer_query_preview adapter dispatch" do test "generates dedicated composer_query_preview fallback markup" do iur = %{ From fe0df114b904a75414e0a2146c09704d1d4e64a5 Mon Sep 17 00:00:00 2001 From: Matt de Courcelle Date: Wed, 27 May 2026 09:03:52 -0500 Subject: [PATCH 2/4] fix(tool_call_card): accessible name + aria-controls + BEM modifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opus Max review P2 fixes: - aria-label now includes status: "Toggle tool call () details" (both Phoenix.Component and live_ui_adapter HTML paths) - live_ui_adapter article gains id + button gains aria-controls linkage to the details section - BEM `--` modifier convention applied consistently to live_ui_adapter (ash-tool-call-card-- and __status-badge--) - Test assertion updated to use ~s|...| sigil since parentheses now appear in the aria-label 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/ash_ui/rendering/live_ui_adapter.ex | 13 ++++++++----- .../live_ui/lib/live_ui/widgets/tool_call_card.ex | 2 +- .../test/live_ui/widgets/tool_call_card_test.exs | 2 +- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/lib/ash_ui/rendering/live_ui_adapter.ex b/lib/ash_ui/rendering/live_ui_adapter.ex index d3498b5a..3246c28d 100644 --- a/lib/ash_ui/rendering/live_ui_adapter.ex +++ b/lib/ash_ui/rendering/live_ui_adapter.ex @@ -901,9 +901,12 @@ defmodule AshUI.Rendering.LiveUIAdapter do args = prop(tool_call, "args", prop(props, "args", %{})) result = prop(tool_call, "tool_result_summary", prop(props, "tool_result_summary")) + iur_id = iur["id"] || iur[:id] || "tool-call-card" + details_id = "#{iur_id}-details" + args_html = if expanded? do - ~s(

Args

#{html_escape(inspect(args, pretty: true, limit: :infinity, printable_limit: :infinity))}
) + ~s(

Args

#{html_escape(inspect(args, pretty: true, limit: :infinity, printable_limit: :infinity))}
) else "" end @@ -925,7 +928,7 @@ defmodule AshUI.Rendering.LiveUIAdapter do
#{event_id} - #{result_status} + #{result_status}

#{compact_output}

#{if diff_summary, do: "

#{diff_summary}

", else: ""} @@ -935,17 +938,17 @@ defmodule AshUI.Rendering.LiveUIAdapter do end """ -
+

#{tool_name}

#{target}

- #{status} + #{status}

#{summary}

- + #{args_html} #{result_html}
diff --git a/packages/live_ui/lib/live_ui/widgets/tool_call_card.ex b/packages/live_ui/lib/live_ui/widgets/tool_call_card.ex index f11fc9e2..074d821d 100644 --- a/packages/live_ui/lib/live_ui/widgets/tool_call_card.ex +++ b/packages/live_ui/lib/live_ui/widgets/tool_call_card.ex @@ -74,7 +74,7 @@ defmodule LiveUi.Widgets.ToolCallCard do