Skip to content

Commit 21d2b17

Browse files
ty13rcodexclaude
authored
feat(live_session_card): composite canonical widget Stage 1-4 (:workflow_progress_and_status family) (#147)
* feat(widget): add :live_session_card composite canonical widget (Phase 41.8) Wires the most complex Phase 41 widget through all four pipeline stages (IUR constructor → UnifiedUI catalog/DSL → LiveUI renderer → AshUI iur_adapter + live_ui_adapter) plus two new test modules and boundary assertions in eight existing test files. Key design points: - Deterministic synthetic id `live_session:<session_id>:<status_version>` preserves coexistence-merge keying for PR 41.10 without coupling. - Composite structure: actor identity header, meters (tools/edits/tokens), NOW STREAMING region (aria-live="polite" + aria-atomic="true"), RECENT activity list capped at 5 items (aria-label per actor), Pin/Interrupt footer actions (aria-pressed on Pin, command-interaction attrs via `command_interaction_attrs/3` helper in renderer). - IUR validation: uuid_string? + datetime_like? + status in [:running] + non-negative meters + exactly 3 mandatory command interactions (pin_toggled / interrupted / expanded_recent). - Stage 1–4 only; no Stage 5 AshUI DSL adoption, no coexistence merge wiring (those land in follow-on PRs 41.9/41.10). Gates: mix compile --warnings-as-errors EXIT=0 / mix test (269 tests, 0 failures) EXIT=0 / mix format --check-formatted on all 20 modified files EXIT=0 Co-Authored-By: Codex GPT-5.5 Pro xhigh <noreply@openai.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(live_session_card): adapter aria-label state + duration label Opus Max review P2 fixes: - adapter Pin button aria-label now flips Pin↔Unpin with @Pinned? state (was hardcoded "Pin" regardless; live_ui_adapter parity with Phoenix.Component) - adapter time element now computes duration label from started_at (was hardcoded "running"; live_ui_adapter parity with Phoenix.Component) - New adapter_duration_label/1 helper parses ISO-8601 started_at and emits Ns / Nm / Nh based on DateTime.utc_now diff 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(live_session_card): add unified_iur reference fixture + cover pre-existing coverage gaps Adds a Components.live_session_card/1 reference fixture to the canonical components--accessibility_and_safety fixture in UnifiedIUR.Fixtures. Uses an ISO 8601 string for started_at rather than a DateTime struct so the reference snapshot_map walk does not hit an Enumerable error. Also adds the three fixtures missing on main (collection_picker, thread_card, composer_query_preview) so this branch does not carry pre-existing coverage redness into its PR gate. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(conformance): add live_session_card to UG-0003 (:workflow_progress_and_status) Phase31DocsConformanceTest asserts each AshUI.WidgetComponents.kinds() name appears in the user guide. live_session_card was added as a canonical widget in Phase 41.8 but not documented in UG-0003, causing a net-new conformance regression on PR #147. Added live_session_card to the Workflow, progress, and status family table row and added a descriptive paragraph covering its key props (session_id, actor_handle, status, status_version, tools_count, edits_count, tokens_consumed, started_at, now_streaming, recent_events, pinned?) and canonical interactions (pin_toggled, interrupted, expanded_recent). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Codex GPT-5.5 Pro xhigh <noreply@openai.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5acc174 commit 21d2b17

22 files changed

Lines changed: 1630 additions & 3 deletions

‎guides/user/UG-0003-widget-types-properties-and-signals.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ authoring boundaries and normalize before renderer-facing output.
9999
| Content, identity, and disclosure | `inline_rich_text_heading`, `disclosure`, `kicker`, `avatar`, `presence_dot` | none |
100100
| Form control and composer | `runtime_form_shell`, `segmented_button_group`, `chat_composer`, `collection_picker`, `mode_nav` | `phoenix_form` -> `runtime_form_shell` |
101101
| Row and artifact | `list_item_multi_column`, `artifact_row`, `thread_card`, `tool_call_card` | none |
102-
| Workflow, progress, and status | `pipeline_stepper_horizontal`, `segmented_progress_bar`, `workflow_stage_list_vertical`, `meter_thin`, `unread_badge`, `workflow_progress_status_card` | none |
102+
| 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 |
103103
| 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 |
104104
| Redline and code | `redline_inline`, `code_block_syntax_highlighted` | none |
105105
| 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`,
138138
`attributes.subject`; it does not expose map placement names, route helpers,
139139
LiveView event fields, or application-specific card names.
140140

141+
`live_session_card` is the canonical card for an actively running assistant
142+
session. Author props such as `session_id`, `actor_handle`, `status`,
143+
`status_version`, `tools_count`, `edits_count`, `tokens_consumed`,
144+
`started_at`, optional `current_step`, `current_task_title`,
145+
`now_streaming`, `recent_events`, and `pinned?`. Use canonical
146+
`pin_toggled`, `interrupted`, and `expanded_recent` interactions. Keep
147+
LiveView event fields and route helpers in the host layer.
148+
141149
`composer_query_preview` is the reusable inline preview band for query results
142150
adjacent to a composer. Author generic props such as `composer_id`, `query`,
143151
`preview_state`, `explanation`, `metrics`, `findings`, and

‎lib/ash_ui/rendering/iur_adapter.ex‎

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,14 @@ defmodule AshUI.Rendering.IURAdapter do
9494
kind = map_element_kind(element.type)
9595
type = map_element_type(kind)
9696
props = if map_size(element.props || %{}) > 0, do: element.props, else: element.attributes
97+
fallback_id = element.id || generate_id()
98+
element_id = element_id_for_kind(kind, convert_props(props), fallback_id)
99+
attribute_element_id = if kind == :live_session_card, do: element_id, else: element.id
97100

98101
Element.new(type, kind,
99-
id: element.id || generate_id(),
102+
id: element_id,
100103
metadata: convert_metadata(element),
101-
attributes: convert_attributes(kind, props, element.id),
104+
attributes: convert_attributes(kind, props, attribute_element_id),
102105
children: convert_children(element.children)
103106
)
104107
end
@@ -155,6 +158,26 @@ defmodule AshUI.Rendering.IURAdapter do
155158

156159
defp map_element_type(_kind), do: :widget
157160

161+
defp element_id_for_kind(:live_session_card, props, fallback_id) do
162+
live_session = props |> fetch(:live_session, %{}) |> normalize_map()
163+
164+
session_id =
165+
first_present(live_session, [:session_id]) ||
166+
first_present(props, [:session_id])
167+
168+
status_version =
169+
first_present(live_session, [:status_version]) ||
170+
first_present(props, [:status_version])
171+
172+
if is_binary(session_id) and not is_nil(status_version) do
173+
"live_session:#{session_id}:#{status_version}"
174+
else
175+
fallback_id
176+
end
177+
end
178+
179+
defp element_id_for_kind(_kind, _props, fallback_id), do: fallback_id
180+
158181
# Convert props with name transformations
159182
defp convert_props(props) when is_map(props) do
160183
Enum.reduce(props, %{}, fn {key, value}, acc ->
@@ -370,6 +393,13 @@ defmodule AshUI.Rendering.IURAdapter do
370393
|> Map.fetch!(:attributes)
371394
end
372395

396+
defp base_attributes(:live_session_card, props) do
397+
props
398+
|> live_session_card_opts()
399+
|> IURComponents.live_session_card()
400+
|> Map.fetch!(:attributes)
401+
end
402+
373403
defp base_attributes(:pipeline_stepper_horizontal = kind, props) do
374404
component_attributes(
375405
kind,
@@ -1341,6 +1371,60 @@ defmodule AshUI.Rendering.IURAdapter do
13411371
|> compact_map()
13421372
end
13431373

1374+
defp live_session_card_opts(props) do
1375+
live_session = props |> fetch(:live_session, %{}) |> normalize_map()
1376+
1377+
%{
1378+
session_id:
1379+
first_present(live_session, [:session_id]) ||
1380+
first_present(props, [:session_id]),
1381+
actor_handle:
1382+
first_present(live_session, [:actor_handle]) ||
1383+
first_present(props, [:actor_handle]),
1384+
status:
1385+
normalize_existing_atom(
1386+
first_present(live_session, [:status]) ||
1387+
first_present(props, [:status]) ||
1388+
:running
1389+
),
1390+
status_version:
1391+
first_present(live_session, [:status_version]) ||
1392+
first_present(props, [:status_version]),
1393+
tools_count:
1394+
first_present(live_session, [:tools_count]) ||
1395+
first_present(props, [:tools_count]),
1396+
edits_count:
1397+
first_present(live_session, [:edits_count]) ||
1398+
first_present(props, [:edits_count]),
1399+
tokens_consumed:
1400+
first_present(live_session, [:tokens_consumed]) ||
1401+
first_present(props, [:tokens_consumed]),
1402+
started_at:
1403+
first_present(live_session, [:started_at]) ||
1404+
first_present(props, [:started_at]),
1405+
current_step:
1406+
first_present(live_session, [:current_step]) ||
1407+
first_present(props, [:current_step]),
1408+
current_task_title:
1409+
first_present(live_session, [:current_task_title]) ||
1410+
first_present(props, [:current_task_title]),
1411+
now_streaming:
1412+
first_present(live_session, [:now_streaming]) ||
1413+
first_present(props, [:now_streaming]),
1414+
recent_events:
1415+
first_present(live_session, [:recent_events]) ||
1416+
fetch(props, :recent_events, []),
1417+
pinned?:
1418+
boolean_present(live_session, [:pinned?], boolean_present(props, [:pinned?], false)),
1419+
pin_intent: first_present(props, [:pin_intent]) || :pin_toggled,
1420+
interrupt_intent: first_present(props, [:interrupt_intent]) || :interrupted,
1421+
expanded_recent_intent: first_present(props, [:expanded_recent_intent]) || :expanded_recent,
1422+
interactions: fetch(props, :interactions),
1423+
interaction: fetch(props, :interaction)
1424+
}
1425+
|> compact_map()
1426+
end
1427+
13441428
defp normalize_heading_segments(props) do
13451429
case fetch(props, :segments) do
13461430
segments when is_list(segments) ->

‎lib/ash_ui/rendering/live_ui_adapter.ex‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1090,6 +1090,92 @@ defmodule AshUI.Rendering.LiveUIAdapter do
10901090
"""
10911091
end
10921092

1093+
defp generate_heex(%{"type" => "live_session_card"} = iur, _opts) do
1094+
props = iur["props"] || %{}
1095+
live_session = prop(props, "live_session", props) |> normalize_item()
1096+
1097+
session_id = escaped_text_prop(live_session, "session_id", "")
1098+
actor_handle = escaped_text_prop(live_session, "actor_handle", "")
1099+
status = escaped_text_prop(live_session, "status", "running")
1100+
status_version = numeric_value(live_session, "status_version", 0)
1101+
tools_count = numeric_value(live_session, "tools_count", 0)
1102+
edits_count = numeric_value(live_session, "edits_count", 0)
1103+
tokens_consumed = numeric_value(live_session, "tokens_consumed", 0)
1104+
started_at = escaped_text_prop(live_session, "started_at", "")
1105+
current_step = escaped_text_prop(live_session, "current_step")
1106+
current_task_title = escaped_text_prop(live_session, "current_task_title")
1107+
now_streaming = escaped_text_prop(live_session, "now_streaming")
1108+
pinned? = truthy_prop(live_session, "pinned?", false)
1109+
iur_id = iur["id"] || iur[:id] || "live-session-card"
1110+
1111+
recent_events =
1112+
live_session
1113+
|> prop("recent_events", [])
1114+
|> List.wrap()
1115+
|> Enum.take(5)
1116+
|> Enum.map_join(fn event ->
1117+
event = normalize_item(event)
1118+
kind = escaped_text_prop(event, "kind", "")
1119+
body = escaped_text_prop(event, ["body", "body_fragment", "fragment"], "")
1120+
1121+
"""
1122+
<li class="ash-live-session-card__recent-item" data-event-kind="#{kind}">
1123+
<span class="ash-live-session-card__recent-kind">#{kind}</span>
1124+
<span class="ash-live-session-card__recent-body">#{body}</span>
1125+
</li>
1126+
"""
1127+
end)
1128+
1129+
task_html =
1130+
cond do
1131+
current_task_title ->
1132+
~s(<p class="ash-live-session-card__task">#{current_task_title}</p>)
1133+
1134+
current_step ->
1135+
~s(<p class="ash-live-session-card__task">#{current_step}</p>)
1136+
1137+
true ->
1138+
""
1139+
end
1140+
1141+
now_streaming_html =
1142+
if now_streaming do
1143+
"""
1144+
<div class="ash-live-session-card__now-streaming" aria-live="polite" aria-atomic="true">
1145+
<span class="ash-live-session-card__live-indicator" aria-hidden="true">LIVE</span>
1146+
<span class="ash-live-session-card__now-streaming-text">#{now_streaming}</span>
1147+
</div>
1148+
"""
1149+
else
1150+
""
1151+
end
1152+
1153+
"""
1154+
<article id="#{html_attr(iur_id)}" class="#{css_classes(["ash-live-session-card", "ash-live-session-card--#{status}", pinned? && "is-pinned", prop_class(iur)])}" data-live-ui-widget="live-session-card" data-session-id="#{session_id}" data-status-version="#{status_version}" data-pinned="#{pinned?}"#{style_attr(prop_style(iur))}>
1155+
<header class="ash-live-session-card__header">
1156+
<span class="ash-live-session-card__avatar" aria-hidden="true">#{String.first(actor_handle) || "?"}</span>
1157+
<div class="ash-live-session-card__identity">
1158+
<h3 class="ash-live-session-card__actor">#{actor_handle}</h3>
1159+
#{task_html}
1160+
</div>
1161+
<span class="ash-live-session-card__status-badge" role="status">#{String.upcase(status)}</span>
1162+
<time class="ash-live-session-card__duration" datetime="#{started_at}">#{adapter_duration_label(started_at)}</time>
1163+
</header>
1164+
<div class="ash-live-session-card__meters">
1165+
<div class="ash-live-session-card__meter" data-meter="tools"><span class="ash-live-session-card__meter-value">#{tools_count}</span><span class="ash-live-session-card__meter-label">tools</span></div>
1166+
<div class="ash-live-session-card__meter" data-meter="edits"><span class="ash-live-session-card__meter-value">#{edits_count}</span><span class="ash-live-session-card__meter-label">edits</span></div>
1167+
<div class="ash-live-session-card__meter" data-meter="tokens"><span class="ash-live-session-card__meter-value">#{tokens_consumed}</span><span class="ash-live-session-card__meter-label">tokens</span></div>
1168+
</div>
1169+
#{now_streaming_html}
1170+
<ol class="ash-live-session-card__recent" aria-label="Recent activity for #{actor_handle}" data-live-ui-intent="expanded_recent">#{recent_events}</ol>
1171+
<footer class="ash-live-session-card__actions">
1172+
<button type="button" class="ash-live-session-card__pin" aria-label="#{if pinned?, do: "Unpin", else: "Pin"} #{actor_handle} running session" aria-pressed="#{pinned?}" data-live-ui-intent="pin_toggled">#{if(pinned?, do: "Pinned", else: "Pin")}</button>
1173+
<button type="button" class="ash-live-session-card__interrupt" aria-label="Interrupt #{actor_handle} running session" data-live-ui-intent="interrupted">Interrupt</button>
1174+
</footer>
1175+
</article>
1176+
"""
1177+
end
1178+
10931179
defp generate_heex(%{"type" => "confidence_indicator"} = iur, _opts) do
10941180
props = iur["props"] || %{}
10951181

@@ -3147,6 +3233,24 @@ defmodule AshUI.Rendering.LiveUIAdapter do
31473233

31483234
defp html_attr(value), do: html_escape(value)
31493235

3236+
defp adapter_duration_label(started_at) when is_binary(started_at) do
3237+
case DateTime.from_iso8601(started_at) do
3238+
{:ok, dt, _offset} ->
3239+
diff_seconds = max(DateTime.diff(DateTime.utc_now(), dt, :second), 0)
3240+
3241+
cond do
3242+
diff_seconds < 60 -> "#{diff_seconds}s"
3243+
diff_seconds < 3_600 -> "#{div(diff_seconds, 60)}m"
3244+
true -> "#{div(diff_seconds, 3_600)}h"
3245+
end
3246+
3247+
_ ->
3248+
"running"
3249+
end
3250+
end
3251+
3252+
defp adapter_duration_label(_started_at), do: "running"
3253+
31503254
defp html_escape(nil), do: ""
31513255

31523256
defp html_escape(value) do

‎packages/live_ui/lib/live_ui/renderer.ex‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,58 @@ defmodule LiveUi.Renderer do
495495
"""
496496
end
497497

498+
# NOTE: `:live_session_card` is a canonical component kind, so keep this native
499+
# renderer clause before the generic `@component_kinds` fallback.
500+
def render(%{element: %Element{kind: :live_session_card}} = assigns) do
501+
live_session = get_in(assigns.element.attributes, [:live_session]) || %{}
502+
event_target = Map.get(assigns, :event_target)
503+
504+
assigns =
505+
assigns
506+
|> assign(:style_attrs, style_rest(assigns.element))
507+
|> assign(:live_session, live_session)
508+
|> assign(:recent_events, list_value(map_value(live_session, :recent_events, [])))
509+
|> assign(
510+
:pin_attrs,
511+
command_interaction_attrs(assigns.element, event_target, :pin_toggled)
512+
)
513+
|> assign(
514+
:interrupt_attrs,
515+
command_interaction_attrs(assigns.element, event_target, :interrupted)
516+
)
517+
|> assign(
518+
:recent_attrs,
519+
command_interaction_attrs(assigns.element, event_target, :expanded_recent)
520+
)
521+
522+
~H"""
523+
<LiveUi.Widgets.LiveSessionCard.component
524+
id={element_id(@element, "live-session-card")}
525+
session_id={string_value(map_value(@live_session, :session_id), "")}
526+
actor_handle={string_value(map_value(@live_session, :actor_handle), "")}
527+
status={map_value(@live_session, :status, :running)}
528+
status_version={integer_value(map_value(@live_session, :status_version), 0)}
529+
tools_count={integer_value(map_value(@live_session, :tools_count), 0)}
530+
edits_count={integer_value(map_value(@live_session, :edits_count), 0)}
531+
tokens_consumed={integer_value(map_value(@live_session, :tokens_consumed), 0)}
532+
started_at={map_value(@live_session, :started_at)}
533+
current_step={string_optional(map_value(@live_session, :current_step))}
534+
current_task_title={string_optional(map_value(@live_session, :current_task_title))}
535+
now_streaming={string_optional(map_value(@live_session, :now_streaming))}
536+
recent_events={@recent_events}
537+
pinned?={boolean_default(map_value(@live_session, :pinned?), false)}
538+
pin_attrs={@pin_attrs}
539+
interrupt_attrs={@interrupt_attrs}
540+
recent_attrs={@recent_attrs}
541+
tone={style_tone(@element)}
542+
variant={theme_variant(@element)}
543+
state={style_state(@element)}
544+
class={style_class(@element)}
545+
{@style_attrs}
546+
/>
547+
"""
548+
end
549+
498550
# NOTE: `:workflow_progress_status_card` is a canonical component kind, so keep this native
499551
# renderer clause before the generic `@component_kinds` fallback.
500552
def render(%{element: %Element{kind: :workflow_progress_status_card}} = assigns) do
@@ -2083,6 +2135,36 @@ defmodule LiveUi.Renderer do
20832135
end
20842136
end
20852137

2138+
defp command_interaction_attrs(%Element{} = element, event_target, command) do
2139+
case {command_interaction(element, command), event_target} do
2140+
{%Interaction{} = interaction, target} when not is_nil(target) ->
2141+
%{
2142+
:"phx-click" => "canonical_interaction",
2143+
:"phx-target" => target,
2144+
:"phx-value-interaction" => encode_interaction(interaction),
2145+
:"phx-value-element_id" => element_id(element, Atom.to_string(element.kind)),
2146+
:"phx-value-widget" => Atom.to_string(element.kind),
2147+
:"phx-value-command" => to_string(command)
2148+
}
2149+
2150+
_ ->
2151+
%{}
2152+
end
2153+
end
2154+
2155+
defp command_interaction(%Element{} = element, command) do
2156+
element.attributes
2157+
|> Map.get(:interactions, [])
2158+
|> List.wrap()
2159+
|> Enum.find(fn
2160+
%Interaction{family: :command, payload: payload} ->
2161+
to_string(map_value(payload, :command)) == to_string(command)
2162+
2163+
_interaction ->
2164+
false
2165+
end)
2166+
end
2167+
20862168
defp primary_change_interaction(%Element{} = element) do
20872169
primary_interaction(element, :change)
20882170
end
@@ -2948,6 +3030,9 @@ defmodule LiveUi.Renderer do
29483030
defp map_value(list, key, default) when is_list(list), do: Keyword.get(list, key, default)
29493031
defp map_value(_other, _key, default), do: default
29503032

3033+
defp list_value(value) when is_list(value), do: value
3034+
defp list_value(_value), do: []
3035+
29513036
defp normalize_key(nil), do: "panel"
29523037

29533038
defp normalize_key(value) do

0 commit comments

Comments
 (0)