Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion guides/user/UG-0003-widget-types-properties-and-signals.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ authoring boundaries and normalize before renderer-facing output.
|---|---|---|
| Content, identity, and disclosure | `inline_rich_text_heading`, `disclosure`, `kicker`, `avatar`, `presence_dot` | none |
| Form control and composer | `runtime_form_shell`, `segmented_button_group`, `chat_composer`, `collection_picker`, `mode_nav` | `phoenix_form` -> `runtime_form_shell` |
| Row and artifact | `list_item_multi_column`, `artifact_row`, `thread_card` | none |
| Row and artifact | `list_item_multi_column`, `artifact_row`, `thread_card`, `tool_call_card` | none |
| Workflow, progress, and status | `pipeline_stepper_horizontal`, `segmented_progress_bar`, `workflow_stage_list_vertical`, `meter_thin`, `unread_badge`, `workflow_progress_status_card` | none |
| Layer shell and callout | `sticky_frosted_header`, `slide_over_panel`, `event_callout`, `top_strip`, `sidebar_shell`, `sidebar_section`, `sidebar_item`, `command_palette`, `right_rail`, `composer_query_preview`, `propose_new_doc_card` | none |
| Redline and code | `redline_inline`, `code_block_syntax_highlighted` | none |
Expand Down Expand Up @@ -161,6 +161,13 @@ canonical `accept`, `reject`, `preview`, `body_toggled`, and
`conversation_seed_toggled` interactions for operator actions. Keep LiveView
event fields, route helpers, and product-specific copy in the host layer.

`tool_call_card` is the canonical card for a single assistant tool call in a
conversation timeline. Author props such as `tool_name`, `tool_kind`, `target`,
`summary`, `status`, optional `args`, `expanded?`, `actor_handle`,
`started_at`, `duration_ms`, and an optional `tool_result_summary` map for the
paired result child. Use canonical `expand_toggled` interaction for expansion.
Keep LiveView event fields and route helpers in the host layer.

You can also author `custom:*` types. They are accepted as widget types, but the
shipped validation/runtime does not automatically give them built-in signal
semantics. Some explicitly supported custom surfaces do have dedicated fallback
Expand Down
56 changes: 56 additions & 0 deletions lib/ash_ui/rendering/iur_adapter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions lib/ash_ui/rendering/live_ui_adapter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -880,6 +880,81 @@ 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"))

iur_id = iur["id"] || iur[:id] || "tool-call-card"
details_id = "#{iur_id}-details"

args_html =
if expanded? do
~s(<section id="#{details_id}" class="ash-tool-call-card__details"><h4 class="ash-tool-call-card__args-label">Args</h4><pre class="ash-tool-call-card__args"><code>#{html_escape(inspect(args, pretty: true, limit: :infinity, printable_limit: :infinity))}</code></pre></section>)
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))

"""
<section class="#{css_classes(["ash-tool-call-card__result", error? && "has-error"])}">
<header class="ash-tool-call-card__result-header">
<span class="ash-tool-call-card__result-event">#{event_id}</span>
<span class="ash-tool-call-card__result-status ash-tool-call-card__result-status--#{result_status}">#{result_status}</span>
</header>
<p class="ash-tool-call-card__result-output">#{compact_output}</p>
#{if diff_summary, do: "<p class=\"ash-tool-call-card__result-diff\">#{diff_summary}</p>", else: ""}
#{if error?, do: "<p class=\"ash-tool-call-card__result-error\">Error</p>", else: ""}
</section>
"""
end

"""
<article id="#{iur_id}" class="#{css_classes(["ash-tool-call-card", "ash-tool-call-card--#{status}", expanded? && "is-expanded", prop_class(iur)])}" data-live-ui-widget="tool-call-card" data-tool-kind="#{tool_kind}" data-status="#{status}"#{style_attr(prop_style(iur))}>
<header class="ash-tool-call-card__header">
<span class="ash-tool-call-card__glyph" aria-hidden="true">#{tool_kind}</span>
<div class="ash-tool-call-card__identity">
<h3 class="ash-tool-call-card__name">#{tool_name}</h3>
<p class="ash-tool-call-card__target">#{target}</p>
</div>
<span class="ash-tool-call-card__status-badge ash-tool-call-card__status-badge--#{status}">#{status}</span>
</header>
<p class="ash-tool-call-card__summary">#{summary}</p>
<button type="button" class="ash-tool-call-card__expand-toggle" aria-label="Toggle tool call #{tool_name} (#{status}) details" aria-expanded="#{expanded?}" aria-controls="#{details_id}">Details</button>
#{args_html}
#{result_html}
</article>
"""
end

defp generate_heex(%{"type" => "pipeline_stepper_horizontal"} = iur, _opts) do
props = iur["props"] || %{}
steps = prop(props, "steps", [])
Expand Down
61 changes: 61 additions & 0 deletions packages/live_ui/lib/live_ui/renderer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ defmodule LiveUi.Renderer do
:supervision_tree_viewer,
:table,
:thread_card,
:tool_call_card,
:tabs,
:text,
:text_input,
Expand Down Expand Up @@ -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"""
<LiveUi.Widgets.ToolCallCard.component
id={element_id(@element, "tool-call-card")}
tool_name={string_value(map_value(@tool_call, :tool_name), "")}
tool_kind={map_value(@tool_call, :tool_kind, :other)}
target={string_value(map_value(@tool_call, :target), "")}
summary={string_value(map_value(@tool_call, :summary), "")}
status={map_value(@tool_call, :status, :pending)}
args={@tool_call_args}
expanded?={boolean_default(map_value(@tool_call, :expanded?), false)}
actor_handle={string_optional(map_value(@tool_call, :actor_handle))}
started_at={map_value(@tool_call, :started_at)}
duration_ms={integer_optional(map_value(@tool_call, :duration_ms))}
approval_event_id={string_optional(map_value(@tool_call, :approval_event_id))}
paired_result_event_id={string_optional(map_value(@tool_call, :paired_result_event_id))}
tool_result_summary={@tool_result_summary}
expand_attrs={@expand_attrs}
tone={style_tone(@element)}
variant={theme_variant(@element)}
state={style_state(@element)}
class={style_class(@element)}
{@style_attrs}
/>
"""
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
Expand Down Expand Up @@ -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))
Expand Down
8 changes: 8 additions & 0 deletions packages/live_ui/lib/live_ui/widgets.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
12 changes: 12 additions & 0 deletions packages/live_ui/lib/live_ui/widgets/row_and_artifact.ex
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading