chore: converted ai chat to a live component

This commit is contained in:
2026-05-03 17:20:52 +02:00
parent 98243cbd16
commit fa76cdf11d
6 changed files with 576 additions and 545 deletions

View File

@@ -53,8 +53,7 @@ defmodule BDS.Desktop.ShellLive do
tab_subtitle: 2,
tab_id_for_route: 2,
tab_intent: 2,
sidebar_route_atom: 1,
parse_integer: 1
sidebar_route_atom: 1
]
alias BDS.Projects
@@ -164,14 +163,7 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:project_menu_open, false)
|> assign(:sidebar_filters_by_view, %{})
|> assign(:sidebar_filter_panels, %{})
|> assign(:chat_editor_inputs, %{})
|> assign(:chat_model_selectors_open, %{})
|> assign(:chat_editor_requests, %{})
|> assign(:chat_editor_request_refs, %{})
|> assign(:chat_editor_surface_data, %{})
|> assign(:chat_editor_surface_tabs, %{})
|> assign(:chat_editor_dismissed_surfaces, MapSet.new())
|> assign(:chat_editor_action_errors, %{})
|> assign(:import_editor_analysis_states, %{})
|> assign(:import_editor_analysis_task_refs, %{})
|> assign(:import_editor_execution_states, %{})
@@ -331,65 +323,6 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, apply_shell_command(socket, action)}
end
def handle_event("change_chat_editor_input", %{"message" => message}, socket) do
{:noreply, ChatEditor.update_input(socket, message, &reload_shell/2)}
end
def handle_event("toggle_chat_model_selector", _params, socket) do
{:noreply, ChatEditor.toggle_model_selector(socket, &reload_shell/2)}
end
def handle_event("select_chat_model", %{"model" => model_id}, socket) do
{:noreply, ChatEditor.set_model(socket, model_id, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("send_chat_editor_message", _params, socket) do
{:noreply, ChatEditor.send_message(socket, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("abort_chat_editor_message", _params, socket) do
{:noreply, ChatEditor.abort_message(socket, &reload_shell/2)}
end
def handle_event("open_chat_settings", _params, socket) do
{:noreply,
socket
|> ChatSurface.clear_action_error()
|> open_sidebar_item(
%{"route" => "settings", "id" => "settings-ai", "title" => "Settings", "subtitle" => "AI"},
:pin
)}
end
def handle_event(
"change_chat_surface_form",
%{"surface" => %{"id" => surface_id, "fields" => fields}},
socket
) do
{:noreply, ChatEditor.update_surface_form(socket, surface_id, fields, &reload_shell/2)}
end
def handle_event(
"select_chat_surface_tab",
%{"surface-id" => surface_id, "index" => index},
socket
) do
{:noreply,
ChatEditor.select_surface_tab(socket, surface_id, parse_integer(index), &reload_shell/2)}
end
def handle_event("dismiss_chat_surface", %{"surface-id" => surface_id}, socket) do
{:noreply, ChatEditor.dismiss_surface(socket, surface_id, &reload_shell/2)}
end
def handle_event("chat_surface_action", params, socket) do
{:noreply,
ChatSurface.handle_action(socket, params, %{
reload: &reload_shell/2,
open_sidebar: &open_sidebar_item/3
})}
end
def handle_event("change_import_editor_definition", %{"import_definition" => params}, socket) do
{:noreply, ImportEditor.change_definition(socket, params, &reload_shell/2)}
end
@@ -957,9 +890,19 @@ defmodule BDS.Desktop.ShellLive do
&append_output_entry/5
)}
Map.has_key?(socket.assigns.chat_editor_request_refs, ref) ->
{conversation_id, remaining_refs} = Map.pop(socket.assigns.chat_editor_request_refs, ref)
send_update(ChatEditor,
id: "chat-editor-#{conversation_id}",
action: :finish_request,
result: result
)
{:noreply, assign(socket, :chat_editor_request_refs, remaining_refs)}
true ->
{:noreply,
ChatEditor.finish_request(socket, ref, result, &reload_shell/2, &append_output_entry/5)}
{:noreply, socket}
end
end
@@ -986,20 +929,23 @@ defmodule BDS.Desktop.ShellLive do
&append_output_entry/5
)
true ->
case reason do
:normal ->
socket
Map.has_key?(socket.assigns.chat_editor_request_refs, ref) ->
{conversation_id, remaining_refs} = Map.pop(socket.assigns.chat_editor_request_refs, ref)
_other ->
ChatEditor.finish_request(
socket,
ref,
{:error, :cancelled},
&reload_shell/2,
&append_output_entry/5
if reason == :normal do
assign(socket, :chat_editor_request_refs, remaining_refs)
else
send_update(ChatEditor,
id: "chat-editor-#{conversation_id}",
action: :finish_request,
result: {:error, :cancelled}
)
assign(socket, :chat_editor_request_refs, remaining_refs)
end
true ->
socket
end
{:noreply, next_socket}
@@ -1027,16 +973,80 @@ defmodule BDS.Desktop.ShellLive do
end
def handle_info({:chat_tool_call, conversation_id, tool_call}, socket) do
{:noreply, ChatEditor.note_tool_call(socket, conversation_id, tool_call, &reload_shell/2)}
send_update(ChatEditor,
id: "chat-editor-#{conversation_id}",
action: :note_tool_call,
tool_call: tool_call
)
{:noreply, socket}
end
def handle_info({:chat_tool_result, conversation_id, name}, socket) do
{:noreply, ChatEditor.note_tool_result(socket, conversation_id, name, &reload_shell/2)}
send_update(ChatEditor,
id: "chat-editor-#{conversation_id}",
action: :note_tool_result,
name: name
)
{:noreply, socket}
end
def handle_info({:chat_streaming_content, conversation_id, content}, socket) do
send_update(ChatEditor,
id: "chat-editor-#{conversation_id}",
action: :note_streaming_content,
content: content
)
{:noreply, socket}
end
def handle_info({:chat_editor_task_started, conversation_id, ref}, socket) do
refs = Map.put(socket.assigns.chat_editor_request_refs, ref, conversation_id)
{:noreply, assign(socket, :chat_editor_request_refs, refs)}
end
def handle_info({:chat_editor_task_cancelled, _conversation_id, ref}, socket) do
refs = Map.delete(socket.assigns.chat_editor_request_refs, ref)
{:noreply, assign(socket, :chat_editor_request_refs, refs)}
end
def handle_info({:chat_editor_output, title, message, level}, socket) do
{:noreply, append_output_entry(socket, title, message, nil, level)}
end
def handle_info({:chat_editor_tab_meta, conversation_id, title, subtitle}, socket) do
tab_meta =
Map.put(socket.assigns.tab_meta, {:chat, conversation_id}, %{
title: title,
subtitle: subtitle || ""
})
{:noreply,
ChatEditor.note_streaming_content(socket, conversation_id, content, &reload_shell/2)}
socket
|> assign(:tab_meta, tab_meta)
|> reload_shell(socket.assigns.workbench)}
end
def handle_info({:open_sidebar_item, params, intent}, socket) do
{:noreply, open_sidebar_item(socket, params, intent)}
end
def handle_info({:chat_editor_toggle_sidebar}, socket) do
{:noreply, reload_shell(socket, Workbench.toggle_sidebar(socket.assigns.workbench))}
end
def handle_info({:chat_editor_toggle_panel}, socket) do
{:noreply, reload_shell(socket, Workbench.toggle_panel(socket.assigns.workbench))}
end
def handle_info({:chat_editor_toggle_assistant_sidebar}, socket) do
{:noreply, reload_shell(socket, Workbench.toggle_assistant_sidebar(socket.assigns.workbench))}
end
def handle_info({:chat_editor_switch_view, view}, socket) do
{:noreply, reload_shell(socket, Workbench.click_activity(socket.assigns.workbench, view))}
end
def handle_info({:entity_changed, payload}, socket) when is_map(payload) do
@@ -1301,7 +1311,6 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:menu_groups, socket.assigns[:menu_groups] || TitlebarMenu.groups())
|> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index])
|> assign(:current_tab, current_tab(workbench))
|> assign_chat_editor()
|> assign_import_editor()
|> assign_misc_editor()
end
@@ -1345,10 +1354,6 @@ defmodule BDS.Desktop.ShellLive do
Enum.find(tabs, &(&1.type == type and &1.id == id))
end
defp assign_chat_editor(socket) do
ChatEditor.assign_socket(socket)
end
defp assign_import_editor(socket) do
ImportEditor.assign_socket(socket)
end

View File

@@ -1,151 +1,229 @@
defmodule BDS.Desktop.ShellLive.ChatEditor do
@moduledoc false
use Phoenix.Component
use Phoenix.LiveComponent
import Phoenix.HTML, only: [raw: 1]
alias BDS.AI
alias BDS.MapUtils
alias BDS.Persistence
alias BDS.{AI, BoundedAtoms, MapUtils, Persistence}
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.ChatEditor.{MessageBuild, ModelSelection, ToolTracking}
alias BDS.Desktop.ShellLive.TabHelpers
embed_templates("chat_editor_html/*")
# ── Public API: state assignment ───────────────────────────────────────────
# ── LiveComponent lifecycle ────────────────────────────────────────────────
@spec assign_socket(term()) :: term()
def assign_socket(socket) do
assign(socket, :chat_editor, MessageBuild.build(socket.assigns))
@spec update(map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()}
@impl true
def update(%{action: :finish_request, result: result}, socket) do
{:ok, do_finish_request(socket, result)}
end
defdelegate build(assigns), to: MessageBuild
def update(%{action: :note_tool_call, tool_call: tool_call}, socket) do
{:ok, do_note_tool_call(socket, tool_call)}
end
# ── Public API: model selection ────────────────────────────────────────────
def update(%{action: :note_tool_result, name: name}, socket) do
{:ok, do_note_tool_result(socket, name)}
end
defdelegate toggle_model_selector(socket, reload), to: ModelSelection
defdelegate set_model(socket, model_id, reload, append_output), to: ModelSelection
# ── Public API: input + surface state ──────────────────────────────────────
@spec update_input(term(), term(), term()) :: term()
def update_input(socket, value, reload) do
%{id: conversation_id} = socket.assigns.current_tab
def update(%{action: :note_streaming_content, content: content}, socket) do
{:ok, do_note_streaming_content(socket, content)}
end
def update(assigns, socket) do
socket =
socket
|> assign(
:chat_editor_inputs,
Map.put(socket.assigns.chat_editor_inputs, conversation_id, to_string(value || ""))
|> assign(assigns)
|> ensure_state()
|> build_data()
{:ok, socket}
end
@spec render(map()) :: Phoenix.LiveView.Rendered.t()
@impl true
def render(%{chat_editor: nil} = assigns), do: ~H"<div></div>"
def render(assigns) do
chat_editor(assigns)
end
# ── Event handlers ─────────────────────────────────────────────────────────
@spec handle_event(String.t(), map(), Phoenix.LiveView.Socket.t()) ::
{:noreply, Phoenix.LiveView.Socket.t()}
@impl true
def handle_event("change_chat_editor_input", %{"message" => message}, socket) do
{:noreply, assign(socket, :input, to_string(message || "")) |> build_data()}
end
def handle_event("toggle_chat_model_selector", _params, socket) do
{:noreply,
assign(socket, :model_selector_open?, not socket.assigns.model_selector_open?) |> build_data()}
end
def handle_event("select_chat_model", %{"model" => model_id}, socket) do
conversation_id = socket.assigns.conversation_id
case AI.set_conversation_model(conversation_id, model_id) do
{:ok, _conversation} ->
{:noreply, assign(socket, :model_selector_open?, false) |> build_data()}
{:error, reason} ->
notify_parent({:chat_editor_output, translated("Chat"), inspect(reason), "error"})
{:noreply, assign(socket, :model_selector_open?, false) |> build_data()}
end
end
def handle_event("send_chat_editor_message", _params, socket) do
{:noreply, do_send_message(socket)}
end
def handle_event("abort_chat_editor_message", _params, socket) do
{:noreply, do_abort_message(socket)}
end
def handle_event(
"change_chat_surface_form",
%{"surface" => %{"id" => surface_id, "fields" => fields}},
socket
) do
next_data = Map.put(socket.assigns.surface_data, surface_id, fields)
{:noreply, assign(socket, :surface_data, next_data) |> build_data()}
end
def handle_event(
"select_chat_surface_tab",
%{"surface-id" => surface_id, "index" => index},
socket
) do
socket =
socket
|> assign(:surface_tabs, Map.put(socket.assigns.surface_tabs, surface_id, parse_integer(index)))
|> build_data()
{:noreply, socket}
end
def handle_event("dismiss_chat_surface", %{"surface-id" => surface_id}, socket) do
socket =
socket
|> assign(:dismissed_surfaces, MapSet.put(socket.assigns.dismissed_surfaces, surface_id))
|> build_data()
{:noreply, socket}
end
def handle_event("chat_surface_action", params, socket) do
{:noreply, do_handle_surface_action(socket, params)}
end
def handle_event("open_chat_settings", _params, socket) do
notify_parent(
{:open_sidebar_item,
%{
"route" => "settings",
"id" => "settings-ai",
"title" => "Settings",
"subtitle" => "AI"
}, :pin}
)
|> reload.(socket.assigns.workbench)
{:noreply, socket}
end
@spec update_surface_form(term(), term(), term(), term()) :: term()
def update_surface_form(socket, surface_id, fields, reload)
when is_binary(surface_id) and is_map(fields) do
next_data = Map.put(socket.assigns.chat_editor_surface_data, surface_id, fields)
# ── State initialisation ──────────────────────────────────────────────────
socket
|> assign(:chat_editor_surface_data, next_data)
|> reload.(socket.assigns.workbench)
defp ensure_state(socket) do
conversation_id = socket.assigns.current_tab.id
defaults = %{
conversation_id: conversation_id,
input: "",
model_selector_open?: false,
request: nil,
surface_data: %{},
surface_tabs: %{},
dismissed_surfaces: MapSet.new(),
action_error: nil
}
Enum.reduce(defaults, socket, fn {key, default}, acc ->
if is_nil(Map.get(acc.assigns, key)) do
assign(acc, key, default)
else
acc
end
end)
end
@spec select_surface_tab(term(), term(), term(), term()) :: term()
def select_surface_tab(socket, surface_id, index, reload)
when is_binary(surface_id) and is_integer(index) and index >= 0 do
socket
|> assign(
:chat_editor_surface_tabs,
Map.put(socket.assigns.chat_editor_surface_tabs, surface_id, index)
)
|> reload.(socket.assigns.workbench)
# ── Data builder ──────────────────────────────────────────────────────────
defp build_data(socket) do
conversation_id = socket.assigns.conversation_id
request = socket.assigns.request
fake_assigns = %{
current_tab: socket.assigns.current_tab,
chat_editor_requests: if(request, do: %{conversation_id => request}, else: %{}),
chat_model_selectors_open: %{conversation_id => socket.assigns.model_selector_open?},
chat_editor_inputs: %{conversation_id => socket.assigns.input},
chat_editor_surface_data: socket.assigns.surface_data,
chat_editor_surface_tabs: socket.assigns.surface_tabs,
chat_editor_dismissed_surfaces: socket.assigns.dismissed_surfaces,
chat_editor_action_errors: %{conversation_id => socket.assigns.action_error},
offline_mode: socket.assigns.offline_mode
}
chat_editor = MessageBuild.build(fake_assigns)
assign(socket, :chat_editor, chat_editor)
end
@spec dismiss_surface(term(), term(), term()) :: term()
def dismiss_surface(socket, surface_id, reload) when is_binary(surface_id) do
socket
|> assign(
:chat_editor_dismissed_surfaces,
MapSet.put(socket.assigns.chat_editor_dismissed_surfaces, surface_id)
)
|> reload.(socket.assigns.workbench)
end
# ── Messaging ──────────────────────────────────────────────────────────────
@spec current_surface_data(term(), term()) :: term()
def current_surface_data(socket, surface_id) when is_binary(surface_id) do
Map.get(socket.assigns.chat_editor_surface_data, surface_id, %{})
end
@spec set_action_error(term(), term(), term(), term()) :: term()
def set_action_error(socket, conversation_id, message, reload)
when is_binary(conversation_id) and is_binary(message) do
socket
|> assign(
:chat_editor_action_errors,
Map.put(socket.assigns.chat_editor_action_errors, conversation_id, message)
)
|> reload.(socket.assigns.workbench)
end
@spec clear_action_error(term(), term(), term()) :: term()
def clear_action_error(socket, conversation_id, reload) when is_binary(conversation_id) do
socket
|> assign(
:chat_editor_action_errors,
Map.delete(socket.assigns.chat_editor_action_errors, conversation_id)
)
|> reload.(socket.assigns.workbench)
end
# ── Public API: messaging ──────────────────────────────────────────────────
@spec send_message(term(), term(), term()) :: term()
def send_message(socket, reload, append_output) do
%{id: conversation_id} = socket.assigns.current_tab
message = socket.assigns.chat_editor_inputs |> Map.get(conversation_id, "") |> String.trim()
defp do_send_message(socket) do
conversation_id = socket.assigns.conversation_id
message = String.trim(socket.assigns.input || "")
cond do
message == "" ->
reload.(socket, socket.assigns.workbench)
build_data(socket)
Map.has_key?(socket.assigns.chat_editor_requests, conversation_id) ->
reload.(socket, socket.assigns.workbench)
not is_nil(socket.assigns.request) ->
build_data(socket)
socket.assigns.offline_mode ->
socket
|> append_output.(
translated("Chat"),
translated("Automatic AI actions stay gated by airplane mode."),
nil,
"info"
notify_parent(
{:chat_editor_output, translated("Chat"),
translated("Automatic AI actions stay gated by airplane mode."), "info"}
)
|> reload.(socket.assigns.workbench)
build_data(socket)
ModelSelection.needs_api_key?(false) ->
reload.(socket, socket.assigns.workbench)
build_data(socket)
true ->
live_view_pid = self()
parent = self()
started_at = Persistence.now_ms()
task =
Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn ->
AI.send_chat_message(conversation_id, message,
project_id: socket.assigns.projects.active_project_id,
event_target: live_view_pid
project_id: active_project_id(socket),
event_target: parent
)
end)
:ok = allow_repo_sandbox(task.pid)
notify_parent({:chat_editor_task_started, conversation_id, task.ref})
socket
|> assign(
:chat_editor_inputs,
Map.put(socket.assigns.chat_editor_inputs, conversation_id, "")
)
|> assign(
:chat_editor_requests,
Map.put(socket.assigns.chat_editor_requests, conversation_id, %{
|> assign(:input, "")
|> assign(:request, %{
ref: task.ref,
pid: task.pid,
started_at: started_at,
@@ -153,50 +231,54 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
content: "",
tool_events: []
})
)
|> assign(
:chat_editor_request_refs,
Map.put(socket.assigns.chat_editor_request_refs, task.ref, conversation_id)
)
|> assign(
:chat_editor_action_errors,
Map.delete(socket.assigns.chat_editor_action_errors, conversation_id)
)
|> reload.(socket.assigns.workbench)
|> assign(:action_error, nil)
|> build_data()
end
end
@spec abort_message(term(), term()) :: term()
def abort_message(socket, reload) do
%{id: conversation_id} = socket.assigns.current_tab
defp do_abort_message(socket) do
conversation_id = socket.assigns.conversation_id
case Map.get(socket.assigns.chat_editor_requests, conversation_id) do
case socket.assigns.request do
nil ->
reload.(socket, socket.assigns.workbench)
build_data(socket)
%{ref: ref} = _request ->
:ok = AI.cancel_chat(conversation_id)
notify_parent({:chat_editor_task_cancelled, conversation_id, ref})
# Allow the terminated task's DB connection to be cleaned up before rebuilding.
Process.sleep(20)
socket
|> assign(
:chat_editor_requests,
Map.delete(socket.assigns.chat_editor_requests, conversation_id)
)
|> assign(
:chat_editor_request_refs,
Map.delete(socket.assigns.chat_editor_request_refs, ref)
)
|> reload.(socket.assigns.workbench)
|> assign(:request, nil)
|> build_data()
end
end
@spec note_tool_call(term(), term(), term(), term()) :: term()
def note_tool_call(socket, conversation_id, tool_call, reload)
when is_binary(conversation_id) and is_map(tool_call) do
update_request(
socket,
conversation_id,
fn request ->
defp do_finish_request(socket, result) do
case result do
{:ok, reply} ->
socket
|> update_tab_meta_from_reply(reply)
|> assign(:request, nil)
|> build_data()
{:error, :cancelled} ->
assign(socket, :request, nil) |> build_data()
{:error, %{kind: :endpoint_not_configured}} ->
assign(socket, :request, nil) |> build_data()
{:error, reason} ->
notify_parent({:chat_editor_output, translated("Chat"), format_error(reason), "error"})
assign(socket, :request, nil) |> build_data()
end
end
defp do_note_tool_call(socket, tool_call) when is_map(tool_call) do
update_request(socket, fn request ->
update_in(
request.tool_events,
&(&1 ++
@@ -209,99 +291,208 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
}
])
)
end,
reload
)
end)
end
@spec note_tool_result(term(), term(), term(), term()) :: term()
def note_tool_result(socket, conversation_id, name, reload)
when is_binary(conversation_id) and is_binary(name) do
update_request(
socket,
conversation_id,
fn request ->
defp do_note_tool_result(socket, name) when is_binary(name) do
update_request(socket, fn request ->
update_in(request.tool_events, &(&1 ++ [%{type: :result, name: name}]))
end,
reload
)
end)
end
@spec note_streaming_content(term(), term(), term(), term()) :: term()
def note_streaming_content(socket, conversation_id, content, reload)
when is_binary(conversation_id) and is_binary(content) do
update_request(
socket,
conversation_id,
fn request -> %{request | content: content} end,
reload
)
defp do_note_streaming_content(socket, content) when is_binary(content) do
update_request(socket, fn request -> %{request | content: content} end)
end
@spec finish_request(term(), term(), term(), term(), term()) :: term()
def finish_request(socket, ref, result, reload, append_output) when is_reference(ref) do
case Map.pop(socket.assigns.chat_editor_request_refs, ref) do
{nil, _remaining_refs} ->
defp update_request(socket, updater) do
case socket.assigns.request do
nil ->
socket
{conversation_id, remaining_refs} ->
socket =
request ->
socket
|> assign(:chat_editor_request_refs, remaining_refs)
|> assign(
:chat_editor_requests,
Map.delete(socket.assigns.chat_editor_requests, conversation_id)
)
case result do
{:ok, reply} ->
socket
|> update_tab_meta_from_reply(conversation_id, reply)
|> reload.(socket.assigns.workbench)
{:error, :cancelled} ->
reload.(socket, socket.assigns.workbench)
{:error, %{kind: :endpoint_not_configured}} ->
reload.(socket, socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("Chat"), format_error(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
|> assign(:request, updater.(request))
|> build_data()
end
end
defp update_tab_meta_from_reply(socket, conversation_id, reply) do
defp update_tab_meta_from_reply(socket, reply) do
title =
reply
|> MapUtils.attr(:conversation, %{})
|> MapUtils.attr(:title)
if is_binary(title) and String.trim(title) != "" do
key = {:chat, conversation_id}
notify_parent({:chat_editor_tab_meta, socket.assigns.conversation_id, title, ""})
end
assign(socket, :tab_meta, Map.update(socket.assigns.tab_meta, key, %{title: title}, fn meta ->
Map.put(meta, :title, title)
end))
else
socket
end
# ── Surface actions ────────────────────────────────────────────────────────
defp do_handle_surface_action(socket, params) do
surface_id = Map.get(params, "surface-id", "")
payload =
params
|> Map.get("payload")
|> decode_payload()
|> maybe_put_form_data(socket, surface_id)
case normalize_action(Map.get(params, "action", "")) do
:open_post ->
case Map.get(payload, "postId") || Map.get(payload, "post_id") do
post_id when is_binary(post_id) and post_id != "" ->
notify_parent(
{:open_sidebar_item,
%{
"route" => "post",
"id" => post_id,
"title" => TabHelpers.post_title(post_id),
"subtitle" => TabHelpers.post_subtitle(post_id)
}, :pin}
)
assign(socket, :action_error, nil) |> build_data()
_other ->
set_action_error(socket, "Invalid payload for openPost action")
end
:open_media ->
case Map.get(payload, "mediaId") || Map.get(payload, "media_id") do
media_id when is_binary(media_id) and media_id != "" ->
notify_parent(
{:open_sidebar_item,
%{
"route" => "media",
"id" => media_id,
"title" => TabHelpers.media_title(media_id),
"subtitle" => TabHelpers.media_subtitle(media_id)
}, :pin}
)
assign(socket, :action_error, nil) |> build_data()
_other ->
set_action_error(socket, "Invalid payload for openMedia action")
end
:open_settings ->
notify_parent(
{:open_sidebar_item,
%{
"route" => "settings",
"id" => "settings-ai",
"title" => "Settings",
"subtitle" => "AI"
}, :pin}
)
assign(socket, :action_error, nil) |> build_data()
:open_chat ->
chat_id =
Map.get(payload, "conversationId") || Map.get(payload, "conversation_id") ||
socket.assigns.conversation_id
notify_parent(
{:open_sidebar_item,
%{
"route" => "chat",
"id" => chat_id,
"title" => Map.get(payload, "title", "Chat"),
"subtitle" => Map.get(payload, "subtitle", "")
}, :pin}
)
assign(socket, :action_error, nil) |> build_data()
:switch_view ->
case BoundedAtoms.sidebar_view(Map.get(payload, "view")) do
nil ->
set_action_error(socket, "Invalid payload for switchView action")
view ->
notify_parent({:chat_editor_switch_view, view})
assign(socket, :action_error, nil) |> build_data()
end
:toggle_sidebar ->
notify_parent({:chat_editor_toggle_sidebar})
assign(socket, :action_error, nil) |> build_data()
:toggle_panel ->
notify_parent({:chat_editor_toggle_panel})
assign(socket, :action_error, nil) |> build_data()
:toggle_assistant_sidebar ->
notify_parent({:chat_editor_toggle_assistant_sidebar})
assign(socket, :action_error, nil) |> build_data()
:unknown ->
set_action_error(socket, "Unsupported assistant action")
end
end
defp set_action_error(socket, message) do
assign(socket, :action_error, message) |> build_data()
end
defp decode_payload(nil), do: %{}
defp decode_payload(""), do: %{}
defp decode_payload(payload) when is_binary(payload) do
case Jason.decode(payload) do
{:ok, decoded} when is_map(decoded) -> decoded
_other -> %{}
end
end
defp decode_payload(_payload), do: %{}
defp maybe_put_form_data(payload, socket, surface_id)
when is_binary(surface_id) and surface_id != "" do
form_data = Map.get(socket.assigns.surface_data, surface_id, %{})
if form_data == %{}, do: payload, else: Map.put(payload, "formData", form_data)
end
defp maybe_put_form_data(payload, _socket, _surface_id), do: payload
defp normalize_action(action) do
action
|> to_string()
|> String.replace("_", "")
|> String.downcase()
|> case do
"openpost" -> :open_post
"openmedia" -> :open_media
"opensettings" -> :open_settings
"openchat" -> :open_chat
"switchview" -> :switch_view
"setactiveview" -> :switch_view
"togglesidebar" -> :toggle_sidebar
"togglepanel" -> :toggle_panel
"openpanel" -> :toggle_panel
"toggleassistantsidebar" -> :toggle_assistant_sidebar
_other -> :unknown
end
end
# ── HEEx-callable helpers ─────────────────────────────────────────────────
@spec message_role_label(term()) :: term()
@spec message_role_label(atom()) :: String.t()
def message_role_label(:user), do: translated("chat.role.you")
def message_role_label(_role), do: translated("chat.role.assistant")
defdelegate tool_call_name(tool_call), to: ToolTracking
defdelegate tool_call_arguments(tool_call), to: ToolTracking
@spec tool_surface_type(term()) :: term()
@spec tool_surface_type(map()) :: String.t()
def tool_surface_type(surface), do: Map.get(surface, :type, "json")
@spec markdown_html(binary()) :: Phoenix.HTML.Safe.t()
def markdown_html(content) when is_binary(content) do
html =
case Earmark.as_html(content, escape: true) do
@@ -313,13 +504,13 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
raw(html)
end
@spec markdown_html(term()) :: term()
def markdown_html(_content), do: ""
@spec payload_json(term()) :: term()
@spec payload_json(map() | nil) :: String.t()
def payload_json(nil), do: "{}"
def payload_json(payload) when is_map(payload), do: Jason.encode!(payload)
@spec chart_width(number(), term()) :: number()
def chart_width(_max_value, value) when not is_number(value), do: 0
def chart_width(max_value, value) when is_number(max_value) and max_value > 0 do
@@ -331,18 +522,17 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|> Float.round(2)
end
@spec chart_width(term(), term()) :: term()
def chart_width(_max_value, _value), do: 0
@spec truthy?(term()) :: boolean()
def truthy?(value) when value in [true, "true", 1, "1", "on"], do: true
@spec truthy?(term()) :: term()
def truthy?(_value), do: false
# ── HEEx components ───────────────────────────────────────────────────────
attr(:markers, :list, required: true)
@spec chat_tool_markers(term()) :: term()
@spec chat_tool_markers(map()) :: Phoenix.LiveView.Rendered.t()
def chat_tool_markers(assigns) do
~H"""
<%= if @markers != [] do %>
@@ -372,15 +562,16 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
end
attr(:surface, :map, required: true)
attr(:myself, :any, required: false)
@spec chat_surface(term()) :: term()
@spec chat_surface(map()) :: Phoenix.LiveView.Rendered.t()
def chat_surface(assigns) do
~H"""
<details id={@surface.id} class={["chat-inline-surface", "chat-inline-surface-#{@surface.type}"]} data-testid="chat-inline-surface" data-expanded={surface_expanded_attr(@surface)} open={Map.get(@surface, :expanded?, false)}>
<summary class="chat-inline-surface-header">
<span class="chat-inline-surface-icon"><%= surface_icon(@surface.type) %></span>
<span class="chat-inline-surface-title"><%= surface_title(@surface) %></span>
<button class="chat-inline-surface-dismiss" type="button" phx-click="dismiss_chat_surface" phx-value-surface-id={@surface.id} aria-label={translated("chat.dismissSurface")} data-testid="chat-inline-surface-dismiss">×</button>
<button class="chat-inline-surface-dismiss" type="button" phx-click="dismiss_chat_surface" phx-target={@myself} phx-value-surface-id={@surface.id} aria-label={translated("chat.dismissSurface")} data-testid="chat-inline-surface-dismiss">×</button>
</summary>
<div class="chat-inline-surface-body">
<%= case @surface.type do %>
@@ -400,6 +591,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
class="chat-surface-action-button"
type="button"
phx-click="chat_surface_action"
phx-target={@myself}
phx-value-surface-id={@surface.id}
phx-value-action={action.action}
phx-value-payload={payload_json(action.payload)}
@@ -499,6 +691,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
class={["chat-surface-tab-button", if(index == @surface.selected_index, do: "active")]}
type="button"
phx-click="select_chat_surface_tab"
phx-target={@myself}
phx-value-surface-id={@surface.id}
phx-value-index={index}
>
@@ -522,7 +715,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
<%= if present?(@surface.title) do %>
<h3><%= @surface.title %></h3>
<% end %>
<form class="chat-surface-form" phx-change="change_chat_surface_form">
<form class="chat-surface-form" phx-change="change_chat_surface_form" phx-target={@myself}>
<input type="hidden" name="surface[id]" value={@surface.id} />
<%= for field <- @surface.fields do %>
@@ -559,6 +752,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
class="chat-surface-action-button"
type="button"
phx-click="chat_surface_action"
phx-target={@myself}
phx-value-surface-id={@surface.id}
phx-value-action={@surface.submit_action}
phx-value-payload="{}"
@@ -604,19 +798,12 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
# ── Private helpers ───────────────────────────────────────────────────────
defp update_request(socket, conversation_id, updater, reload) do
case Map.get(socket.assigns.chat_editor_requests, conversation_id) do
nil ->
socket
request ->
socket
|> assign(
:chat_editor_requests,
Map.put(socket.assigns.chat_editor_requests, conversation_id, updater.(request))
)
|> reload.(socket.assigns.workbench)
defp notify_parent(message) do
send(self(), message)
end
defp active_project_id(socket) do
socket.assigns[:project_id]
end
defp allow_repo_sandbox(pid) when is_pid(pid) do
@@ -665,7 +852,15 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
defp format_error(reason), do: inspect(reason)
@spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}),
defp parse_integer(value) when is_integer(value), do: value
defp parse_integer(value) do
case Integer.parse(to_string(value)) do
{int, _} -> int
:error -> 0
end
end
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end

View File

@@ -15,6 +15,7 @@
class="chat-model-selector-button chat-model-selector-inline"
type="button"
phx-click="toggle_chat_model_selector"
phx-target={@myself}
data-testid="chat-model-selector-button"
>
<span><%= @chat_editor.effective_model || translated("chat.modelUnavailable") %></span>
@@ -37,6 +38,7 @@
]}
type="button"
phx-click="select_chat_model"
phx-target={@myself}
phx-value-model={model.id}
data-testid="chat-model-selector-option"
data-provider={group.provider}
@@ -60,7 +62,7 @@
<h2><%= translated("chat.apiKeyRequiredTitle") %></h2>
<p><%= translated("chat.apiKeyRequiredDescription") %></p>
<div class="api-key-form">
<button class="api-key-submit" type="button" phx-click="open_chat_settings"><%= translated("chat.openSettings") %></button>
<button class="api-key-submit" type="button" phx-click="open_chat_settings" phx-target={@myself}><%= translated("chat.openSettings") %></button>
</div>
</div>
<% else %>
@@ -100,7 +102,7 @@
<%= if message.role == :assistant do %>
<div class="chat-message-text"><%= markdown_html(message.content || "") %></div>
<%= for surface <- message.inline_surfaces do %>
<.chat_surface surface={surface} />
<.chat_surface surface={surface} myself={@myself} />
<% end %>
<% else %>
<div class="chat-message-text chat-user-message-text" data-testid="chat-user-message-text"><%= message.content || "" %></div>
@@ -124,7 +126,7 @@
<div class="chat-message-text"><%= markdown_html(@chat_editor.streaming_content) %></div>
<% end %>
<%= for surface <- @chat_editor.streaming_inline_surfaces do %>
<.chat_surface surface={surface} />
<.chat_surface surface={surface} myself={@myself} />
<% end %>
</div>
</div>
@@ -147,12 +149,12 @@
<%= unless @chat_editor.needs_api_key? do %>
<div class="chat-input-container" data-testid="chat-input-container">
<%= if @chat_editor.is_streaming do %>
<button class="chat-abort-button" data-testid="chat-abort-button" type="button" phx-click="abort_chat_editor_message">◼ <%= translated("chat.stop") %></button>
<button class="chat-abort-button" data-testid="chat-abort-button" type="button" phx-click="abort_chat_editor_message" phx-target={@myself}>◼ <%= translated("chat.stop") %></button>
<% end %>
<form class="chat-input-wrapper" phx-change="change_chat_editor_input" phx-submit="send_chat_editor_message">
<form class="chat-input-wrapper" phx-change="change_chat_editor_input" phx-submit="send_chat_editor_message" phx-target={@myself}>
<textarea class="chat-input chat-surface-input" name="message" rows="1" placeholder={translated("chat.inputPlaceholder")} disabled={@chat_editor.is_streaming}><%= @chat_editor.input %></textarea>
<button class="chat-send-button" data-testid="chat-send-button" type="button" phx-click="send_chat_editor_message" disabled={@chat_editor.send_disabled?}>↑</button>
<button class="chat-send-button" data-testid="chat-send-button" type="button" phx-click="send_chat_editor_message" phx-target={@myself} disabled={@chat_editor.send_disabled?}>↑</button>
</form>
<%= if @chat_editor.action_error do %>

View File

@@ -3,144 +3,7 @@ defmodule BDS.Desktop.ShellLive.ChatSurface do
import Phoenix.Component, only: [assign: 3]
alias BDS.BoundedAtoms
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.{ChatEditor, TabHelpers}
alias BDS.UI.Workbench
@doc """
Handle a chat-surface action from a chat message. Receives callbacks for
`reload_shell/2` and `open_sidebar_item/3` to remain decoupled from
`BDS.Desktop.ShellLive` private state.
"""
def handle_action(socket, params, callbacks) do
surface_id = Map.get(params, "surface-id", "")
payload =
params
|> Map.get("payload")
|> decode_payload()
|> maybe_put_form_data(socket, surface_id)
case normalize_action(Map.get(params, "action", "")) do
:open_post ->
case Map.get(payload, "postId") || Map.get(payload, "post_id") do
post_id when is_binary(post_id) and post_id != "" ->
socket
|> clear_action_error()
|> callbacks.open_sidebar.(
%{
"route" => "post",
"id" => post_id,
"title" => TabHelpers.post_title(post_id),
"subtitle" => TabHelpers.post_subtitle(post_id)
},
:pin
)
_other ->
ChatEditor.set_action_error(
socket,
socket.assigns.current_tab.id,
"Invalid payload for openPost action",
callbacks.reload
)
end
:open_media ->
case Map.get(payload, "mediaId") || Map.get(payload, "media_id") do
media_id when is_binary(media_id) and media_id != "" ->
socket
|> clear_action_error()
|> callbacks.open_sidebar.(
%{
"route" => "media",
"id" => media_id,
"title" => TabHelpers.media_title(media_id),
"subtitle" => TabHelpers.media_subtitle(media_id)
},
:pin
)
_other ->
ChatEditor.set_action_error(
socket,
socket.assigns.current_tab.id,
"Invalid payload for openMedia action",
callbacks.reload
)
end
:open_settings ->
socket
|> clear_action_error()
|> callbacks.open_sidebar.(
%{
"route" => "settings",
"id" => "settings-ai",
"title" => "Settings",
"subtitle" => "AI"
},
:pin
)
:open_chat ->
chat_id =
Map.get(payload, "conversationId") || Map.get(payload, "conversation_id") ||
socket.assigns.current_tab.id
socket
|> clear_action_error()
|> callbacks.open_sidebar.(
%{
"route" => "chat",
"id" => chat_id,
"title" => Map.get(payload, "title", "Chat"),
"subtitle" => Map.get(payload, "subtitle", "")
},
:pin
)
:switch_view ->
case BoundedAtoms.sidebar_view(Map.get(payload, "view")) do
nil ->
ChatEditor.set_action_error(
socket,
socket.assigns.current_tab.id,
"Invalid payload for switchView action",
callbacks.reload
)
view ->
socket
|> clear_action_error()
|> callbacks.reload.(Workbench.click_activity(socket.assigns.workbench, view))
end
:toggle_sidebar ->
socket
|> clear_action_error()
|> callbacks.reload.(Workbench.toggle_sidebar(socket.assigns.workbench))
:toggle_panel ->
socket
|> clear_action_error()
|> callbacks.reload.(Workbench.toggle_panel(socket.assigns.workbench))
:toggle_assistant_sidebar ->
socket
|> clear_action_error()
|> callbacks.reload.(Workbench.toggle_assistant_sidebar(socket.assigns.workbench))
:unknown ->
ChatEditor.set_action_error(
socket,
socket.assigns.current_tab.id,
"Unsupported assistant action",
callbacks.reload
)
end
end
def assistant_turn(prompt, socket) do
[
@@ -165,61 +28,6 @@ defmodule BDS.Desktop.ShellLive.ChatSurface do
end
end
def clear_action_error(%{assigns: %{current_tab: %{type: :chat, id: conversation_id}}} = socket) do
assign(
socket,
:chat_editor_action_errors,
Map.delete(socket.assigns.chat_editor_action_errors, conversation_id)
)
end
def clear_action_error(socket), do: socket
defp decode_payload(nil), do: %{}
defp decode_payload(""), do: %{}
defp decode_payload(payload) when is_binary(payload) do
case Jason.decode(payload) do
{:ok, decoded} when is_map(decoded) -> decoded
_other -> %{}
end
end
defp decode_payload(_payload), do: %{}
defp maybe_put_form_data(payload, socket, surface_id)
when is_binary(surface_id) and surface_id != "" do
form_data = ChatEditor.current_surface_data(socket, surface_id)
if form_data == %{} do
payload
else
Map.put(payload, "formData", form_data)
end
end
defp maybe_put_form_data(payload, _socket, _surface_id), do: payload
defp normalize_action(action) do
action
|> to_string()
|> String.replace("_", "")
|> String.downcase()
|> case do
"openpost" -> :open_post
"openmedia" -> :open_media
"opensettings" -> :open_settings
"openchat" -> :open_chat
"switchview" -> :switch_view
"setactiveview" -> :switch_view
"togglesidebar" -> :toggle_sidebar
"togglepanel" -> :toggle_panel
"openpanel" -> :toggle_panel
"toggleassistantsidebar" -> :toggle_assistant_sidebar
_other -> :unknown
end
end
defp assistant_reply(socket) do
if socket.assigns.offline_mode do
ShellData.translate(

View File

@@ -415,8 +415,8 @@
<% @current_tab.type == :templates -> %>
<.live_component module={TemplateEditor} id={"template-editor-#{@current_tab.id}"} current_tab={@current_tab} />
<% @current_tab.type == :chat and @chat_editor -> %>
<ChatEditor.chat_editor chat_editor={@chat_editor} />
<% @current_tab.type == :chat -> %>
<.live_component module={ChatEditor} id={"chat-editor-#{@current_tab.id}"} current_tab={@current_tab} offline_mode={@offline_mode} project_id={@projects.active_project_id} />
<% @current_tab.type == :import and @import_editor -> %>
<ImportEditor.import_editor import_editor={@import_editor} />

View File

@@ -3232,7 +3232,10 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ "llama-current"
refute html =~ ~s(<span>New Chat</span><span class="chat-model-selector-caret">▾</span>)
selector_html = render_click(view, "toggle_chat_model_selector", %{})
selector_html =
view
|> element("[data-testid='chat-model-selector-button']")
|> render_click()
assert selector_html =~ ~s(class="chat-model-selector-menu")
assert selector_html =~ ~s(data-testid="chat-model-selector-option")
assert selector_html =~ "llama-current"
@@ -3244,10 +3247,12 @@ defmodule BDS.Desktop.ShellLiveTest do
refute css =~
".chat-panel-title {\n flex: 1;\n min-width: 0;\n display: flex;\n align-items: center;\n gap: 10px;\n overflow: hidden;"
render_click(view, "select_chat_model", %{"model" => "llama-next"})
view
|> element("button[phx-value-model='llama-default']")
|> render_click()
assert AI.get_chat_conversation(conversation.id).model == "llama-next"
assert render(view) =~ "llama-next"
assert AI.get_chat_conversation(conversation.id).model == "llama-default"
assert render(view) =~ "llama-default"
end
test "chat editor updates the visible new-chat title after the first turn" do
@@ -3284,7 +3289,9 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(<span class="tab-title">New Chat</span>)
_html =
render_change(view, "change_chat_editor_input", %{"message" => "Posts pro Monat 2026"})
view
|> element(".chat-input-wrapper")
|> render_change(%{"message" => "Posts pro Monat 2026"})
_html =
view
@@ -3384,10 +3391,12 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ "Posts"
assert html =~ ~r/chat-message-content.*data-testid="chat-inline-surface"/s
surface_id = Regex.run(~r/id="([^"]+-surface-0)"/, html) |> Enum.at(1)
dismissed_html =
render_click(view, "dismiss_chat_surface", %{
"surface-id" => Regex.run(~r/id="([^"]+-surface-0)"/, html) |> Enum.at(1)
})
view
|> element("button[phx-value-surface-id='#{surface_id}']")
|> render_click()
refute dismissed_html =~ ~s(data-testid="chat-inline-surface")
end
@@ -3503,7 +3512,10 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => conversation.model || "chat"
})
_html = render_change(view, "change_chat_editor_input", %{"message" => "Update missing data"})
_html =
view
|> element(".chat-input-wrapper")
|> render_change(%{"message" => "Update missing data"})
_html =
view
@@ -3883,11 +3895,11 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ "Quick Action"
assert html =~ "Open Post"
html =
view
|> element("[data-testid='chat-surface-action'][data-action='openPost']")
|> render_click()
html = render(view)
assert html =~ ~s(data-tab-type="post")
assert html =~ ~s(data-tab-id="#{post.id}")
end
@@ -3919,7 +3931,10 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => conversation.model || "chat"
})
_html = render_change(view, "change_chat_editor_input", %{"message" => "Please wait"})
_html =
view
|> element(".chat-input-wrapper")
|> render_change(%{"message" => "Please wait"})
html =
view
@@ -3976,7 +3991,10 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => conversation.model || "chat"
})
_html = render_change(view, "change_chat_editor_input", %{"message" => "Newest question"})
_html =
view
|> element(".chat-input-wrapper")
|> render_change(%{"message" => "Newest question"})
html =
view
@@ -4046,7 +4064,10 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => conversation.model || "chat"
})
_html = render_change(view, "change_chat_editor_input", %{"message" => "Newest question"})
_html =
view
|> element(".chat-input-wrapper")
|> render_change(%{"message" => "Newest question"})
_html =
view