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:
@@ -186,4 +186,12 @@ defmodule BDS.AI do
|
|||||||
|
|
||||||
@spec cancel_chat(String.t()) :: :ok
|
@spec cancel_chat(String.t()) :: :ok
|
||||||
defdelegate cancel_chat(conversation_id), to: Chat
|
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
|
end
|
||||||
|
|||||||
@@ -62,6 +62,42 @@ defmodule BDS.AI.Chat do
|
|||||||
Repo.get(ChatConversation, conversation_id)
|
Repo.get(ChatConversation, conversation_id)
|
||||||
end
|
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()}
|
@spec delete_chat_conversation(String.t()) :: {:ok, :deleted} | {:error, :not_found | term()}
|
||||||
def delete_chat_conversation(conversation_id) when is_binary(conversation_id) do
|
def delete_chat_conversation(conversation_id) when is_binary(conversation_id) do
|
||||||
case Repo.get(ChatConversation, conversation_id) do
|
case Repo.get(ChatConversation, conversation_id) do
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ defmodule BDS.AI.ChatConversation do
|
|||||||
title: String.t() | nil,
|
title: String.t() | nil,
|
||||||
model: String.t() | nil,
|
model: String.t() | nil,
|
||||||
copilot_session_id: String.t() | nil,
|
copilot_session_id: String.t() | nil,
|
||||||
|
surface_state: map() | nil,
|
||||||
created_at: integer() | nil,
|
created_at: integer() | nil,
|
||||||
updated_at: integer() | nil
|
updated_at: integer() | nil
|
||||||
}
|
}
|
||||||
@@ -19,13 +20,14 @@ defmodule BDS.AI.ChatConversation do
|
|||||||
field :title, :string
|
field :title, :string
|
||||||
field :model, :string
|
field :model, :string
|
||||||
field :copilot_session_id, :string
|
field :copilot_session_id, :string
|
||||||
|
field :surface_state, :map
|
||||||
field :created_at, :integer
|
field :created_at, :integer
|
||||||
field :updated_at, :integer
|
field :updated_at, :integer
|
||||||
end
|
end
|
||||||
|
|
||||||
def changeset(conversation, attrs) do
|
def changeset(conversation, attrs) do
|
||||||
conversation
|
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]
|
empty_values: [nil]
|
||||||
)
|
)
|
||||||
|> validate_required([:id, :title, :created_at, :updated_at])
|
|> validate_required([:id, :title, :created_at, :updated_at])
|
||||||
|
|||||||
@@ -116,6 +116,15 @@ defmodule BDS.Desktop.ShellLive.Bridges do
|
|||||||
{:noreply, assign(socket, :chat_editor_request_refs, refs)}
|
{:noreply, assign(socket, :chat_editor_request_refs, refs)}
|
||||||
end
|
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
|
def handle_info({:chat_editor_toggle_sidebar}, socket, callbacks) do
|
||||||
{:noreply,
|
{:noreply,
|
||||||
callbacks.refresh_layout.(socket, Workbench.toggle_sidebar(socket.assigns.workbench))}
|
callbacks.refresh_layout.(socket, Workbench.toggle_sidebar(socket.assigns.workbench))}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
defmodule BDS.Desktop.ShellLive.ChatEditor do
|
defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
use Phoenix.LiveComponent
|
use Phoenix.LiveComponent
|
||||||
|
|
||||||
import Phoenix.HTML, only: [raw: 1]
|
import Phoenix.HTML, only: [raw: 1]
|
||||||
@@ -37,6 +39,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
{:ok, do_note_streaming_content(socket, content)}
|
{:ok, do_note_streaming_content(socket, content)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def update(%{action: :persist_surface_state}, socket) do
|
||||||
|
{:ok, persist_surface_state(socket)}
|
||||||
|
end
|
||||||
|
|
||||||
def update(assigns, socket) do
|
def update(assigns, socket) do
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
@@ -97,7 +103,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
socket
|
socket
|
||||||
) do
|
) do
|
||||||
next_data = Map.put(socket.assigns.surface_data, surface_id, fields)
|
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
|
end
|
||||||
|
|
||||||
def handle_event(
|
def handle_event(
|
||||||
@@ -111,6 +117,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
:surface_tabs,
|
:surface_tabs,
|
||||||
Map.put(socket.assigns.surface_tabs, surface_id, parse_integer(index))
|
Map.put(socket.assigns.surface_tabs, surface_id, parse_integer(index))
|
||||||
)
|
)
|
||||||
|
|> persist_surface_state()
|
||||||
|> build_data()
|
|> build_data()
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
@@ -120,6 +127,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:dismissed_surfaces, MapSet.put(socket.assigns.dismissed_surfaces, surface_id))
|
|> assign(:dismissed_surfaces, MapSet.put(socket.assigns.dismissed_surfaces, surface_id))
|
||||||
|
|> persist_surface_state()
|
||||||
|> build_data()
|
|> build_data()
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
@@ -148,14 +156,29 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
defp ensure_state(socket) do
|
defp ensure_state(socket) do
|
||||||
conversation_id = socket.assigns.current_tab.id
|
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 = %{
|
defaults = %{
|
||||||
conversation_id: conversation_id,
|
conversation_id: conversation_id,
|
||||||
input: "",
|
input: "",
|
||||||
model_selector_open?: false,
|
model_selector_open?: false,
|
||||||
request: nil,
|
request: nil,
|
||||||
surface_data: %{},
|
surface_data: surface_data,
|
||||||
surface_tabs: %{},
|
surface_tabs: surface_tabs,
|
||||||
dismissed_surfaces: MapSet.new(),
|
dismissed_surfaces: dismissed_surfaces,
|
||||||
action_error: nil
|
action_error: nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -819,6 +842,41 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
|
|
||||||
# ── Private helpers ───────────────────────────────────────────────────────
|
# ── 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
|
defp active_project_id(socket) do
|
||||||
socket.assigns[:project_id]
|
socket.assigns[:project_id]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1477,6 +1477,39 @@ defmodule BDS.AITest do
|
|||||||
assert Enum.map(messages, & &1.role) == [:user]
|
assert Enum.map(messages, & &1.role) == [:user]
|
||||||
end
|
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
|
defp create_project_fixture(name) do
|
||||||
temp_dir = Path.join(System.tmp_dir!(), "bds-ai-#{System.unique_integer([:positive])}")
|
temp_dir = Path.join(System.tmp_dir!(), "bds-ai-#{System.unique_integer([:positive])}")
|
||||||
on_exit(fn -> File.rm_rf(temp_dir) end)
|
on_exit(fn -> File.rm_rf(temp_dir) end)
|
||||||
|
|||||||
@@ -4043,6 +4043,87 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
assert live_js =~ "this.syncExpandedSurfaces();"
|
assert live_js =~ "this.syncExpandedSurfaces();"
|
||||||
end
|
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
|
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"})
|
assert {:ok, conversation} = AI.start_chat(%{title: "Tool Chat", model: "gpt-4.1"})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user