From f7a4a9512ca427ca1ad65738f532fe3792b376c9 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Wed, 27 May 2026 20:13:33 +0200 Subject: [PATCH] fix: persist a2ui surfaces in the database for chats to re-hydrate on opening an old chat, unless manually dismissed --- lib/bds/ai.ex | 8 ++ lib/bds/ai/chat.ex | 36 +++++++++ lib/bds/ai/chat_conversation.ex | 4 +- lib/bds/desktop/shell_live/bridges.ex | 9 +++ lib/bds/desktop/shell_live/chat_editor.ex | 66 ++++++++++++++- ...52_add_chat_conversation_surface_state.exs | 9 +++ test/bds/ai_test.exs | 33 ++++++++ test/bds/desktop/shell_live_test.exs | 81 +++++++++++++++++++ 8 files changed, 241 insertions(+), 5 deletions(-) create mode 100644 priv/repo/migrations/20260527175452_add_chat_conversation_surface_state.exs diff --git a/lib/bds/ai.ex b/lib/bds/ai.ex index 8e629d3..7dc5520 100644 --- a/lib/bds/ai.ex +++ b/lib/bds/ai.ex @@ -186,4 +186,12 @@ defmodule BDS.AI do @spec cancel_chat(String.t()) :: :ok defdelegate cancel_chat(conversation_id), to: Chat + + @spec get_surface_state(String.t()) :: map() + defdelegate get_surface_state(conversation_id), to: Chat + + @spec put_surface_state(String.t(), map(), map(), MapSet.t()) :: + {:ok, map()} | {:error, term()} + defdelegate put_surface_state(conversation_id, surface_data, surface_tabs, dismissed_surfaces), + to: Chat end diff --git a/lib/bds/ai/chat.ex b/lib/bds/ai/chat.ex index 280e8f3..7fde7cf 100644 --- a/lib/bds/ai/chat.ex +++ b/lib/bds/ai/chat.ex @@ -62,6 +62,42 @@ defmodule BDS.AI.Chat do Repo.get(ChatConversation, conversation_id) end + @spec get_surface_state(String.t()) :: map() + def get_surface_state(conversation_id) when is_binary(conversation_id) do + case Repo.get(ChatConversation, conversation_id) do + %ChatConversation{surface_state: state} when is_map(state) -> state + _other -> %{} + end + end + + @spec put_surface_state(String.t(), map(), map(), MapSet.t()) :: + {:ok, map()} | {:error, term()} + def put_surface_state(conversation_id, surface_data, surface_tabs, dismissed_surfaces) + when is_binary(conversation_id) do + case Repo.get(ChatConversation, conversation_id) do + nil -> + {:error, :not_found} + + %ChatConversation{} = conversation -> + state = %{ + "surface_data" => surface_data, + "surface_tabs" => surface_tabs, + "dismissed_surfaces" => MapSet.to_list(dismissed_surfaces) + } + + conversation + |> ChatConversation.changeset(%{ + surface_state: state, + updated_at: Persistence.now_ms() + }) + |> Repo.update() + |> case do + {:ok, _updated} -> {:ok, state} + error -> error + end + end + end + @spec delete_chat_conversation(String.t()) :: {:ok, :deleted} | {:error, :not_found | term()} def delete_chat_conversation(conversation_id) when is_binary(conversation_id) do case Repo.get(ChatConversation, conversation_id) do diff --git a/lib/bds/ai/chat_conversation.ex b/lib/bds/ai/chat_conversation.ex index 9480fbe..5c6d4ff 100644 --- a/lib/bds/ai/chat_conversation.ex +++ b/lib/bds/ai/chat_conversation.ex @@ -11,6 +11,7 @@ defmodule BDS.AI.ChatConversation do title: String.t() | nil, model: String.t() | nil, copilot_session_id: String.t() | nil, + surface_state: map() | nil, created_at: integer() | nil, updated_at: integer() | nil } @@ -19,13 +20,14 @@ defmodule BDS.AI.ChatConversation do field :title, :string field :model, :string field :copilot_session_id, :string + field :surface_state, :map field :created_at, :integer field :updated_at, :integer end def changeset(conversation, attrs) do conversation - |> cast(attrs, [:id, :title, :model, :copilot_session_id, :created_at, :updated_at], + |> cast(attrs, [:id, :title, :model, :copilot_session_id, :surface_state, :created_at, :updated_at], empty_values: [nil] ) |> validate_required([:id, :title, :created_at, :updated_at]) diff --git a/lib/bds/desktop/shell_live/bridges.ex b/lib/bds/desktop/shell_live/bridges.ex index 37d7506..9449149 100644 --- a/lib/bds/desktop/shell_live/bridges.ex +++ b/lib/bds/desktop/shell_live/bridges.ex @@ -116,6 +116,15 @@ defmodule BDS.Desktop.ShellLive.Bridges do {:noreply, assign(socket, :chat_editor_request_refs, refs)} end + def handle_info({:persist_surface_state, conversation_id}, socket, _callbacks) do + send_update(ChatEditor, + id: "chat-editor-#{conversation_id}", + action: :persist_surface_state + ) + + {:noreply, socket} + end + def handle_info({:chat_editor_toggle_sidebar}, socket, callbacks) do {:noreply, callbacks.refresh_layout.(socket, Workbench.toggle_sidebar(socket.assigns.workbench))} diff --git a/lib/bds/desktop/shell_live/chat_editor.ex b/lib/bds/desktop/shell_live/chat_editor.ex index 1f570cf..b01dbc4 100644 --- a/lib/bds/desktop/shell_live/chat_editor.ex +++ b/lib/bds/desktop/shell_live/chat_editor.ex @@ -1,6 +1,8 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do @moduledoc false + require Logger + use Phoenix.LiveComponent import Phoenix.HTML, only: [raw: 1] @@ -37,6 +39,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do {:ok, do_note_streaming_content(socket, content)} end + def update(%{action: :persist_surface_state}, socket) do + {:ok, persist_surface_state(socket)} + end + def update(assigns, socket) do socket = socket @@ -97,7 +103,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do socket ) do next_data = Map.put(socket.assigns.surface_data, surface_id, fields) - {:noreply, assign(socket, :surface_data, next_data) |> build_data()} + {:noreply, assign(socket, :surface_data, next_data) |> schedule_surface_state_persist() |> build_data()} end def handle_event( @@ -111,6 +117,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do :surface_tabs, Map.put(socket.assigns.surface_tabs, surface_id, parse_integer(index)) ) + |> persist_surface_state() |> build_data() {:noreply, socket} @@ -120,6 +127,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do socket = socket |> assign(:dismissed_surfaces, MapSet.put(socket.assigns.dismissed_surfaces, surface_id)) + |> persist_surface_state() |> build_data() {:noreply, socket} @@ -148,14 +156,29 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do defp ensure_state(socket) do conversation_id = socket.assigns.current_tab.id + persisted = AI.get_surface_state(conversation_id) + + {surface_data, surface_tabs, dismissed_surfaces} = + case persisted do + state when is_map(state) and map_size(state) > 0 -> + { + state["surface_data"] || %{}, + state["surface_tabs"] || %{}, + MapSet.new(state["dismissed_surfaces"] || []) + } + + _other -> + {%{}, %{}, MapSet.new()} + end + defaults = %{ conversation_id: conversation_id, input: "", model_selector_open?: false, request: nil, - surface_data: %{}, - surface_tabs: %{}, - dismissed_surfaces: MapSet.new(), + surface_data: surface_data, + surface_tabs: surface_tabs, + dismissed_surfaces: dismissed_surfaces, action_error: nil } @@ -819,6 +842,41 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do # ── Private helpers ─────────────────────────────────────────────────────── + @surface_state_debounce_ms 500 + + defp persist_surface_state(socket) do + conversation_id = socket.assigns.conversation_id + surface_data = socket.assigns.surface_data + surface_tabs = socket.assigns.surface_tabs + dismissed_surfaces = socket.assigns.dismissed_surfaces + + case AI.put_surface_state(conversation_id, surface_data, surface_tabs, dismissed_surfaces) do + {:ok, _state} -> + :ok + + {:error, reason} -> + Logger.warning("Failed to persist surface state for conversation #{conversation_id}", + reason: inspect(reason) + ) + end + + socket + end + + defp schedule_surface_state_persist(socket) do + if socket.assigns[:surface_state_timer] do + Process.cancel_timer(socket.assigns[:surface_state_timer]) + end + + timer = + Process.send_after( + self(), + {:persist_surface_state, socket.assigns.conversation_id}, + @surface_state_debounce_ms + ) + + assign(socket, :surface_state_timer, timer) + end defp active_project_id(socket) do socket.assigns[:project_id] diff --git a/priv/repo/migrations/20260527175452_add_chat_conversation_surface_state.exs b/priv/repo/migrations/20260527175452_add_chat_conversation_surface_state.exs new file mode 100644 index 0000000..bfe6e3c --- /dev/null +++ b/priv/repo/migrations/20260527175452_add_chat_conversation_surface_state.exs @@ -0,0 +1,9 @@ +defmodule BDS.Repo.Migrations.AddChatConversationSurfaceState do + use Ecto.Migration + + def change do + alter table(:chat_conversations) do + add :surface_state, :text + end + end +end diff --git a/test/bds/ai_test.exs b/test/bds/ai_test.exs index 4451696..b5256e8 100644 --- a/test/bds/ai_test.exs +++ b/test/bds/ai_test.exs @@ -1477,6 +1477,39 @@ defmodule BDS.AITest do assert Enum.map(messages, & &1.role) == [:user] end + test "get_surface_state and put_surface_state persist and restore surface UI state" do + assert {:ok, conversation} = BDS.AI.start_chat(%{title: "Surface State", model: "gpt-4.1"}) + + surface_data = %{"msg-1-surface-0" => %{"query" => "hello"}} + surface_tabs = %{"msg-1-surface-1" => 2} + dismissed = MapSet.new(["msg-1-surface-0"]) + + assert {:ok, _state} = + BDS.AI.put_surface_state( + conversation.id, + surface_data, + surface_tabs, + dismissed + ) + + loaded = BDS.AI.get_surface_state(conversation.id) + assert loaded["surface_data"] == surface_data + assert loaded["surface_tabs"] == surface_tabs + assert MapSet.new(loaded["dismissed_surfaces"]) == dismissed + end + + test "get_surface_state returns empty map for conversation without surface state" do + assert {:ok, conversation} = BDS.AI.start_chat(%{title: "No Surface State", model: "gpt-4.1"}) + + loaded = BDS.AI.get_surface_state(conversation.id) + assert loaded == %{} + end + + test "get_surface_state returns empty map for unknown conversation" do + loaded = BDS.AI.get_surface_state("nonexistent-id") + assert loaded == %{} + end + defp create_project_fixture(name) do temp_dir = Path.join(System.tmp_dir!(), "bds-ai-#{System.unique_integer([:positive])}") on_exit(fn -> File.rm_rf(temp_dir) end) diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs index 36946b5..5924b38 100644 --- a/test/bds/desktop/shell_live_test.exs +++ b/test/bds/desktop/shell_live_test.exs @@ -4043,6 +4043,87 @@ defmodule BDS.Desktop.ShellLiveTest do assert live_js =~ "this.syncExpandedSurfaces();" end + test "chat editor restores dismissed surfaces from persisted surface state when reopening a chat" do + assert {:ok, conversation} = AI.start_chat(%{title: "Reopen Chat", model: "gpt-4.1"}) + + now = Persistence.now_ms() + + Repo.insert!( + BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{ + conversation_id: conversation.id, + role: :user, + content: "Show me two cards", + created_at: now + }) + ) + + Repo.insert!( + BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{ + conversation_id: conversation.id, + role: :assistant, + content: "Here are two cards.", + tool_calls: + Jason.encode!([ + %{ + "id" => "call-card-a", + "name" => "render_card", + "arguments" => %{ + "title" => "UniqueTitleAlpha", + "body" => "First card alpha" + } + }, + %{ + "id" => "call-card-b", + "name" => "render_card", + "arguments" => %{ + "title" => "UniqueTitleBeta", + "body" => "Second card beta" + } + } + ]), + created_at: now + 1 + }) + ) + + {: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" + }) + + assert length(:binary.matches(html, ~s(data-testid="chat-inline-surface"))) == 2 + + surface_id_a = Regex.run(~r/id="([^"]+-surface-0)"/, html) |> Enum.at(1) + + dismissed_html = + view + |> element("button[phx-value-surface-id='#{surface_id_a}']") + |> render_click() + + assert length(:binary.matches(dismissed_html, ~s(data-testid="chat-inline-surface"))) == 1 + + persisted = AI.get_surface_state(conversation.id) + assert MapSet.new(persisted["dismissed_surfaces"]) == MapSet.new([surface_id_a]) + + {:ok, view2, _html2} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + + html2 = + render_click(view2, "pin_sidebar_item", %{ + "route" => "chat", + "id" => conversation.id, + "title" => conversation.title, + "subtitle" => conversation.model || "chat" + }) + + assert length(:binary.matches(html2, ~s(data-testid="chat-inline-surface"))) == 1 + assert html2 =~ "UniqueTitleBeta" + refute html2 =~ ~r/id="#{Regex.escape(surface_id_a)}"/ + end + test "chat editor folds tool-only assistant steps into the final assistant answer" do assert {:ok, conversation} = AI.start_chat(%{title: "Tool Chat", model: "gpt-4.1"})