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 @@ -100,7 +100,7 @@ authoring boundaries and normalize before renderer-facing output.
| 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`, `tool_call_card` | none |
| Workflow, progress, and status | `pipeline_stepper_horizontal`, `segmented_progress_bar`, `workflow_stage_list_vertical`, `meter_thin`, `unread_badge`, `live_session_card`, `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 |
| 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`, `escalation_card` | none |
| Redline and code | `redline_inline`, `code_block_syntax_highlighted` | none |
| Composition behavior | `list_repeat` | `repeat` -> `list_repeat`, `ui_relationship_repeat` -> `list_repeat` |

Expand Down Expand Up @@ -176,6 +176,13 @@ conversation timeline. Author props such as `tool_name`, `tool_kind`, `target`,
paired result child. Use canonical `expand_toggled` interaction for expansion.
Keep LiveView event fields and route helpers in the host layer.

`escalation_card` is the canonical callout card for a cross-team escalation
raised by an MCP tool. Author props such as `target_project_id`, `severity`,
`text`, optional `proposed_action`, `related_finding_id`, `actor_handle`,
`escalated_at`, and `acknowledged?`. Use canonical `acknowledge` and
`route_to_rail` interactions for operator routing. 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
57 changes: 57 additions & 0 deletions lib/ash_ui/rendering/iur_adapter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,13 @@ defmodule AshUI.Rendering.IURAdapter do
|> Map.fetch!(:attributes)
end

defp base_attributes(:escalation_card, props) do
props
|> escalation_card_opts()
|> IURComponents.escalation_card()
|> Map.fetch!(:attributes)
end

defp base_attributes(:redline_inline = kind, props) do
component_attributes(
kind,
Expand Down Expand Up @@ -1234,6 +1241,56 @@ defmodule AshUI.Rendering.IURAdapter do
|> compact_map()
end

defp escalation_card_opts(props) do
escalation = props |> fetch(:escalation, %{}) |> normalize_map()

%{
id: first_present(props, [:_element_id, :id]),
target_project_id:
first_present(escalation, [:target_project_id]) ||
first_present(props, [:target_project_id, :project_id, :project]),
text:
first_present(escalation, [:text]) ||
first_present(props, [:text, :description, :message]),
severity:
normalize_existing_atom(
first_present(escalation, [:severity]) ||
first_present(props, [:severity]) ||
:p2
),
related_finding_id:
first_present(escalation, [:related_finding_id]) ||
first_present(props, [:related_finding_id]),
proposed_action:
first_present(escalation, [:proposed_action]) ||
first_present(props, [:proposed_action]),
target_finding_id:
first_present(escalation, [:target_finding_id]) ||
first_present(props, [:target_finding_id]),
target_severity:
normalize_existing_atom(
first_present(escalation, [:target_severity]) ||
first_present(props, [:target_severity])
),
originating_severity:
normalize_existing_atom(
first_present(escalation, [:originating_severity]) ||
first_present(props, [:originating_severity])
),
actor_handle:
first_present(escalation, [:actor_handle]) ||
first_present(props, [:actor_handle]),
escalated_at:
first_present(escalation, [:escalated_at]) ||
first_present(props, [:escalated_at]),
acknowledge_intent: first_present(props, [:acknowledge_intent]),
route_intent: first_present(props, [:route_intent]),
interactions: fetch(props, :interactions),
interaction: fetch(props, :interaction)
}
|> compact_map()
end

defp collection_picker_opts(props) do
picker = props |> fetch(:collection_picker, %{}) |> normalize_map()

Expand Down
77 changes: 77 additions & 0 deletions lib/ash_ui/rendering/live_ui_adapter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,83 @@ defmodule AshUI.Rendering.LiveUIAdapter do
"""
end

defp generate_heex(%{"type" => "escalation_card"} = iur, _opts) do
props = iur["props"] || %{}
escalation = props |> prop("escalation", props) |> normalize_item()

target_project_id =
escaped_text_prop(
escalation,
"target_project_id",
escaped_text_prop(props, "target_project_id", "")
)

text =
escaped_text_prop(
escalation,
"text",
escaped_text_prop(props, ["text", "description"], "")
)

severity =
escaped_text_prop(escalation, "severity", escaped_text_prop(props, "severity", "p2"))

actor_handle = escaped_text_prop(escalation, "actor_handle")
proposed_action = escaped_text_prop(escalation, "proposed_action")

acknowledged? =
truthy_prop(escalation, "acknowledged?", truthy_prop(props, "acknowledged?", false))

iur_id = html_attr(iur["id"] || iur[:id] || "escalation-card")
severity_label = String.upcase(severity)

meta_html =
if target_project_id != "" || proposed_action do
project_row =
if target_project_id != "" do
"<dt>Target project</dt><dd>#{target_project_id}</dd>"
else
""
end

action_row =
if proposed_action do
"<dt>Proposed action</dt><dd>#{proposed_action}</dd>"
else
""
end

~s(<dl class="ash-escalation-card__meta">#{project_row}#{action_row}</dl>)
else
""
end

footer_html =
if acknowledged? do
~s(<p class="ash-escalation-card__acknowledged" role="status">Acknowledged</p>)
else
"""
<footer class="ash-escalation-card__actions">
<button type="button" class="ash-escalation-card__acknowledge" aria-label="Acknowledge #{severity} escalation">Acknowledge</button>
<button type="button" class="ash-escalation-card__route-to-rail" aria-label="Route #{severity} escalation to rail">Route to rail</button>
</footer>
"""
end

"""
<article id="#{iur_id}" class="#{css_classes(["ash-escalation-card", "ash-escalation-card--#{severity}", prop_class(iur)])}" data-live-ui-widget="escalation-card" data-severity="#{severity}" data-acknowledged="#{acknowledged?}"#{style_attr(prop_style(iur))} role="alert" aria-labelledby="#{iur_id}-title">
<header class="ash-escalation-card__header">
<span class="ash-escalation-card__severity-badge" role="status">#{severity_label}</span>
<h3 id="#{iur_id}-title" class="ash-escalation-card__title">Escalation</h3>
#{if actor_handle, do: "<span class=\"ash-escalation-card__actor\">#{actor_handle}</span>", else: ""}
</header>
<p class="ash-escalation-card__text">#{text}</p>
#{meta_html}
#{footer_html}
</article>
"""
end

defp generate_heex(%{"type" => "list_item_multi_column"} = iur, opts) do
props = iur["props"] || %{}
active? = truthy_prop(props, "active?", truthy_prop(props, "active", false))
Expand Down
94 changes: 94 additions & 0 deletions packages/live_ui/lib/live_ui/renderer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1013,6 +1013,46 @@ defmodule LiveUi.Renderer do
"""
end

# NOTE: `:escalation_card` is a canonical layer-shell callout. Keep this
# native clause before the generic component fallback so acknowledge and
# route_to_rail actions keep canonical interaction transport.
def render(%{element: %Element{kind: :escalation_card}} = assigns) do
escalation = escalation_attributes(assigns.element)

assigns =
assigns
|> assign(:escalation, escalation)
|> assign(
:action_attrs,
escalation_action_attrs(assigns.element, Map.get(assigns, :event_target))
)
|> assign(:style_attrs, style_rest(assigns.element))

~H"""
<LiveUi.Widgets.EscalationCard.component
id={element_id(@element, "escalation-card")}
target_project_id={string_value(map_value(@escalation, :target_project_id), "")}
severity={map_value(@escalation, :severity, :p2)}
text={string_value(map_value(@escalation, :text), "")}
related_finding_id={string_optional(map_value(@escalation, :related_finding_id))}
proposed_action={string_optional(map_value(@escalation, :proposed_action))}
target_finding_id={string_optional(map_value(@escalation, :target_finding_id))}
target_severity={map_value(@escalation, :target_severity)}
originating_severity={map_value(@escalation, :originating_severity)}
actor_handle={string_optional(map_value(@escalation, :actor_handle))}
escalated_at={string_optional(map_value(@escalation, :escalated_at))}
acknowledged?={boolean_default(map_value(@escalation, :acknowledged?), false)}
ack_attrs={Map.get(@action_attrs, :acknowledge, %{})}
route_attrs={Map.get(@action_attrs, :route_to_rail, %{})}
tone={style_tone(@element)}
variant={theme_variant(@element)}
state={style_state(@element)}
class={style_class(@element)}
{@style_attrs}
/>
"""
end

def render(%{element: %Element{kind: kind}} = assigns) when kind in @component_kinds do
assigns = assign(assigns, :style_attrs, style_rest(assigns.element))

Expand Down Expand Up @@ -2910,6 +2950,60 @@ defmodule LiveUi.Renderer do
end)
end

defp escalation_attributes(%Element{} = element) do
element.attributes
|> Map.get(:escalation, Map.get(element.attributes, "escalation", %{}))
|> case do
escalation when is_map(escalation) -> escalation
escalation when is_list(escalation) -> Map.new(escalation)
_other -> %{}
end
end

defp escalation_action_attrs(%Element{} = element, event_target) do
%{
acknowledge: escalation_interaction_attrs(element, event_target, :acknowledge),
route_to_rail: escalation_interaction_attrs(element, event_target, :route_to_rail)
}
end

defp escalation_interaction_attrs(%Element{} = element, event_target, action)
when not is_nil(event_target) do
case escalation_interaction(element, action) do
%Interaction{} = interaction ->
%{
:"phx-click" => "canonical_interaction",
:"phx-target" => event_target,
:"phx-value-interaction" => encode_interaction(interaction),
:"phx-value-element_id" => element_id(element, "escalation-card"),
:"phx-value-widget" => "escalation_card",
:"phx-value-action" => Atom.to_string(action),
:"phx-value-target_project_id" =>
element |> escalation_attributes() |> map_value(:target_project_id, "") |> to_string()
}

_ ->
%{}
end
end

defp escalation_interaction_attrs(_element, _event_target, _action), do: %{}

defp escalation_interaction(%Element{} = element, action) do
element.attributes
|> Map.get(:interactions, [])
|> List.wrap()
|> Enum.find(fn
%Interaction{family: :command, payload: payload, intent: intent} ->
map_value(payload, :command) == action or intent == action or intent == to_string(action)

_other ->
false
end)
end

defp escalation_interaction(_element, _action), do: nil

defp sidebar_section_attributes(%Element{} = element) do
element.attributes
|> Map.get(:section, Map.get(element.attributes, "section", %{}))
Expand Down
Loading
Loading