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_subtitle: 2,
tab_id_for_route: 2, tab_id_for_route: 2,
tab_intent: 2, tab_intent: 2,
sidebar_route_atom: 1, sidebar_route_atom: 1
parse_integer: 1
] ]
alias BDS.Projects alias BDS.Projects
@@ -164,14 +163,7 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:project_menu_open, false) |> assign(:project_menu_open, false)
|> assign(:sidebar_filters_by_view, %{}) |> assign(:sidebar_filters_by_view, %{})
|> assign(:sidebar_filter_panels, %{}) |> 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_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_states, %{})
|> assign(:import_editor_analysis_task_refs, %{}) |> assign(:import_editor_analysis_task_refs, %{})
|> assign(:import_editor_execution_states, %{}) |> assign(:import_editor_execution_states, %{})
@@ -331,65 +323,6 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, apply_shell_command(socket, action)} {:noreply, apply_shell_command(socket, action)}
end 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 def handle_event("change_import_editor_definition", %{"import_definition" => params}, socket) do
{:noreply, ImportEditor.change_definition(socket, params, &reload_shell/2)} {:noreply, ImportEditor.change_definition(socket, params, &reload_shell/2)}
end end
@@ -957,9 +890,19 @@ defmodule BDS.Desktop.ShellLive do
&append_output_entry/5 &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 -> true ->
{:noreply, {:noreply, socket}
ChatEditor.finish_request(socket, ref, result, &reload_shell/2, &append_output_entry/5)}
end end
end end
@@ -986,20 +929,23 @@ defmodule BDS.Desktop.ShellLive do
&append_output_entry/5 &append_output_entry/5
) )
true -> Map.has_key?(socket.assigns.chat_editor_request_refs, ref) ->
case reason do {conversation_id, remaining_refs} = Map.pop(socket.assigns.chat_editor_request_refs, ref)
:normal ->
socket
_other -> if reason == :normal do
ChatEditor.finish_request( assign(socket, :chat_editor_request_refs, remaining_refs)
socket, else
ref, send_update(ChatEditor,
{:error, :cancelled}, id: "chat-editor-#{conversation_id}",
&reload_shell/2, action: :finish_request,
&append_output_entry/5 result: {:error, :cancelled}
) )
assign(socket, :chat_editor_request_refs, remaining_refs)
end end
true ->
socket
end end
{:noreply, next_socket} {:noreply, next_socket}
@@ -1027,16 +973,80 @@ defmodule BDS.Desktop.ShellLive do
end end
def handle_info({:chat_tool_call, conversation_id, tool_call}, socket) do 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 end
def handle_info({:chat_tool_result, conversation_id, name}, socket) do 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 end
def handle_info({:chat_streaming_content, conversation_id, content}, socket) do 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, {: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 end
def handle_info({:entity_changed, payload}, socket) when is_map(payload) do 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(:menu_groups, socket.assigns[:menu_groups] || TitlebarMenu.groups())
|> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index]) |> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index])
|> assign(:current_tab, current_tab(workbench)) |> assign(:current_tab, current_tab(workbench))
|> assign_chat_editor()
|> assign_import_editor() |> assign_import_editor()
|> assign_misc_editor() |> assign_misc_editor()
end end
@@ -1345,10 +1354,6 @@ defmodule BDS.Desktop.ShellLive do
Enum.find(tabs, &(&1.type == type and &1.id == id)) Enum.find(tabs, &(&1.type == type and &1.id == id))
end end
defp assign_chat_editor(socket) do
ChatEditor.assign_socket(socket)
end
defp assign_import_editor(socket) do defp assign_import_editor(socket) do
ImportEditor.assign_socket(socket) ImportEditor.assign_socket(socket)
end end

View File

@@ -1,151 +1,229 @@
defmodule BDS.Desktop.ShellLive.ChatEditor do defmodule BDS.Desktop.ShellLive.ChatEditor do
@moduledoc false @moduledoc false
use Phoenix.Component use Phoenix.LiveComponent
import Phoenix.HTML, only: [raw: 1] import Phoenix.HTML, only: [raw: 1]
alias BDS.AI alias BDS.{AI, BoundedAtoms, MapUtils, Persistence}
alias BDS.MapUtils
alias BDS.Persistence
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.ChatEditor.{MessageBuild, ModelSelection, ToolTracking} alias BDS.Desktop.ShellLive.ChatEditor.{MessageBuild, ModelSelection, ToolTracking}
alias BDS.Desktop.ShellLive.TabHelpers
embed_templates("chat_editor_html/*") embed_templates("chat_editor_html/*")
# ── Public API: state assignment ─────────────────────────────────────────── # ── LiveComponent lifecycle ────────────────────────────────────────────────
@spec assign_socket(term()) :: term() @spec update(map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()}
def assign_socket(socket) do @impl true
assign(socket, :chat_editor, MessageBuild.build(socket.assigns)) def update(%{action: :finish_request, result: result}, socket) do
{:ok, do_finish_request(socket, result)}
end 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 def update(%{action: :note_streaming_content, content: content}, socket) do
defdelegate set_model(socket, model_id, reload, append_output), to: ModelSelection {:ok, do_note_streaming_content(socket, content)}
end
# ── 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(assigns, socket) do
socket =
socket socket
|> assign( |> assign(assigns)
:chat_editor_inputs, |> ensure_state()
Map.put(socket.assigns.chat_editor_inputs, conversation_id, to_string(value || "")) |> 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 end
@spec update_surface_form(term(), term(), term(), term()) :: term() # ── State initialisation ──────────────────────────────────────────────────
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)
socket defp ensure_state(socket) do
|> assign(:chat_editor_surface_data, next_data) conversation_id = socket.assigns.current_tab.id
|> reload.(socket.assigns.workbench)
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 end
@spec select_surface_tab(term(), term(), term(), term()) :: term() # ── Data builder ──────────────────────────────────────────────────────────
def select_surface_tab(socket, surface_id, index, reload)
when is_binary(surface_id) and is_integer(index) and index >= 0 do defp build_data(socket) do
socket conversation_id = socket.assigns.conversation_id
|> assign( request = socket.assigns.request
:chat_editor_surface_tabs,
Map.put(socket.assigns.chat_editor_surface_tabs, surface_id, index) fake_assigns = %{
) current_tab: socket.assigns.current_tab,
|> reload.(socket.assigns.workbench) 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 end
@spec dismiss_surface(term(), term(), term()) :: term() # ── Messaging ──────────────────────────────────────────────────────────────
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
@spec current_surface_data(term(), term()) :: term() defp do_send_message(socket) do
def current_surface_data(socket, surface_id) when is_binary(surface_id) do conversation_id = socket.assigns.conversation_id
Map.get(socket.assigns.chat_editor_surface_data, surface_id, %{}) message = String.trim(socket.assigns.input || "")
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()
cond do cond do
message == "" -> message == "" ->
reload.(socket, socket.assigns.workbench) build_data(socket)
Map.has_key?(socket.assigns.chat_editor_requests, conversation_id) -> not is_nil(socket.assigns.request) ->
reload.(socket, socket.assigns.workbench) build_data(socket)
socket.assigns.offline_mode -> socket.assigns.offline_mode ->
socket notify_parent(
|> append_output.( {:chat_editor_output, translated("Chat"),
translated("Chat"), translated("Automatic AI actions stay gated by airplane mode."), "info"}
translated("Automatic AI actions stay gated by airplane mode."),
nil,
"info"
) )
|> reload.(socket.assigns.workbench)
build_data(socket)
ModelSelection.needs_api_key?(false) -> ModelSelection.needs_api_key?(false) ->
reload.(socket, socket.assigns.workbench) build_data(socket)
true -> true ->
live_view_pid = self() parent = self()
started_at = Persistence.now_ms() started_at = Persistence.now_ms()
task = task =
Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn -> Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn ->
AI.send_chat_message(conversation_id, message, AI.send_chat_message(conversation_id, message,
project_id: socket.assigns.projects.active_project_id, project_id: active_project_id(socket),
event_target: live_view_pid event_target: parent
) )
end) end)
:ok = allow_repo_sandbox(task.pid) :ok = allow_repo_sandbox(task.pid)
notify_parent({:chat_editor_task_started, conversation_id, task.ref})
socket socket
|> assign( |> assign(:input, "")
:chat_editor_inputs, |> assign(:request, %{
Map.put(socket.assigns.chat_editor_inputs, conversation_id, "")
)
|> assign(
:chat_editor_requests,
Map.put(socket.assigns.chat_editor_requests, conversation_id, %{
ref: task.ref, ref: task.ref,
pid: task.pid, pid: task.pid,
started_at: started_at, started_at: started_at,
@@ -153,50 +231,54 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
content: "", content: "",
tool_events: [] tool_events: []
}) })
) |> assign(:action_error, nil)
|> assign( |> build_data()
: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)
end end
end end
@spec abort_message(term(), term()) :: term() defp do_abort_message(socket) do
def abort_message(socket, reload) do conversation_id = socket.assigns.conversation_id
%{id: conversation_id} = socket.assigns.current_tab
case Map.get(socket.assigns.chat_editor_requests, conversation_id) do case socket.assigns.request do
nil -> nil ->
reload.(socket, socket.assigns.workbench) build_data(socket)
%{ref: ref} = _request -> %{ref: ref} = _request ->
:ok = AI.cancel_chat(conversation_id) :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 socket
|> assign( |> assign(:request, nil)
:chat_editor_requests, |> build_data()
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)
end end
end end
@spec note_tool_call(term(), term(), term(), term()) :: term() defp do_finish_request(socket, result) do
def note_tool_call(socket, conversation_id, tool_call, reload) case result do
when is_binary(conversation_id) and is_map(tool_call) do {:ok, reply} ->
update_request( socket
socket, |> update_tab_meta_from_reply(reply)
conversation_id, |> assign(:request, nil)
fn request -> |> 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( update_in(
request.tool_events, request.tool_events,
&(&1 ++ &(&1 ++
@@ -209,99 +291,208 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
} }
]) ])
) )
end, end)
reload
)
end end
@spec note_tool_result(term(), term(), term(), term()) :: term() defp do_note_tool_result(socket, name) when is_binary(name) do
def note_tool_result(socket, conversation_id, name, reload) update_request(socket, fn request ->
when is_binary(conversation_id) and is_binary(name) do
update_request(
socket,
conversation_id,
fn request ->
update_in(request.tool_events, &(&1 ++ [%{type: :result, name: name}])) update_in(request.tool_events, &(&1 ++ [%{type: :result, name: name}]))
end, end)
reload
)
end end
@spec note_streaming_content(term(), term(), term(), term()) :: term() defp do_note_streaming_content(socket, content) when is_binary(content) do
def note_streaming_content(socket, conversation_id, content, reload) update_request(socket, fn request -> %{request | content: content} end)
when is_binary(conversation_id) and is_binary(content) do
update_request(
socket,
conversation_id,
fn request -> %{request | content: content} end,
reload
)
end end
@spec finish_request(term(), term(), term(), term(), term()) :: term() defp update_request(socket, updater) do
def finish_request(socket, ref, result, reload, append_output) when is_reference(ref) do case socket.assigns.request do
case Map.pop(socket.assigns.chat_editor_request_refs, ref) do nil ->
{nil, _remaining_refs} ->
socket socket
{conversation_id, remaining_refs} -> request ->
socket =
socket socket
|> assign(:chat_editor_request_refs, remaining_refs) |> assign(:request, updater.(request))
|> assign( |> build_data()
: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
end end
end end
defp update_tab_meta_from_reply(socket, conversation_id, reply) do defp update_tab_meta_from_reply(socket, reply) do
title = title =
reply reply
|> MapUtils.attr(:conversation, %{}) |> MapUtils.attr(:conversation, %{})
|> MapUtils.attr(:title) |> MapUtils.attr(:title)
if is_binary(title) and String.trim(title) != "" do 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 socket
end 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 end
# ── HEEx-callable helpers ───────────────────────────────────────────────── # ── 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(:user), do: translated("chat.role.you")
def message_role_label(_role), do: translated("chat.role.assistant") def message_role_label(_role), do: translated("chat.role.assistant")
defdelegate tool_call_name(tool_call), to: ToolTracking defdelegate tool_call_name(tool_call), to: ToolTracking
defdelegate tool_call_arguments(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") 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 def markdown_html(content) when is_binary(content) do
html = html =
case Earmark.as_html(content, escape: true) do case Earmark.as_html(content, escape: true) do
@@ -313,13 +504,13 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
raw(html) raw(html)
end end
@spec markdown_html(term()) :: term()
def markdown_html(_content), do: "" 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(nil), do: "{}"
def payload_json(payload) when is_map(payload), do: Jason.encode!(payload) 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 not is_number(value), do: 0
def chart_width(max_value, value) when is_number(max_value) and max_value > 0 do 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) |> Float.round(2)
end end
@spec chart_width(term(), term()) :: term()
def chart_width(_max_value, _value), do: 0 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 def truthy?(value) when value in [true, "true", 1, "1", "on"], do: true
@spec truthy?(term()) :: term()
def truthy?(_value), do: false def truthy?(_value), do: false
# ── HEEx components ─────────────────────────────────────────────────────── # ── HEEx components ───────────────────────────────────────────────────────
attr(:markers, :list, required: true) 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 def chat_tool_markers(assigns) do
~H""" ~H"""
<%= if @markers != [] do %> <%= if @markers != [] do %>
@@ -372,15 +562,16 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
end end
attr(:surface, :map, required: true) 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 def chat_surface(assigns) do
~H""" ~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)}> <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"> <summary class="chat-inline-surface-header">
<span class="chat-inline-surface-icon"><%= surface_icon(@surface.type) %></span> <span class="chat-inline-surface-icon"><%= surface_icon(@surface.type) %></span>
<span class="chat-inline-surface-title"><%= surface_title(@surface) %></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> </summary>
<div class="chat-inline-surface-body"> <div class="chat-inline-surface-body">
<%= case @surface.type do %> <%= case @surface.type do %>
@@ -400,6 +591,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
class="chat-surface-action-button" class="chat-surface-action-button"
type="button" type="button"
phx-click="chat_surface_action" phx-click="chat_surface_action"
phx-target={@myself}
phx-value-surface-id={@surface.id} phx-value-surface-id={@surface.id}
phx-value-action={action.action} phx-value-action={action.action}
phx-value-payload={payload_json(action.payload)} 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")]} class={["chat-surface-tab-button", if(index == @surface.selected_index, do: "active")]}
type="button" type="button"
phx-click="select_chat_surface_tab" phx-click="select_chat_surface_tab"
phx-target={@myself}
phx-value-surface-id={@surface.id} phx-value-surface-id={@surface.id}
phx-value-index={index} phx-value-index={index}
> >
@@ -522,7 +715,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
<%= if present?(@surface.title) do %> <%= if present?(@surface.title) do %>
<h3><%= @surface.title %></h3> <h3><%= @surface.title %></h3>
<% end %> <% 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} /> <input type="hidden" name="surface[id]" value={@surface.id} />
<%= for field <- @surface.fields do %> <%= for field <- @surface.fields do %>
@@ -559,6 +752,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
class="chat-surface-action-button" class="chat-surface-action-button"
type="button" type="button"
phx-click="chat_surface_action" phx-click="chat_surface_action"
phx-target={@myself}
phx-value-surface-id={@surface.id} phx-value-surface-id={@surface.id}
phx-value-action={@surface.submit_action} phx-value-action={@surface.submit_action}
phx-value-payload="{}" phx-value-payload="{}"
@@ -604,19 +798,12 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
# ── Private helpers ─────────────────────────────────────────────────────── # ── Private helpers ───────────────────────────────────────────────────────
defp update_request(socket, conversation_id, updater, reload) do defp notify_parent(message) do
case Map.get(socket.assigns.chat_editor_requests, conversation_id) do send(self(), message)
nil ->
socket
request ->
socket
|> assign(
:chat_editor_requests,
Map.put(socket.assigns.chat_editor_requests, conversation_id, updater.(request))
)
|> reload.(socket.assigns.workbench)
end end
defp active_project_id(socket) do
socket.assigns[:project_id]
end end
defp allow_repo_sandbox(pid) when is_pid(pid) do 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) defp format_error(reason), do: inspect(reason)
@spec translated(term(), term()) :: term() defp parse_integer(value) when is_integer(value), do: value
def translated(text, bindings \\ %{}),
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()) do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end end

View File

@@ -15,6 +15,7 @@
class="chat-model-selector-button chat-model-selector-inline" class="chat-model-selector-button chat-model-selector-inline"
type="button" type="button"
phx-click="toggle_chat_model_selector" phx-click="toggle_chat_model_selector"
phx-target={@myself}
data-testid="chat-model-selector-button" data-testid="chat-model-selector-button"
> >
<span><%= @chat_editor.effective_model || translated("chat.modelUnavailable") %></span> <span><%= @chat_editor.effective_model || translated("chat.modelUnavailable") %></span>
@@ -37,6 +38,7 @@
]} ]}
type="button" type="button"
phx-click="select_chat_model" phx-click="select_chat_model"
phx-target={@myself}
phx-value-model={model.id} phx-value-model={model.id}
data-testid="chat-model-selector-option" data-testid="chat-model-selector-option"
data-provider={group.provider} data-provider={group.provider}
@@ -60,7 +62,7 @@
<h2><%= translated("chat.apiKeyRequiredTitle") %></h2> <h2><%= translated("chat.apiKeyRequiredTitle") %></h2>
<p><%= translated("chat.apiKeyRequiredDescription") %></p> <p><%= translated("chat.apiKeyRequiredDescription") %></p>
<div class="api-key-form"> <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>
</div> </div>
<% else %> <% else %>
@@ -100,7 +102,7 @@
<%= if message.role == :assistant do %> <%= if message.role == :assistant do %>
<div class="chat-message-text"><%= markdown_html(message.content || "") %></div> <div class="chat-message-text"><%= markdown_html(message.content || "") %></div>
<%= for surface <- message.inline_surfaces do %> <%= for surface <- message.inline_surfaces do %>
<.chat_surface surface={surface} /> <.chat_surface surface={surface} myself={@myself} />
<% end %> <% end %>
<% else %> <% else %>
<div class="chat-message-text chat-user-message-text" data-testid="chat-user-message-text"><%= message.content || "" %></div> <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> <div class="chat-message-text"><%= markdown_html(@chat_editor.streaming_content) %></div>
<% end %> <% end %>
<%= for surface <- @chat_editor.streaming_inline_surfaces do %> <%= for surface <- @chat_editor.streaming_inline_surfaces do %>
<.chat_surface surface={surface} /> <.chat_surface surface={surface} myself={@myself} />
<% end %> <% end %>
</div> </div>
</div> </div>
@@ -147,12 +149,12 @@
<%= unless @chat_editor.needs_api_key? do %> <%= unless @chat_editor.needs_api_key? do %>
<div class="chat-input-container" data-testid="chat-input-container"> <div class="chat-input-container" data-testid="chat-input-container">
<%= if @chat_editor.is_streaming do %> <%= 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 %> <% 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> <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> </form>
<%= if @chat_editor.action_error do %> <%= if @chat_editor.action_error do %>

View File

@@ -3,144 +3,7 @@ defmodule BDS.Desktop.ShellLive.ChatSurface do
import Phoenix.Component, only: [assign: 3] import Phoenix.Component, only: [assign: 3]
alias BDS.BoundedAtoms
alias BDS.Desktop.ShellData 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 def assistant_turn(prompt, socket) do
[ [
@@ -165,61 +28,6 @@ defmodule BDS.Desktop.ShellLive.ChatSurface do
end end
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 defp assistant_reply(socket) do
if socket.assigns.offline_mode do if socket.assigns.offline_mode do
ShellData.translate( ShellData.translate(

View File

@@ -415,8 +415,8 @@
<% @current_tab.type == :templates -> %> <% @current_tab.type == :templates -> %>
<.live_component module={TemplateEditor} id={"template-editor-#{@current_tab.id}"} current_tab={@current_tab} /> <.live_component module={TemplateEditor} id={"template-editor-#{@current_tab.id}"} current_tab={@current_tab} />
<% @current_tab.type == :chat and @chat_editor -> %> <% @current_tab.type == :chat -> %>
<ChatEditor.chat_editor chat_editor={@chat_editor} /> <.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 -> %> <% @current_tab.type == :import and @import_editor -> %>
<ImportEditor.import_editor import_editor={@import_editor} /> <ImportEditor.import_editor import_editor={@import_editor} />

View File

@@ -3232,7 +3232,10 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ "llama-current" assert html =~ "llama-current"
refute html =~ ~s(<span>New Chat</span><span class="chat-model-selector-caret">▾</span>) 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(class="chat-model-selector-menu")
assert selector_html =~ ~s(data-testid="chat-model-selector-option") assert selector_html =~ ~s(data-testid="chat-model-selector-option")
assert selector_html =~ "llama-current" assert selector_html =~ "llama-current"
@@ -3244,10 +3247,12 @@ defmodule BDS.Desktop.ShellLiveTest do
refute css =~ 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;" ".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 AI.get_chat_conversation(conversation.id).model == "llama-default"
assert render(view) =~ "llama-next" assert render(view) =~ "llama-default"
end end
test "chat editor updates the visible new-chat title after the first turn" do 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>) assert html =~ ~s(<span class="tab-title">New Chat</span>)
_html = _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 = _html =
view view
@@ -3384,10 +3391,12 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ "Posts" assert html =~ "Posts"
assert html =~ ~r/chat-message-content.*data-testid="chat-inline-surface"/s 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 = dismissed_html =
render_click(view, "dismiss_chat_surface", %{ view
"surface-id" => Regex.run(~r/id="([^"]+-surface-0)"/, html) |> Enum.at(1) |> element("button[phx-value-surface-id='#{surface_id}']")
}) |> render_click()
refute dismissed_html =~ ~s(data-testid="chat-inline-surface") refute dismissed_html =~ ~s(data-testid="chat-inline-surface")
end end
@@ -3503,7 +3512,10 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => conversation.model || "chat" "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 = _html =
view view
@@ -3883,11 +3895,11 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ "Quick Action" assert html =~ "Quick Action"
assert html =~ "Open Post" assert html =~ "Open Post"
html =
view view
|> element("[data-testid='chat-surface-action'][data-action='openPost']") |> element("[data-testid='chat-surface-action'][data-action='openPost']")
|> render_click() |> render_click()
html = render(view)
assert html =~ ~s(data-tab-type="post") assert html =~ ~s(data-tab-type="post")
assert html =~ ~s(data-tab-id="#{post.id}") assert html =~ ~s(data-tab-id="#{post.id}")
end end
@@ -3919,7 +3931,10 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => conversation.model || "chat" "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 = html =
view view
@@ -3976,7 +3991,10 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => conversation.model || "chat" "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 = html =
view view
@@ -4046,7 +4064,10 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => conversation.model || "chat" "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 = _html =
view view