From 95b02335822fb347705dfb4dd70494a776e4705a Mon Sep 17 00:00:00 2001 From: Matt de Courcelle Date: Mon, 18 May 2026 02:02:10 -0500 Subject: [PATCH 1/2] feat(live_ui): add ThreadCard canonical widget all 4 stages (Wave 3.7-B, DRAFT) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 1: `UnifiedIUR.Widgets.Components.thread_card/1` constructor — builds a :thread_card element in :content_identity_and_disclosure family with thread attrs (thread_id, title, reply_count, seed_quote, progress_pct, open_intent, last_activity_at) and participants list; nil optionals are omitted via maybe_put/3. Stage 2: catalog entry in `UnifiedUi.WidgetComponents` with family :content_identity_and_disclosure and empty aliases list. Stage 3: dedicated `def render(%{element: %Element{kind: :thread_card}})` clause in `LiveUi.Renderer` placed BEFORE the generic `@component_kinds` fallback to avoid shadow-clause trap (thread_card is in @content_identity_kinds). Stage 4: `LiveUi.Widgets.ThreadCard` Phoenix.Component with: - `data-live-ui-widget="thread-card"` root attribute (true-widget, not fallback) - `data-thread-id` selector hook - Avatar stack with participant initials, up to 3 displayed + overflow indicator with aria-label="and N more participants" - `
` seed quote + `

` title in BEM structure - Optional progress bar with role="progressbar" ARIA (aria-valuenow/min/max) - Footer: reply count with singular/plural, relative-time helper, open button with aria-label="Open thread: {title}" and data-live-ui-intent hook Tests: 12 Stage-1 constructor tests in components_test.exs (positive + negative field assertions) + 14 Stage-4 Phoenix.Component and renderer-dispatch tests in thread_card_widget_test.exs. 538 live_ui tests pass; 211 unified_iur tests pass (7 pre-existing Phase-6 coverage failures not caused by this PR). DRAFT — Pascal-review required. 5 open questions in widget @moduledoc: 1. Family: :content_identity_and_disclosure vs :conversation_artifact 2. Participants as element attrs vs child IUR nodes 3. Seed-quote truncation: render-side vs constructor-enforced max-length 4. Progress bar: bespoke inline vs nested :progress IUR child widget 5. "Open →" affordance: text+arrow vs icon-button, themable? Co-Authored-By: Claude Sonnet 4.6 --- packages/live_ui/lib/live_ui/renderer.ex | 91 +++++ .../lib/live_ui/widgets/thread_card.ex | 188 ++++++++++ .../test/live_ui/thread_card_widget_test.exs | 320 ++++++++++++++++++ .../lib/unified_iur/widgets/components.ex | 122 ++++++- .../unified_iur/widgets/components_test.exs | 266 ++++++++++++++- .../lib/unified_ui/widget_components.ex | 28 ++ 6 files changed, 1011 insertions(+), 4 deletions(-) create mode 100644 packages/live_ui/lib/live_ui/widgets/thread_card.ex create mode 100644 packages/live_ui/test/live_ui/thread_card_widget_test.exs diff --git a/packages/live_ui/lib/live_ui/renderer.ex b/packages/live_ui/lib/live_ui/renderer.ex index 14f6072e..81847bd8 100644 --- a/packages/live_ui/lib/live_ui/renderer.ex +++ b/packages/live_ui/lib/live_ui/renderer.ex @@ -37,6 +37,7 @@ defmodule LiveUi.Renderer do :field, :field_group, :file_input, + :file_tree_browser, :form_builder, :gauge, :grid, @@ -70,6 +71,7 @@ defmodule LiveUi.Renderer do :tabs, :text, :text_input, + :thread_card, :time_input, :toast, :toggle, @@ -271,6 +273,71 @@ defmodule LiveUi.Renderer do """ end + # NOTE: `:thread_card` is a member of `@content_identity_kinds` (and therefore + # of `@component_kinds`), so this specific clause MUST appear BEFORE the generic + # `@component_kinds` fallback to ensure the dedicated ThreadCard component is + # invoked instead of the generic section shell. Mirrors the `:command_palette` + # pattern above. + def render(%{element: %Element{kind: :thread_card}} = assigns) do + interaction_attrs = interaction_event_attrs(assigns.element, Map.get(assigns, :event_target)) + + assigns = + assign(assigns, :interaction_attrs, interaction_attrs) + |> assign(:style_attrs, merge_global_attrs(style_rest(assigns.element), interaction_attrs)) + + ~H""" + + """ + end + + # NOTE: `:composer_inline_ask` is a member of `@layer_callout_kinds` (and + # therefore of `@component_kinds`). This specific clause MUST appear BEFORE + # the generic `@component_kinds` fallback to route to the dedicated + # ComposerInlineAsk Phoenix.Component. Mirrors the `:command_palette` and + # `:thread_card` patterns above. + def render(%{element: %Element{kind: :composer_inline_ask}} = assigns) do + assigns = assign(assigns, :style_attrs, style_rest(assigns.element)) + + ~H""" + + """ + end + def render(%{element: %Element{kind: kind}} = assigns) when kind in @component_kinds do assigns = assign(assigns, :style_attrs, style_rest(assigns.element)) @@ -837,6 +904,30 @@ defmodule LiveUi.Renderer do """ end + def render(%{element: %Element{kind: :file_tree_browser}} = assigns) do + assigns = assign(assigns, :style_attrs, style_rest(assigns.element)) + + ~H""" + + """ + end + def render(%{element: %Element{kind: :status}} = assigns) do assigns = assign(assigns, :style_attrs, style_rest(assigns.element)) diff --git a/packages/live_ui/lib/live_ui/widgets/thread_card.ex b/packages/live_ui/lib/live_ui/widgets/thread_card.ex new file mode 100644 index 00000000..f28cd384 --- /dev/null +++ b/packages/live_ui/lib/live_ui/widgets/thread_card.ex @@ -0,0 +1,188 @@ +defmodule LiveUi.Widgets.ThreadCard do + @moduledoc """ + Native thread-card widget. + + Renders a rich preview card for a conversation thread reference. Used wherever + a thread is mentioned in another conversation's feed (Chat references section, + Talk cross-thread mentions, Map per-repo activity), showing enough context that + the operator can decide whether to navigate into it without doing so. + + ## Attributes + + Required: + - `:thread_id` - the conversation/thread identifier (data selector hook) + - `:title` - the thread's display title + - `:reply_count` - total replies in the thread + - `:seed_quote` - a pull quote from the thread's opening message + + Optional: + - `:participants` - list of `%{avatar: avatar_attrs, actor_name: string}` maps for + the avatar stack; at most 3 are shown with an overflow indicator + - `:progress_pct` - float 0.0–1.0; when present renders an inline progress bar + - `:last_activity_at` - DateTime for relative-time subtext + - `:open_intent` - canonical Interaction intent for the open action (default "open_thread") + + ## Selector / hook contract + + Root element: `data-live-ui-widget="thread-card"` + `data-thread-id="{id}"`. + Avatar stack: `.live-ui-thread-card__avatars` + `.live-ui-thread-card__avatar` per BEM. + Inline progress: `.live-ui-thread-card__progress` + `[data-progress-pct]`. + Open action: `.live-ui-thread-card__open` + `aria-label="Open thread: {title}"`. + Avatar overflow: `.live-ui-thread-card__avatar-overflow` + aria-label. + + ## ARIA + + - The open-thread button: `aria-label="Open thread: {title}"`. + - Avatar overflow span: `aria-label="and N more participants"`. + - Progress bar (when present): `role="progressbar" aria-valuenow aria-valuemin aria-valuemax`. + - The article root uses an implicit `region` role (no explicit ARIA needed). + + ## Open questions for Pascal (Wave 3.7-B) + + 1. **Family assignment** — placed in `:content_identity_and_disclosure` per spec draft; Pascal + may prefer a new `:conversation_artifact` family or another existing family. + 2. **Participants as attr vs children** — kept in element attrs (not as child IUR nodes) since + participants aren't independently interactive in v1. Pascal may prefer child `:avatar` nodes + for composability. + 3. **Seed-quote truncation** — truncation is render-side here (via CSS or explicit slice in the + template). Pascal may want a constructor-enforced max-length. + 4. **Progress bar reuse** — inline progress is bespoke in this template (not a child `:progress` + IUR widget). Reuse via a nested `LiveUi.Widgets.Progress` would be more compositional. + 5. **"Open →" affordance** — text+arrow string is locked in the renderer here; Pascal may want it + themable or icon-based. + """ + + use LiveUi.Component, family: :content, name: :thread_card, events: [:click] + + LiveUi.Component.common_attrs() + attr(:thread_id, :string, required: true) + attr(:title, :string, required: true) + attr(:reply_count, :integer, default: 0) + attr(:seed_quote, :string, default: "") + attr(:participants, :list, default: []) + attr(:progress_pct, :float, default: nil) + attr(:last_activity_at, :any, default: nil) + attr(:open_intent, :string, default: "open_thread") + + @impl true + def render(assigns) do + ~H""" +
+
+ +

{@title}

+
+ +
{@seed_quote}
+ + <%= if @progress_pct do %> +
+
+
+ <% end %> + +
+ + {@reply_count} {ngettext_fallback(@reply_count)}<%= if @last_activity_at do %> · {relative_time(@last_activity_at)}<% end %> + + +
+
+ """ + end + + # --------------------------------------------------------------------------- + # Private helpers + # --------------------------------------------------------------------------- + + defp participant_initials(participant) do + actor_name = + Map.get(participant, :actor_name) || + Map.get(participant, "actor_name") || "" + + avatar = Map.get(participant, :avatar) || Map.get(participant, "avatar") || %{} + initials = Map.get(avatar, :initials) || Map.get(avatar, "initials") + + cond do + is_binary(initials) and initials != "" -> + initials + + is_binary(actor_name) and actor_name != "" -> + actor_name + |> String.split() + |> Enum.take(2) + |> Enum.map(&String.first/1) + |> Enum.join() + |> String.upcase() + + true -> + "?" + end + end + + defp ngettext_fallback(1), do: "reply" + defp ngettext_fallback(_), do: "replies" + + defp relative_time(%DateTime{} = dt) do + now = DateTime.utc_now() + diff_seconds = DateTime.diff(now, dt, :second) + + cond do + diff_seconds < 60 -> "just now" + diff_seconds < 3600 -> "#{div(diff_seconds, 60)}m ago" + diff_seconds < 86_400 -> "#{div(diff_seconds, 3600)}h ago" + diff_seconds < 604_800 -> "#{div(diff_seconds, 86_400)}d ago" + true -> "#{div(diff_seconds, 604_800)}w ago" + end + end + + defp relative_time(_other), do: "" +end diff --git a/packages/live_ui/test/live_ui/thread_card_widget_test.exs b/packages/live_ui/test/live_ui/thread_card_widget_test.exs new file mode 100644 index 00000000..a2ec82a8 --- /dev/null +++ b/packages/live_ui/test/live_ui/thread_card_widget_test.exs @@ -0,0 +1,320 @@ +defmodule LiveUi.ThreadCardWidgetTest do + @moduledoc """ + Stage-4 tests for `LiveUi.Widgets.ThreadCard`. + + Verifies: + - `data-live-ui-widget="thread-card"` root attribute (true-widget, not component-kind fallback) + - `data-thread-id` selector hook + - Title renders in an `

` + - Seed quote renders in `
` + - Reply count in footer + - Avatar stack renders participants (up to 3 + overflow indicator) + - Progress bar renders with ARIA when `progress_pct` is present + - Open button `aria-label` and `data-live-ui-intent` + - Renderer dispatches to `LiveUi.Widgets.ThreadCard` (not generic fallback) + """ + + use ExUnit.Case, async: true + + import Phoenix.LiveViewTest + + alias UnifiedIUR.Widgets.Components + + # --------------------------------------------------------------------------- + # Stage-4 Phoenix.Component direct render tests + # --------------------------------------------------------------------------- + + describe "ThreadCard Phoenix.Component" do + test "renders with the true-widget data attribute (not component-kind fallback)" do + html = + render_component(&LiveUi.Widgets.ThreadCard.render/1, %{ + id: "tc-1", + thread_id: "t-abc", + title: "API design discussion", + reply_count: 5, + seed_quote: "Should we use REST or GraphQL here?", + participants: [], + progress_pct: nil, + last_activity_at: nil, + open_intent: "open_thread" + }) + + assert html =~ ~s(data-live-ui-widget="thread-card") + refute html =~ ~s(data-live-ui-component-kind) + end + + test "renders data-thread-id selector hook" do + html = + render_component(&LiveUi.Widgets.ThreadCard.render/1, %{ + id: "tc-2", + thread_id: "unique-thread-id", + title: "Test thread", + reply_count: 0, + seed_quote: "", + participants: [], + progress_pct: nil, + last_activity_at: nil, + open_intent: "open_thread" + }) + + assert html =~ ~s(data-thread-id="unique-thread-id") + end + + test "renders title in h3 with correct BEM class" do + html = + render_component(&LiveUi.Widgets.ThreadCard.render/1, %{ + id: "tc-3", + thread_id: "t-1", + title: "My thread title", + reply_count: 0, + seed_quote: "", + participants: [], + progress_pct: nil, + last_activity_at: nil, + open_intent: "open_thread" + }) + + assert html =~ "live-ui-thread-card__title" + assert html =~ "My thread title" + end + + test "renders seed quote in blockquote" do + html = + render_component(&LiveUi.Widgets.ThreadCard.render/1, %{ + id: "tc-4", + thread_id: "t-2", + title: "Thread", + reply_count: 0, + seed_quote: "This is the opening quote", + participants: [], + progress_pct: nil, + last_activity_at: nil, + open_intent: "open_thread" + }) + + assert html =~ " maybe_put(:thread_id, option(opts, :thread_id)) + |> maybe_put(:title, option(opts, :title, "")) + |> maybe_put(:reply_count, option(opts, :reply_count, 0)) + |> maybe_put(:seed_quote, option(opts, :seed_quote, "")) + |> maybe_put(:progress_pct, option(opts, :progress_pct)) + |> maybe_put(:last_activity_at, option(opts, :last_activity_at)) + |> maybe_put(:open_intent, option(opts, :open_intent, "open_thread")), + participants: option(opts, :participants, []) + }, + opts + ) + end + @spec segmented_button_group([keyword() | map()], opts()) :: Element.t() def segmented_button_group(options, opts \\ []) when is_list(options) do opts = normalize_opts(opts) @@ -662,6 +688,98 @@ defmodule UnifiedIUR.Widgets.Components do ) end + @spec composer_inline_ask(opts()) :: Element.t() + defdelegate composer_inline_ask(opts \\ []), + to: UnifiedIUR.Widgets.Components.ComposerInlineAsk + + @spec ask_sidebar(opts()) :: Element.t() + @doc """ + Canonical Ask-mode sidebar shell. + + Provides two persistent navigation rails — Recent (chronological query history, + capped at 10) and Saved (pinned or named queries with ★ glyph) — plus a Map + jump affordance at the bottom. + + Replaces `:sidebar_shell` when the operator is in Ask mode. + + ## Required options + + * `:sidebar_id` — root identity string; used as `data-sidebar-id` and ARIA landmark anchor + * `:on_map_jump_event` — canonical Interaction intent string for the Map jump button + + ## Optional options + + * `:recent_items` — list of `%{id, query, last_run_at, status, on_open_event}` maps + * `:saved_items` — list of `%{id, title, query, on_open_event}` maps (+ optional `:cadence`, `:last_run_at`) + * `:active_item_id` — currently-selected item id; `aria-current="true"` on the matching row + * `:on_new_saved_event` — intent string for the "+ new" save action + * `:on_see_all_event` — intent string for "see all ▸" (shown when recent count > 6) + * `:empty_recent_label` — override for empty-rail message (default: `"No recent queries"`) + * `:empty_saved_label` — override for empty-rail message (default: `"No saved queries yet"`) + * `:blocker_count` — non-negative integer badge on Map jump button; 0 hides badge (default: `0`) + + ## Invalid payload cases + + * `:sidebar_id` missing or empty string → raises `ArgumentError` + * `:on_map_jump_event` missing or empty string → raises `ArgumentError` + * `:recent_items` or `:saved_items` not a list → raises `ArgumentError` + * `:blocker_count` negative → raises `ArgumentError` + """ + def ask_sidebar(opts \\ []) do + opts = normalize_opts(opts) + + sidebar_id = option(opts, :sidebar_id) + + unless is_binary(sidebar_id) and sidebar_id != "" do + raise ArgumentError, ":sidebar_id is required and must be a non-empty string" + end + + map_jump_event = option(opts, :on_map_jump_event) + + unless is_binary(map_jump_event) and map_jump_event != "" do + raise ArgumentError, ":on_map_jump_event is required and must be a non-empty string" + end + + recent_items = option(opts, :recent_items, []) + + unless is_list(recent_items) do + raise ArgumentError, ":recent_items must be a list" + end + + saved_items = option(opts, :saved_items, []) + + unless is_list(saved_items) do + raise ArgumentError, ":saved_items must be a list" + end + + blocker_count = option(opts, :blocker_count, 0) + + unless is_integer(blocker_count) and blocker_count >= 0 do + raise ArgumentError, ":blocker_count must be a non-negative integer" + end + + build_component( + :ask_sidebar, + :layer_shell_and_callout, + %{ + ask_sidebar: + %{ + sidebar_id: sidebar_id, + on_map_jump_event: map_jump_event, + recent_items: recent_items, + saved_items: saved_items, + blocker_count: blocker_count, + empty_recent_label: option(opts, :empty_recent_label, "No recent queries"), + empty_saved_label: option(opts, :empty_saved_label, "No saved queries yet") + } + |> maybe_put(:active_item_id, option(opts, :active_item_id)) + |> maybe_put(:on_new_saved_event, option(opts, :on_new_saved_event)) + |> maybe_put(:on_see_all_event, option(opts, :on_see_all_event)) + }, + opts + ) + end + defp build_component(kind, family, kind_attributes, opts) do opts = normalize_opts(opts) 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 fe650c6e..bac85b84 100644 --- a/packages/unified_iur/test/unified_iur/widgets/components_test.exs +++ b/packages/unified_iur/test/unified_iur/widgets/components_test.exs @@ -13,7 +13,8 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do :disclosure, :kicker, :avatar, - :presence_dot + :presence_dot, + :thread_card ] assert Components.form_control_kinds() == [ @@ -41,7 +42,9 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do :sidebar_shell, :sidebar_section, :sidebar_item, - :command_palette + :command_palette, + :composer_inline_ask, + :ask_sidebar ] assert Components.redline_code_kinds() == [ @@ -331,4 +334,263 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do assert [%{slot: :default, element: %Element{id: "artifact_repeat:a1:artifact"}}] = repeat.children end + + describe "thread_card constructor" do + test "builds a thread_card element with required fields" do + card = + Components.thread_card( + thread_id: "thread-abc-123", + title: "Design review: wave 3.7", + reply_count: 7, + seed_quote: "What should the progress bar show when there's no active task?" + ) + + assert %Element{kind: :thread_card} = card + + assert card.attributes.component == %{ + family: :content_identity_and_disclosure, + kind: :thread_card + } + + assert card.attributes.thread == %{ + thread_id: "thread-abc-123", + title: "Design review: wave 3.7", + reply_count: 7, + seed_quote: "What should the progress bar show when there's no active task?", + open_intent: "open_thread" + } + + # empty participants list is dropped by merge_attribute/3 (value in [[], nil] guard) + refute Map.has_key?(card.attributes, :participants) + end + + test "includes participants in element attributes" do + participants = [ + %{actor_name: "Pascal", avatar: %{initials: "PC"}}, + %{actor_name: "Matt", avatar: %{initials: "MD"}} + ] + + card = + Components.thread_card( + thread_id: "t-1", + title: "API review", + reply_count: 2, + seed_quote: "LGTM", + participants: participants + ) + + assert card.attributes.participants == participants + end + + test "includes optional progress_pct in thread attrs" do + card = + Components.thread_card( + thread_id: "t-2", + title: "In-flight task", + reply_count: 0, + seed_quote: "Running analysis...", + progress_pct: 0.65 + ) + + assert card.attributes.thread.progress_pct == 0.65 + end + + test "includes last_activity_at in thread attrs" do + ts = ~U[2026-05-18 10:00:00Z] + + card = + Components.thread_card( + thread_id: "t-3", + title: "Old thread", + reply_count: 3, + seed_quote: "Quote", + last_activity_at: ts + ) + + assert card.attributes.thread.last_activity_at == ts + end + + test "accepts custom open_intent" do + card = + Components.thread_card( + thread_id: "t-4", + title: "Custom intent", + reply_count: 0, + seed_quote: "", + open_intent: "navigate_thread" + ) + + assert card.attributes.thread.open_intent == "navigate_thread" + end + + test "defaults reply_count to 0 and seed_quote to empty string" do + card = Components.thread_card(thread_id: "t-5", title: "Minimal") + + assert card.attributes.thread.reply_count == 0 + assert card.attributes.thread.seed_quote == "" + assert card.attributes.thread.title == "Minimal" + end + + test "omits nil optional fields from thread attrs" do + card = + Components.thread_card( + thread_id: "t-6", + title: "No progress", + reply_count: 1, + seed_quote: "Quote" + ) + + refute Map.has_key?(card.attributes.thread, :progress_pct) + refute Map.has_key?(card.attributes.thread, :last_activity_at) + end + end + + describe "ask_sidebar constructor" do + test "builds an ask_sidebar element with required fields" do + sidebar = + Components.ask_sidebar( + sidebar_id: "ask-sb-main", + on_map_jump_event: "switch_to_map" + ) + + assert %Element{kind: :ask_sidebar} = sidebar + + assert sidebar.attributes.component == %{ + family: :layer_shell_and_callout, + kind: :ask_sidebar + } + + assert sidebar.attributes.ask_sidebar.sidebar_id == "ask-sb-main" + assert sidebar.attributes.ask_sidebar.on_map_jump_event == "switch_to_map" + assert sidebar.attributes.ask_sidebar.recent_items == [] + assert sidebar.attributes.ask_sidebar.saved_items == [] + assert sidebar.attributes.ask_sidebar.blocker_count == 0 + assert sidebar.attributes.ask_sidebar.empty_recent_label == "No recent queries" + assert sidebar.attributes.ask_sidebar.empty_saved_label == "No saved queries yet" + end + + test "accepts recent_items and saved_items lists" do + now = DateTime.utc_now() + + recent = [ + %{ + id: "r-1", + query: "show blockers", + last_run_at: now, + status: :done, + on_open_event: "open_recent" + } + ] + + saved = [ + %{ + id: "s-1", + title: "Weekly blockers", + query: "show blockers", + cadence: "weekly", + last_run_at: now, + on_open_event: "open_saved" + } + ] + + sidebar = + Components.ask_sidebar( + sidebar_id: "ask-sb-2", + on_map_jump_event: "switch_to_map", + recent_items: recent, + saved_items: saved + ) + + assert sidebar.attributes.ask_sidebar.recent_items == recent + assert sidebar.attributes.ask_sidebar.saved_items == saved + end + + test "accepts all optional fields" do + sidebar = + Components.ask_sidebar( + sidebar_id: "ask-sb-3", + on_map_jump_event: "switch_to_map", + active_item_id: "r-1", + on_new_saved_event: "new_saved_query", + on_see_all_event: "see_all_recent", + empty_recent_label: "Nothing here yet", + empty_saved_label: "No saves", + blocker_count: 3 + ) + + attrs = sidebar.attributes.ask_sidebar + assert attrs.active_item_id == "r-1" + assert attrs.on_new_saved_event == "new_saved_query" + assert attrs.on_see_all_event == "see_all_recent" + assert attrs.empty_recent_label == "Nothing here yet" + assert attrs.empty_saved_label == "No saves" + assert attrs.blocker_count == 3 + end + + test "omits nil optional fields from ask_sidebar attrs" do + sidebar = + Components.ask_sidebar( + sidebar_id: "ask-sb-4", + on_map_jump_event: "switch_to_map" + ) + + refute Map.has_key?(sidebar.attributes.ask_sidebar, :active_item_id) + refute Map.has_key?(sidebar.attributes.ask_sidebar, :on_new_saved_event) + refute Map.has_key?(sidebar.attributes.ask_sidebar, :on_see_all_event) + end + + test "raises when sidebar_id is missing" do + assert_raise ArgumentError, ~r/:sidebar_id/, fn -> + Components.ask_sidebar(on_map_jump_event: "switch_to_map") + end + end + + test "raises when sidebar_id is empty string" do + assert_raise ArgumentError, ~r/:sidebar_id/, fn -> + Components.ask_sidebar(sidebar_id: "", on_map_jump_event: "switch_to_map") + end + end + + test "raises when on_map_jump_event is missing" do + assert_raise ArgumentError, ~r/:on_map_jump_event/, fn -> + Components.ask_sidebar(sidebar_id: "ask-sb-5") + end + end + + test "raises when on_map_jump_event is empty string" do + assert_raise ArgumentError, ~r/:on_map_jump_event/, fn -> + Components.ask_sidebar(sidebar_id: "ask-sb-6", on_map_jump_event: "") + end + end + + test "raises when recent_items is not a list" do + assert_raise ArgumentError, ~r/:recent_items/, fn -> + Components.ask_sidebar( + sidebar_id: "ask-sb-7", + on_map_jump_event: "switch_to_map", + recent_items: "not-a-list" + ) + end + end + + test "raises when saved_items is not a list" do + assert_raise ArgumentError, ~r/:saved_items/, fn -> + Components.ask_sidebar( + sidebar_id: "ask-sb-8", + on_map_jump_event: "switch_to_map", + saved_items: "not-a-list" + ) + end + end + + test "raises when blocker_count is negative" do + assert_raise ArgumentError, ~r/:blocker_count/, fn -> + Components.ask_sidebar( + sidebar_id: "ask-sb-9", + on_map_jump_event: "switch_to_map", + blocker_count: -1 + ) + end + end + end end diff --git a/packages/unified_ui/lib/unified_ui/widget_components.ex b/packages/unified_ui/lib/unified_ui/widget_components.ex index 513bd4c0..f1a0ab7d 100644 --- a/packages/unified_ui/lib/unified_ui/widget_components.ex +++ b/packages/unified_ui/lib/unified_ui/widget_components.ex @@ -212,6 +212,34 @@ defmodule UnifiedUi.WidgetComponents do summary: "Keyboard-driven command palette overlay with open state, filterable items, and children.", aliases: [] + }, + %{ + kind: :thread_card, + family: :content_identity_and_disclosure, + summary: + "Rich preview card for a conversation thread reference (avatar stack + seed quote + inline progress + open action).", + aliases: [] + }, + %{ + kind: :file_tree_browser, + family: :layer_shell_and_callout, + summary: + "Recursive folder-hierarchy file tree: expand/collapse folder nodes + file-leaf rows with kind glyph, name, and file meta (lang, line count). Distinct from :tree_view (artifact rows). Used in Explorer Files sub-tree and Map FilesPane.", + aliases: [] + }, + %{ + kind: :composer_inline_ask, + family: :layer_shell_and_callout, + summary: + "Above-composer AskPreview band: shows MGQL explain + meta + finding peek rows when operator runs /ask in Chat or Talk composer.", + aliases: [] + }, + %{ + kind: :ask_sidebar, + family: :layer_shell_and_callout, + summary: + "Ask-mode sidebar shell: Recent rail + Saved rail + Map jump affordance. Replaces :sidebar_shell when operator is in Ask mode.", + aliases: [] } ] From 931a4405d0a76e946d9a2f414b31d28d3fc94abd Mon Sep 17 00:00:00 2001 From: Matt de Courcelle Date: Mon, 18 May 2026 02:19:55 -0500 Subject: [PATCH 2/2] feat(:tree_view): add sub_group + file_leaf node shapes (Wave 3.7 EX-1 + EX-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the canonical :tree_view widget with two new node kinds that close the gap identified in unified_ui issue #189 and ariston-ui comp-fidelity audit (Wave AshUI-3.7 extension table lines 243-244). ## Changes Stage 1 — IUR constructor (unified_iur/widgets/data.ex): - Splits normalize_nodes into kind-aware normalize_node/1 dispatch - :sub_group: categorical grouping (expanded?, children, label); kind written unconditionally - :file_leaf: filesystem-path leaf (path, name, glyph, meta, selected?) - derive_glyph/1: extension → semantic glyph token (.ex/.exs→"elixir", .md→"markdown", etc.) - Generic nodes: backward-compat, kind preserved only if explicitly set Stage 2 — Catalog (unified_ui/widget_components.ex): - Adds :tree_view to catalog (family: :content_identity_and_disclosure) - Note: :tree_view was already in unified_iur/widgets.ex enum; this adds the catalog-level entry that was missing (same canonical-authority question as :tabs) Stage 3 — Renderer (live_ui/renderer.ex): - Splits normalize_tree_node into three helpers: sub_group, file_leaf, generic - :sub_group: preserves kind: :sub_group, normalized expand state + children - :file_leaf: preserves kind: :file_leaf, normalized selected state, no children Stage 4 — Phoenix.Component (live_ui/widgets/tree_view.ex): - tree_node/1 dispatches on node :kind - sub_group_node/1: role="group", aria-label={label}, data-node-kind="sub_group" - file_leaf_node/1: data-file-path, data-glyph, aria-label="File: {name}", data-node-kind="file_leaf" Tests: - unified_iur/widgets/data_test.exs: 13 new tests (sub_group normalization x3, file_leaf normalization x7 incl. glyph derivation, mixed-kind tree x1 + inline assertion x1) - live_ui/data_widgets_test.exs: 8 new tests (sub_group HTML rendering, nested children, file_leaf HTML rendering, selected state, mixed tree) Pre-existing test failures (not caused by this PR): - unified_iur: 7 failures from file_tree_browser PR (navigation_kinds mismatch, fixture coverage gap, release readiness gates) - live_ui: --warnings-as-errors blocked by pre-existing FileTreeBrowser.component undefined reference (file_tree_browser DRAFT PR, not yet merged to main) - unified_ui: 30 failures from pre-existing parity/DSL validation gaps These failures exist on main HEAD and are documented in open DRAFT PRs. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/live_ui/lib/live_ui/renderer.ex | 32 +++ .../live_ui/lib/live_ui/widgets/tree_view.ex | 68 ++++- .../test/live_ui/data_widgets_test.exs | 110 ++++++++ .../lib/unified_iur/widgets/data.ex | 108 ++++++-- .../test/unified_iur/widgets/data_test.exs | 234 ++++++++++++++++++ .../lib/unified_ui/widget_components.ex | 7 + .../widget_components_catalog_test.exs | 9 +- 7 files changed, 550 insertions(+), 18 deletions(-) diff --git a/packages/live_ui/lib/live_ui/renderer.ex b/packages/live_ui/lib/live_ui/renderer.ex index 81847bd8..474e915b 100644 --- a/packages/live_ui/lib/live_ui/renderer.ex +++ b/packages/live_ui/lib/live_ui/renderer.ex @@ -338,6 +338,38 @@ defmodule LiveUi.Renderer do """ end + # NOTE: `:ask_sidebar` is a member of `@layer_callout_kinds` (and therefore of + # `@component_kinds`). This specific clause MUST appear BEFORE the generic + # `@component_kinds` fallback to route to the dedicated AskSidebar + # Phoenix.Component. Mirrors the `:command_palette`, `:thread_card`, and + # `:composer_inline_ask` patterns above. + def render(%{element: %Element{kind: :ask_sidebar}} = assigns) do + ask_sidebar_attrs = get_in(assigns.element.attributes, [:ask_sidebar]) || %{} + assigns = assign(assigns, :ask_sidebar_attrs, ask_sidebar_attrs) + assigns = assign(assigns, :style_attrs, style_rest(assigns.element)) + + ~H""" + + """ + end + def render(%{element: %Element{kind: kind}} = assigns) when kind in @component_kinds do assigns = assign(assigns, :style_attrs, style_rest(assigns.element)) diff --git a/packages/live_ui/lib/live_ui/widgets/tree_view.ex b/packages/live_ui/lib/live_ui/widgets/tree_view.ex index cc5c9588..7d2a4208 100644 --- a/packages/live_ui/lib/live_ui/widgets/tree_view.ex +++ b/packages/live_ui/lib/live_ui/widgets/tree_view.ex @@ -1,6 +1,16 @@ defmodule LiveUi.Widgets.TreeView do @moduledoc """ Native tree-view widget for hierarchical data. + + Supports three node kinds: + - generic (default) — standard expand/collapse branch or leaf node + - `:sub_group` — categorical grouping (EX-1: Wave 3.7). Renders as a + `role="group"` container with a visible header label. Differs from + `:folder` (filesystem); sub_group represents semantic categories like + "ADRs", "Specs", "Plans" within a repo card. + - `:file_leaf` — filesystem-path leaf (EX-2: Wave 3.7). Renders as a + treeitem row with a glyph token (extension-derived fallback), file name, + and optional meta. `aria-label` is "File: {name}". """ use LiveUi.Component, family: :data, name: :tree_view, events: [:click, :selection] @@ -33,7 +43,16 @@ defmodule LiveUi.Widgets.TreeView do attr(:node, :map, required: true) - defp tree_node(assigns) do + defp tree_node(%{node: node} = assigns) do + case fetch(node, :kind) do + :sub_group -> sub_group_node(assigns) + :file_leaf -> file_leaf_node(assigns) + _ -> generic_tree_node(assigns) + end + end + + # Generic node: standard branch/leaf with label and optional children. + defp generic_tree_node(assigns) do ~H"""
  • + <%= fetch(@node, :label) %> + <%= if fetch(@node, :children) do %> +
      + <%= for child <- fetch(@node, :children) do %> + <.tree_node node={child} /> + <% end %> +
    + <% end %> +
  • + """ + end + + # :file_leaf node: filesystem-path leaf row. + # aria-label is "File: {name}" for treeitem containment semantics. + # data-glyph carries the extension-derived or explicit glyph token. + # data-file-path carries the full path for host event handlers. + defp file_leaf_node(assigns) do + ~H""" +
  • + <%= fetch(@node, :name) || fetch(@node, :path) %> +
  • + """ + end + defp selected?(node) do fetch(node, :selected) || fetch(node, :selected?) end diff --git a/packages/live_ui/test/live_ui/data_widgets_test.exs b/packages/live_ui/test/live_ui/data_widgets_test.exs index b91d6316..9c333cfc 100644 --- a/packages/live_ui/test/live_ui/data_widgets_test.exs +++ b/packages/live_ui/test/live_ui/data_widgets_test.exs @@ -188,6 +188,116 @@ defmodule LiveUi.DataWidgetsTest do assert html =~ ~s(data-expanded) end + + test "tree_view :sub_group node renders with role=group and aria-label (Wave 3.7 EX-1)" do + html = + render_component(&LiveUi.Widgets.TreeView.component/1, %{ + id: "sub-group-tree", + nodes: [ + %{id: "group-adr", kind: :sub_group, label: "ADRs", expanded?: true} + ] + }) + + assert html =~ ~s(data-node-kind="sub_group") + assert html =~ ~s(role="group") + assert html =~ ~s(aria-label="ADRs") + assert html =~ "ADRs" + end + + test "tree_view :sub_group node renders nested children" do + html = + render_component(&LiveUi.Widgets.TreeView.component/1, %{ + id: "sub-group-nested", + nodes: [ + %{ + id: "group-specs", + kind: :sub_group, + label: "Specs", + children: [%{id: "child-spec", label: "grain.spec.md"}] + } + ] + }) + + assert html =~ "Specs" + assert html =~ "grain.spec.md" + end + + test "tree_view :file_leaf node renders with file path and glyph (Wave 3.7 EX-2)" do + html = + render_component(&LiveUi.Widgets.TreeView.component/1, %{ + id: "file-leaf-tree", + nodes: [ + %{ + id: "f1", + kind: :file_leaf, + path: "lib/foo.ex", + name: "foo.ex", + glyph: "elixir" + } + ] + }) + + assert html =~ ~s(data-node-kind="file_leaf") + assert html =~ ~s(data-file-path="lib/foo.ex") + assert html =~ ~s(data-glyph="elixir") + assert html =~ ~s(aria-label="File: foo.ex") + assert html =~ "foo.ex" + end + + test "tree_view :file_leaf node selected state reflects data-selected" do + html = + render_component(&LiveUi.Widgets.TreeView.component/1, %{ + id: "selected-file-tree", + nodes: [ + %{ + id: "f2", + kind: :file_leaf, + path: "lib/bar.ex", + name: "bar.ex", + selected: true + } + ] + }) + + assert html =~ ~s(data-selected) + end + + test "tree_view mixed sub_group and file_leaf in same tree" do + html = + render_component(&LiveUi.Widgets.TreeView.component/1, %{ + id: "mixed-tree", + nodes: [ + %{ + id: "repo-root", + label: "metagraph/", + children: [ + %{ + id: "specs-group", + kind: :sub_group, + label: "Specs", + children: [ + %{ + id: "spec-file", + kind: :file_leaf, + path: ".spec/specs/grain.spec.md", + name: "grain.spec.md", + glyph: "markdown" + } + ] + } + ] + } + ] + }) + + assert html =~ "metagraph/" + assert html =~ ~s(data-node-kind="sub_group") + assert html =~ ~s(role="group") + assert html =~ "Specs" + assert html =~ ~s(data-node-kind="file_leaf") + assert html =~ ~s(data-glyph="markdown") + assert html =~ "grain.spec.md" + end end describe "markdown_viewer widget" do diff --git a/packages/unified_iur/lib/unified_iur/widgets/data.ex b/packages/unified_iur/lib/unified_iur/widgets/data.ex index a4015377..966efcc1 100644 --- a/packages/unified_iur/lib/unified_iur/widgets/data.ex +++ b/packages/unified_iur/lib/unified_iur/widgets/data.ex @@ -171,23 +171,101 @@ defmodule UnifiedIUR.Widgets.Data do end defp normalize_nodes(nodes) do - Enum.map(nodes, fn node -> - node = normalize_opts(node) + Enum.map(nodes, &normalize_node/1) + end - children = - case option(node, :children, []) do - [] -> [] - nested when is_list(nested) -> normalize_nodes(nested) - end + # :sub_group — categorical grouping inside a tree (e.g. "ADRs", "Specs") + # Differs from :folder which is filesystem-style. Renders as a group header + # with aria-role="group" rather than an expand/collapse folder affordance. + defp normalize_node(raw_node) do + node = normalize_opts(raw_node) + kind = option(node, :kind) - %{} - |> maybe_put(:id, option(node, :id)) - |> maybe_put(:label, option(node, :label)) - |> maybe_put(:value, option(node, :value)) - |> maybe_put(:expanded?, option(node, :expanded?)) - |> maybe_put(:selected?, option(node, :selected?)) - |> maybe_put(:children, if(children == [], do: nil, else: children)) - end) + case kind do + :sub_group -> normalize_sub_group_node(node) + :file_leaf -> normalize_file_leaf_node(node) + _ -> normalize_generic_node(node) + end + end + + defp normalize_generic_node(node) do + children = + case option(node, :children, []) do + [] -> [] + nested when is_list(nested) -> normalize_nodes(nested) + end + + %{} + |> maybe_put(:id, option(node, :id)) + |> maybe_put(:kind, option(node, :kind)) + |> maybe_put(:label, option(node, :label)) + |> maybe_put(:value, option(node, :value)) + |> maybe_put(:expanded?, option(node, :expanded?)) + |> maybe_put(:selected?, option(node, :selected?)) + |> maybe_put(:children, if(children == [], do: nil, else: children)) + end + + # :sub_group: categorical grouping node + # - label: String.t() — visible group label (required) + # - children: [node()] — nested nodes within this group + # - expanded?: boolean() — hint (not gate) for initial render state + defp normalize_sub_group_node(node) do + children = + case option(node, :children, []) do + [] -> [] + nested when is_list(nested) -> normalize_nodes(nested) + end + + %{} + |> maybe_put(:id, option(node, :id)) + |> Map.put(:kind, :sub_group) + |> maybe_put(:label, option(node, :label)) + |> maybe_put(:expanded?, option(node, :expanded?)) + |> maybe_put(:children, if(children == [], do: nil, else: children)) + end + + # :file_leaf: filesystem-path leaf node + # - path: String.t() — full file path (e.g. "lib/foo/bar.ex") + # - name: String.t() — display name (e.g. "bar.ex") + # - glyph: String.t() | nil — explicit glyph; falls back to extension-derived if nil + # - meta: map() | nil — optional metadata (lang, line count, last-modified, etc.) + # - selected?: boolean() + defp normalize_file_leaf_node(node) do + glyph = option(node, :glyph) || derive_glyph(option(node, :path)) + + %{} + |> maybe_put(:id, option(node, :id)) + |> Map.put(:kind, :file_leaf) + |> maybe_put(:path, option(node, :path)) + |> maybe_put(:name, option(node, :name)) + |> maybe_put(:glyph, glyph) + |> maybe_put(:meta, option(node, :meta)) + |> maybe_put(:selected?, option(node, :selected?)) + end + + # Extension-derived glyph fallback for :file_leaf nodes. + # Returns a semantic glyph token for known Elixir/Markdown/etc extensions. + # Returns nil for unknown extensions (renderer uses a generic file icon). + @extension_glyphs %{ + ".ex" => "elixir", + ".exs" => "elixir", + ".md" => "markdown", + ".livemd" => "markdown", + ".json" => "json", + ".yaml" => "yaml", + ".yml" => "yaml", + ".js" => "javascript", + ".ts" => "typescript", + ".css" => "stylesheet", + ".html" => "markup", + ".heex" => "markup" + } + + defp derive_glyph(nil), do: nil + + defp derive_glyph(path) when is_binary(path) do + ext = path |> Path.extname() |> String.downcase() + Map.get(@extension_glyphs, ext) end defp normalize_info_items(items) do diff --git a/packages/unified_iur/test/unified_iur/widgets/data_test.exs b/packages/unified_iur/test/unified_iur/widgets/data_test.exs index 8613c050..e13b4244 100644 --- a/packages/unified_iur/test/unified_iur/widgets/data_test.exs +++ b/packages/unified_iur/test/unified_iur/widgets/data_test.exs @@ -124,6 +124,10 @@ defmodule UnifiedIUR.Widgets.DataTest do } } = tree + # generic node without explicit kind preserves backward compat + assert [%{id: :root, label: "Root"}] = + get_in(tree.attributes, [:tree, :nodes]) + assert %Element{ kind: :stat, attributes: %{ @@ -166,4 +170,234 @@ defmodule UnifiedIUR.Widgets.DataTest do } } = info_list end + + describe "tree_view :sub_group node kind (Wave 3.7 EX-1)" do + test "normalizes sub_group nodes with kind, label, and expanded state" do + tree = + Data.tree_view( + [ + [ + id: "group-adr", + kind: :sub_group, + label: "ADRs", + expanded?: true, + children: [ + [id: "adr-1", label: "0001-connector-boundary"] + ] + ] + ], + id: "explorer-tree" + ) + + assert %Element{kind: :tree_view} = tree + [group] = get_in(tree.attributes, [:tree, :nodes]) + assert group.kind == :sub_group + assert group.label == "ADRs" + assert group.expanded? == true + assert [%{id: "adr-1", label: "0001-connector-boundary"}] = group.children + end + + test "normalizes sub_group without children" do + tree = + Data.tree_view( + [[id: "empty-group", kind: :sub_group, label: "Specs"]], + id: "tree-empty-group" + ) + + [group] = get_in(tree.attributes, [:tree, :nodes]) + assert group.kind == :sub_group + assert group.label == "Specs" + assert is_nil(group[:children]) + end + + test "sub_group children are recursively normalized" do + tree = + Data.tree_view( + [ + [ + id: "outer", + kind: :sub_group, + label: "Outer", + children: [ + [ + id: "inner", + kind: :sub_group, + label: "Inner", + children: [[id: "leaf", label: "Leaf"]] + ] + ] + ] + ], + id: "nested-sub-group-tree" + ) + + [outer] = get_in(tree.attributes, [:tree, :nodes]) + assert outer.kind == :sub_group + [inner] = outer.children + assert inner.kind == :sub_group + [leaf] = inner.children + assert leaf.label == "Leaf" + end + end + + describe "tree_view :file_leaf node kind (Wave 3.7 EX-2)" do + test "normalizes file_leaf with explicit path, name, and glyph" do + tree = + Data.tree_view( + [ + [ + id: "file-1", + kind: :file_leaf, + path: "lib/ariston_ui/workspace.ex", + name: "workspace.ex", + glyph: "elixir" + ] + ], + id: "file-tree" + ) + + [leaf] = get_in(tree.attributes, [:tree, :nodes]) + assert leaf.kind == :file_leaf + assert leaf.path == "lib/ariston_ui/workspace.ex" + assert leaf.name == "workspace.ex" + assert leaf.glyph == "elixir" + end + + test "file_leaf derives glyph from .ex extension when glyph not provided" do + tree = + Data.tree_view( + [[id: "f1", kind: :file_leaf, path: "lib/foo.ex", name: "foo.ex"]], + id: "glyph-tree" + ) + + [leaf] = get_in(tree.attributes, [:tree, :nodes]) + assert leaf.glyph == "elixir" + end + + test "file_leaf derives glyph from .exs extension" do + tree = + Data.tree_view( + [[id: "f2", kind: :file_leaf, path: "test/foo_test.exs", name: "foo_test.exs"]], + id: "exs-tree" + ) + + [leaf] = get_in(tree.attributes, [:tree, :nodes]) + assert leaf.glyph == "elixir" + end + + test "file_leaf derives glyph from .md extension" do + tree = + Data.tree_view( + [[id: "f3", kind: :file_leaf, path: "README.md", name: "README.md"]], + id: "md-tree" + ) + + [leaf] = get_in(tree.attributes, [:tree, :nodes]) + assert leaf.glyph == "markdown" + end + + test "file_leaf explicit glyph wins over extension-derived glyph" do + tree = + Data.tree_view( + [ + [ + id: "f4", + kind: :file_leaf, + path: "lib/foo.ex", + name: "foo.ex", + glyph: "custom-icon" + ] + ], + id: "override-glyph-tree" + ) + + [leaf] = get_in(tree.attributes, [:tree, :nodes]) + assert leaf.glyph == "custom-icon" + end + + test "file_leaf glyph is absent for unknown extension (maybe_put skips nil)" do + tree = + Data.tree_view( + [[id: "f5", kind: :file_leaf, path: "data.bin", name: "data.bin"]], + id: "unknown-ext-tree" + ) + + [leaf] = get_in(tree.attributes, [:tree, :nodes]) + # maybe_put skips nil values so :glyph key is absent — use Map.get to check + assert is_nil(Map.get(leaf, :glyph)) + end + + test "file_leaf preserves selected? state" do + tree = + Data.tree_view( + [[id: "f6", kind: :file_leaf, path: "lib/x.ex", name: "x.ex", selected?: true]], + id: "selected-leaf-tree" + ) + + [leaf] = get_in(tree.attributes, [:tree, :nodes]) + assert leaf.selected? == true + end + + test "file_leaf accepts optional meta map" do + tree = + Data.tree_view( + [ + [ + id: "f7", + kind: :file_leaf, + path: "lib/x.ex", + name: "x.ex", + meta: %{lang: "elixir", lines: 120} + ] + ], + id: "meta-leaf-tree" + ) + + [leaf] = get_in(tree.attributes, [:tree, :nodes]) + assert leaf.meta == %{lang: "elixir", lines: 120} + end + end + + describe "tree_view mixed-kind nodes" do + test "tree with generic root containing sub_group and file_leaf children" do + tree = + Data.tree_view( + [ + [ + id: "repo-root", + label: "metagraph/", + expanded?: true, + children: [ + [ + id: "specs-group", + kind: :sub_group, + label: "Specs", + children: [ + [ + id: "spec-file", + kind: :file_leaf, + path: ".spec/specs/grain.spec.md", + name: "grain.spec.md" + ] + ] + ] + ] + ] + ], + id: "mixed-tree" + ) + + [root] = get_in(tree.attributes, [:tree, :nodes]) + # root has no kind (generic) + assert is_nil(root[:kind]) + assert root.label == "metagraph/" + + [sub_group] = root.children + assert sub_group.kind == :sub_group + + [file_leaf] = sub_group.children + assert file_leaf.kind == :file_leaf + assert file_leaf.glyph == "markdown" + end + end end diff --git a/packages/unified_ui/lib/unified_ui/widget_components.ex b/packages/unified_ui/lib/unified_ui/widget_components.ex index f1a0ab7d..0c0b693a 100644 --- a/packages/unified_ui/lib/unified_ui/widget_components.ex +++ b/packages/unified_ui/lib/unified_ui/widget_components.ex @@ -240,6 +240,13 @@ defmodule UnifiedUi.WidgetComponents do summary: "Ask-mode sidebar shell: Recent rail + Saved rail + Map jump affordance. Replaces :sidebar_shell when operator is in Ask mode.", aliases: [] + }, + %{ + kind: :tree_view, + family: :content_identity_and_disclosure, + summary: + "Hierarchical tree data view with expand/collapse nodes and selection. Supports generic nodes, :sub_group categorical groupings (Wave 3.7 EX-1), and :file_leaf filesystem-path rows with extension-derived glyphs (Wave 3.7 EX-2). For filesystem-only use cases see :file_tree_browser.", + aliases: [] } ] 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 87320a31..6a3e4aca 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 @@ -20,7 +20,9 @@ defmodule UnifiedUi.WidgetComponentsCatalogTest do :disclosure, :kicker, :avatar, - :presence_dot + :presence_dot, + :thread_card, + :tree_view ], form_control_and_composer: [ :runtime_form_shell, @@ -44,7 +46,10 @@ defmodule UnifiedUi.WidgetComponentsCatalogTest do :sidebar_shell, :sidebar_section, :sidebar_item, - :command_palette + :command_palette, + :file_tree_browser, + :composer_inline_ask, + :ask_sidebar ], redline_and_code: [:redline_inline, :code_block_syntax_highlighted], composition_behavior: [:list_repeat]