fix: persist a2ui surfaces in the database for chats to re-hydrate on

opening an old chat, unless manually dismissed
This commit is contained in:
2026-05-27 20:13:33 +02:00
parent 141c2bfc89
commit f7a4a9512c
8 changed files with 241 additions and 5 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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])

View File

@@ -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))}

View File

@@ -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]

View File

@@ -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

View File

@@ -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)

View File

@@ -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"})