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 + """ +
    + + #{now_streaming} +
    + """ + else + "" + end + + """ +
    +
    + +
    +

    #{actor_handle}

    + #{task_html} +
    + #{String.upcase(status)} + +
    +
    +
    #{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""" +
    +
    + +
    +

    <%= @actor_handle %>

    + <%= if present?(@current_task_title) do %> +

    <%= @current_task_title %>

    + <% else %> + <%= if present?(@current_step) do %> +

    <%= @current_step %>

    + <% end %> + <% end %> +
    + + <%= @status_label %> + + +
    + +
    +
    + <%= @tools_count %> + tools +
    +
    + <%= @edits_count %> + edits +
    +
    + <%= @tokens_consumed %> + tokens +
    +
    + +
    + + <%= @now_streaming %> +
    + +
      +
    1. + + <%= event_kind(event) %> + + + <%= event_body(event) %> + +
    2. +
    + +
    + + +
    +
    + """ + 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