diff --git a/packages/live_ui/lib/live_ui/renderer.ex b/packages/live_ui/lib/live_ui/renderer.ex
index 14f6072e..33f07191 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,
@@ -130,7 +132,10 @@ defmodule LiveUi.Renderer do
disabled={Map.get(item, :disabled)}
{Map.get(item, :attrs, %{})}
>
- {Map.get(item, :label) || Map.get(item, "label") || ""}
+ <%= if glyph = Map.get(item, :glyph) || Map.get(item, "glyph") do %>
+ {glyph}
+ <% end %>
+ {Map.get(item, :label) || Map.get(item, "label") || ""}
<% end %>
@@ -183,6 +188,11 @@ defmodule LiveUi.Renderer do
"""
end
+ # NOTE: `:sidebar_item` is a member of `@layer_callout_kinds` (and therefore
+ # of `@component_kinds`), so the generic fallback below would shadow any later
+ # `:sidebar_item` clause. This specific clause MUST appear BEFORE the generic
+ # `@component_kinds` fallback to route to the dedicated SidebarItem component.
+ # Mirrors the `:thread_card` and `:command_palette` patterns.
def render(%{element: %Element{kind: :sidebar_item}} = assigns) do
interaction_attrs = interaction_event_attrs(assigns.element, Map.get(assigns, :event_target))
@@ -191,21 +201,19 @@ defmodule LiveUi.Renderer do
|> assign(:style_attrs, merge_global_attrs(style_rest(assigns.element), interaction_attrs))
~H"""
-
+ label={string_value(get_in(@element.attributes, [:item, :label]), "")}
+ selected?={get_in(@element.attributes, [:item, :selected?]) == true}
+ avatar_url={get_in(@element.attributes, [:item, :avatar_url])}
+ item_state={get_in(@element.attributes, [:item, :item_state])}
+ item_intent={string_optional(get_in(@element.attributes, [:item, :item_intent]))}
+ tone={style_tone(@element)}
+ variant={theme_variant(@element)}
+ state={style_state(@element)}
+ class={style_class(@element)}
+ {@style_attrs}
+ />
"""
end
@@ -271,6 +279,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 +910,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))
@@ -1688,7 +1785,46 @@ defmodule LiveUi.Renderer do
defp normalize_tree_node(node, source_element, event_target) do
node = Map.new(node)
node_id = Map.get(node, :id) || Map.get(node, "id")
+ kind = Map.get(node, :kind) || Map.get(node, "kind")
+
+ case kind do
+ :sub_group ->
+ normalize_sub_group_tree_node(node, node_id, source_element, event_target)
+
+ :file_leaf ->
+ normalize_file_leaf_tree_node(node, node_id, source_element, event_target)
+
+ _ ->
+ normalize_generic_tree_node(node, node_id, source_element, event_target)
+ end
+ end
+
+ # :sub_group — categorical grouping node: renders as role="group" container
+ # with optional expand state and nested children.
+ defp normalize_sub_group_tree_node(node, node_id, source_element, event_target) do
+ children =
+ node
+ |> Map.get(:children, Map.get(node, "children", []))
+ |> List.wrap()
+ |> Enum.map(&normalize_tree_node(&1, source_element, event_target))
+
+ node
+ |> Map.put(:kind, :sub_group)
+ |> Map.put(:expanded, Map.get(node, :expanded, Map.get(node, :expanded?)))
+ |> Map.put(:children, children)
+ |> maybe_put_item_attrs(collection_item_attrs(source_element, event_target, node_id))
+ end
+
+ # :file_leaf — filesystem-path leaf node: renders as a treeitem row
+ # with glyph token, path, and optional meta.
+ defp normalize_file_leaf_tree_node(node, node_id, source_element, event_target) do
+ node
+ |> Map.put(:kind, :file_leaf)
+ |> Map.put(:selected, Map.get(node, :selected, Map.get(node, :selected?)))
+ |> maybe_put_item_attrs(collection_item_attrs(source_element, event_target, node_id))
+ end
+ defp normalize_generic_tree_node(node, node_id, source_element, event_target) do
children =
node
|> Map.get(:children, Map.get(node, "children", []))
diff --git a/packages/live_ui/lib/live_ui/widgets/sidebar_item.ex b/packages/live_ui/lib/live_ui/widgets/sidebar_item.ex
new file mode 100644
index 00000000..dd501b21
--- /dev/null
+++ b/packages/live_ui/lib/live_ui/widgets/sidebar_item.ex
@@ -0,0 +1,111 @@
+defmodule LiveUi.Widgets.SidebarItem do
+ @moduledoc """
+ Native sidebar-item widget.
+
+ Renders a navigable sidebar list item with selected state, intent, and optional
+ badge children. Extends the base `:sidebar_item` with:
+
+ - `avatar_url` — optional URL for a small avatar image rendered before the label.
+ When `nil` (default), no image is rendered. The `
` is `aria-hidden="true"`
+ because the label already provides the accessible name.
+ - `item_state` — optional workflow state atom (`:default`, `:stalled`, `:blocked`,
+ `:errored`). When `nil` (default), no extra state modifier class is applied.
+ State is exposed as a BEM modifier class (`live-ui-sidebar-item--state-{value}`)
+ and as a `data-live-ui-item-state` hook attribute for CSS/JS targeting.
+
+ ## Attributes
+
+ Required:
+ - `:id` - element identifier
+ - `:label` - display text for the item
+
+ Optional:
+ - `:selected?` - boolean; adds `--selected` BEM modifier and `aria-current="page"` (default `false`)
+ - `:avatar_url` - URL string; when present, renders `
` before label (default `nil`)
+ - `:item_state` - atom; one of `:default | :stalled | :blocked | :errored` (default `nil`)
+ - `:item_intent` - canonical Interaction intent for click events (default `nil`)
+
+ ## Selector / hook contract
+
+ Root ``: `data-live-ui-widget="sidebar-item"` + `data-live-ui-item-state="{state}"` (omitted when nil).
+ Button: `.live-ui-sidebar-item-button`.
+ Avatar (when present): `.live-ui-sidebar-item__avatar` + `aria-hidden="true"`.
+ State modifier on root: `live-ui-sidebar-item--state-{stalled|blocked|errored}`.
+
+ ## ARIA
+
+ - `aria-current="page"` on the inner button when `selected?` is `true`.
+ - Avatar `
` carries `aria-hidden="true"` since the sibling label text is the
+ accessible name; no `alt` text needed.
+ - `:blocked` items remain clickable by default (no `aria-disabled`). Pascal may
+ add `aria-disabled="true"` in a follow-up once the UX intent is confirmed
+ (open question #4 in unified_ui #184).
+
+ ## Open questions for Pascal (unified_ui #184)
+
+ 1. **Avatar composition** — currently renders `
` inline (smallest delta). If the
+ `:avatar` canonical widget (ash_ui PR #116) stabilises before this is adopted, prefer
+ composing via that widget for DRY rendering.
+ 2. **State enum completeness** — `:stalled | :blocked | :errored` are the audit-required
+ values. `:loading` / `:in_progress` deferred pending Pascal confirmation.
+ 3. **`state` vs `tone`** — `item_state` is named to avoid collision with the `LiveUi.Component`
+ common `:state` assign (style-hook). Pascal may prefer a different name.
+ 4. **ARIA for `:blocked`** — `aria-disabled="true"` deferred; comp shows visual treatment
+ but intent unclear (still-clickable vs truly disabled).
+ 5. **Avatar fallback** — initials fallback for missing `avatar_url` on DM rows is
+ consumer-side; out of scope for this extension.
+ """
+
+ use LiveUi.Component, family: :content, name: :sidebar_item, events: [:click]
+
+ LiveUi.Component.common_attrs()
+ attr(:label, :string, required: true)
+ attr(:selected?, :boolean, default: false)
+ attr(:avatar_url, :string, default: nil)
+ attr(:item_state, :atom, default: nil)
+ attr(:item_intent, :string, default: nil)
+
+ slot(:inner_block)
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+
+ """
+ end
+
+ # ---------------------------------------------------------------------------
+ # Private helpers
+ # ---------------------------------------------------------------------------
+
+ defp state_modifier_class(nil), do: nil
+ defp state_modifier_class(:default), do: nil
+ defp state_modifier_class(state), do: "live-ui-sidebar-item--state-#{state}"
+end
diff --git a/packages/live_ui/test/live_ui/sidebar_item_widget_test.exs b/packages/live_ui/test/live_ui/sidebar_item_widget_test.exs
new file mode 100644
index 00000000..4dd898ae
--- /dev/null
+++ b/packages/live_ui/test/live_ui/sidebar_item_widget_test.exs
@@ -0,0 +1,265 @@
+defmodule LiveUi.SidebarItemWidgetTest do
+ @moduledoc """
+ Stage-4 tests for `LiveUi.Widgets.SidebarItem`.
+
+ Verifies:
+ - `data-live-ui-widget="sidebar-item"` root attribute (true-widget, not component-kind fallback)
+ - Label renders inside the button
+ - `aria-current="page"` on the button when `selected?` is `true`
+ - `live-ui-sidebar-item--selected` BEM modifier class when selected
+ - Avatar `
` rendered with correct class + `aria-hidden="true"` when `avatar_url` present
+ - Avatar `
` omitted when `avatar_url` is `nil`
+ - `data-live-ui-item-state` hook present when `item_state` is `:stalled`, `:blocked`, `:errored`
+ - `data-live-ui-item-state` absent when `item_state` is `nil` or `:default`
+ - BEM state modifier class applied for each non-default state
+ - Renderer dispatches to `LiveUi.Widgets.SidebarItem` (not generic fallback)
+ """
+
+ use ExUnit.Case, async: true
+
+ import Phoenix.LiveViewTest
+
+ alias UnifiedIUR.Widgets.Components
+
+ # ---------------------------------------------------------------------------
+ # Stage-4 Phoenix.Component direct render tests
+ # ---------------------------------------------------------------------------
+
+ describe "SidebarItem Phoenix.Component" do
+ test "renders with the true-widget data attribute (not component-kind fallback)" do
+ html =
+ render_component(&LiveUi.Widgets.SidebarItem.render/1, %{
+ id: "si-1",
+ label: "Overview",
+ selected?: false,
+ avatar_url: nil,
+ item_state: nil,
+ item_intent: nil
+ })
+
+ assert html =~ ~s(data-live-ui-widget="sidebar-item")
+ refute html =~ ~s(data-live-ui-component-kind)
+ end
+
+ test "renders the label text inside the button" do
+ html =
+ render_component(&LiveUi.Widgets.SidebarItem.render/1, %{
+ id: "si-2",
+ label: "Design Docs",
+ selected?: false,
+ avatar_url: nil,
+ item_state: nil,
+ item_intent: nil
+ })
+
+ assert html =~ "Design Docs"
+ assert html =~ "live-ui-sidebar-item-button"
+ end
+
+ test "renders aria-current=page and --selected class when selected" do
+ html =
+ render_component(&LiveUi.Widgets.SidebarItem.render/1, %{
+ id: "si-3",
+ label: "Active Item",
+ selected?: true,
+ avatar_url: nil,
+ item_state: nil,
+ item_intent: nil
+ })
+
+ assert html =~ ~s(aria-current="page")
+ assert html =~ "live-ui-sidebar-item--selected"
+ end
+
+ test "omits aria-current and --selected class when not selected" do
+ html =
+ render_component(&LiveUi.Widgets.SidebarItem.render/1, %{
+ id: "si-4",
+ label: "Inactive",
+ selected?: false,
+ avatar_url: nil,
+ item_state: nil,
+ item_intent: nil
+ })
+
+ refute html =~ ~s(aria-current)
+ refute html =~ "live-ui-sidebar-item--selected"
+ end
+
+ test "renders avatar img with aria-hidden when avatar_url is present" do
+ html =
+ render_component(&LiveUi.Widgets.SidebarItem.render/1, %{
+ id: "si-5",
+ label: "Alice DM",
+ selected?: false,
+ avatar_url: "https://example.com/alice.png",
+ item_state: nil,
+ item_intent: nil
+ })
+
+ assert html =~ ~s(
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)
@@ -596,6 +622,8 @@ defmodule UnifiedIUR.Widgets.Components do
)
end
+ @valid_sidebar_item_states [:default, :stalled, :blocked, :errored]
+
@spec sidebar_item(
String.t(),
[Element.t() | Element.Child.t() | {atom(), Element.t()} | map()],
@@ -605,6 +633,13 @@ defmodule UnifiedIUR.Widgets.Components do
when is_binary(label) and is_list(children) do
opts = normalize_opts(opts)
+ raw_state = option(opts, :item_state)
+
+ if not is_nil(raw_state) and raw_state not in @valid_sidebar_item_states do
+ raise ArgumentError,
+ ":item_state must be one of #{inspect(@valid_sidebar_item_states)}, got: #{inspect(raw_state)}"
+ end
+
build_component(
:sidebar_item,
:layer_shell_and_callout,
@@ -615,6 +650,8 @@ defmodule UnifiedIUR.Widgets.Components do
selected?: option(opts, :selected?, false)
}
|> maybe_put(:item_intent, option(opts, :item_intent))
+ |> maybe_put(:avatar_url, option(opts, :avatar_url))
+ |> maybe_put(:item_state, raw_state)
},
Map.put(opts, :children, children)
)
@@ -662,6 +699,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..21b275ea 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,395 @@ 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
+
+ describe "mode_nav constructor" do
+ test "normalizes items and preserves glyph when present" do
+ element =
+ Components.mode_nav(
+ [
+ %{value: :map, label: "Map", glyph: "🗺"},
+ %{value: :chat, label: "Chat", glyph: "💬"},
+ %{value: :ask, label: "Ask"}
+ ],
+ id: "mode-nav-glyph",
+ aria_label: "Application modes",
+ navigation_intent: :switch_mode
+ )
+
+ assert %Element{kind: :mode_nav} = element
+ assert element.id == "mode-nav-glyph"
+
+ items = get_in(element.attributes, [:navigation, :items])
+ assert length(items) == 3
+
+ [map_item, chat_item, ask_item] = items
+ assert map_item.label == "Map"
+ assert map_item.glyph == "🗺"
+ assert chat_item.label == "Chat"
+ assert chat_item.glyph == "💬"
+ assert ask_item.label == "Ask"
+ refute Map.has_key?(ask_item, :glyph)
+ end
+
+ test "normalizes items without glyph — backward-compatible" do
+ element =
+ Components.mode_nav(
+ [
+ %{value: :workspace, label: "Workspace", current?: true},
+ %{value: :settings, label: "Settings"}
+ ],
+ id: "mode-nav-no-glyph",
+ navigation_intent: :switch_mode
+ )
+
+ items = get_in(element.attributes, [:navigation, :items])
+ assert length(items) == 2
+ Enum.each(items, fn item -> refute Map.has_key?(item, :glyph) end)
+ end
+ end
+
+ describe "sidebar_item constructor (avatar_url + item_state extension)" do
+ test "builds a basic sidebar_item with defaults" do
+ item = Components.sidebar_item("Overview", [], id: "sb-item-overview")
+
+ assert %Element{kind: :sidebar_item} = item
+ assert item.id == "sb-item-overview"
+ assert item.attributes.item.label == "Overview"
+ assert item.attributes.item.selected? == false
+ refute Map.has_key?(item.attributes.item, :avatar_url)
+ refute Map.has_key?(item.attributes.item, :item_state)
+ end
+
+ test "includes avatar_url in item attrs when provided" do
+ item =
+ Components.sidebar_item("Alice", [],
+ id: "sb-item-alice",
+ avatar_url: "https://example.com/alice.png"
+ )
+
+ assert item.attributes.item.avatar_url == "https://example.com/alice.png"
+ end
+
+ test "omits avatar_url from item attrs when nil" do
+ item = Components.sidebar_item("Bob", [], id: "sb-item-bob")
+ refute Map.has_key?(item.attributes.item, :avatar_url)
+ end
+
+ test "includes item_state in item attrs for each valid state" do
+ for state <- [:stalled, :blocked, :errored] do
+ item =
+ Components.sidebar_item("Item", [],
+ id: "sb-item-#{state}",
+ item_state: state
+ )
+
+ assert item.attributes.item.item_state == state
+ end
+ end
+
+ test "omits item_state from item attrs when nil" do
+ item = Components.sidebar_item("Clean", [], id: "sb-item-clean")
+ refute Map.has_key?(item.attributes.item, :item_state)
+ end
+
+ test "accepts :default as a valid item_state (omitted from attrs since it is the default)" do
+ # :default is valid (no error), but maybe_put drops it since it is
+ # conceptually the same as nil — no marker stored.
+ # The constructor guards against *invalid* atoms, not against :default.
+ assert %Element{kind: :sidebar_item} =
+ Components.sidebar_item("Default", [],
+ id: "sb-item-default",
+ item_state: :default
+ )
+ end
+
+ test "raises on invalid item_state atom" do
+ assert_raise ArgumentError, ~r/:item_state/, fn ->
+ Components.sidebar_item("Item", [], id: "sb-item-bad", item_state: :unknown_state)
+ end
+ end
+
+ test "preserves backward-compat: selected? and item_intent still work" do
+ item =
+ Components.sidebar_item("Details", [],
+ id: "sb-item-details",
+ selected?: true,
+ item_intent: :open_details
+ )
+
+ assert item.attributes.item.selected? == true
+ assert item.attributes.item.item_intent == :open_details
+ end
+
+ test "combines avatar_url and item_state together" do
+ item =
+ Components.sidebar_item("Stalled DM", [],
+ id: "sb-item-stalled-dm",
+ avatar_url: "https://example.com/user.png",
+ item_state: :stalled
+ )
+
+ assert item.attributes.item.avatar_url == "https://example.com/user.png"
+ assert item.attributes.item.item_state == :stalled
+ end
+ end
end