From c495a2ed0a66198525a1122e6113f478ca19ba07 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Fri, 1 May 2026 23:15:04 +0200 Subject: [PATCH] fix: A2UI now behaves better --- lib/bds/ai/openai_compatible_runtime.ex | 2 +- lib/bds/desktop/shell_live/chat_editor.ex | 6 +- .../chat_editor_html/chat_editor.html.heex | 14 ++- priv/ui/app.css | 2 + priv/ui/live.js | 6 ++ test/bds/ai_test.exs | 48 +++++++++ test/bds/desktop/shell_live_test.exs | 97 ++++++++++++++++++- 7 files changed, 163 insertions(+), 12 deletions(-) diff --git a/lib/bds/ai/openai_compatible_runtime.ex b/lib/bds/ai/openai_compatible_runtime.ex index 2d77b96..5d76ef6 100644 --- a/lib/bds/ai/openai_compatible_runtime.ex +++ b/lib/bds/ai/openai_compatible_runtime.ex @@ -36,7 +36,7 @@ defmodule BDS.AI.OpenAICompatibleRuntime do "messages" => request.messages, "max_tokens" => request.max_output_tokens } - |> maybe_put_tools(request.tools) + |> maybe_put_tools(Map.get(request, :tools, [])) with {:ok, response} <- HttpClient.post(url, headers, Jason.encode!(payload)), 200 <- response.status do diff --git a/lib/bds/desktop/shell_live/chat_editor.ex b/lib/bds/desktop/shell_live/chat_editor.ex index fb48409..795ffc4 100644 --- a/lib/bds/desktop/shell_live/chat_editor.ex +++ b/lib/bds/desktop/shell_live/chat_editor.ex @@ -352,7 +352,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do @spec chat_surface(term()) :: term() def chat_surface(assigns) do ~H""" -
+
<%= surface_icon(@surface.type) %> <%= surface_title(@surface) %> @@ -566,6 +566,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do defp surface_icon("tabs"), do: "▧" defp surface_icon(_type), do: "■" + defp surface_expanded_attr(surface) do + if Map.get(surface, :expanded?, false), do: "true", else: "false" + end + defp surface_title(surface) do cond do present?(Map.get(surface, :title)) -> Map.get(surface, :title) diff --git a/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex b/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex index 1da9c52..d0eaee2 100644 --- a/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex +++ b/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex @@ -99,16 +99,15 @@ <%= if message.role == :assistant do %>
<%= markdown_html(message.content || "") %>
+ <%= for surface <- message.inline_surfaces do %> + <.chat_surface surface={surface} /> + <% end %> <% else %>
<%= message.content || "" %>
<% end %> - <%= for surface <- message.inline_surfaces do %> - <.chat_surface surface={surface} /> - <% end %> - <% end %> <%= if @chat_editor.is_streaming and (@chat_editor.streaming_content != "" or @chat_editor.streaming_tool_markers != []) do %> @@ -124,12 +123,11 @@ <%= if @chat_editor.streaming_content != "" do %>
<%= markdown_html(@chat_editor.streaming_content) %>
<% end %> + <%= for surface <- @chat_editor.streaming_inline_surfaces do %> + <.chat_surface surface={surface} /> + <% end %> - - <%= for surface <- @chat_editor.streaming_inline_surfaces do %> - <.chat_surface surface={surface} /> - <% end %> <% end %> <%= if @chat_editor.is_streaming and @chat_editor.streaming_content == "" and @chat_editor.streaming_tool_markers == [] do %> diff --git a/priv/ui/app.css b/priv/ui/app.css index dbe0973..1740389 100644 --- a/priv/ui/app.css +++ b/priv/ui/app.css @@ -5620,6 +5620,8 @@ button svg * { } .chat-inline-surface { + width: 100%; + margin: 12px 0 0; overflow: hidden; } diff --git a/priv/ui/live.js b/priv/ui/live.js index 00a7439..aa6e79a 100644 --- a/priv/ui/live.js +++ b/priv/ui/live.js @@ -788,6 +788,10 @@ document.addEventListener("DOMContentLoaded", () => { }); }; + this.surfaceObserver = new MutationObserver(() => { + this.syncExpandedSurfaces(); + }); + this.handleScroll = () => { if (!this.scrollContainer) { this.stickToBottom = true; @@ -832,6 +836,7 @@ document.addEventListener("DOMContentLoaded", () => { this.syncScrollContainer(); this.syncExpandedSurfaces(); + this.surfaceObserver.observe(this.el, { childList: true, subtree: true }); this.autoResize(); window.requestAnimationFrame(() => this.scrollToBottom(true)); }, @@ -844,6 +849,7 @@ document.addEventListener("DOMContentLoaded", () => { }, destroyed() { + this.surfaceObserver.disconnect(); this.el.removeEventListener("input", this.handleInput); this.el.removeEventListener("keydown", this.handleKeyDown); diff --git a/test/bds/ai_test.exs b/test/bds/ai_test.exs index 2f23ef5..da158de 100644 --- a/test/bds/ai_test.exs +++ b/test/bds/ai_test.exs @@ -107,6 +107,27 @@ defmodule BDS.AITest do end end + defmodule RecordingCompletionServer do + use Plug.Router + + plug(:match) + plug(:dispatch) + + post "/v1/chat/completions" do + {:ok, body, conn} = Plug.Conn.read_body(conn) + send(Application.fetch_env!(:bds, :test_pid), {:completion_payload, Jason.decode!(body)}) + + response = %{ + "choices" => [%{"message" => %{"content" => "Short Title"}}], + "usage" => %{"prompt_tokens" => 4, "completion_tokens" => 2} + } + + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp(200, Jason.encode!(response)) + end + end + defmodule FakeRuntime do def generate(endpoint, request, opts) do test_pid = Keyword.fetch!(opts, :test_pid) @@ -313,6 +334,33 @@ defmodule BDS.AITest do ) end + test "openai-compatible generation accepts title requests without tools" do + Application.put_env(:bds, :test_pid, self()) + + server = + start_supervised!({Bandit, plug: RecordingCompletionServer, port: 0, startup_log: false}) + + {:ok, {_address, port}} = ThousandIsland.listener_info(server) + + assert {:ok, %{content: "Short Title"}} = + BDS.AI.OpenAICompatibleRuntime.generate( + %{url: "http://127.0.0.1:#{port}/v1", api_key: nil}, + %{ + operation: :chat_title, + model: "qwen3.5-122b", + messages: [%{"role" => "user", "content" => "Topic: posts per month"}], + max_output_tokens: 20 + }, + [] + ) + + assert_received {:completion_payload, payload} + assert payload["model"] == "qwen3.5-122b" + assert payload["max_tokens"] == 20 + refute Map.has_key?(payload, "tools") + refute Map.has_key?(payload, "tool_choice") + end + test "airplane mode routes title tasks to airplane endpoint and offline title model" do assert {:ok, _endpoint} = BDS.AI.put_endpoint( diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs index 383ae86..a33f81c 100644 --- a/test/bds/desktop/shell_live_test.exs +++ b/test/bds/desktop/shell_live_test.exs @@ -2271,6 +2271,7 @@ defmodule BDS.Desktop.ShellLiveTest do assert html =~ "Blog Stats" assert html =~ "Metric" assert html =~ "Posts" + assert html =~ ~r/chat-message-content.*data-testid="chat-inline-surface"/s dismissed_html = render_click(view, "dismiss_chat_surface", %{ @@ -2333,21 +2334,113 @@ defmodule BDS.Desktop.ShellLiveTest do }) assert length(:binary.matches(html, ~s(data-testid="chat-inline-surface"))) == 2 - assert length(:binary.matches(html, "data-expanded")) == 2 + assert length(:binary.matches(html, ~s(data-expanded="true"))) == 2 + assert length(:binary.matches(html, ~s(open=""))) == 2 assert html =~ "Earlier Missing Data" assert html =~ "The first data request needs review." assert html =~ "Latest Missing Data" assert html =~ "The second data request needs review." + assert html =~ ~r/chat-message-content.*Earlier Missing Data.*Latest Missing Data/s + end + + test "chat editor keeps previous surfaces visible while a new update surface streams" do + assert :ok = AI.set_airplane_mode(false) + + server = + start_supervised!({Bandit, plug: DelayedChatServer, port: 0, startup_log: false}) + + {:ok, {_address, port}} = ThousandIsland.listener_info(server) + + assert {:ok, _endpoint} = + AI.put_endpoint(:online, %{ + url: "http://127.0.0.1:#{port}/v1", + api_key: "online-secret", + model: "gpt-4.1" + }) + + assert {:ok, conversation} = AI.start_chat(%{title: "Update Surfaces", model: "gpt-4.1"}) + + now = Persistence.now_ms() + + Repo.insert!( + BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{ + conversation_id: conversation.id, + role: :assistant, + content: "Earlier missing data.", + tool_calls: + Jason.encode!([ + %{ + "id" => "call-card-old", + "name" => "render_card", + "arguments" => %{ + "title" => "Earlier Missing Data", + "body" => "The first data request needs review." + } + } + ]), + created_at: now + }) + ) + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + + _html = + render_click(view, "pin_sidebar_item", %{ + "route" => "chat", + "id" => conversation.id, + "title" => conversation.title, + "subtitle" => conversation.model || "chat" + }) + + _html = render_change(view, "change_chat_editor_input", %{"message" => "Update missing data"}) + + _html = + view + |> element("[data-testid='chat-send-button']") + |> render_click() + + send(view.pid, { + :chat_tool_call, + conversation.id, + %{ + id: "call-card-new", + name: "render_card", + arguments: %{ + "title" => "Latest Missing Data", + "body" => "The second data request needs review." + } + } + }) + + html = render(view) + + assert length(:binary.matches(html, ~s(data-testid="chat-inline-surface"))) == 2 + assert length(:binary.matches(html, ~s(data-expanded="true"))) == 2 + assert length(:binary.matches(html, ~s(open=""))) == 2 + assert html =~ "Earlier Missing Data" + assert html =~ "The first data request needs review." + assert html =~ "Latest Missing Data" + assert html =~ "The second data request needs review." + assert html =~ ~r/chat-message-content.*Earlier Missing Data.*Latest Missing Data/s + + _html = + view + |> element("[data-testid='chat-abort-button']") + |> render_click() + + Process.sleep(350) end test "chat editor hook reopens server-expanded A2UI surfaces after patches" do live_js = File.read!(Path.expand("../../../priv/ui/live.js", __DIR__)) chat_editor = File.read!(Path.expand("../../../lib/bds/desktop/shell_live/chat_editor.ex", __DIR__)) - assert chat_editor =~ "data-expanded={Map.get(@surface, :expanded?, false)}" + assert chat_editor =~ "data-expanded={surface_expanded_attr(@surface)}" assert live_js =~ "this.syncExpandedSurfaces = () =>" assert live_js =~ "querySelectorAll(\".chat-inline-surface[data-expanded='true']\")" assert live_js =~ "surface.open = true;" + assert live_js =~ "this.surfaceObserver = new MutationObserver" + assert live_js =~ "this.surfaceObserver.disconnect();" assert live_js =~ "this.syncExpandedSurfaces();" end