{@title}
+{@seed_quote}+ + <%= if @progress_pct do %> +
diff --git a/packages/live_ui/lib/live_ui/renderer.ex b/packages/live_ui/lib/live_ui/renderer.ex
index 14f6072e..474e915b 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,103 @@ 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"""
+ {@title}
+ {@seed_quote}
+
+ <%= if @progress_pct do %>
+
` + - 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/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/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_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 513bd4c0..0c0b693a 100644 --- a/packages/unified_ui/lib/unified_ui/widget_components.ex +++ b/packages/unified_ui/lib/unified_ui/widget_components.ex @@ -212,6 +212,41 @@ 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: [] + }, + %{ + 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]