diff --git a/.spec/specs/canonical_widget_components.spec.md b/.spec/specs/canonical_widget_components.spec.md index 23bee38e..a829de26 100644 --- a/.spec/specs/canonical_widget_components.spec.md +++ b/.spec/specs/canonical_widget_components.spec.md @@ -52,6 +52,10 @@ Required behavior: - Supported aliases match `UnifiedUi.WidgetComponents.aliases/0`. - `thread_card` is a first-class `row_and_artifact` component, not a content-identity component or baseline navigation primitive. +- `composer_query_preview` is a first-class `layer_shell_and_callout` + component for composer-adjacent query result previews; it does not expose + product-specific Ask/MGQL names or renderer event fields in its canonical + contract. - Drift is caught by package-boundary tests before release. ### ash_ui.canonical_widget_components.authoring_admission diff --git a/guides/user/UG-0003-widget-types-properties-and-signals.md b/guides/user/UG-0003-widget-types-properties-and-signals.md index 49626b37..1d62e383 100644 --- a/guides/user/UG-0003-widget-types-properties-and-signals.md +++ b/guides/user/UG-0003-widget-types-properties-and-signals.md @@ -100,7 +100,7 @@ authoring boundaries and normalize before renderer-facing output. | Form control and composer | `runtime_form_shell`, `segmented_button_group`, `chat_composer`, `mode_nav` | `phoenix_form` -> `runtime_form_shell` | | Row and artifact | `list_item_multi_column`, `artifact_row`, `thread_card` | none | | Workflow, progress, and status | `pipeline_stepper_horizontal`, `segmented_progress_bar`, `workflow_stage_list_vertical`, `meter_thin`, `unread_badge`, `workflow_progress_status_card` | none | -| Layer shell and callout | `sticky_frosted_header`, `slide_over_panel`, `event_callout`, `top_strip`, `sidebar_shell`, `sidebar_section`, `sidebar_item`, `command_palette`, `right_rail` | none | +| Layer shell and callout | `sticky_frosted_header`, `slide_over_panel`, `event_callout`, `top_strip`, `sidebar_shell`, `sidebar_section`, `sidebar_item`, `command_palette`, `right_rail`, `composer_query_preview` | none | | Redline and code | `redline_inline`, `code_block_syntax_highlighted` | none | | Composition behavior | `list_repeat` | `repeat` -> `list_repeat`, `ui_relationship_repeat` -> `list_repeat` | @@ -134,6 +134,13 @@ such as `subject_id`, `name`, `path`, `progress_pct`, `active_count`, `attributes.subject`; it does not expose map placement names, route helpers, LiveView event fields, or application-specific card names. +`composer_query_preview` is the reusable inline preview band for query results +adjacent to a composer. Author generic props such as `composer_id`, `query`, +`preview_state`, `explanation`, `metrics`, `findings`, and +`max_findings_shown`; use canonical interactions for dismiss, open, and save +actions. Keep product mode names, keyboard shortcut hints, route helpers, and +LiveView event fields in the host layer. + You can also author `custom:*` types. They are accepted as widget types, but the shipped validation/runtime does not automatically give them built-in signal semantics. Some explicitly supported custom surfaces do have dedicated fallback @@ -236,6 +243,7 @@ Signal support is type-specific. | `form_builder` | `:submit` | | `diff_banner` | `:change`, `:input` | | `right_rail` | `:change`, `:toggle`, `:click` | +| `composer_query_preview` | `:click` | If you declare a signal outside this matrix, authoring validation raises. @@ -269,6 +277,7 @@ The current action-binding-capable widgets are: - `context_selector` - `file_tree_browser` - `right_rail` +- `composer_query_preview` ## Compatibility and Normalization Notes diff --git a/lib/ash_ui/rendering/iur_adapter.ex b/lib/ash_ui/rendering/iur_adapter.ex index 54fddb80..d862cf42 100644 --- a/lib/ash_ui/rendering/iur_adapter.ex +++ b/lib/ash_ui/rendering/iur_adapter.ex @@ -503,6 +503,13 @@ defmodule AshUI.Rendering.IURAdapter do ) end + defp base_attributes(:composer_query_preview, props) do + props + |> composer_query_preview_opts() + |> IURComponents.composer_query_preview() + |> Map.fetch!(:attributes) + end + defp base_attributes(:redline_inline = kind, props) do component_attributes( kind, @@ -1054,6 +1061,51 @@ defmodule AshUI.Rendering.IURAdapter do |> compact_map() end + defp composer_query_preview_opts(props) do + preview = props |> fetch(:query_preview, %{}) |> normalize_map() + + %{ + id: first_present(props, [:_element_id, :id]), + composer_id: first_present(preview, [:composer_id]) || first_present(props, [:composer_id]), + query: + first_present(preview, [:query]) || first_present(props, [:query, :ask_query, :value]), + preview_state: + normalize_existing_atom( + first_present(preview, [:preview_state]) || + first_present(props, [:preview_state]) || + :empty + ), + explanation: + first_present(preview, [:explanation]) || + first_present(props, [:explanation, :explain, :summary]), + metrics: + normalize_optional_map( + first_present(preview, [:metrics]) || first_present(props, [:metrics, :meta]) + ), + findings: + first_present(preview, [:findings]) || + first_present(props, [:findings, :preview_findings]) || + [], + max_findings_shown: + first_present(preview, [:max_findings_shown]) || + first_present(props, [:max_findings_shown]) || + 2, + error_message: + first_present(preview, [:error_message]) || first_present(props, [:error_message]), + loading_label: + first_present(preview, [:loading_label]) || first_present(props, [:loading_label]), + empty_label: first_present(preview, [:empty_label]) || first_present(props, [:empty_label]), + open_label: first_present(preview, [:open_label]) || first_present(props, [:open_label]), + save_label: first_present(preview, [:save_label]) || first_present(props, [:save_label]), + dismiss_intent: first_present(props, [:dismiss_intent]), + open_intent: first_present(props, [:open_intent]), + save_intent: first_present(props, [:save_intent]), + interactions: fetch(props, :interactions), + interaction: fetch(props, :interaction) + } + |> compact_map() + end + defp workflow_progress_status_card_opts(props) do %{ subject_id: first_present(props, [:subject_id]), diff --git a/lib/ash_ui/rendering/live_ui_adapter.ex b/lib/ash_ui/rendering/live_ui_adapter.ex index 4db62202..5e9325c2 100644 --- a/lib/ash_ui/rendering/live_ui_adapter.ex +++ b/lib/ash_ui/rendering/live_ui_adapter.ex @@ -558,6 +558,97 @@ defmodule AshUI.Rendering.LiveUIAdapter do """ end + defp generate_heex(%{"type" => "composer_query_preview"} = iur, _opts) do + props = iur["props"] || %{} + preview = props |> prop("query_preview", props) |> normalize_item() + composer_id = escaped_text_prop(preview, "composer_id", iur["id"] || "") + query = escaped_text_prop(preview, "query", "") + preview_state = escaped_text_prop(preview, "preview_state", "empty") + explanation = escaped_text_prop(preview, "explanation") + error_message = escaped_text_prop(preview, "error_message", "Query failed. Try again.") + loading_label = escaped_text_prop(preview, "loading_label", "Searching") + empty_label = escaped_text_prop(preview, "empty_label", "No results for this query.") + metrics = preview |> prop("metrics", %{}) |> normalize_item() + findings = List.wrap(prop(preview, "findings", [])) + max_findings_shown = max(numeric_count(prop(preview, "max_findings_shown", 2)), 1) + + metrics_html = + if metrics == %{} do + "" + else + results_count = escaped_text_prop(metrics, "results_count") + duration_ms = prop(metrics, "duration_ms") + sources_visited = escaped_text_prop(metrics, "sources_visited") + + duration = + case duration_ms do + value when is_integer(value) or is_float(value) -> + :erlang.float_to_binary(value / 1000, decimals: 2) <> "s" + + value when not is_nil(value) -> + html_escape(value) + + _other -> + nil + end + + """ +
+ #{if results_count != "", do: "#{results_count} results", else: ""} + #{if duration, do: "#{duration}", else: ""} + #{if sources_visited != "", do: "#{sources_visited} sources", else: ""} +
+ """ + end + + findings_html = + findings + |> Enum.take(max_findings_shown) + |> Enum.map_join(fn finding -> + finding = normalize_item(finding) + id = escaped_text_prop(finding, ["id", "finding_id"], "") + n = escaped_text_prop(finding, "n", "") + snippet = escaped_text_prop(finding, "snippet", "") + confidence = escaped_text_prop(finding, "confidence", "") + + """ +
  • + ##{n} + #{snippet} + #{confidence} +
  • + """ + end) + + body_html = + case preview_state do + "loading" -> + ~s(
    #{loading_label}
    ) + + "ready" -> + """ + #{if explanation, do: "
    #{explanation}
    ", else: ""} + #{metrics_html} + #{if findings_html != "", do: "", else: ""} + """ + + "error" -> + ~s() + + _other -> + ~s(
    #{empty_label}
    ) + end + + """ +
    +
    + #{query} +
    + #{body_html} +
    + """ + end + defp generate_heex(%{"type" => "list_item_multi_column"} = iur, opts) do props = iur["props"] || %{} active? = truthy_prop(props, "active?", truthy_prop(props, "active", false)) diff --git a/lib/ash_ui/resources/validations/authoring.ex b/lib/ash_ui/resources/validations/authoring.ex index c2c16063..11be5d09 100644 --- a/lib/ash_ui/resources/validations/authoring.ex +++ b/lib/ash_ui/resources/validations/authoring.ex @@ -40,6 +40,7 @@ defmodule AshUI.Resources.Validations.Authoring do "context_selector", "file_tree_browser", "right_rail", + "composer_query_preview", "workflow_progress_status_card" ]) @signal_capabilities %{ @@ -60,6 +61,7 @@ defmodule AshUI.Resources.Validations.Authoring do "context_selector" => [:change, :input], "file_tree_browser" => [:change, :input, :toggle], "right_rail" => [:change, :toggle, :click], + "composer_query_preview" => [:click], "workflow_progress_status_card" => [:click, :change] } diff --git a/packages/live_ui/lib/live_ui/renderer.ex b/packages/live_ui/lib/live_ui/renderer.ex index 5cf4f0da..956fe651 100644 --- a/packages/live_ui/lib/live_ui/renderer.ex +++ b/packages/live_ui/lib/live_ui/renderer.ex @@ -31,6 +31,7 @@ defmodule LiveUi.Renderer do :cluster_dashboard, :column, :command_palette, + :composer_query_preview, :confidence_indicator, :content, :context_selector, @@ -740,6 +741,48 @@ defmodule LiveUi.Renderer do """ end + # NOTE: `:composer_query_preview` is a canonical layer-shell component. Keep + # this native renderer clause before the generic component fallback so action + # buttons receive canonical interaction transport. + def render(%{element: %Element{kind: :composer_query_preview}} = assigns) do + preview = query_preview_attributes(assigns.element) + + assigns = + assigns + |> assign(:preview, preview) + |> assign( + :action_attrs, + query_preview_action_attrs(assigns.element, Map.get(assigns, :event_target), preview) + ) + |> assign(: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)) @@ -2332,6 +2375,63 @@ defmodule LiveUi.Renderer do end end + defp query_preview_attributes(%Element{} = element) do + element.attributes + |> Map.get(:query_preview, Map.get(element.attributes, "query_preview", %{})) + |> case do + preview when is_map(preview) -> preview + preview when is_list(preview) -> Map.new(preview) + _other -> %{} + end + end + + defp query_preview_action_attrs(%Element{} = element, event_target, preview) do + query = map_value(preview, :query) + + %{ + dismiss: + query_preview_interaction_attrs( + element, + event_target, + primary_interaction(element, :close), + query + ), + open: + query_preview_interaction_attrs( + element, + event_target, + primary_interaction(element, :open), + query + ), + save: + query_preview_interaction_attrs( + element, + event_target, + primary_interaction(element, :command), + query + ) + } + end + + defp query_preview_interaction_attrs( + %Element{} = element, + event_target, + %Interaction{} = interaction, + query + ) + when not is_nil(event_target) do + %{ + :"phx-click" => "canonical_interaction", + :"phx-target" => event_target, + :"phx-value-interaction" => encode_interaction(interaction), + :"phx-value-element_id" => element_id(element, "composer-query-preview"), + :"phx-value-widget" => "composer_query_preview", + :"phx-value-query" => to_string(query || "") + } + end + + defp query_preview_interaction_attrs(_element, _event_target, _interaction, _query), do: %{} + defp right_rail_panels(rail) do rail |> map_value(:panels, []) diff --git a/packages/live_ui/lib/live_ui/widgets/composer_query_preview.ex b/packages/live_ui/lib/live_ui/widgets/composer_query_preview.ex new file mode 100644 index 00000000..56aee037 --- /dev/null +++ b/packages/live_ui/lib/live_ui/widgets/composer_query_preview.ex @@ -0,0 +1,184 @@ +defmodule LiveUi.Widgets.ComposerQueryPreview do + @moduledoc """ + Native renderer for the canonical `:composer_query_preview` layer callout. + + The widget is a result-preview band mounted next to a composer. It receives + canonical query preview attributes and renderer-supplied interaction attrs for + dismiss, open, and save actions. It does not own query lifecycle, product mode, + route, path, or Phoenix event names. + """ + + use LiveUi.Component, + family: :layer_shell_and_callout, + name: :composer_query_preview, + events: [:close, :open, :command] + + LiveUi.Component.common_attrs() + attr(:composer_id, :string, required: true) + attr(:query, :string, required: true) + attr(:preview_state, :atom, default: :empty) + attr(:explanation, :string, default: nil) + attr(:metrics, :map, default: nil) + attr(:findings, :list, default: []) + attr(:max_findings_shown, :integer, default: 2) + attr(:error_message, :string, default: nil) + attr(:loading_label, :string, default: "Searching") + attr(:empty_label, :string, default: "No results for this query.") + attr(:open_label, :string, default: "Open query") + attr(:save_label, :string, default: "Save query") + attr(:dismiss_attrs, :map, default: %{}) + attr(:open_attrs, :map, default: %{}) + attr(:save_attrs, :map, default: %{}) + + @impl true + def render(assigns) do + ~H""" +
    +
    + + <%= @query %> + + +
    + + <%= case @preview_state do %> + <% :loading -> %> +
    + <%= @loading_label %> +
    + + <% :ready -> %> +
    + <%= @explanation %> +
    + +
    + + <%= metric_value(@metrics, :results_count) %> results + + + <%= duration_label(metric_value(@metrics, :duration_ms)) %> + + + <%= metric_value(@metrics, :sources_visited) %> sources + +
    + + + +
    @max_findings_shown} + class="live-ui-composer-query-preview__overflow" + aria-label={"#{length(@findings) - @max_findings_shown} more results"} + > + +<%= length(@findings) - @max_findings_shown %> more +
    + + <% :empty -> %> +
    <%= @empty_label %>
    + + <% :error -> %> + + + <% _ -> %> + <% end %> + +
    + + +
    +
    + """ + end + + defp metric_value(nil, _key), do: nil + + defp metric_value(metrics, key) when is_map(metrics) do + Map.get(metrics, key, Map.get(metrics, to_string(key))) + end + + defp metric_value(metrics, key) when is_list(metrics), do: Keyword.get(metrics, key) + defp metric_value(_metrics, _key), do: nil + + defp finding_value(finding, key) when is_map(finding) do + Map.get(finding, key, Map.get(finding, to_string(key))) + end + + defp finding_value(finding, key) when is_list(finding), do: Keyword.get(finding, key) + defp finding_value(_finding, _key), do: nil + + defp duration_label(value) when is_integer(value) or is_float(value) do + seconds = value / 1000 + :erlang.float_to_binary(seconds, decimals: 2) <> "s" + end + + defp duration_label(value), do: to_string(value) + + defp confidence_percent(value) when is_integer(value) or is_float(value) do + value + |> max(0) + |> min(1) + |> Kernel.*(100) + |> round() + end + + defp confidence_percent(_value), do: 0 + + defp confidence_label(value) when is_integer(value) or is_float(value) do + :erlang.float_to_binary(value / 1, decimals: 2) + end + + defp confidence_label(_value), do: "0.00" +end diff --git a/packages/live_ui/lib/live_ui/widgets/layer_shell_and_callout.ex b/packages/live_ui/lib/live_ui/widgets/layer_shell_and_callout.ex index 8932f0b7..b129bcea 100644 --- a/packages/live_ui/lib/live_ui/widgets/layer_shell_and_callout.ex +++ b/packages/live_ui/lib/live_ui/widgets/layer_shell_and_callout.ex @@ -4,7 +4,8 @@ defmodule LiveUi.Widgets.LayerShellAndCallout do """ @modules [ - LiveUi.Widgets.RightRail + LiveUi.Widgets.RightRail, + LiveUi.Widgets.ComposerQueryPreview ] @spec modules() :: [module()] diff --git a/packages/live_ui/test/live_ui/composer_query_preview_widget_test.exs b/packages/live_ui/test/live_ui/composer_query_preview_widget_test.exs new file mode 100644 index 00000000..8efe199e --- /dev/null +++ b/packages/live_ui/test/live_ui/composer_query_preview_widget_test.exs @@ -0,0 +1,128 @@ +defmodule LiveUi.ComposerQueryPreviewWidgetTest do + use ExUnit.Case, async: true + + import Phoenix.LiveViewTest + + alias UnifiedIUR.Widgets.Components + + describe "ComposerQueryPreview Phoenix.Component" do + test "is registered in the layer shell component family" do + metadata = LiveUi.Component.metadata(LiveUi.Widgets.ComposerQueryPreview) + + assert LiveUi.Widgets.ComposerQueryPreview in LiveUi.Widgets.LayerShellAndCallout.modules() + + assert LiveUi.Widgets.ComposerQueryPreview in LiveUi.Widgets.layer_shell_and_callout_modules() + + assert LiveUi.Widgets.ComposerQueryPreview in LiveUi.Widgets.modules() + assert metadata.family == :layer_shell_and_callout + assert metadata.name == :composer_query_preview + end + + test "renders ready results without the generic component fallback" do + html = + render_component(&LiveUi.Widgets.ComposerQueryPreview.component/1, %{ + id: "query-preview", + composer_id: "composer-main", + query: "release blockers", + preview_state: :ready, + explanation: "Three likely blockers found.", + metrics: %{results_count: 3, duration_ms: 42, sources_visited: 8}, + findings: [ + %{id: "finding-1", n: 1, snippet: "Conformance missing", confidence: 0.91} + ], + max_findings_shown: 2 + }) + + assert html =~ ~s(data-live-ui-widget="composer-query-preview") + assert html =~ ~s(data-composer-id="composer-main") + assert html =~ ~s(data-preview-state="ready") + assert html =~ "Three likely blockers found." + assert html =~ "Conformance missing" + assert html =~ "3" + assert html =~ "0.04s" + refute html =~ ~s(data-live-ui-component-kind) + refute html =~ "data-live-ui-intent" + end + + test "renders loading and error states with accessibility state" do + loading_html = + render_component(&LiveUi.Widgets.ComposerQueryPreview.component/1, %{ + id: "loading-preview", + composer_id: "composer-main", + query: "status", + preview_state: :loading + }) + + error_html = + render_component(&LiveUi.Widgets.ComposerQueryPreview.component/1, %{ + id: "error-preview", + composer_id: "composer-main", + query: "status", + preview_state: :error, + error_message: "No index available" + }) + + assert loading_html =~ ~s(aria-busy="true") + refute loading_html =~ "live-ui-composer-query-preview__actions" + assert error_html =~ ~s(role="alert") + assert error_html =~ "No index available" + end + + test "accepts renderer-supplied canonical action attrs" do + html = + render_component(&LiveUi.Widgets.ComposerQueryPreview.component/1, %{ + id: "query-preview-actions", + composer_id: "composer-main", + query: "release blockers", + preview_state: :empty, + dismiss_attrs: %{ + "phx-click": "canonical_interaction", + "phx-value-widget": "composer_query_preview" + }, + open_attrs: %{ + "phx-click": "canonical_interaction", + "phx-value-widget": "composer_query_preview" + }, + save_attrs: %{ + "phx-click": "canonical_interaction", + "phx-value-widget": "composer_query_preview" + } + }) + + assert html =~ ~s(phx-click="canonical_interaction") + assert html =~ ~s(phx-value-widget="composer_query_preview") + refute html =~ "open in Ask" + end + end + + describe "LiveUi.Renderer integration" do + test "dispatches canonical composer_query_preview through native component boundary" do + element = + Components.composer_query_preview( + id: "query-preview-renderer", + composer_id: "composer-main", + query: "release blockers", + preview_state: :ready, + explanation: "Three likely blockers found.", + metrics: %{results_count: 3, duration_ms: 42, sources_visited: 8}, + findings: [ + %{id: "finding-1", n: 1, snippet: "Conformance missing", confidence: 0.91} + ] + ) + + html = + render_component(&LiveUi.Renderer.render/1, %{ + element: element, + event_target: "#runtime-host" + }) + + assert html =~ ~s(data-live-ui-widget="composer-query-preview") + assert html =~ ~s(phx-click="canonical_interaction") + assert html =~ ~s(phx-target="#runtime-host") + assert html =~ ~s(phx-value-widget="composer_query_preview") + assert html =~ ~s(phx-value-query="release blockers") + refute html =~ ~s(data-live-ui-component-kind) + refute html =~ "open in Ask" + end + end +end diff --git a/packages/unified_iur/lib/unified_iur/validate.ex b/packages/unified_iur/lib/unified_iur/validate.ex index 5e67eae7..31cf4a54 100644 --- a/packages/unified_iur/lib/unified_iur/validate.ex +++ b/packages/unified_iur/lib/unified_iur/validate.ex @@ -143,6 +143,16 @@ defmodule UnifiedIUR.Validate do guidance: "Represent rail panel selection and collapse as UnifiedIUR.Interaction structs, not renderer event names." }, + invalid_query_preview: %{ + construct_family: :widget_components, + guidance: + "Represent composer_query_preview with generic composer, query, preview state, metrics, and renderer-independent action intent." + }, + invalid_query_preview_finding: %{ + construct_family: :widget_components, + guidance: + "Represent composer_query_preview findings as opaque result descriptors with id, ordinal, snippet, and confidence." + }, invalid_workflow_progress_status_card: %{ construct_family: :widget_components, guidance: @@ -174,6 +184,27 @@ defmodule UnifiedIUR.Validate do @artifact_kinds [:pr, :doc, :spec, :file, :grain, :generic] @artifact_badge_tones [:positive, :warning, :danger, :info, :neutral] @rail_sides [:right] + @query_preview_states [:loading, :ready, :empty, :error] + @query_preview_forbidden_keys ~w[ + ask_query + event + event_target + helper + live_action + mgql + module + on_click + on_dismiss + on_open_in_ask + on_save_query + path + phx-click + phx_click + phx_event + route + runtime_module + url + ] @rail_forbidden_panel_keys ~w[ event helper @@ -604,6 +635,15 @@ defmodule UnifiedIUR.Validate do |> Kernel.++(validate_rail_interactions(Map.get(attributes, :interactions, []))) end + defp validate_component_contracts(%Element{ + kind: :composer_query_preview, + attributes: attributes + }) do + attributes + |> Map.get(:query_preview, %{}) + |> validate_query_preview_shape() + end + defp validate_component_contracts(%Element{ kind: :workflow_progress_status_card, attributes: attributes @@ -1244,6 +1284,173 @@ defmodule UnifiedIUR.Validate do ] end + defp validate_query_preview_shape(preview) when is_map(preview) do + preview_state = fetch(preview, :preview_state, :empty) + max_findings_shown = fetch(preview, :max_findings_shown, 2) + + [] + |> maybe_add( + not non_empty_string?(fetch(preview, :composer_id)), + Error.new( + :invalid_query_preview, + "composer_query_preview requires composer_id as a non-empty string", + path: [:attributes, :query_preview, :composer_id], + details: %{composer_id: inspect(fetch(preview, :composer_id))} + ) + ) + |> maybe_add( + not non_empty_string?(fetch(preview, :query)), + Error.new( + :invalid_query_preview, + "composer_query_preview requires query as a non-empty string", + path: [:attributes, :query_preview, :query], + details: %{query: inspect(fetch(preview, :query))} + ) + ) + |> maybe_add( + not valid_query_preview_state?(preview_state), + Error.new( + :invalid_query_preview, + "composer_query_preview preview_state must be one of #{inspect(@query_preview_states)}", + path: [:attributes, :query_preview, :preview_state], + details: %{preview_state: inspect(preview_state)} + ) + ) + |> maybe_add( + ready_query_preview?(preview_state) and not non_empty_string?(fetch(preview, :explanation)), + Error.new( + :invalid_query_preview, + "composer_query_preview ready previews require explanation as a non-empty string", + path: [:attributes, :query_preview, :explanation] + ) + ) + |> maybe_add( + not positive_integer?(max_findings_shown), + Error.new( + :invalid_query_preview, + "composer_query_preview max_findings_shown must be a positive integer", + path: [:attributes, :query_preview, :max_findings_shown], + details: %{max_findings_shown: inspect(max_findings_shown)} + ) + ) + |> maybe_add( + has_forbidden_query_preview_key_deep?(preview), + Error.new( + :invalid_query_preview, + "composer_query_preview must not include product-specific, renderer event, or host route fields", + path: [:attributes, :query_preview] + ) + ) + |> Kernel.++(validate_query_preview_metrics(fetch(preview, :metrics))) + |> Kernel.++( + validate_query_preview_findings( + fetch(preview, :findings, []), + [:attributes, :query_preview, :findings] + ) + ) + end + + defp validate_query_preview_shape(_preview) do + [ + Error.new( + :invalid_query_preview, + "composer_query_preview attributes.query_preview must be a map", + path: [:attributes, :query_preview] + ) + ] + end + + defp validate_query_preview_metrics(nil), do: [] + + defp validate_query_preview_metrics(metrics) when is_map(metrics) or is_list(metrics) do + metrics = normalize_map(metrics) + + [:results_count, :findings_count, :duration_ms, :sources_visited, :grains_visited] + |> Enum.flat_map(fn key -> + value = fetch(metrics, key) + + maybe_add( + [], + not is_nil(value) and not non_negative_integer?(value), + Error.new( + :invalid_query_preview, + "composer_query_preview metrics must be non-negative integer counts", + path: [:attributes, :query_preview, :metrics, key], + details: %{value: inspect(value)} + ) + ) + end) + end + + defp validate_query_preview_metrics(_metrics) do + [ + Error.new( + :invalid_query_preview, + "composer_query_preview metrics must be a map when present", + path: [:attributes, :query_preview, :metrics] + ) + ] + end + + defp validate_query_preview_findings(findings, path) when is_list(findings) do + findings + |> Enum.with_index() + |> Enum.flat_map(fn {finding, index} -> + validate_query_preview_finding(finding, path ++ [index]) + end) + end + + defp validate_query_preview_findings(_findings, path) do + [ + Error.new( + :invalid_query_preview_finding, + "composer_query_preview findings must be a list", + path: path + ) + ] + end + + defp validate_query_preview_finding(finding, path) when is_map(finding) or is_list(finding) do + finding = normalize_map(finding) + id = fetch(finding, :id) || fetch(finding, :finding_id) + confidence = fetch(finding, :confidence) + + [] + |> maybe_add( + not non_empty_string?(id) or not positive_integer?(fetch(finding, :n)) or + not non_empty_string?(fetch(finding, :snippet)), + Error.new( + :invalid_query_preview_finding, + "composer_query_preview finding requires id, positive n, and snippet", + path: path, + details: %{ + id: inspect(id), + n: inspect(fetch(finding, :n)), + snippet: inspect(fetch(finding, :snippet)) + } + ) + ) + |> maybe_add( + not (is_number(confidence) and confidence >= 0.0 and confidence <= 1.0), + Error.new( + :invalid_query_preview_finding, + "composer_query_preview finding confidence must be in 0.0..1.0", + path: path ++ [:confidence], + details: %{confidence: inspect(confidence)} + ) + ) + end + + defp validate_query_preview_finding(_finding, path) do + [ + Error.new( + :invalid_query_preview_finding, + "composer_query_preview findings must be maps", + path: path + ) + ] + end + defp validate_selection_options(options, path) when is_list(options) do options |> Enum.with_index() @@ -1513,10 +1720,47 @@ defmodule UnifiedIUR.Validate do defp has_forbidden_progress_card_key_deep?(_value), do: false + defp has_forbidden_query_preview_key?(value) when is_map(value) do + value + |> Map.keys() + |> Enum.any?(&(to_string(&1) in @query_preview_forbidden_keys)) + end + + defp has_forbidden_query_preview_key?(_value), do: false + + defp has_forbidden_query_preview_key_deep?(%_{} = struct) do + struct + |> Map.from_struct() + |> has_forbidden_query_preview_key_deep?() + end + + defp has_forbidden_query_preview_key_deep?(value) when is_map(value) do + has_forbidden_query_preview_key?(value) or + Enum.any?(Map.values(value), &has_forbidden_query_preview_key_deep?/1) + end + + defp has_forbidden_query_preview_key_deep?(value) when is_list(value) do + Enum.any?(value, &has_forbidden_query_preview_key_deep?/1) + end + + defp has_forbidden_query_preview_key_deep?(_value), do: false + + defp valid_query_preview_state?(state) when state in @query_preview_states, do: true + + defp valid_query_preview_state?(state) when is_binary(state) do + state in Enum.map(@query_preview_states, &Atom.to_string/1) + end + + defp valid_query_preview_state?(_state), do: false + + defp ready_query_preview?(state), do: state in [:ready, "ready"] + defp invalid_subject_progress?(value), do: not (is_number(value) and value >= 0.0 and value <= 100.0) + defp non_empty_string?(value), do: is_binary(value) and value != "" defp non_negative_integer?(value), do: is_integer(value) and value >= 0 + defp positive_integer?(value), do: is_integer(value) and value > 0 defp active_panel_in_panels?(active_panel, panels) when is_list(panels) do Enum.any?(panels, fn diff --git a/packages/unified_iur/lib/unified_iur/widgets/components.ex b/packages/unified_iur/lib/unified_iur/widgets/components.ex index 10059c46..f398f7c4 100644 --- a/packages/unified_iur/lib/unified_iur/widgets/components.ex +++ b/packages/unified_iur/lib/unified_iur/widgets/components.ex @@ -73,9 +73,12 @@ defmodule UnifiedIUR.Widgets.Components do :sidebar_section, :sidebar_item, :right_rail, - :command_palette + :command_palette, + :composer_query_preview ] + @query_preview_states [:loading, :ready, :empty, :error] + @redline_code_kinds [ :redline_inline, :code_block_syntax_highlighted @@ -1057,6 +1060,63 @@ defmodule UnifiedIUR.Widgets.Components do ) end + @spec composer_query_preview(opts()) :: Element.t() + def composer_query_preview(opts \\ []) do + opts = normalize_opts(opts) + + composer_id = + required_string!( + opts, + :composer_id, + "composer_query_preview requires a non-empty :composer_id string" + ) + + query = + required_string!( + opts, + :query, + "composer_query_preview requires a non-empty :query string" + ) + + preview_state = normalize_query_preview_state!(option(opts, :preview_state, :empty)) + max_findings_shown = option(opts, :max_findings_shown, 2) + validate_positive_integer!(max_findings_shown, "max_findings_shown") + + explanation = option(opts, :explanation) + metrics = normalize_query_preview_metrics!(option(opts, :metrics)) + findings = normalize_query_preview_findings!(option(opts, :findings, [])) + + if preview_state == :ready and not non_empty_string?(explanation) do + raise ArgumentError, + "composer_query_preview requires a non-empty :explanation string when preview_state is :ready" + end + + opts = put_query_preview_interactions(opts, composer_id, query) + + build_component( + :composer_query_preview, + :layer_shell_and_callout, + %{ + query_preview: + %{ + composer_id: composer_id, + query: query, + preview_state: preview_state, + max_findings_shown: max_findings_shown, + findings: findings + } + |> maybe_put(:explanation, explanation) + |> maybe_put(:metrics, metrics) + |> maybe_put(:error_message, option(opts, :error_message)) + |> maybe_put(:loading_label, option(opts, :loading_label, "Searching")) + |> maybe_put(:empty_label, option(opts, :empty_label, "No results for this query.")) + |> maybe_put(:open_label, option(opts, :open_label, "Open query")) + |> maybe_put(:save_label, option(opts, :save_label, "Save query")) + }, + opts + ) + end + defp build_component(kind, family, kind_attributes, opts) do opts = normalize_opts(opts) @@ -1181,6 +1241,41 @@ defmodule UnifiedIUR.Widgets.Components do end end + defp put_query_preview_interactions(opts, composer_id, query) do + cond do + Map.has_key?(opts, :interactions) or Map.has_key?(opts, "interactions") or + Map.has_key?(opts, :interaction) or Map.has_key?(opts, "interaction") -> + opts + + true -> + Map.put(opts, :interactions, query_preview_interactions(opts, composer_id, query)) + end + end + + defp query_preview_interactions(opts, composer_id, query) do + [ + Interaction.close( + intent: option(opts, :dismiss_intent, :dismiss_query_preview), + element_id: option(opts, :id), + entity: composer_id, + value: query + ), + Interaction.open( + intent: option(opts, :open_intent, :open_query_preview), + element_id: option(opts, :id), + entity: composer_id, + value: query + ), + Interaction.command( + intent: option(opts, :save_intent, :save_query), + element_id: option(opts, :id), + entity: composer_id, + command: :save_query, + value: query + ) + ] + end + defp normalize_thread_participants!(nil), do: [] defp normalize_thread_participants!(participants) when is_list(participants) do @@ -1221,6 +1316,111 @@ defmodule UnifiedIUR.Widgets.Components do raise ArgumentError, "thread_card :progress_pct must be a number" end + defp normalize_query_preview_state!(state) when state in @query_preview_states, do: state + + defp normalize_query_preview_state!(state) when is_binary(state) do + state + |> String.to_existing_atom() + |> normalize_query_preview_state!() + rescue + ArgumentError -> + raise ArgumentError, + "composer_query_preview :preview_state must be one of #{inspect(@query_preview_states)}" + end + + defp normalize_query_preview_state!(_state) do + raise ArgumentError, + "composer_query_preview :preview_state must be one of #{inspect(@query_preview_states)}" + end + + defp normalize_query_preview_metrics!(nil), do: nil + + defp normalize_query_preview_metrics!(metrics) when is_map(metrics) or is_list(metrics) do + metrics = normalize_map(metrics) + results_count = option(metrics, :results_count, option(metrics, :findings_count)) + duration_ms = option(metrics, :duration_ms) + sources_visited = option(metrics, :sources_visited, option(metrics, :grains_visited)) + + %{} + |> maybe_put(:results_count, normalize_non_negative_integer!(results_count, "results_count")) + |> maybe_put(:duration_ms, normalize_non_negative_integer!(duration_ms, "duration_ms")) + |> maybe_put( + :sources_visited, + normalize_non_negative_integer!(sources_visited, "sources_visited") + ) + |> empty_map_to_nil() + end + + defp normalize_query_preview_metrics!(_metrics) do + raise ArgumentError, "composer_query_preview :metrics must be a map" + end + + defp normalize_query_preview_findings!(findings) when is_list(findings) do + Enum.map(findings, &normalize_query_preview_finding!/1) + end + + defp normalize_query_preview_findings!(_findings) do + raise ArgumentError, "composer_query_preview :findings must be a list of maps" + end + + defp normalize_query_preview_finding!(finding) when is_map(finding) or is_list(finding) do + finding = normalize_map(finding) + id = option(finding, :id, option(finding, :finding_id)) + n = option(finding, :n) + snippet = option(finding, :snippet) + confidence = option(finding, :confidence) + + unless non_empty_string?(id) do + raise ArgumentError, "composer_query_preview findings require a non-empty :id string" + end + + unless positive_integer?(n) do + raise ArgumentError, "composer_query_preview findings require positive integer :n values" + end + + unless non_empty_string?(snippet) do + raise ArgumentError, "composer_query_preview findings require non-empty :snippet strings" + end + + unless normalized_confidence?(confidence) do + raise ArgumentError, "composer_query_preview finding :confidence must be in 0.0..1.0" + end + + %{ + id: id, + n: n, + snippet: snippet, + confidence: confidence / 1 + } + end + + defp normalize_query_preview_finding!(_finding) do + raise ArgumentError, "composer_query_preview findings must be maps" + end + + defp required_string!(opts, key, message) do + case option(opts, key) do + value when is_binary(value) and value != "" -> value + _other -> raise ArgumentError, message + end + end + + defp validate_positive_integer!(value, field) do + unless positive_integer?(value) do + raise ArgumentError, "composer_query_preview :#{field} must be a positive integer" + end + end + + defp normalize_non_negative_integer!(nil, _field), do: nil + + defp normalize_non_negative_integer!(value, _field) when is_integer(value) and value >= 0 do + value + end + + defp normalize_non_negative_integer!(_value, field) do + raise ArgumentError, "composer_query_preview metrics.#{field} must be a non-negative integer" + end + defp empty_to_nil([]), do: nil defp empty_to_nil(value), do: value @@ -1229,6 +1429,13 @@ defmodule UnifiedIUR.Widgets.Components do defp non_empty_string?(value), do: is_binary(value) and byte_size(value) > 0 defp non_negative_integer?(value), do: is_integer(value) and value >= 0 + defp positive_integer?(value), do: is_integer(value) and value > 0 + + defp normalized_confidence?(value) when is_integer(value) or is_float(value) do + value >= 0.0 and value <= 1.0 + end + + defp normalized_confidence?(_value), do: false defp normalize_metadata(opts) do opts diff --git a/packages/unified_iur/test/unified_iur/validate_test.exs b/packages/unified_iur/test/unified_iur/validate_test.exs index e8f9b6c8..bf9642dc 100644 --- a/packages/unified_iur/test/unified_iur/validate_test.exs +++ b/packages/unified_iur/test/unified_iur/validate_test.exs @@ -147,6 +147,57 @@ defmodule UnifiedIUR.ValidateTest do assert panel_error.code == :invalid_rail_panel end + test "validates canonical composer query preview shape" do + valid_preview = + Components.composer_query_preview( + id: :query_preview, + composer_id: "composer-main", + query: "release blockers", + preview_state: :ready, + explanation: "Two release checks need attention.", + metrics: %{results_count: 2, duration_ms: 34, sources_visited: 4}, + findings: [%{id: "finding-1", n: 1, snippet: "CI is still pending.", confidence: 0.82}] + ) + + event_leak = + Element.new(:widget, :composer_query_preview, + attributes: %{ + component: %{family: :layer_shell_and_callout, kind: :composer_query_preview}, + query_preview: %{ + "on_open_in_ask" => "open", + composer_id: "composer-main", + query: "release blockers", + preview_state: :ready, + explanation: "Two release checks need attention.", + max_findings_shown: 2, + findings: [] + } + } + ) + + invalid_finding = + Element.new(:widget, :composer_query_preview, + attributes: %{ + component: %{family: :layer_shell_and_callout, kind: :composer_query_preview}, + query_preview: %{ + composer_id: "composer-main", + query: "release blockers", + preview_state: :ready, + explanation: "Two release checks need attention.", + max_findings_shown: 2, + findings: [%{id: "finding-1", n: 1, snippet: "", confidence: 1.2}] + } + } + ) + + assert :ok = Validate.element(valid_preview) + assert {:error, [preview_error]} = Validate.element(event_leak) + assert preview_error.code == :invalid_query_preview + + assert {:error, finding_errors} = Validate.element(invalid_finding) + assert Enum.all?(finding_errors, &(&1.code == :invalid_query_preview_finding)) + end + test "validates first-class artifact row fields" do valid_artifact = Components.artifact_row("ADR", [], 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 ef31b007..ae64d808 100644 --- a/packages/unified_iur/test/unified_iur/widgets/components_test.exs +++ b/packages/unified_iur/test/unified_iur/widgets/components_test.exs @@ -49,7 +49,8 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do :sidebar_section, :sidebar_item, :right_rail, - :command_palette + :command_palette, + :composer_query_preview ] assert Components.redline_code_kinds() == [ @@ -258,6 +259,40 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do end end + test "validates canonical composer query preview shape" do + assert_raise ArgumentError, ~r/non-empty :composer_id/, fn -> + Components.composer_query_preview(query: "status") + end + + assert_raise ArgumentError, ~r/non-empty :query/, fn -> + Components.composer_query_preview(composer_id: "composer-main") + end + + assert_raise ArgumentError, ~r/preview_state must be one of/, fn -> + Components.composer_query_preview( + composer_id: "composer-main", + query: "status", + preview_state: :stale + ) + end + + assert_raise ArgumentError, ~r/explanation.*ready/, fn -> + Components.composer_query_preview( + composer_id: "composer-main", + query: "status", + preview_state: :ready + ) + end + + assert_raise ArgumentError, ~r/confidence must be in 0\.0\.\.1\.0/, fn -> + Components.composer_query_preview( + composer_id: "composer-main", + query: "status", + findings: [%{id: "finding-1", n: 1, snippet: "bad", confidence: 1.2}] + ) + end + end + test "represents workflow, layer, callout, redline, and code components" do stepper = Components.pipeline_stepper_horizontal( @@ -300,6 +335,19 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do action_intent: :inspect_event ) + query_preview = + Components.composer_query_preview( + id: "preview-search", + composer_id: "composer-main", + query: "release blockers", + preview_state: :ready, + explanation: "Three likely blockers found.", + metrics: %{results_count: 3, duration_ms: 42, sources_visited: 8}, + findings: [ + %{id: "finding-1", n: 1, snippet: "Conformance missing", confidence: 0.91} + ] + ) + redline = Components.redline_inline([%{state: :insert, text: "new"}]) code = @@ -364,6 +412,34 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do } assert [%{element: %Element{kind: :button}}] = callout.children + + assert query_preview.attributes.component == %{ + family: :layer_shell_and_callout, + kind: :composer_query_preview + } + + assert query_preview.attributes.query_preview == %{ + composer_id: "composer-main", + query: "release blockers", + preview_state: :ready, + max_findings_shown: 2, + findings: [ + %{id: "finding-1", n: 1, snippet: "Conformance missing", confidence: 0.91} + ], + explanation: "Three likely blockers found.", + metrics: %{results_count: 3, duration_ms: 42, sources_visited: 8}, + loading_label: "Searching", + empty_label: "No results for this query.", + open_label: "Open query", + save_label: "Save query" + } + + assert [ + %Interaction{family: :close, intent: :dismiss_query_preview}, + %Interaction{family: :open, intent: :open_query_preview}, + %Interaction{family: :command, intent: :save_query} + ] = query_preview.attributes.interactions + assert redline.attributes.redline == %{segments: [%{state: :insert, text: "new"}]} assert redline.attributes.text_safety == %{content: :plain_text} diff --git a/packages/unified_ui/lib/unified_ui/compiler/pipeline.ex b/packages/unified_ui/lib/unified_ui/compiler/pipeline.ex index d92198fa..878783a9 100644 --- a/packages/unified_ui/lib/unified_ui/compiler/pipeline.ex +++ b/packages/unified_ui/lib/unified_ui/compiler/pipeline.ex @@ -1006,6 +1006,25 @@ defmodule UnifiedUi.Compiler.Pipeline do ]) ) + :composer_query_preview -> + Widgets.Components.composer_query_preview( + common_opts(node, attachments, [ + :composer_id, + :query, + :preview_state, + :explanation, + :metrics, + :findings, + :max_findings_shown, + :error_message, + :loading_label, + :empty_label, + :open_intent, + :save_intent, + :dismiss_intent + ]) + ) + :right_rail -> lower_right_rail(node, context, visited, attachments) @@ -1547,6 +1566,7 @@ defmodule UnifiedUi.Compiler.Pipeline do ) end + defp default_dismiss_interaction(%Node{kind: :composer_query_preview}), do: nil defp default_dismiss_interaction(%Node{dismiss_intent: nil}), do: nil defp default_dismiss_interaction(node) do diff --git a/packages/unified_ui/lib/unified_ui/dsl/entities/widget_components.ex b/packages/unified_ui/lib/unified_ui/dsl/entities/widget_components.ex index 842a788e..d9a9acdb 100644 --- a/packages/unified_ui/lib/unified_ui/dsl/entities/widget_components.ex +++ b/packages/unified_ui/lib/unified_ui/dsl/entities/widget_components.ex @@ -219,6 +219,28 @@ defmodule UnifiedUi.Dsl.Entities.WidgetComponents do action_intent: [type: :atom, required: false], summary: [type: :string, required: false] ), + leaf( + :composer_query_preview, + @layer_family, + composer_id: [type: :string, required: true], + query: [type: :string, required: true], + preview_state: [ + type: {:in, [:loading, :ready, :empty, :error]}, + required: false, + default: :empty + ], + explanation: [type: :string, required: false], + metrics: [type: :any, required: false], + findings: [type: :any, required: false, default: []], + max_findings_shown: [type: :integer, required: false, default: 2], + error_message: [type: :string, required: false], + loading_label: [type: :string, required: false, default: "Searching"], + empty_label: [type: :string, required: false, default: "No results for this query."], + open_intent: [type: :atom, required: false], + save_intent: [type: :atom, required: false], + dismiss_intent: [type: :atom, required: false], + summary: [type: :string, required: false] + ), right_rail_entity() ] end diff --git a/packages/unified_ui/lib/unified_ui/dsl/node.ex b/packages/unified_ui/lib/unified_ui/dsl/node.ex index 6c2dabd0..fbd714db 100644 --- a/packages/unified_ui/lib/unified_ui/dsl/node.ex +++ b/packages/unified_ui/lib/unified_ui/dsl/node.ex @@ -206,6 +206,17 @@ defmodule UnifiedUi.Dsl.Node do collapsible?: boolean() | nil, panel_select_intent: atom() | nil, collapse_intent: atom() | nil, + composer_id: String.t() | nil, + query: String.t() | nil, + preview_state: atom() | nil, + explanation: String.t() | nil, + findings: list() | nil, + max_findings_shown: integer() | nil, + error_message: String.t() | nil, + loading_label: String.t() | nil, + empty_label: String.t() | nil, + open_intent: atom() | nil, + save_intent: atom() | nil, density: atom() | nil, children: [t()] } @@ -392,6 +403,17 @@ defmodule UnifiedUi.Dsl.Node do collapsible?: nil, panel_select_intent: nil, collapse_intent: nil, + composer_id: nil, + query: nil, + preview_state: nil, + explanation: nil, + findings: nil, + max_findings_shown: nil, + error_message: nil, + loading_label: nil, + empty_label: nil, + open_intent: nil, + save_intent: nil, density: nil, children: [] @@ -500,6 +522,18 @@ defmodule UnifiedUi.Dsl.Node do collapsible?: node.collapsible?, panel_select_intent: node.panel_select_intent, collapse_intent: node.collapse_intent, + composer_id: node.composer_id, + query: node.query, + preview_state: node.preview_state, + explanation: node.explanation, + metrics: summary_metrics(node), + findings: node.findings, + max_findings_shown: node.max_findings_shown, + error_message: node.error_message, + loading_label: node.loading_label, + empty_label: node.empty_label, + open_intent: node.open_intent, + save_intent: node.save_intent, density: node.density, current: node.current, minimum: node.minimum, @@ -541,5 +575,15 @@ defmodule UnifiedUi.Dsl.Node do dismiss_intent end + defp summary_dismiss_intent(%__MODULE__{ + kind: :composer_query_preview, + dismiss_intent: dismiss_intent + }) do + dismiss_intent + end + defp summary_dismiss_intent(_node), do: nil + + defp summary_metrics(%__MODULE__{kind: :composer_query_preview, metrics: metrics}), do: metrics + defp summary_metrics(_node), do: nil end diff --git a/packages/unified_ui/lib/unified_ui/dsl/placement.ex b/packages/unified_ui/lib/unified_ui/dsl/placement.ex index aa44ae47..6183484b 100644 --- a/packages/unified_ui/lib/unified_ui/dsl/placement.ex +++ b/packages/unified_ui/lib/unified_ui/dsl/placement.ex @@ -96,7 +96,8 @@ defmodule UnifiedUi.Dsl.Placement do :workflow_stage_list_vertical, :meter_thin, :redline_inline, - :code_block_syntax_highlighted + :code_block_syntax_highlighted, + :composer_query_preview ] @advanced_leaf_kinds [ diff --git a/packages/unified_ui/lib/unified_ui/dsl/verifiers/validate_widget_components.ex b/packages/unified_ui/lib/unified_ui/dsl/verifiers/validate_widget_components.ex index 7ee7d5f4..7dbdcd34 100644 --- a/packages/unified_ui/lib/unified_ui/dsl/verifiers/validate_widget_components.ex +++ b/packages/unified_ui/lib/unified_ui/dsl/verifiers/validate_widget_components.ex @@ -40,6 +40,7 @@ defmodule UnifiedUi.Dsl.Verifiers.ValidateWidgetComponents do runtime_module url ] + @query_preview_states [:loading, :ready, :empty, :error] @spec verify(map()) :: :ok | {:error, Spark.Error.DslError.t()} def verify(dsl) do @@ -337,6 +338,51 @@ defmodule UnifiedUi.Dsl.Verifiers.ValidateWidgetComponents do end end + def validate_node(%Node{ + kind: :composer_query_preview, + id: id, + composer_id: composer_id, + query: query, + preview_state: preview_state, + explanation: explanation, + metrics: metrics, + findings: findings, + max_findings_shown: max_findings_shown + }) do + cond do + not is_binary(composer_id) or composer_id == "" -> + {:error, [:composition, :composer_query_preview, id], + "composer_query_preview #{inspect(id)} composer_id must be a non-empty string"} + + not is_binary(query) or query == "" -> + {:error, [:composition, :composer_query_preview, id], + "composer_query_preview #{inspect(id)} query must be a non-empty string"} + + preview_state not in @query_preview_states -> + {:error, [:composition, :composer_query_preview, id], + "composer_query_preview #{inspect(id)} preview_state must be one of #{inspect(@query_preview_states)}"} + + preview_state == :ready and (not is_binary(explanation) or explanation == "") -> + {:error, [:composition, :composer_query_preview, id], + "composer_query_preview #{inspect(id)} explanation is required for ready previews"} + + not valid_optional_query_preview_metrics?(metrics) -> + {:error, [:composition, :composer_query_preview, id], + "composer_query_preview #{inspect(id)} metrics must be a map with non-negative integer counts"} + + not valid_query_preview_findings?(findings) -> + {:error, [:composition, :composer_query_preview, id], + "composer_query_preview #{inspect(id)} findings must be a list of maps with id, n, snippet, and confidence"} + + not valid_positive_integer?(max_findings_shown) -> + {:error, [:composition, :composer_query_preview, id], + "composer_query_preview #{inspect(id)} max_findings_shown must be a positive integer"} + + true -> + :ok + end + end + def validate_node(%Node{kind: :redline_inline, id: id, segments: segments}) do if valid_redline_segments?(segments) do :ok @@ -615,6 +661,40 @@ defmodule UnifiedUi.Dsl.Verifiers.ValidateWidgetComponents do defp valid_rail_panel?(_panel), do: false + defp valid_optional_query_preview_metrics?(nil), do: true + + defp valid_optional_query_preview_metrics?(metrics) when is_map(metrics) or is_list(metrics) do + metrics = normalize_map(metrics) + + [:results_count, :findings_count, :duration_ms, :sources_visited, :grains_visited] + |> Enum.all?(fn key -> + case field(metrics, key) do + nil -> true + value -> valid_non_negative_integer?(value) + end + end) + end + + defp valid_optional_query_preview_metrics?(_metrics), do: false + + defp valid_query_preview_findings?(findings) when is_list(findings) do + Enum.all?(findings, &valid_query_preview_finding?/1) + end + + defp valid_query_preview_findings?(_findings), do: false + + defp valid_query_preview_finding?(finding) when is_map(finding) or is_list(finding) do + finding = normalize_map(finding) + id = field(finding, :id) || field(finding, :finding_id) + confidence = field(finding, :confidence) + + is_binary(id) and id != "" and valid_positive_integer?(field(finding, :n)) and + is_binary(field(finding, :snippet)) and field(finding, :snippet) != "" and + is_number(confidence) and confidence >= 0.0 and confidence <= 1.0 + end + + defp valid_query_preview_finding?(_finding), do: false + defp active_rail_panel?(active_panel, panels) do valid_scalar?(active_panel) and Enum.any?(panels, fn panel -> @@ -658,6 +738,7 @@ defmodule UnifiedUi.Dsl.Verifiers.ValidateWidgetComponents do defp valid_non_negative_integer?(nil), do: true defp valid_non_negative_integer?(value), do: is_integer(value) and value >= 0 + defp valid_positive_integer?(value), do: is_integer(value) and value > 0 defp valid_subject_dependency_names?(nil), do: true diff --git a/packages/unified_ui/lib/unified_ui/widget_components.ex b/packages/unified_ui/lib/unified_ui/widget_components.ex index 877dd2fe..abd3666a 100644 --- a/packages/unified_ui/lib/unified_ui/widget_components.ex +++ b/packages/unified_ui/lib/unified_ui/widget_components.ex @@ -227,6 +227,13 @@ defmodule UnifiedUi.WidgetComponents do "Keyboard-driven command palette overlay with open state, filterable items, and children.", aliases: [] }, + %{ + kind: :composer_query_preview, + family: :layer_shell_and_callout, + summary: + "Inline composer-adjacent query preview with state, result summary, metrics, findings, and canonical actions.", + aliases: [] + }, %{ kind: :workflow_progress_status_card, family: :workflow_progress_and_status, diff --git a/packages/unified_ui/test/unified_ui/advanced_widget_families_test.exs b/packages/unified_ui/test/unified_ui/advanced_widget_families_test.exs index f086e4c2..a0079f8b 100644 --- a/packages/unified_ui/test/unified_ui/advanced_widget_families_test.exs +++ b/packages/unified_ui/test/unified_ui/advanced_widget_families_test.exs @@ -181,6 +181,7 @@ defmodule UnifiedUi.AdvancedWidgetFamiliesTest do :sticky_frosted_header, :slide_over_panel, :event_callout, + :composer_query_preview, :right_rail, :redline_inline, :code_block_syntax_highlighted, diff --git a/packages/unified_ui/test/unified_ui/operational_widget_components_test.exs b/packages/unified_ui/test/unified_ui/operational_widget_components_test.exs index ef17c952..c93b4b3a 100644 --- a/packages/unified_ui/test/unified_ui/operational_widget_components_test.exs +++ b/packages/unified_ui/test/unified_ui/operational_widget_components_test.exs @@ -122,6 +122,19 @@ defmodule UnifiedUi.OperationalWidgetComponentsTest do end end + composer_query_preview :query_preview do + composer_id("composer-main") + query("release blockers") + preview_state(:ready) + explanation("Three likely blockers found.") + metrics(%{results_count: 3, duration_ms: 42, sources_visited: 8}) + findings([%{id: "finding-1", n: 1, snippet: "Conformance missing", confidence: 0.91}]) + max_findings_shown(2) + open_intent(:open_query_preview) + save_intent(:save_query) + dismiss_intent(:dismiss_query_preview) + end + redline_inline :copy_redline do segments([ %{state: :keep, text: "Keep this "}, @@ -160,6 +173,7 @@ defmodule UnifiedUi.OperationalWidgetComponentsTest do :sticky_frosted_header, :slide_over_panel, :event_callout, + :composer_query_preview, :right_rail ] @@ -192,6 +206,10 @@ defmodule UnifiedUi.OperationalWidgetComponentsTest do by_id.details_panel.modal?} == {:layer_shell_and_callout, :slide_over_panel, true, false} + assert {by_id.query_preview.family, by_id.query_preview.kind, by_id.query_preview.query, + by_id.query_preview.preview_state} == + {:layer_shell_and_callout, :composer_query_preview, "release blockers", :ready} + assert {by_id.copy_redline.family, by_id.copy_redline.kind, by_id.copy_redline.text_safety} == {:redline_and_code, :redline_inline, :plain_text} @@ -246,6 +264,17 @@ defmodule UnifiedUi.OperationalWidgetComponentsTest do dismiss_intent: :close_details } = summary.details_panel + assert %{ + family: :layer_shell_and_callout, + kind: :composer_query_preview, + composer_id: "composer-main", + query: "release blockers", + preview_state: :ready, + open_intent: :open_query_preview, + save_intent: :save_query, + dismiss_intent: :dismiss_query_preview + } = summary.query_preview + assert %{ family: :redline_and_code, kind: :code_block_syntax_highlighted, @@ -316,6 +345,18 @@ defmodule UnifiedUi.OperationalWidgetComponentsTest do assert panel_message =~ "must remain non-modal" + assert {:error, [:composition, :composer_query_preview, :bad_query_preview], + query_preview_message} = + ValidateWidgetComponents.validate_node(%Node{ + kind: :composer_query_preview, + id: :bad_query_preview, + composer_id: "composer-main", + query: "status", + preview_state: :ready + }) + + assert query_preview_message =~ "explanation is required" + assert {:error, [:composition, :redline_inline, :bad_redline], redline_message} = ValidateWidgetComponents.validate_node(%Node{ kind: :redline_inline, 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 9deadc18..2c7c2bdb 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 @@ -46,7 +46,8 @@ defmodule UnifiedUi.WidgetComponentsCatalogTest do :sidebar_section, :sidebar_item, :right_rail, - :command_palette + :command_palette, + :composer_query_preview ], redline_and_code: [:redline_inline, :code_block_syntax_highlighted], composition_behavior: [:list_repeat] @@ -64,6 +65,7 @@ defmodule UnifiedUi.WidgetComponentsCatalogTest do assert :right_rail in kinds assert :unread_badge in kinds assert :command_palette in kinds + assert :composer_query_preview in kinds assert :workflow_progress_status_card in kinds end diff --git a/packages/unified_ui/test/unified_ui/widget_components_compiler_lowering_test.exs b/packages/unified_ui/test/unified_ui/widget_components_compiler_lowering_test.exs index 7f9e6d77..4767a8d5 100644 --- a/packages/unified_ui/test/unified_ui/widget_components_compiler_lowering_test.exs +++ b/packages/unified_ui/test/unified_ui/widget_components_compiler_lowering_test.exs @@ -117,6 +117,18 @@ defmodule UnifiedUi.WidgetComponentsCompilerLoweringTest do action_intent(:inspect_event) end + composer_query_preview :query_preview do + composer_id("composer-main") + query("release blockers") + preview_state(:ready) + explanation("Two release checks need attention.") + metrics(%{results_count: 2, duration_ms: 34, sources_visited: 4}) + findings([%{id: "finding-1", n: 1, snippet: "CI is still pending.", confidence: 0.82}]) + open_intent(:open_query_preview) + save_intent(:save_query) + dismiss_intent(:dismiss_query_preview) + end + right_rail :workspace_rail do side(:right) @@ -160,6 +172,7 @@ defmodule UnifiedUi.WidgetComponentsCompilerLoweringTest do artifact = Tree.find_by_id(iur, :artifact) progress = Tree.find_by_id(iur, :quality_bar) panel = Tree.find_by_id(iur, :details_panel) + query_preview = Tree.find_by_id(iur, :query_preview) rail = Tree.find_by_id(iur, :workspace_rail) code = Tree.find_by_id(iur, :code_sample) @@ -227,6 +240,27 @@ defmodule UnifiedUi.WidgetComponentsCompilerLoweringTest do dismiss_intent: :close_panel } + assert query_preview.attributes.component == %{ + family: :layer_shell_and_callout, + kind: :composer_query_preview + } + + assert query_preview.attributes.query_preview == %{ + composer_id: "composer-main", + query: "release blockers", + preview_state: :ready, + explanation: "Two release checks need attention.", + metrics: %{results_count: 2, duration_ms: 34, sources_visited: 4}, + findings: [ + %{id: "finding-1", n: 1, snippet: "CI is still pending.", confidence: 0.82} + ], + max_findings_shown: 2, + loading_label: "Searching", + empty_label: "No results for this query.", + open_label: "Open query", + save_label: "Save query" + } + assert rail.attributes.component == %{ family: :layer_shell_and_callout, kind: :right_rail @@ -265,6 +299,7 @@ defmodule UnifiedUi.WidgetComponentsCompilerLoweringTest do stepper = Tree.find_by_id(iur, :release_steps) panel = Tree.find_by_id(iur, :details_panel) callout = Tree.find_by_id(iur, :incident_callout) + query_preview = Tree.find_by_id(iur, :query_preview) rail = Tree.find_by_id(iur, :workspace_rail) assert [%Interaction{family: :selection, intent: :select_status} = selection] = @@ -306,6 +341,12 @@ defmodule UnifiedUi.WidgetComponentsCompilerLoweringTest do assert [%Interaction{family: :click, intent: :inspect_event}] = callout.attributes.interactions + assert Enum.map(query_preview.attributes.interactions, &{&1.family, &1.intent}) == [ + {:close, :dismiss_query_preview}, + {:open, :open_query_preview}, + {:command, :save_query} + ] + assert [ %Interaction{family: :selection, intent: :select_rail_panel} = panel_select, %Interaction{family: :change, intent: :toggle_rail} = collapse_change diff --git a/specs/contracts/canonical_widget_components_contract.md b/specs/contracts/canonical_widget_components_contract.md index ebac237c..196bc9e9 100644 --- a/specs/contracts/canonical_widget_components_contract.md +++ b/specs/contracts/canonical_widget_components_contract.md @@ -39,6 +39,7 @@ Ash UI MUST adopt these canonical widget-component kinds: | `meter_thin` | workflow progress and status | - | | `slide_over_panel` | layer shell and callout | - | | `event_callout` | layer shell and callout | - | +| `composer_query_preview` | layer shell and callout | - | | `redline_inline` | redline and code | - | | `code_block_syntax_highlighted` | redline and code | - | | `chat_composer` | form control and composer | - | diff --git a/test/ash_ui/phase_31_canonical_conversion_test.exs b/test/ash_ui/phase_31_canonical_conversion_test.exs index 6fe723b8..2839caec 100644 --- a/test/ash_ui/phase_31_canonical_conversion_test.exs +++ b/test/ash_ui/phase_31_canonical_conversion_test.exs @@ -43,6 +43,17 @@ defmodule AshUI.Phase31CanonicalConversionTest do {:sticky_frosted_header, :layer_shell_and_callout, %{title: "Workspace"}, :shell}, {:slide_over_panel, :layer_shell_and_callout, %{label: "Details", open?: true}, :panel}, {:event_callout, :layer_shell_and_callout, %{message: "Deployment paused"}, :callout}, + {:composer_query_preview, :layer_shell_and_callout, + %{ + composer_id: "composer-main", + query: "release blockers", + preview_state: :ready, + explanation: "Three likely blockers found.", + metrics: %{results_count: 3, duration_ms: 42, sources_visited: 8}, + findings: [ + %{id: "finding-1", n: 1, snippet: "Conformance missing", confidence: 0.91} + ] + }, :query_preview}, {:redline_inline, :redline_and_code, %{segments: [%{state: :insert, text: "new"}]}, :redline}, {:code_block_syntax_highlighted, :redline_and_code, %{language: :elixir, tokens: [%{type: :keyword, text: "def"}]}, :code}, diff --git a/test/ash_ui/phase_31_package_boundary_test.exs b/test/ash_ui/phase_31_package_boundary_test.exs index 2ef5a65a..ad21d44d 100644 --- a/test/ash_ui/phase_31_package_boundary_test.exs +++ b/test/ash_ui/phase_31_package_boundary_test.exs @@ -64,7 +64,8 @@ defmodule AshUI.Phase31PackageBoundaryTest do :sidebar_section, :sidebar_item, :right_rail, - :command_palette + :command_palette, + :composer_query_preview ] assert families.redline_and_code == [:redline_inline, :code_block_syntax_highlighted] diff --git a/test/ash_ui/phase_33_resource_admission_test.exs b/test/ash_ui/phase_33_resource_admission_test.exs new file mode 100644 index 00000000..55a29bd3 --- /dev/null +++ b/test/ash_ui/phase_33_resource_admission_test.exs @@ -0,0 +1,65 @@ +defmodule AshUI.Phase33ResourceAdmissionTest do + use ExUnit.Case, async: true + + alias AshUI.DSL.Storage + alias AshUI.Resources.Validations.Authoring + + @moduletag :conformance + + describe "Section 33 - composer query preview admission" do + test "resource and persisted DSL authoring admit composer_query_preview" do + definition = %{ + type: :composer_query_preview, + props: %{composer_id: "composer-main", query: "release blockers"}, + variants: [], + metadata: %{} + } + + assert Authoring.validate_element_definition!(definition) == definition + + assert Storage.canonical_widget_type(:composer_query_preview) == + {:ok, "composer_query_preview"} + + assert Storage.validate_write(%{ + type: "composer_query_preview", + props: %{"composer_id" => "composer-main", "query" => "release blockers"}, + children: [], + signals: [], + metadata: %{} + }) == :ok + end + + test "composer_query_preview exposes click-based action admission" do + definition = %{type: :composer_query_preview, props: %{}, variants: [], metadata: %{}} + + assert :ok = + Authoring.validate_element_authority!( + definition, + [], + [ + %{ + id: :open_query_preview, + signal: :click, + source: "query_preview", + target: "preview" + } + ] + ) + + assert_raise ArgumentError, ~r/supported signals/, fn -> + Authoring.validate_element_authority!( + definition, + [], + [ + %{ + id: :submit_query_preview, + signal: :submit, + source: "query_preview", + target: "preview" + } + ] + ) + end + end + end +end diff --git a/test/ash_ui/rendering/live_ui_adapter_test.exs b/test/ash_ui/rendering/live_ui_adapter_test.exs index dfc5bb91..05bc55a8 100644 --- a/test/ash_ui/rendering/live_ui_adapter_test.exs +++ b/test/ash_ui/rendering/live_ui_adapter_test.exs @@ -1241,6 +1241,74 @@ defmodule AshUI.Rendering.LiveUIAdapterTest do end end + describe "composer_query_preview adapter dispatch" do + test "generates dedicated composer_query_preview fallback markup" do + iur = %{ + "type" => "composer_query_preview", + "id" => "query-preview-adapter-test", + "props" => %{ + "query_preview" => %{ + "composer_id" => "composer-main", + "query" => "release blockers", + "preview_state" => "ready", + "explanation" => "Three likely blockers found.", + "metrics" => %{ + "results_count" => 3, + "duration_ms" => 42, + "sources_visited" => 8 + }, + "findings" => [ + %{ + "id" => "finding-1", + "n" => 1, + "snippet" => "Conformance missing", + "confidence" => 0.91 + } + ] + } + }, + "children" => [], + "metadata" => %{} + } + + {:ok, heex} = LiveUIAdapter.render(iur, force_fallback: true) + + assert heex =~ "ash-composer-query-preview" + assert heex =~ ~s(data-live-ui-widget="composer-query-preview") + assert heex =~ ~s(data-composer-id="composer-main") + assert heex =~ "Three likely blockers found." + assert heex =~ "Conformance missing" + assert heex =~ "3" + assert heex =~ "0.04s" + refute heex =~ "data-live-ui-intent" + refute heex =~ "open in Ask" + end + + test "escapes query preview fallback content" do + iur = %{ + "type" => "composer_query_preview", + "id" => "query-preview-escape-test", + "props" => %{ + "query_preview" => %{ + "composer_id" => "composer-main", + "query" => "", + "preview_state" => "error", + "error_message" => "bad" + } + }, + "children" => [], + "metadata" => %{} + } + + {:ok, heex} = LiveUIAdapter.render(iur, force_fallback: true) + + assert heex =~ "<script>alert(1)</script>" + assert heex =~ "<b>bad</b>" + refute heex =~ "