diff --git a/lib/ash_ui/rendering/iur_adapter.ex b/lib/ash_ui/rendering/iur_adapter.ex
index 0b263d90..fd2a6b3e 100644
--- a/lib/ash_ui/rendering/iur_adapter.ex
+++ b/lib/ash_ui/rendering/iur_adapter.ex
@@ -624,6 +624,31 @@ defmodule AshUI.Rendering.IURAdapter do
defp base_attributes(:command_palette, props),
do: %{command_palette: Map.drop(props, attachment_prop_keys())}
+ defp base_attributes(:ask_sidebar = kind, props) do
+ component_attributes(
+ kind,
+ :layer_shell_and_callout,
+ %{
+ ask_sidebar:
+ compact_map(%{
+ sidebar_id: first_present(props, [:sidebar_id, :id_key]),
+ on_map_jump_event: first_present(props, [:on_map_jump_event, :map_jump_event]),
+ recent_items: fetch(props, :recent_items, []),
+ saved_items: fetch(props, :saved_items, []),
+ blocker_count: fetch(props, :blocker_count, 0),
+ empty_recent_label:
+ first_present(props, [:empty_recent_label]) || "No recent queries",
+ empty_saved_label:
+ first_present(props, [:empty_saved_label]) || "No saved queries yet"
+ })
+ |> maybe_put(:active_item_id, first_present(props, [:active_item_id]))
+ |> maybe_put(:on_new_saved_event, first_present(props, [:on_new_saved_event]))
+ |> maybe_put(:on_see_all_event, first_present(props, [:on_see_all_event]))
+ },
+ props
+ )
+ end
+
defp base_attributes(:list, props), do: %{list: Map.drop(props, attachment_prop_keys())}
defp base_attributes(:table, props), do: %{table: Map.drop(props, attachment_prop_keys())}
defp base_attributes(:tree_view, props), do: %{tree: Map.drop(props, attachment_prop_keys())}
diff --git a/lib/ash_ui/rendering/live_ui_adapter.ex b/lib/ash_ui/rendering/live_ui_adapter.ex
index abdb81b8..d8b5d197 100644
--- a/lib/ash_ui/rendering/live_ui_adapter.ex
+++ b/lib/ash_ui/rendering/live_ui_adapter.ex
@@ -670,6 +670,101 @@ defmodule AshUI.Rendering.LiveUIAdapter do
"""
end
+ defp generate_heex(%{"type" => "ask_sidebar"} = iur, _opts) do
+ props = iur["props"] || %{}
+ sidebar_id = text_prop(props, ["sidebar_id"], "ask-sidebar")
+ on_map_jump_event = text_prop(props, ["on_map_jump_event"], "")
+ recent_items = prop(props, "recent_items", [])
+ saved_items = prop(props, "saved_items", [])
+ active_item_id = prop(props, "active_item_id", nil)
+ on_new_saved_event = text_prop(props, ["on_new_saved_event"], nil)
+ on_see_all_event = text_prop(props, ["on_see_all_event"], nil)
+ empty_recent_label = escaped_text_prop(props, ["empty_recent_label"], "No recent queries")
+ empty_saved_label = escaped_text_prop(props, ["empty_saved_label"], "No saved queries yet")
+ blocker_count_raw = prop(props, "blocker_count", 0)
+
+ blocker_count =
+ case blocker_count_raw do
+ n when is_integer(n) -> n
+ n when is_float(n) -> round(n)
+ s when is_binary(s) -> String.to_integer(s)
+ _ -> 0
+ end
+
+ recent_items_html =
+ Enum.map_join(recent_items, "\n", fn item ->
+ item = normalize_item(item)
+ item_id = text_prop(item, ["id", "item_id"], "")
+ item_label = escaped_text_prop(item, ["label", "title"], "")
+ is_active = active_item_id != nil and item_id == active_item_id
+
+ active_attr =
+ if is_active,
+ do:
+ " aria-current=\"true\" class=\"ash-ask-sidebar__item ash-ask-sidebar__item--active\"",
+ else: " class=\"ash-ask-sidebar__item\""
+
+ "
#{item_label}"
+ end)
+
+ saved_items_html =
+ Enum.map_join(saved_items, "\n", fn item ->
+ item = normalize_item(item)
+ item_id = text_prop(item, ["id", "item_id"], "")
+ item_label = escaped_text_prop(item, ["label", "title"], "")
+ is_active = active_item_id != nil and item_id == active_item_id
+
+ active_attr =
+ if is_active,
+ do:
+ " aria-current=\"true\" class=\"ash-ask-sidebar__item ash-ask-sidebar__item--active\"",
+ else: " class=\"ash-ask-sidebar__item\""
+
+ "#{item_label}"
+ end)
+
+ see_all_btn =
+ if on_see_all_event && on_see_all_event != "",
+ do:
+ "",
+ else: ""
+
+ new_saved_btn =
+ if on_new_saved_event && on_new_saved_event != "",
+ do:
+ "",
+ else: ""
+
+ map_jump_btn =
+ if on_map_jump_event != "" do
+ badge =
+ if blocker_count > 0,
+ do:
+ "",
+ else: ""
+
+ ""
+ else
+ ""
+ end
+
+ """
+
+ """
+ end
+
defp generate_heex(%{"type" => "sticky_frosted_header"} = iur, opts) do
props = iur["props"] || %{}
title = escaped_text_prop(props, ["title", "label"], "")
diff --git a/packages/live_ui/lib/live_ui/widgets/ask_sidebar.ex b/packages/live_ui/lib/live_ui/widgets/ask_sidebar.ex
new file mode 100644
index 00000000..3dcddf5b
--- /dev/null
+++ b/packages/live_ui/lib/live_ui/widgets/ask_sidebar.ex
@@ -0,0 +1,314 @@
+defmodule LiveUi.Widgets.AskSidebar do
+ @moduledoc """
+ Native Ask-mode sidebar shell widget.
+
+ The Ask-mode sidebar replaces `:sidebar_shell` while the operator is in Ask
+ mode. It provides two persistent navigation rails:
+
+ - **Recent** — chronological query history, capped at 10, with relative
+ timestamps and a running-status indicator.
+ - **Saved** — pinned or named queries with a ★ glyph, optional cadence label,
+ and a "+ new" action.
+
+ A **Map jump** affordance at the bottom lets the operator switch back to Map
+ mode without losing the Ask surface. It carries an optional blocker-count badge.
+
+ The widget is a pure display surface — all state is host-managed. No internal
+ state; all transitions are driven by parent assigns.
+
+ ## Required attributes
+
+ * `:sidebar_id` — root identity string; used as `data-sidebar-id` and as the
+ suffix for scoped ARIA heading ids.
+ * `:on_map_jump_event` — canonical Interaction intent string for the Map jump
+ button (emitted as `data-live-ui-intent`).
+
+ ## Optional attributes
+
+ * `:recent_items` — list of `%{id, query, last_run_at, status, on_open_event}`
+ maps. Renderer caps display at 10.
+ * `:saved_items` — list of `%{id, title, query, on_open_event}` maps. Optional
+ per-item keys: `:cadence`, `:last_run_at`.
+ * `:active_item_id` — id of the currently-open item; row gets `aria-current="true"`.
+ * `:on_new_saved_event` — intent string for the "+ new" save button in the Saved rail.
+ * `:on_see_all_event` — intent string for "see all ▸" (visible when `recent_items`
+ exceeds 6).
+ * `:empty_recent_label` — message when Recent rail is empty
+ (default `"No recent queries"`).
+ * `:empty_saved_label` — message when Saved rail is empty
+ (default `"No saved queries yet"`).
+ * `:blocker_count` — non-negative integer badge on the Map jump button; `0`
+ hides the badge (default `0`).
+
+ ## Selector / hook contract
+
+ Root: `data-live-ui-widget="ask-sidebar"` + `data-sidebar-id="{id}"`.
+ Recent rail: `.live-ui-ask-sidebar__section[aria-labelledby="ask-recent-h-{id}"]`.
+ Saved rail: `.live-ui-ask-sidebar__section[aria-labelledby="ask-saved-h-{id}"]`.
+ Active item: `.live-ui-ask-sidebar__item--active` + `aria-current="true"`.
+ Running status: `.live-ui-ask-sidebar__status-running`.
+ Map jump: `.live-ui-ask-sidebar__map-jump-btn`.
+ Blocker badge: `.live-ui-ask-sidebar__blocker-badge`.
+
+ ## ARIA
+
+ - Root `