diff --git a/guides/user/UG-0003-widget-types-properties-and-signals.md b/guides/user/UG-0003-widget-types-properties-and-signals.md
index ef225fc1..eaa1dda9 100644
--- a/guides/user/UG-0003-widget-types-properties-and-signals.md
+++ b/guides/user/UG-0003-widget-types-properties-and-signals.md
@@ -99,7 +99,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`, `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 |
+| 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 |
| Redline and code | `redline_inline`, `code_block_syntax_highlighted` | none |
| Composition behavior | `list_repeat` | `repeat` -> `list_repeat`, `ui_relationship_repeat` -> `list_repeat` |
@@ -138,6 +138,14 @@ such as `subject_id`, `name`, `path`, `progress_pct`, `active_count`,
`attributes.subject`; it does not expose map placement names, route helpers,
LiveView event fields, or application-specific card names.
+`live_session_card` is the canonical card for an actively running assistant
+session. Author props such as `session_id`, `actor_handle`, `status`,
+`status_version`, `tools_count`, `edits_count`, `tokens_consumed`,
+`started_at`, optional `current_step`, `current_task_title`,
+`now_streaming`, `recent_events`, and `pinned?`. Use canonical
+`pin_toggled`, `interrupted`, and `expanded_recent` interactions. Keep
+LiveView event fields and route helpers in the host layer.
+
`composer_query_preview` is the reusable inline preview band for query results
adjacent to a composer. Author generic props such as `composer_id`, `query`,
`preview_state`, `explanation`, `metrics`, `findings`, and
diff --git a/lib/ash_ui/rendering/iur_adapter.ex b/lib/ash_ui/rendering/iur_adapter.ex
index 5bbb3651..3ba00f16 100644
--- a/lib/ash_ui/rendering/iur_adapter.ex
+++ b/lib/ash_ui/rendering/iur_adapter.ex
@@ -94,11 +94,14 @@ defmodule AshUI.Rendering.IURAdapter do
kind = map_element_kind(element.type)
type = map_element_type(kind)
props = if map_size(element.props || %{}) > 0, do: element.props, else: element.attributes
+ fallback_id = element.id || generate_id()
+ element_id = element_id_for_kind(kind, convert_props(props), fallback_id)
+ attribute_element_id = if kind == :live_session_card, do: element_id, else: element.id
Element.new(type, kind,
- id: element.id || generate_id(),
+ id: element_id,
metadata: convert_metadata(element),
- attributes: convert_attributes(kind, props, element.id),
+ attributes: convert_attributes(kind, props, attribute_element_id),
children: convert_children(element.children)
)
end
@@ -155,6 +158,26 @@ defmodule AshUI.Rendering.IURAdapter do
defp map_element_type(_kind), do: :widget
+ defp element_id_for_kind(:live_session_card, props, fallback_id) do
+ live_session = props |> fetch(:live_session, %{}) |> normalize_map()
+
+ session_id =
+ first_present(live_session, [:session_id]) ||
+ first_present(props, [:session_id])
+
+ status_version =
+ first_present(live_session, [:status_version]) ||
+ first_present(props, [:status_version])
+
+ if is_binary(session_id) and not is_nil(status_version) do
+ "live_session:#{session_id}:#{status_version}"
+ else
+ fallback_id
+ end
+ end
+
+ defp element_id_for_kind(_kind, _props, fallback_id), do: fallback_id
+
# Convert props with name transformations
defp convert_props(props) when is_map(props) do
Enum.reduce(props, %{}, fn {key, value}, acc ->
@@ -370,6 +393,13 @@ defmodule AshUI.Rendering.IURAdapter do
|> Map.fetch!(:attributes)
end
+ defp base_attributes(:live_session_card, props) do
+ props
+ |> live_session_card_opts()
+ |> IURComponents.live_session_card()
+ |> Map.fetch!(:attributes)
+ end
+
defp base_attributes(:pipeline_stepper_horizontal = kind, props) do
component_attributes(
kind,
@@ -1341,6 +1371,60 @@ defmodule AshUI.Rendering.IURAdapter do
|> compact_map()
end
+ defp live_session_card_opts(props) do
+ live_session = props |> fetch(:live_session, %{}) |> normalize_map()
+
+ %{
+ session_id:
+ first_present(live_session, [:session_id]) ||
+ first_present(props, [:session_id]),
+ actor_handle:
+ first_present(live_session, [:actor_handle]) ||
+ first_present(props, [:actor_handle]),
+ status:
+ normalize_existing_atom(
+ first_present(live_session, [:status]) ||
+ first_present(props, [:status]) ||
+ :running
+ ),
+ status_version:
+ first_present(live_session, [:status_version]) ||
+ first_present(props, [:status_version]),
+ tools_count:
+ first_present(live_session, [:tools_count]) ||
+ first_present(props, [:tools_count]),
+ edits_count:
+ first_present(live_session, [:edits_count]) ||
+ first_present(props, [:edits_count]),
+ tokens_consumed:
+ first_present(live_session, [:tokens_consumed]) ||
+ first_present(props, [:tokens_consumed]),
+ started_at:
+ first_present(live_session, [:started_at]) ||
+ first_present(props, [:started_at]),
+ current_step:
+ first_present(live_session, [:current_step]) ||
+ first_present(props, [:current_step]),
+ current_task_title:
+ first_present(live_session, [:current_task_title]) ||
+ first_present(props, [:current_task_title]),
+ now_streaming:
+ first_present(live_session, [:now_streaming]) ||
+ first_present(props, [:now_streaming]),
+ recent_events:
+ first_present(live_session, [:recent_events]) ||
+ fetch(props, :recent_events, []),
+ pinned?:
+ boolean_present(live_session, [:pinned?], boolean_present(props, [:pinned?], false)),
+ pin_intent: first_present(props, [:pin_intent]) || :pin_toggled,
+ interrupt_intent: first_present(props, [:interrupt_intent]) || :interrupted,
+ expanded_recent_intent: first_present(props, [:expanded_recent_intent]) || :expanded_recent,
+ interactions: fetch(props, :interactions),
+ interaction: fetch(props, :interaction)
+ }
+ |> compact_map()
+ end
+
defp normalize_heading_segments(props) do
case fetch(props, :segments) do
segments when is_list(segments) ->
diff --git a/lib/ash_ui/rendering/live_ui_adapter.ex b/lib/ash_ui/rendering/live_ui_adapter.ex
index 3246c28d..e478dc6c 100644
--- a/lib/ash_ui/rendering/live_ui_adapter.ex
+++ b/lib/ash_ui/rendering/live_ui_adapter.ex
@@ -1090,6 +1090,92 @@ defmodule AshUI.Rendering.LiveUIAdapter do
"""
end
+ defp generate_heex(%{"type" => "live_session_card"} = iur, _opts) do
+ props = iur["props"] || %{}
+ live_session = prop(props, "live_session", props) |> normalize_item()
+
+ session_id = escaped_text_prop(live_session, "session_id", "")
+ actor_handle = escaped_text_prop(live_session, "actor_handle", "")
+ status = escaped_text_prop(live_session, "status", "running")
+ status_version = numeric_value(live_session, "status_version", 0)
+ tools_count = numeric_value(live_session, "tools_count", 0)
+ edits_count = numeric_value(live_session, "edits_count", 0)
+ tokens_consumed = numeric_value(live_session, "tokens_consumed", 0)
+ started_at = escaped_text_prop(live_session, "started_at", "")
+ current_step = escaped_text_prop(live_session, "current_step")
+ current_task_title = escaped_text_prop(live_session, "current_task_title")
+ now_streaming = escaped_text_prop(live_session, "now_streaming")
+ pinned? = truthy_prop(live_session, "pinned?", false)
+ iur_id = iur["id"] || iur[:id] || "live-session-card"
+
+ recent_events =
+ live_session
+ |> prop("recent_events", [])
+ |> List.wrap()
+ |> Enum.take(5)
+ |> Enum.map_join(fn event ->
+ event = normalize_item(event)
+ kind = escaped_text_prop(event, "kind", "")
+ body = escaped_text_prop(event, ["body", "body_fragment", "fragment"], "")
+
+ """
+
+ #{kind}
+ #{body}
+
+ """
+ end)
+
+ task_html =
+ cond do
+ current_task_title ->
+ ~s(#{current_task_title}
)
+
+ current_step ->
+ ~s(#{current_step}
)
+
+ true ->
+ ""
+ end
+
+ now_streaming_html =
+ if now_streaming do
+ """
+
+ LIVE
+ #{now_streaming}
+
+ """
+ else
+ ""
+ end
+
+ """
+
+
+
+
#{tools_count}tools
+
#{edits_count}edits
+
#{tokens_consumed}tokens
+
+ #{now_streaming_html}
+ #{recent_events}
+
+
+ """
+ end
+
defp generate_heex(%{"type" => "confidence_indicator"} = iur, _opts) do
props = iur["props"] || %{}
@@ -3147,6 +3233,24 @@ defmodule AshUI.Rendering.LiveUIAdapter do
defp html_attr(value), do: html_escape(value)
+ defp adapter_duration_label(started_at) when is_binary(started_at) do
+ case DateTime.from_iso8601(started_at) do
+ {:ok, dt, _offset} ->
+ diff_seconds = max(DateTime.diff(DateTime.utc_now(), dt, :second), 0)
+
+ cond do
+ diff_seconds < 60 -> "#{diff_seconds}s"
+ diff_seconds < 3_600 -> "#{div(diff_seconds, 60)}m"
+ true -> "#{div(diff_seconds, 3_600)}h"
+ end
+
+ _ ->
+ "running"
+ end
+ end
+
+ defp adapter_duration_label(_started_at), do: "running"
+
defp html_escape(nil), do: ""
defp html_escape(value) do
diff --git a/packages/live_ui/lib/live_ui/renderer.ex b/packages/live_ui/lib/live_ui/renderer.ex
index 2f465a1d..bac155b6 100644
--- a/packages/live_ui/lib/live_ui/renderer.ex
+++ b/packages/live_ui/lib/live_ui/renderer.ex
@@ -495,6 +495,58 @@ defmodule LiveUi.Renderer do
"""
end
+ # NOTE: `:live_session_card` is a canonical component kind, so keep this native
+ # renderer clause before the generic `@component_kinds` fallback.
+ def render(%{element: %Element{kind: :live_session_card}} = assigns) do
+ live_session = get_in(assigns.element.attributes, [:live_session]) || %{}
+ event_target = Map.get(assigns, :event_target)
+
+ assigns =
+ assigns
+ |> assign(:style_attrs, style_rest(assigns.element))
+ |> assign(:live_session, live_session)
+ |> assign(:recent_events, list_value(map_value(live_session, :recent_events, [])))
+ |> assign(
+ :pin_attrs,
+ command_interaction_attrs(assigns.element, event_target, :pin_toggled)
+ )
+ |> assign(
+ :interrupt_attrs,
+ command_interaction_attrs(assigns.element, event_target, :interrupted)
+ )
+ |> assign(
+ :recent_attrs,
+ command_interaction_attrs(assigns.element, event_target, :expanded_recent)
+ )
+
+ ~H"""
+
+ """
+ end
+
# NOTE: `:workflow_progress_status_card` is a canonical component kind, so keep this native
# renderer clause before the generic `@component_kinds` fallback.
def render(%{element: %Element{kind: :workflow_progress_status_card}} = assigns) do
@@ -2083,6 +2135,36 @@ defmodule LiveUi.Renderer do
end
end
+ defp command_interaction_attrs(%Element{} = element, event_target, command) do
+ case {command_interaction(element, command), event_target} do
+ {%Interaction{} = interaction, target} when not is_nil(target) ->
+ %{
+ :"phx-click" => "canonical_interaction",
+ :"phx-target" => target,
+ :"phx-value-interaction" => encode_interaction(interaction),
+ :"phx-value-element_id" => element_id(element, Atom.to_string(element.kind)),
+ :"phx-value-widget" => Atom.to_string(element.kind),
+ :"phx-value-command" => to_string(command)
+ }
+
+ _ ->
+ %{}
+ end
+ end
+
+ defp command_interaction(%Element{} = element, command) do
+ element.attributes
+ |> Map.get(:interactions, [])
+ |> List.wrap()
+ |> Enum.find(fn
+ %Interaction{family: :command, payload: payload} ->
+ to_string(map_value(payload, :command)) == to_string(command)
+
+ _interaction ->
+ false
+ end)
+ end
+
defp primary_change_interaction(%Element{} = element) do
primary_interaction(element, :change)
end
@@ -2948,6 +3030,9 @@ defmodule LiveUi.Renderer do
defp map_value(list, key, default) when is_list(list), do: Keyword.get(list, key, default)
defp map_value(_other, _key, default), do: default
+ defp list_value(value) when is_list(value), do: value
+ defp list_value(_value), do: []
+
defp normalize_key(nil), do: "panel"
defp normalize_key(value) do
diff --git a/packages/live_ui/lib/live_ui/widgets/live_session_card.ex b/packages/live_ui/lib/live_ui/widgets/live_session_card.ex
new file mode 100644
index 00000000..0b684895
--- /dev/null
+++ b/packages/live_ui/lib/live_ui/widgets/live_session_card.ex
@@ -0,0 +1,220 @@
+defmodule LiveUi.Widgets.LiveSessionCard do
+ @moduledoc """
+ Native live-session card widget.
+
+ Renders one actively running assistant session as a compact workflow progress
+ and status card with prop-sourced meters, live assistant text, recent events,
+ and Pin/Interrupt controls.
+ """
+
+ use LiveUi.Component,
+ family: :workflow_progress_and_status,
+ name: :live_session_card,
+ slots: [],
+ events: [:pin_toggled, :interrupted, :expanded_recent]
+
+ LiveUi.Component.common_attrs()
+ attr(:session_id, :string, required: true)
+ attr(:actor_handle, :string, required: true)
+ attr(:status, :atom, required: true)
+ attr(:status_version, :integer, required: true)
+ attr(:tools_count, :integer, required: true)
+ attr(:edits_count, :integer, required: true)
+ attr(:tokens_consumed, :integer, required: true)
+ attr(:started_at, :any, required: true)
+ attr(:current_step, :string, default: nil)
+ attr(:current_task_title, :string, default: nil)
+ attr(:now_streaming, :string, default: nil)
+ attr(:recent_events, :list, default: [])
+ attr(:pinned?, :boolean, default: false)
+ attr(:pin_attrs, :any, default: [])
+ attr(:interrupt_attrs, :any, default: [])
+ attr(:recent_attrs, :any, default: [])
+
+ @impl true
+ def render(assigns) do
+ assigns =
+ assigns
+ |> assign(:recent_events, recent_events(assigns.recent_events))
+ |> assign(:status_label, status_label(assigns.status))
+ |> assign(:duration_label, duration_label(assigns.started_at))
+ |> assign(:started_at_iso, timestamp_iso8601(assigns.started_at))
+
+ ~H"""
+
+
+
+
+
+ <%= @tools_count %>
+ tools
+
+
+ <%= @edits_count %>
+ edits
+
+
+ <%= @tokens_consumed %>
+ tokens
+
+
+
+
+ LIVE
+ <%= @now_streaming %>
+
+
+
+ -
+
+ <%= event_kind(event) %>
+
+
+ <%= event_body(event) %>
+
+
+
+
+
+
+ """
+ end
+
+ defp recent_events(events) when is_list(events), do: Enum.take(events, 5)
+ defp recent_events(_events), do: []
+
+ defp avatar_initials(handle) when is_binary(handle) do
+ handle
+ |> String.trim_leading("@")
+ |> String.trim()
+ |> String.first()
+ |> case do
+ nil -> "?"
+ "" -> "?"
+ first -> String.upcase(first)
+ end
+ end
+
+ defp avatar_initials(_handle), do: "?"
+
+ defp status_label(status) do
+ status
+ |> to_string()
+ |> String.replace("_", " ")
+ |> String.upcase()
+ 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(_value), do: nil
+
+ defp duration_label(%DateTime{} = started_at) do
+ diff_seconds = max(DateTime.diff(DateTime.utc_now(), started_at, :second), 0)
+
+ cond do
+ diff_seconds < 60 -> "#{diff_seconds}s"
+ diff_seconds < 3_600 -> "#{div(diff_seconds, 60)}m"
+ true -> "#{div(diff_seconds, 3_600)}h"
+ end
+ end
+
+ defp duration_label(_started_at), do: "running"
+
+ defp event_kind(event) do
+ event
+ |> event_value(:kind, "")
+ |> to_string()
+ |> String.replace("_", " ")
+ end
+
+ defp event_body(event), do: event_value(event, :body, event_value(event, :body_fragment, ""))
+
+ defp event_value(event, key, default) when is_map(event) do
+ Map.get(event, key, Map.get(event, to_string(key), default))
+ end
+
+ defp event_value(_event, _key, default), do: default
+
+ defp pin_label(true), do: "Pinned"
+ defp pin_label(false), do: "Pin"
+
+ defp pin_aria_label(true, actor_handle), do: "Unpin #{actor_handle} running session"
+ defp pin_aria_label(false, actor_handle), do: "Pin #{actor_handle} running session"
+
+ defp present?(value), do: is_binary(value) and String.trim(value) != ""
+
+ defp pin_attrs(attrs) when attrs in [nil, [], %{}], do: %{:"phx-click" => "pin_toggled"}
+ defp pin_attrs(attrs), do: attrs
+
+ defp interrupt_attrs(attrs) when attrs in [nil, [], %{}], do: %{:"phx-click" => "interrupted"}
+ defp interrupt_attrs(attrs), do: attrs
+
+ defp recent_list_attrs(attrs) when attrs in [nil, [], %{}],
+ do: %{:"phx-click" => "expanded_recent"}
+
+ defp recent_list_attrs(attrs), do: attrs
+end
diff --git a/packages/live_ui/lib/live_ui/widgets/workflow_progress_and_status.ex b/packages/live_ui/lib/live_ui/widgets/workflow_progress_and_status.ex
index 8f7cbe5e..11a9fab1 100644
--- a/packages/live_ui/lib/live_ui/widgets/workflow_progress_and_status.ex
+++ b/packages/live_ui/lib/live_ui/widgets/workflow_progress_and_status.ex
@@ -4,6 +4,7 @@ defmodule LiveUi.Widgets.WorkflowProgressAndStatus do
"""
@modules [
+ LiveUi.Widgets.LiveSessionCard,
LiveUi.Widgets.WorkflowProgressStatusCard
]
diff --git a/packages/live_ui/test/live_ui/widgets/live_session_card_test.exs b/packages/live_ui/test/live_ui/widgets/live_session_card_test.exs
new file mode 100644
index 00000000..35c0bef7
--- /dev/null
+++ b/packages/live_ui/test/live_ui/widgets/live_session_card_test.exs
@@ -0,0 +1,174 @@
+defmodule LiveUi.Widgets.LiveSessionCardTest do
+ use ExUnit.Case, async: true
+
+ import Phoenix.LiveViewTest
+
+ alias LiveUi.Component
+ alias UnifiedIUR.Widgets.Components
+
+ @session_id "550e8400-e29b-41d4-a716-446655440000"
+ @started_at ~U[2026-05-27 15:00:00Z]
+
+ describe "live_session_card widget metadata" do
+ test "registers as a workflow_progress_and_status widget with control events" do
+ metadata = Component.metadata(LiveUi.Widgets.LiveSessionCard)
+
+ assert metadata.mountable?
+ assert metadata.component_module == LiveUi.Widgets.LiveSessionCard.Component
+ assert metadata.family == :workflow_progress_and_status
+ assert metadata.name == :live_session_card
+ assert :pin_toggled in metadata.events
+ assert :interrupted in metadata.events
+ assert :expanded_recent in metadata.events
+ end
+
+ test "is present in workflow progress aggregation" do
+ assert LiveUi.Widgets.LiveSessionCard in LiveUi.Widgets.WorkflowProgressAndStatus.modules()
+
+ assert LiveUi.Widgets.LiveSessionCard in LiveUi.Widgets.workflow_progress_and_status_modules()
+ end
+ end
+
+ describe "live_session_card component rendering" do
+ test "renders the canonical root hooks, status badge, meters, and actions" do
+ html =
+ render_component(
+ &LiveUi.Widgets.LiveSessionCard.component/1,
+ base_assigns()
+ )
+
+ assert html =~ ~s(data-live-ui-widget="live-session-card")
+ assert html =~ ~s(data-session-id="#{@session_id}")
+ assert html =~ ~s(data-status-version="7")
+ assert html =~ ~s(data-pinned="false")
+ assert html =~ "live-ui-live-session-card--running"
+ assert html =~ "@opus"
+ assert html =~ "RUNNING"
+ assert html =~ ~s(data-meter="tools")
+ assert html =~ ~s(data-meter="edits")
+ assert html =~ ~s(data-meter="tokens")
+ assert html =~ "Pin"
+ assert html =~ "Interrupt"
+ end
+
+ test "renders live region and recent activity list with accessibility labels" do
+ html =
+ render_component(
+ &LiveUi.Widgets.LiveSessionCard.component/1,
+ base_assigns(
+ now_streaming: "Writing adapter tests.",
+ recent_events: [
+ %{kind: :assistant_text, body: "Checking renderer."},
+ %{kind: :tool_call, body: "mix test"}
+ ]
+ )
+ )
+
+ assert html =~ ~s(aria-live="polite")
+ assert html =~ "Writing adapter tests."
+ assert html =~ ~s(aria-label="Recent activity for @opus")
+ assert html =~ "assistant text"
+ assert html =~ "Checking renderer."
+ assert html =~ "tool call"
+ assert html =~ "mix test"
+ end
+
+ test "caps rendered recent events at five" do
+ recent_events =
+ for index <- 1..6 do
+ %{kind: :assistant_text, body: "event #{index}"}
+ end
+
+ html =
+ render_component(
+ &LiveUi.Widgets.LiveSessionCard.component/1,
+ base_assigns(recent_events: recent_events)
+ )
+
+ assert html =~ "event 5"
+ refute html =~ "event 6"
+ end
+
+ test "renders pinned state and fallback event attrs" do
+ html =
+ render_component(
+ &LiveUi.Widgets.LiveSessionCard.component/1,
+ base_assigns(pinned?: true)
+ )
+
+ assert html =~ ~s(data-pinned="true")
+ assert html =~ "is-pinned"
+ assert html =~ ~s(aria-pressed="true")
+ assert html =~ ~s(aria-label="Unpin @opus running session")
+ assert html =~ ~s(phx-click="pin_toggled")
+ assert html =~ ~s(phx-click="interrupted")
+ assert html =~ ~s(phx-click="expanded_recent")
+ end
+ end
+
+ describe "renderer dispatch" do
+ test "live_session_card kind is in supported_kinds" do
+ assert :live_session_card in LiveUi.Renderer.supported_kinds()
+ end
+
+ test "renders via dedicated renderer clause with canonical command attrs" do
+ element = Components.live_session_card(base_iur_opts())
+
+ html =
+ render_component(&LiveUi.Renderer.render/1, %{
+ element: element,
+ event_target: "#runtime-host"
+ })
+
+ assert html =~ ~s(data-live-ui-widget="live-session-card")
+ assert html =~ ~s(id="live_session:#{@session_id}:7")
+ assert html =~ ~s(phx-click="canonical_interaction")
+ assert html =~ ~s(phx-target="#runtime-host")
+ assert html =~ ~s(phx-value-widget="live_session_card")
+ assert html =~ ~s(phx-value-command="pin_toggled")
+ assert html =~ ~s(phx-value-command="interrupted")
+ assert html =~ ~s(phx-value-command="expanded_recent")
+ refute html =~ ~s(data-live-ui-component-kind="live_session_card")
+ refute html =~ ~s(data-live-ui-unsupported-native-component)
+ end
+ end
+
+ defp base_assigns(overrides \\ []) do
+ Map.merge(
+ %{
+ id: "live_session:#{@session_id}:7",
+ session_id: @session_id,
+ actor_handle: "@opus",
+ status: :running,
+ status_version: 7,
+ tools_count: 3,
+ edits_count: 2,
+ tokens_consumed: 12_345,
+ started_at: @started_at,
+ current_task_title: "Implement live session card",
+ current_step: "renderer",
+ recent_events: [],
+ pinned?: false
+ },
+ Map.new(overrides)
+ )
+ end
+
+ defp base_iur_opts(overrides \\ []) do
+ Keyword.merge(
+ [
+ session_id: @session_id,
+ actor_handle: "@opus",
+ status: :running,
+ status_version: 7,
+ tools_count: 3,
+ edits_count: 2,
+ tokens_consumed: 12_345,
+ started_at: @started_at,
+ now_streaming: "Writing renderer tests.",
+ recent_events: [%{kind: :assistant_text, body: "Working."}]
+ ],
+ overrides
+ )
+ end
+end
diff --git a/packages/unified_iur/lib/unified_iur/fixtures.ex b/packages/unified_iur/lib/unified_iur/fixtures.ex
index dee9d61c..883c5f23 100644
--- a/packages/unified_iur/lib/unified_iur/fixtures.ex
+++ b/packages/unified_iur/lib/unified_iur/fixtures.ex
@@ -1240,6 +1240,18 @@ defmodule UnifiedIUR.Fixtures do
status: :complete,
args: %{file_path: "lib/ariston_ui/workspace.ex"},
expanded?: false
+ )},
+ {:content,
+ Components.live_session_card(
+ session_id: "550e8400-e29b-41d4-a716-446655440000",
+ actor_handle: "@opus",
+ status: :running,
+ status_version: 1,
+ tools_count: 5,
+ edits_count: 2,
+ tokens_consumed: 8192,
+ started_at: "2026-05-28T10:00:00Z",
+ current_task_title: "Fix coverage fixtures"
)}
],
id: "component-safety-fixture"
diff --git a/packages/unified_iur/lib/unified_iur/validate.ex b/packages/unified_iur/lib/unified_iur/validate.ex
index 7a3b394b..19d68c64 100644
--- a/packages/unified_iur/lib/unified_iur/validate.ex
+++ b/packages/unified_iur/lib/unified_iur/validate.ex
@@ -193,6 +193,11 @@ defmodule UnifiedIUR.Validate do
guidance:
"Represent workflow_progress_status_card with subject id, name, progress, status_counts, dependencies, and renderer-independent metadata."
},
+ invalid_live_session_card: %{
+ construct_family: :widget_components,
+ guidance:
+ "Represent live_session_card with a running session id, actor handle, monotonic status version, prop-sourced meters, streaming text, recent event fragments, and semantic control events."
+ },
invalid_workflow_status_count: %{
construct_family: :widget_components,
guidance:
@@ -224,6 +229,10 @@ defmodule UnifiedIUR.Validate do
@query_preview_states [:loading, :ready, :empty, :error]
@propose_new_doc_statuses [:pending, :accepted, :rejected, :archived]
@propose_new_doc_actions [:accept, :reject, :preview]
+ @live_session_statuses [:running]
+ @live_session_action_names ~w[pin interrupt expanded_recent]
+ @live_session_action_keys [:pin, :interrupt, :expanded_recent]
+ @live_session_event_names ~w[pin_toggled interrupted expanded_recent]
@collection_picker_forbidden_keys ~w[
bundle
bundle_id
@@ -759,6 +768,23 @@ defmodule UnifiedIUR.Validate do
|> validate_subject_shape()
end
+ defp validate_component_contracts(%Element{
+ id: id,
+ kind: :live_session_card,
+ attributes: attributes
+ }) do
+ live_session = Map.get(attributes, :live_session, %{})
+
+ []
+ |> Kernel.++(validate_live_session_shape(live_session, id))
+ |> Kernel.++(
+ validate_live_session_interactions(
+ Map.get(attributes, :interactions, []),
+ [:attributes, :interactions]
+ )
+ )
+ end
+
defp validate_component_contracts(_element), do: []
defp validate_tool_call_shape(tool_call) when is_map(tool_call) do
@@ -982,6 +1008,356 @@ defmodule UnifiedIUR.Validate do
fetch(summary, :event_id, fetch(summary, :result_event_id))
end
+ defp validate_live_session_shape(live_session, element_id) when is_map(live_session) do
+ session_id = fetch(live_session, :session_id)
+ actor_handle = fetch(live_session, :actor_handle)
+ status = fetch(live_session, :status)
+ status_version = fetch(live_session, :status_version)
+ expected_id = live_session_card_id(session_id, status_version)
+
+ []
+ |> maybe_add(
+ not uuid_string?(session_id),
+ Error.new(
+ :invalid_live_session_card,
+ "live_session_card requires session_id as a uuid string",
+ path: [:attributes, :live_session, :session_id],
+ details: %{session_id: inspect(session_id)}
+ )
+ )
+ |> maybe_add(
+ not non_blank_string?(actor_handle),
+ Error.new(
+ :invalid_live_session_card,
+ "live_session_card requires actor_handle as a non-blank string",
+ path: [:attributes, :live_session, :actor_handle],
+ details: %{actor_handle: inspect(actor_handle)}
+ )
+ )
+ |> maybe_add(
+ status not in @live_session_statuses,
+ Error.new(
+ :invalid_live_session_card,
+ "live_session_card status must be :running",
+ path: [:attributes, :live_session, :status],
+ details: %{status: inspect(status)}
+ )
+ )
+ |> maybe_add(
+ not non_negative_integer?(status_version),
+ Error.new(
+ :invalid_live_session_card,
+ "live_session_card status_version must be a non-negative integer",
+ path: [:attributes, :live_session, :status_version],
+ details: %{status_version: inspect(status_version)}
+ )
+ )
+ |> maybe_add(
+ not is_nil(expected_id) and element_id != expected_id,
+ Error.new(
+ :invalid_live_session_card,
+ "live_session_card id must be live_session::",
+ path: [:id],
+ details: %{id: inspect(element_id), expected_id: expected_id}
+ )
+ )
+ |> Kernel.++(
+ validate_live_session_meter(
+ fetch(live_session, :tools_count),
+ [:attributes, :live_session, :tools_count]
+ )
+ )
+ |> Kernel.++(
+ validate_live_session_meter(
+ fetch(live_session, :edits_count),
+ [:attributes, :live_session, :edits_count]
+ )
+ )
+ |> Kernel.++(
+ validate_live_session_meter(
+ fetch(live_session, :tokens_consumed),
+ [:attributes, :live_session, :tokens_consumed]
+ )
+ )
+ |> maybe_add(
+ not datetime_like?(fetch(live_session, :started_at)),
+ Error.new(
+ :invalid_live_session_card,
+ "live_session_card started_at must be a datetime",
+ path: [:attributes, :live_session, :started_at],
+ details: %{started_at: inspect(fetch(live_session, :started_at))}
+ )
+ )
+ |> Kernel.++(
+ validate_optional_live_session_string(
+ fetch(live_session, :current_step),
+ [:attributes, :live_session, :current_step],
+ "current_step"
+ )
+ )
+ |> Kernel.++(
+ validate_optional_live_session_string(
+ fetch(live_session, :current_task_title),
+ [:attributes, :live_session, :current_task_title],
+ "current_task_title"
+ )
+ )
+ |> Kernel.++(
+ validate_optional_live_session_string(
+ fetch(live_session, :now_streaming),
+ [:attributes, :live_session, :now_streaming],
+ "now_streaming"
+ )
+ )
+ |> Kernel.++(
+ validate_live_session_recent_events(
+ fetch(live_session, :recent_events, []),
+ [:attributes, :live_session, :recent_events]
+ )
+ )
+ |> maybe_add(
+ has_key?(live_session, :pinned?) and not is_boolean(fetch(live_session, :pinned?)),
+ Error.new(
+ :invalid_live_session_card,
+ "live_session_card pinned? must be a boolean",
+ path: [:attributes, :live_session, :pinned?],
+ details: %{pinned?: inspect(fetch(live_session, :pinned?))}
+ )
+ )
+ |> Kernel.++(
+ validate_live_session_actions(
+ fetch(live_session, :actions),
+ [:attributes, :live_session, :actions]
+ )
+ )
+ end
+
+ defp validate_live_session_shape(_live_session, _element_id) do
+ [
+ Error.new(
+ :invalid_live_session_card,
+ "live_session_card attributes.live_session must be a map",
+ path: [:attributes, :live_session]
+ )
+ ]
+ end
+
+ defp validate_live_session_meter(value, path) do
+ maybe_add(
+ [],
+ not non_negative_integer?(value),
+ Error.new(
+ :invalid_live_session_card,
+ "live_session_card meter values must be non-negative integers",
+ path: path,
+ details: %{value: inspect(value)}
+ )
+ )
+ end
+
+ defp validate_optional_live_session_string(nil, _path, _field), do: []
+
+ defp validate_optional_live_session_string(value, path, field) do
+ maybe_add(
+ [],
+ not is_binary(value),
+ Error.new(
+ :invalid_live_session_card,
+ "live_session_card #{field} must be a string",
+ path: path,
+ details: %{value: inspect(value)}
+ )
+ )
+ end
+
+ defp validate_live_session_recent_events(events, path) when is_list(events) do
+ maybe_add(
+ [],
+ length(events) > 5,
+ Error.new(
+ :invalid_live_session_card,
+ "live_session_card recent_events accepts at most 5 items",
+ path: path,
+ details: %{count: length(events)}
+ )
+ ) ++
+ (events
+ |> Enum.take(5)
+ |> Enum.with_index()
+ |> Enum.flat_map(fn {event, index} ->
+ validate_live_session_recent_event(event, path ++ [index])
+ end))
+ end
+
+ defp validate_live_session_recent_events(_events, path) do
+ [
+ Error.new(
+ :invalid_live_session_card,
+ "live_session_card recent_events must be a list",
+ path: path
+ )
+ ]
+ end
+
+ defp validate_live_session_recent_event(event, path) when is_map(event) or is_list(event) do
+ event = normalize_map(event)
+ kind = fetch(event, :kind)
+ body = fetch(event, :body, fetch(event, :body_fragment, fetch(event, :fragment)))
+
+ []
+ |> maybe_add(
+ not ((is_atom(kind) and not is_nil(kind)) or non_blank_string?(kind)),
+ Error.new(
+ :invalid_live_session_card,
+ "live_session_card recent_events require kind",
+ path: path ++ [:kind],
+ details: %{kind: inspect(kind)}
+ )
+ )
+ |> maybe_add(
+ not is_binary(body),
+ Error.new(
+ :invalid_live_session_card,
+ "live_session_card recent_events require body as a string",
+ path: path ++ [:body],
+ details: %{body: inspect(body)}
+ )
+ )
+ end
+
+ defp validate_live_session_recent_event(_event, path) do
+ [
+ Error.new(
+ :invalid_live_session_card,
+ "live_session_card recent_events must be maps",
+ path: path
+ )
+ ]
+ end
+
+ defp validate_live_session_actions(nil, _path), do: []
+
+ defp validate_live_session_actions(actions, path) when is_map(actions) do
+ action_names = Enum.map(Map.keys(actions), &to_string/1)
+ unknown = Enum.reject(action_names, &(&1 in @live_session_action_names))
+
+ unknown_errors =
+ maybe_add(
+ [],
+ unknown != [],
+ Error.new(
+ :invalid_live_session_card,
+ "live_session_card actions contain unknown names",
+ path: path,
+ details: %{unknown: unknown}
+ )
+ )
+
+ action_errors =
+ @live_session_action_keys
+ |> Enum.flat_map(fn key ->
+ case fetch(actions, key) do
+ nil -> []
+ action -> validate_live_session_action(action, to_string(key), path ++ [key])
+ end
+ end)
+
+ unknown_errors ++ action_errors
+ end
+
+ defp validate_live_session_actions(_actions, path) do
+ [
+ Error.new(
+ :invalid_live_session_card,
+ "live_session_card actions must be a map",
+ path: path
+ )
+ ]
+ end
+
+ defp validate_live_session_action(action, action_name, path)
+ when is_map(action) or is_list(action) do
+ action = normalize_map(action)
+ event = fetch(action, :event)
+
+ maybe_add(
+ [],
+ not live_session_event_name?(event),
+ Error.new(
+ :invalid_live_session_card,
+ "live_session_card action #{action_name} has an unknown event",
+ path: path ++ [:event],
+ details: %{event: inspect(event)}
+ )
+ )
+ end
+
+ defp validate_live_session_action(_action, action_name, path) do
+ [
+ Error.new(
+ :invalid_live_session_card,
+ "live_session_card action #{action_name} must be a map",
+ path: path
+ )
+ ]
+ end
+
+ defp validate_live_session_interactions(interactions, path) when is_list(interactions) do
+ commands =
+ interactions
+ |> Enum.filter(&match?(%Interaction{family: :command}, &1))
+ |> Enum.map(fn %Interaction{payload: payload} -> fetch(payload, :command) end)
+
+ unknown =
+ commands
+ |> Enum.map(&to_string/1)
+ |> Enum.reject(&(&1 in @live_session_event_names))
+
+ missing =
+ Enum.reject(@live_session_event_names, fn expected ->
+ Enum.any?(commands, &(to_string(&1) == expected))
+ end)
+
+ []
+ |> maybe_add(
+ unknown != [],
+ Error.new(
+ :invalid_live_session_card,
+ "live_session_card interactions contain unknown command events",
+ path: path,
+ details: %{unknown: unknown}
+ )
+ )
+ |> maybe_add(
+ missing != [],
+ Error.new(
+ :invalid_live_session_card,
+ "live_session_card interactions must include pin_toggled, interrupted, and expanded_recent",
+ path: path,
+ details: %{missing: missing}
+ )
+ )
+ end
+
+ defp validate_live_session_interactions(_interactions, path) do
+ [
+ Error.new(
+ :invalid_live_session_card,
+ "live_session_card interactions must be a list",
+ path: path
+ )
+ ]
+ end
+
+ defp live_session_event_name?(event), do: to_string(event) in @live_session_event_names
+
+ defp live_session_card_id(session_id, status_version)
+ when is_binary(session_id) and is_integer(status_version) do
+ "live_session:#{session_id}:#{status_version}"
+ end
+
+ defp live_session_card_id(_session_id, _status_version), do: nil
+
defp validate_subject_shape(subject) when is_map(subject) do
[]
|> maybe_add(
@@ -2606,6 +2982,25 @@ defmodule UnifiedIUR.Validate do
defp non_negative_integer?(value), do: is_integer(value) and value >= 0
defp positive_integer?(value), do: is_integer(value) and value > 0
+ defp uuid_string?(value) when is_binary(value) do
+ Regex.match?(
+ ~r/\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\z/,
+ value
+ )
+ end
+
+ defp uuid_string?(_value), do: false
+
+ defp datetime_like?(%DateTime{}), do: true
+ defp datetime_like?(%NaiveDateTime{}), do: true
+
+ defp datetime_like?(value) when is_binary(value) and value != "" do
+ match?({:ok, _dt, _offset}, DateTime.from_iso8601(value)) or
+ match?({:ok, _ndt}, NaiveDateTime.from_iso8601(value))
+ end
+
+ defp datetime_like?(_value), do: false
+
defp active_panel_in_panels?(active_panel, panels) when is_list(panels) do
Enum.any?(panels, fn
panel when is_map(panel) or is_list(panel) ->
diff --git a/packages/unified_iur/lib/unified_iur/widgets/components.ex b/packages/unified_iur/lib/unified_iur/widgets/components.ex
index 0c70ed7b..0188f25d 100644
--- a/packages/unified_iur/lib/unified_iur/widgets/components.ex
+++ b/packages/unified_iur/lib/unified_iur/widgets/components.ex
@@ -63,6 +63,7 @@ defmodule UnifiedIUR.Widgets.Components do
:workflow_stage_list_vertical,
:meter_thin,
:unread_badge,
+ :live_session_card,
:workflow_progress_status_card
]
@@ -84,6 +85,7 @@ defmodule UnifiedIUR.Widgets.Components do
@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]
+ @live_session_statuses [:running]
@redline_code_kinds [
:redline_inline,
@@ -856,6 +858,95 @@ defmodule UnifiedIUR.Widgets.Components do
)
end
+ @spec live_session_card(opts()) :: Element.t()
+ def live_session_card(opts \\ []) do
+ opts = normalize_opts(opts)
+
+ session_id =
+ required_string!(
+ opts,
+ :session_id,
+ "live_session_card requires a non-empty :session_id string"
+ )
+
+ unless uuid_string?(session_id) do
+ raise ArgumentError, "live_session_card :session_id must be a uuid string"
+ end
+
+ actor_handle =
+ required_string!(
+ opts,
+ :actor_handle,
+ "live_session_card requires a non-empty :actor_handle string"
+ )
+
+ unless non_blank_string?(actor_handle) do
+ raise ArgumentError, "live_session_card requires a non-blank :actor_handle string"
+ end
+
+ status = normalize_live_session_status!(option(opts, :status))
+ status_version = required_non_negative_integer!(opts, :status_version)
+ tools_count = required_non_negative_integer!(opts, :tools_count)
+ edits_count = required_non_negative_integer!(opts, :edits_count)
+ tokens_consumed = required_non_negative_integer!(opts, :tokens_consumed)
+ started_at = option(opts, :started_at)
+
+ unless datetime_like?(started_at) do
+ raise ArgumentError, "live_session_card requires a :started_at datetime"
+ end
+
+ pinned? = option(opts, :pinned?, false)
+
+ unless is_boolean(pinned?) do
+ raise ArgumentError, "live_session_card :pinned? must be a boolean"
+ end
+
+ synthetic_id = live_session_card_id(session_id, status_version)
+
+ case option(opts, :id) do
+ nil ->
+ :ok
+
+ ^synthetic_id ->
+ :ok
+
+ _other ->
+ raise ArgumentError,
+ "live_session_card id must be deterministic #{inspect(synthetic_id)}"
+ end
+
+ opts =
+ opts
+ |> Map.put(:id, synthetic_id)
+ |> put_live_session_interactions(session_id)
+
+ build_component(
+ :live_session_card,
+ :workflow_progress_and_status,
+ %{
+ live_session:
+ %{
+ session_id: session_id,
+ actor_handle: actor_handle,
+ status: status,
+ status_version: status_version,
+ tools_count: tools_count,
+ edits_count: edits_count,
+ tokens_consumed: tokens_consumed,
+ started_at: started_at,
+ recent_events:
+ normalize_live_session_recent_events!(option(opts, :recent_events, [])),
+ pinned?: pinned?,
+ actions: live_session_actions()
+ }
+ |> maybe_put(:current_step, optional_binary!(opts, :current_step))
+ |> maybe_put(:current_task_title, optional_binary!(opts, :current_task_title))
+ |> maybe_put(:now_streaming, optional_binary!(opts, :now_streaming))
+ },
+ opts
+ )
+ end
+
@spec workflow_progress_status_card(opts()) :: Element.t()
def workflow_progress_status_card(opts \\ []) do
opts = normalize_opts(opts)
@@ -1154,6 +1245,113 @@ defmodule UnifiedIUR.Widgets.Components do
%{selected?: selected?}
end
+ defp normalize_live_session_status!(status) when status in @live_session_statuses, do: status
+
+ defp normalize_live_session_status!(status) when is_binary(status) do
+ status
+ |> String.to_existing_atom()
+ |> normalize_live_session_status!()
+ rescue
+ ArgumentError ->
+ raise ArgumentError,
+ "live_session_card :status must be one of #{inspect(@live_session_statuses)}"
+ end
+
+ defp normalize_live_session_status!(_status) do
+ raise ArgumentError,
+ "live_session_card :status must be one of #{inspect(@live_session_statuses)}"
+ end
+
+ defp required_non_negative_integer!(opts, key) do
+ value = option(opts, key)
+
+ unless non_negative_integer?(value) do
+ raise ArgumentError, "live_session_card :#{key} must be a non-negative integer"
+ end
+
+ value
+ end
+
+ defp optional_binary!(opts, key) do
+ value = option(opts, key)
+
+ cond do
+ is_nil(value) -> nil
+ is_binary(value) -> value
+ true -> raise ArgumentError, "live_session_card :#{key} must be a string"
+ end
+ end
+
+ defp normalize_live_session_recent_events!(nil), do: []
+
+ defp normalize_live_session_recent_events!(events) when is_list(events) do
+ if length(events) > 5 do
+ raise ArgumentError, "live_session_card :recent_events accepts at most 5 items"
+ end
+
+ Enum.map(events, &normalize_live_session_recent_event!/1)
+ end
+
+ defp normalize_live_session_recent_events!(_events) do
+ raise ArgumentError, "live_session_card :recent_events must be a list"
+ end
+
+ defp normalize_live_session_recent_event!(event) when is_map(event) or is_list(event) do
+ event = normalize_map(event)
+ kind = option(event, :kind)
+ body = option(event, :body, option(event, :body_fragment, option(event, :fragment)))
+
+ unless (is_atom(kind) and not is_nil(kind)) or non_blank_string?(kind) do
+ raise ArgumentError, "live_session_card recent_events require :kind"
+ end
+
+ unless is_binary(body) do
+ raise ArgumentError, "live_session_card recent_events require a :body string"
+ end
+
+ %{
+ kind: kind,
+ body: body
+ }
+ |> maybe_put(:event_id, option(event, :event_id))
+ |> maybe_put(:occurred_at, option(event, :occurred_at))
+ end
+
+ defp normalize_live_session_recent_event!(_event) do
+ raise ArgumentError, "live_session_card recent_events must be maps"
+ end
+
+ defp live_session_card_id(session_id, status_version) do
+ "live_session:#{session_id}:#{status_version}"
+ end
+
+ defp live_session_actions do
+ %{
+ pin: %{event: :pin_toggled},
+ interrupt: %{event: :interrupted},
+ expanded_recent: %{event: :expanded_recent}
+ }
+ end
+
+ defp uuid_string?(value) when is_binary(value) do
+ Regex.match?(
+ ~r/\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\z/,
+ value
+ )
+ end
+
+ defp uuid_string?(_value), do: false
+
+ defp datetime_like?(%DateTime{}), do: true
+ defp datetime_like?(%NaiveDateTime{}), do: true
+
+ defp datetime_like?(value) when is_binary(value) and value != "" do
+ match?({:ok, _dt, _offset}, DateTime.from_iso8601(value)) or
+ match?({:ok, _ndt}, NaiveDateTime.from_iso8601(value))
+ end
+
+ defp datetime_like?(_value), do: false
+
@spec command_palette(
[keyword() | map()],
[Element.t() | Element.Child.t() | {atom(), Element.t()} | map()],
@@ -1477,6 +1675,39 @@ defmodule UnifiedIUR.Widgets.Components do
end
end
+ defp put_live_session_interactions(opts, session_id) do
+ cond do
+ explicit_interactions?(opts) ->
+ opts
+
+ true ->
+ Map.put(opts, :interactions, live_session_interactions(opts, session_id))
+ end
+ end
+
+ defp live_session_interactions(opts, session_id) do
+ [
+ live_session_command_interaction(opts, session_id, :pin_toggled, :pin_intent),
+ live_session_command_interaction(opts, session_id, :interrupted, :interrupt_intent),
+ live_session_command_interaction(
+ opts,
+ session_id,
+ :expanded_recent,
+ :expanded_recent_intent
+ )
+ ]
+ end
+
+ defp live_session_command_interaction(opts, session_id, command, intent_key) do
+ Interaction.command(
+ intent: option(opts, intent_key, command),
+ element_id: option(opts, :id),
+ entity: session_id,
+ command: command,
+ value: session_id
+ )
+ 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
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 a3990013..98d8db27 100644
--- a/packages/unified_iur/test/unified_iur/widgets/components_test.exs
+++ b/packages/unified_iur/test/unified_iur/widgets/components_test.exs
@@ -39,6 +39,7 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do
:workflow_stage_list_vertical,
:meter_thin,
:unread_badge,
+ :live_session_card,
:workflow_progress_status_card
]
diff --git a/packages/unified_iur/test/unified_iur/widgets/live_session_card_test.exs b/packages/unified_iur/test/unified_iur/widgets/live_session_card_test.exs
new file mode 100644
index 00000000..93647215
--- /dev/null
+++ b/packages/unified_iur/test/unified_iur/widgets/live_session_card_test.exs
@@ -0,0 +1,147 @@
+defmodule UnifiedIUR.Widgets.LiveSessionCardTest do
+ use ExUnit.Case, async: true
+
+ alias UnifiedIUR.{Element, Interaction, Validate}
+ alias UnifiedIUR.Widgets.Components
+
+ @session_id "550e8400-e29b-41d4-a716-446655440000"
+ @started_at ~U[2026-05-27 15:00:00Z]
+
+ describe "live_session_card constructor" do
+ test "builds a valid running session card with deterministic id" do
+ card = Components.live_session_card(valid_opts())
+
+ assert %Element{kind: :live_session_card} = card
+ assert card.id == "live_session:#{@session_id}:7"
+
+ assert card.attributes.component == %{
+ family: :workflow_progress_and_status,
+ kind: :live_session_card
+ }
+
+ assert card.attributes.live_session.session_id == @session_id
+ assert card.attributes.live_session.actor_handle == "@opus"
+ assert card.attributes.live_session.status == :running
+ assert card.attributes.live_session.status_version == 7
+ assert card.attributes.live_session.tools_count == 3
+ assert card.attributes.live_session.edits_count == 2
+ assert card.attributes.live_session.tokens_consumed == 12_345
+ assert card.attributes.live_session.started_at == @started_at
+ assert card.attributes.live_session.pinned? == false
+
+ assert [
+ %Interaction{family: :command, payload: %{command: :pin_toggled}},
+ %Interaction{family: :command, payload: %{command: :interrupted}},
+ %Interaction{family: :command, payload: %{command: :expanded_recent}}
+ ] = card.attributes.interactions
+
+ assert :ok = Validate.element(card)
+ end
+
+ test "preserves optional streaming and recent activity fragments" do
+ card =
+ Components.live_session_card(
+ valid_opts(
+ current_step: "reading",
+ current_task_title: "Implementing card",
+ now_streaming: "Updating renderer wiring.",
+ pinned?: true,
+ recent_events: [
+ %{kind: :assistant_text, body_fragment: "Working through tests."},
+ %{kind: "tool_call", body: "mix test packages/unified_iur/test"}
+ ]
+ )
+ )
+
+ assert card.attributes.live_session.current_step == "reading"
+ assert card.attributes.live_session.current_task_title == "Implementing card"
+ assert card.attributes.live_session.now_streaming == "Updating renderer wiring."
+ assert card.attributes.live_session.pinned? == true
+
+ assert card.attributes.live_session.recent_events == [
+ %{kind: :assistant_text, body: "Working through tests."},
+ %{kind: "tool_call", body: "mix test packages/unified_iur/test"}
+ ]
+
+ assert :ok = Validate.element(card)
+ end
+
+ test "raises for non-running sessions, invalid meters, and excessive recent events" do
+ assert_raise ArgumentError, ~r/status must be one of/, fn ->
+ Components.live_session_card(valid_opts(status: :complete))
+ end
+
+ assert_raise ArgumentError, ~r/status_version must be a non-negative integer/, fn ->
+ Components.live_session_card(valid_opts(status_version: -1))
+ end
+
+ assert_raise ArgumentError, ~r/tools_count must be a non-negative integer/, fn ->
+ Components.live_session_card(valid_opts(tools_count: -1))
+ end
+
+ assert_raise ArgumentError, ~r/recent_events accepts at most 5/, fn ->
+ Components.live_session_card(
+ valid_opts(recent_events: List.duplicate(%{kind: :assistant_text, body: "event"}, 6))
+ )
+ end
+ end
+
+ test "raises when an explicit id does not match the synthetic key" do
+ assert_raise ArgumentError, ~r/id must be deterministic/, fn ->
+ Components.live_session_card(valid_opts(id: "live-session-card"))
+ end
+ end
+
+ test "validates malformed raw cards with structured diagnostics" do
+ invalid =
+ Element.new(:widget, :live_session_card,
+ id: "bad-id",
+ attributes: %{
+ component: %{family: :workflow_progress_and_status, kind: :live_session_card},
+ live_session: %{
+ session_id: "not-a-uuid",
+ actor_handle: " ",
+ status: :complete,
+ status_version: -1,
+ tools_count: -1,
+ edits_count: "2",
+ tokens_consumed: -3,
+ started_at: "not-a-date",
+ now_streaming: %{text: "bad"},
+ recent_events: List.duplicate(%{kind: "", body: 10}, 6),
+ pinned?: "false",
+ actions: %{pause: %{event: :pause}}
+ },
+ interactions: []
+ }
+ )
+
+ assert {:error, errors} = Validate.element(invalid)
+ assert Enum.all?(errors, &(&1.code == :invalid_live_session_card))
+ assert Enum.any?(errors, &(&1.path == [:attributes, :live_session, :session_id]))
+ assert Enum.any?(errors, &(&1.path == [:attributes, :live_session, :recent_events]))
+ assert Enum.any?(errors, &(&1.path == [:attributes, :interactions]))
+ end
+ end
+
+ test "is included in workflow and aggregate component kind lists" do
+ assert :live_session_card in Components.workflow_kinds()
+ assert :live_session_card in Components.kinds()
+ end
+
+ defp valid_opts(overrides \\ []) do
+ Keyword.merge(
+ [
+ session_id: @session_id,
+ actor_handle: "@opus",
+ status: :running,
+ status_version: 7,
+ tools_count: 3,
+ edits_count: 2,
+ tokens_consumed: 12_345,
+ started_at: @started_at
+ ],
+ overrides
+ )
+ end
+end
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 212b6c4c..6142e131 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
@@ -17,6 +17,7 @@ defmodule UnifiedUi.Dsl.Entities.WidgetComponents do
@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]
+ @live_session_statuses [:running]
@spec entities() :: [Spark.Dsl.Entity.t()]
def entities do
@@ -192,6 +193,27 @@ defmodule UnifiedUi.Dsl.Entities.WidgetComponents do
state: [type: :atom, required: false],
summary: [type: :string, required: false]
),
+ leaf(
+ :live_session_card,
+ @workflow_family,
+ session_id: [type: :string, required: true],
+ actor_handle: [type: :string, required: true],
+ status: [type: {:in, @live_session_statuses}, required: true],
+ status_version: [type: :integer, required: true],
+ tools_count: [type: :integer, required: true],
+ edits_count: [type: :integer, required: true],
+ tokens_consumed: [type: :integer, required: true],
+ started_at: [type: :any, required: true],
+ current_step: [type: :string, required: false],
+ current_task_title: [type: :string, required: false],
+ now_streaming: [type: :string, required: false],
+ recent_events: [type: :any, required: false, default: []],
+ pinned?: [type: :boolean, required: false, default: false],
+ pin_intent: [type: :atom, required: false],
+ interrupt_intent: [type: :atom, required: false],
+ expanded_recent_intent: [type: :atom, required: false],
+ summary: [type: :string, required: false]
+ ),
leaf(
:workflow_progress_status_card,
@workflow_family,
diff --git a/packages/unified_ui/lib/unified_ui/widget_components.ex b/packages/unified_ui/lib/unified_ui/widget_components.ex
index 98d61d58..18e2a4b6 100644
--- a/packages/unified_ui/lib/unified_ui/widget_components.ex
+++ b/packages/unified_ui/lib/unified_ui/widget_components.ex
@@ -234,6 +234,13 @@ defmodule UnifiedUi.WidgetComponents do
summary: "Compact unread count badge with threshold capping.",
aliases: []
},
+ %{
+ kind: :live_session_card,
+ family: :workflow_progress_and_status,
+ summary:
+ "Composite running-session status card with actor identity, meters, live assistant text, recent events, and control actions.",
+ aliases: []
+ },
%{
kind: :command_palette,
family: :layer_shell_and_callout,
diff --git a/packages/unified_ui/test/unified_ui/advanced_widget_families_test.exs b/packages/unified_ui/test/unified_ui/advanced_widget_families_test.exs
index 3062bd24..96dc1eb9 100644
--- a/packages/unified_ui/test/unified_ui/advanced_widget_families_test.exs
+++ b/packages/unified_ui/test/unified_ui/advanced_widget_families_test.exs
@@ -178,6 +178,7 @@ defmodule UnifiedUi.AdvancedWidgetFamiliesTest do
:segmented_progress_bar,
:workflow_stage_list_vertical,
:meter_thin,
+ :live_session_card,
:workflow_progress_status_card,
:sticky_frosted_header,
:slide_over_panel,
diff --git a/packages/unified_ui/test/unified_ui/operational_widget_components_test.exs b/packages/unified_ui/test/unified_ui/operational_widget_components_test.exs
index 4d601d4c..6a6e69dd 100644
--- a/packages/unified_ui/test/unified_ui/operational_widget_components_test.exs
+++ b/packages/unified_ui/test/unified_ui/operational_widget_components_test.exs
@@ -166,6 +166,7 @@ defmodule UnifiedUi.OperationalWidgetComponentsTest do
:segmented_progress_bar,
:workflow_stage_list_vertical,
:meter_thin,
+ :live_session_card,
:workflow_progress_status_card
]
diff --git a/packages/unified_ui/test/unified_ui/phase_2_integration_test.exs b/packages/unified_ui/test/unified_ui/phase_2_integration_test.exs
index dba347aa..1027c39c 100644
--- a/packages/unified_ui/test/unified_ui/phase_2_integration_test.exs
+++ b/packages/unified_ui/test/unified_ui/phase_2_integration_test.exs
@@ -313,6 +313,7 @@ defmodule UnifiedUi.Phase2IntegrationTest do
:segmented_progress_bar,
:workflow_stage_list_vertical,
:meter_thin,
+ :live_session_card,
:workflow_progress_status_card,
:sticky_frosted_header,
:slide_over_panel,
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 ad2e0d00..6b40f135 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
@@ -41,6 +41,7 @@ defmodule UnifiedUi.WidgetComponentsCatalogTest do
:workflow_stage_list_vertical,
:meter_thin,
:unread_badge,
+ :live_session_card,
:workflow_progress_status_card
],
layer_shell_and_callout: [
@@ -75,6 +76,7 @@ defmodule UnifiedUi.WidgetComponentsCatalogTest do
assert :composer_query_preview in kinds
assert :propose_new_doc_card in kinds
assert :collection_picker in kinds
+ assert :live_session_card in kinds
assert :workflow_progress_status_card in kinds
assert :tool_call_card in kinds
end
diff --git a/test/ash_ui/phase_31_canonical_conversion_test.exs b/test/ash_ui/phase_31_canonical_conversion_test.exs
index 72af0fec..f9e91a33 100644
--- a/test/ash_ui/phase_31_canonical_conversion_test.exs
+++ b/test/ash_ui/phase_31_canonical_conversion_test.exs
@@ -48,6 +48,17 @@ defmodule AshUI.Phase31CanonicalConversionTest do
%{stages: [%{id: :authored, label: "Authored"}]}, :workflow},
{:meter_thin, :workflow_progress_and_status, %{current: 50, minimum: 0, maximum: 100},
:meter},
+ {:live_session_card, :workflow_progress_and_status,
+ %{
+ session_id: "550e8400-e29b-41d4-a716-446655440000",
+ actor_handle: "@opus",
+ status: :running,
+ status_version: 1,
+ tools_count: 1,
+ edits_count: 0,
+ tokens_consumed: 100,
+ started_at: ~U[2026-05-27 15:00:00Z]
+ }, :live_session},
{:sticky_frosted_header, :layer_shell_and_callout, %{title: "Workspace"}, :shell},
{:slide_over_panel, :layer_shell_and_callout, %{label: "Details", open?: true}, :panel},
{:event_callout, :layer_shell_and_callout, %{message: "Deployment paused"}, :callout},
diff --git a/test/ash_ui/phase_31_package_boundary_test.exs b/test/ash_ui/phase_31_package_boundary_test.exs
index fd7d40bb..34282b9e 100644
--- a/test/ash_ui/phase_31_package_boundary_test.exs
+++ b/test/ash_ui/phase_31_package_boundary_test.exs
@@ -58,6 +58,7 @@ defmodule AshUI.Phase31PackageBoundaryTest do
:workflow_stage_list_vertical,
:meter_thin,
:unread_badge,
+ :live_session_card,
:workflow_progress_status_card
]
diff --git a/test/ash_ui/rendering/iur_adapter_test.exs b/test/ash_ui/rendering/iur_adapter_test.exs
index b746439c..8c19290e 100644
--- a/test/ash_ui/rendering/iur_adapter_test.exs
+++ b/test/ash_ui/rendering/iur_adapter_test.exs
@@ -574,6 +574,80 @@ defmodule AshUI.Rendering.IURAdapterTest do
assert error.message =~ "progress must be in 0.0..100.0"
end
+
+ test "routes live_session_card kind through workflow_progress_and_status family with synthetic id" do
+ session_id = "550e8400-e29b-41d4-a716-446655440000"
+
+ ash_iur =
+ IUR.new(:screen,
+ id: "live-session-screen",
+ name: "live_session_screen",
+ attributes: %{},
+ children: [
+ IUR.new(:live_session_card,
+ id: "ignored-source-id",
+ props: %{
+ "session_id" => session_id,
+ "actor_handle" => "@opus",
+ "status" => :running,
+ "status_version" => 3,
+ "tools_count" => 4,
+ "edits_count" => 2,
+ "tokens_consumed" => 9_000,
+ "started_at" => ~U[2026-05-27 15:00:00Z],
+ "now_streaming" => "Writing tests.",
+ "recent_events" => [
+ %{"kind" => "assistant_text", "body" => "Working."}
+ ],
+ "pinned?" => true
+ }
+ )
+ ]
+ )
+
+ assert {:ok, canonical} = IURAdapter.to_canonical(ash_iur)
+ [child] = canonical.children
+
+ assert child.element.id == "live_session:#{session_id}:3"
+ assert child.element.kind == :live_session_card
+ assert child.element.type == :widget
+ assert child.element.attributes.component.family == :workflow_progress_and_status
+ assert child.element.attributes.live_session.session_id == session_id
+ assert child.element.attributes.live_session.status == :running
+ assert child.element.attributes.live_session.tools_count == 4
+ assert child.element.attributes.live_session.edits_count == 2
+ assert child.element.attributes.live_session.tokens_consumed == 9_000
+ assert child.element.attributes.live_session.pinned? == true
+ assert :ok = UnifiedIUR.Validate.element(child.element)
+ end
+
+ test "returns structured conversion errors for invalid live_session_card payloads" do
+ ash_iur =
+ IUR.new(:screen,
+ id: "live-session-screen-invalid",
+ name: "live_session_screen",
+ attributes: %{},
+ children: [
+ IUR.new(:live_session_card,
+ props: %{
+ "session_id" => "not-a-uuid",
+ "actor_handle" => "@opus",
+ "status" => :running,
+ "status_version" => 1,
+ "tools_count" => 0,
+ "edits_count" => 0,
+ "tokens_consumed" => 0,
+ "started_at" => ~U[2026-05-27 15:00:00Z]
+ }
+ )
+ ]
+ )
+
+ assert {:error, {:conversion_failed, %ArgumentError{} = error}} =
+ IURAdapter.to_canonical(ash_iur)
+
+ assert error.message =~ "session_id must be a uuid"
+ end
end
describe "error handling" do
diff --git a/test/ash_ui/rendering/live_ui_adapter_test.exs b/test/ash_ui/rendering/live_ui_adapter_test.exs
index e380df8d..d76a347c 100644
--- a/test/ash_ui/rendering/live_ui_adapter_test.exs
+++ b/test/ash_ui/rendering/live_ui_adapter_test.exs
@@ -1502,4 +1502,49 @@ defmodule AshUI.Rendering.LiveUIAdapterTest do
assert heex =~ ~s(data-selected="true")
end
end
+
+ describe "live_session_card adapter dispatch" do
+ test "generates dedicated live_session_card markup" do
+ iur = %{
+ "type" => "live_session_card",
+ "id" => "live_session:550e8400-e29b-41d4-a716-446655440000:3",
+ "props" => %{
+ "live_session" => %{
+ "session_id" => "550e8400-e29b-41d4-a716-446655440000",
+ "actor_handle" => "@opus",
+ "status" => "running",
+ "status_version" => 3,
+ "tools_count" => 4,
+ "edits_count" => 2,
+ "tokens_consumed" => 9000,
+ "started_at" => "2026-05-27T15:00:00Z",
+ "current_task_title" => "Implement live session card",
+ "now_streaming" => "Writing adapter tests.",
+ "recent_events" => [
+ %{"kind" => "assistant_text", "body" => "Working."}
+ ],
+ "pinned?" => true
+ }
+ },
+ "children" => [],
+ "metadata" => %{}
+ }
+
+ {:ok, heex} = LiveUIAdapter.render(iur, force_fallback: true)
+
+ assert heex =~ "ash-live-session-card"
+ assert heex =~ ~s(data-live-ui-widget="live-session-card")
+ assert heex =~ ~s(data-session-id="550e8400-e29b-41d4-a716-446655440000")
+ assert heex =~ ~s(data-status-version="3")
+ assert heex =~ ~s(data-pinned="true")
+ assert heex =~ "4"
+ assert heex =~ "2"
+ assert heex =~ "9000"
+ assert heex =~ "Writing adapter tests."
+ assert heex =~ "assistant_text"
+ assert heex =~ "data-live-ui-intent=\"pin_toggled\""
+ assert heex =~ "data-live-ui-intent=\"interrupted\""
+ assert heex =~ "data-live-ui-intent=\"expanded_recent\""
+ end
+ end
end