From 0929a4e798c5fa920856cd3c6aa20a9fbb31006c Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Tue, 28 Apr 2026 20:38:26 +0200 Subject: [PATCH] feat: step 6 first round --- lib/bds/ai.ex | 133 +- lib/bds/desktop/shell_live.ex | 215 +++ lib/bds/desktop/shell_live/chat_editor.ex | 810 ++++++++- .../chat_editor_html/chat_editor.html.heex | 177 +- priv/i18n/locales/de.json | 6 + priv/i18n/locales/en.json | 6 + priv/i18n/locales/es.json | 6 + priv/i18n/locales/fr.json | 6 + priv/i18n/locales/it.json | 6 + priv/ui/app.css | 1566 ++++++++++++++++- priv/ui/live.js | 107 ++ test/bds/desktop/shell_live_test.exs | 175 ++ 12 files changed, 3072 insertions(+), 141 deletions(-) diff --git a/lib/bds/ai.ex b/lib/bds/ai.ex index b41c997..eb57c39 100644 --- a/lib/bds/ai.ex +++ b/lib/bds/ai.ex @@ -521,11 +521,19 @@ defmodule BDS.AI do with {:ok, response} <- runtime.generate(endpoint_with_model(endpoint, model), request, opts), {:ok, assistant_message} <- persist_assistant_response(conversation.id, response), :ok <- touch_conversation(conversation.id) do + if is_binary(Map.get(response, :content)) and String.trim(Map.get(response, :content)) != "" do + notify_chat_event(opts, {:chat_streaming_content, conversation.id, Map.get(response, :content)}) + end + tool_calls = decode_tool_calls(Map.get(response, :tool_calls)) + Enum.each(tool_calls, fn tool_call -> + notify_chat_event(opts, {:chat_tool_call, conversation.id, tool_call}) + end) + cond do tool_calls != [] and tools != [] -> - with {:ok, tool_messages} <- execute_tool_calls(conversation.id, tool_calls, project_id), + with {:ok, tool_messages} <- execute_tool_calls(conversation.id, tool_calls, project_id, opts), updated_messages <- load_chat_messages(conversation.id), {:ok, reply} <- chat_round( @@ -575,7 +583,7 @@ defmodule BDS.AI do }) end - defp execute_tool_calls(conversation_id, tool_calls, project_id) do + defp execute_tool_calls(conversation_id, tool_calls, project_id, opts) do tool_messages = Enum.map(tool_calls, fn tool_call -> result = execute_tool(tool_call.name, tool_call.arguments || %{}, project_id) @@ -589,6 +597,8 @@ defmodule BDS.AI do created_at: Persistence.now_ms() }) + notify_chat_event(opts, {:chat_tool_result, conversation_id, tool_call.name}) + format_chat_message(message) end) @@ -652,7 +662,51 @@ defmodule BDS.AI do %{ type: "form", title: arguments["title"], - fields: arguments["fields"] || [] + fields: arguments["fields"] || [], + submit_label: arguments["submit_label"] || arguments["submitLabel"], + submit_action: arguments["submit_action"] || arguments["submitAction"] + } + end + + defp execute_tool("render_card", arguments, _project_id) do + %{ + type: "card", + title: arguments["title"], + subtitle: arguments["subtitle"], + body: arguments["body"], + actions: arguments["actions"] || [] + } + end + + defp execute_tool("render_metric", arguments, _project_id) do + %{ + type: "metric", + label: arguments["label"], + value: arguments["value"] + } + end + + defp execute_tool("render_list", arguments, _project_id) do + %{ + type: "list", + title: arguments["title"], + items: arguments["items"] || [] + } + end + + defp execute_tool("render_tabs", arguments, _project_id) do + %{ + type: "tabs", + title: arguments["title"], + tabs: arguments["tabs"] || [] + } + end + + defp execute_tool("render_mindmap", arguments, _project_id) do + %{ + type: "mindmap", + title: arguments["title"], + nodes: arguments["nodes"] || [] } end @@ -736,9 +790,14 @@ defmodule BDS.AI do project_tools ++ [ + %{name: "render_card", spec: tool_spec("render_card", "Return a structured card payload", render_card_schema())}, %{name: "render_table", spec: tool_spec("render_table", "Return a structured table payload", render_table_schema())}, %{name: "render_chart", spec: tool_spec("render_chart", "Return a structured chart payload", render_chart_schema())}, - %{name: "render_form", spec: tool_spec("render_form", "Return a structured form payload", render_form_schema())} + %{name: "render_form", spec: tool_spec("render_form", "Return a structured form payload", render_form_schema())}, + %{name: "render_metric", spec: tool_spec("render_metric", "Return a structured metric payload", render_metric_schema())}, + %{name: "render_list", spec: tool_spec("render_list", "Return a structured list payload", render_list_schema())}, + %{name: "render_tabs", spec: tool_spec("render_tabs", "Return a structured tabs payload", render_tabs_schema())}, + %{name: "render_mindmap", spec: tool_spec("render_mindmap", "Return a structured mindmap payload", render_mindmap_schema())} ] else [] @@ -1162,7 +1221,61 @@ defmodule BDS.AI do "type" => "object", "properties" => %{ "title" => %{"type" => "string"}, - "fields" => %{"type" => "array"} + "fields" => %{"type" => "array"}, + "submitLabel" => %{"type" => "string"}, + "submitAction" => %{"type" => "string"} + } + } + end + + defp render_card_schema do + %{ + "type" => "object", + "properties" => %{ + "title" => %{"type" => "string"}, + "subtitle" => %{"type" => "string"}, + "body" => %{"type" => "string"}, + "actions" => %{"type" => "array"} + } + } + end + + defp render_metric_schema do + %{ + "type" => "object", + "properties" => %{ + "label" => %{"type" => "string"}, + "value" => %{"type" => "string"} + } + } + end + + defp render_list_schema do + %{ + "type" => "object", + "properties" => %{ + "title" => %{"type" => "string"}, + "items" => %{"type" => "array"} + } + } + end + + defp render_tabs_schema do + %{ + "type" => "object", + "properties" => %{ + "title" => %{"type" => "string"}, + "tabs" => %{"type" => "array"} + } + } + end + + defp render_mindmap_schema do + %{ + "type" => "object", + "properties" => %{ + "title" => %{"type" => "string"}, + "nodes" => %{"type" => "array"} } } end @@ -1170,6 +1283,16 @@ defmodule BDS.AI do defp normalize_limit(value) when is_integer(value) and value > 0 and value <= 50, do: value defp normalize_limit(_value), do: 10 + defp notify_chat_event(opts, event) do + case Keyword.get(opts, :event_target) do + pid when is_pid(pid) -> send(pid, event) + callback when is_function(callback, 1) -> callback.(event) + _other -> :ok + end + + :ok + end + defp truncate_text(nil, _max_length), do: "" defp truncate_text(text, max_length) when is_binary(text) do diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index 2e4f23e..bc8022a 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -98,6 +98,11 @@ defmodule BDS.Desktop.ShellLive do |> assign(:template_editor_drafts, %{}) |> 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_action_errors, %{}) |> assign(:misc_editor_selected_pairs, %{}) |> assign(:misc_editor_git_selected_files, %{}) |> assign(:metadata_diff_active_tabs, %{}) @@ -697,6 +702,29 @@ defmodule BDS.Desktop.ShellLive 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 + |> clear_chat_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("chat_surface_action", params, socket) do + {:noreply, handle_chat_surface_action(socket, params)} + end + def handle_event("rerun_misc_editor", _params, socket) do case MiscEditor.rerun(socket) do {:command, action} -> {:noreply, apply_shell_command(socket, action)} @@ -1068,6 +1096,33 @@ defmodule BDS.Desktop.ShellLive do end @impl true + def handle_info({ref, result}, socket) when is_reference(ref) do + Process.demonitor(ref, [:flush]) + {:noreply, ChatEditor.finish_request(socket, ref, result, &reload_shell/2, &append_output_entry/5)} + end + + def handle_info({:DOWN, ref, :process, _pid, reason}, socket) when is_reference(ref) do + next_socket = + case reason do + :normal -> socket + _other -> ChatEditor.finish_request(socket, ref, {:error, :cancelled}, &reload_shell/2, &append_output_entry/5) + end + + {:noreply, next_socket} + 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)} + end + + def handle_info({:chat_tool_result, conversation_id, name}, socket) do + {:noreply, ChatEditor.note_tool_result(socket, conversation_id, name, &reload_shell/2)} + end + + def handle_info({:chat_streaming_content, conversation_id, content}, socket) do + {:noreply, ChatEditor.note_streaming_content(socket, conversation_id, content, &reload_shell/2)} + end + def handle_info(:refresh_task_status, socket) do raw_task_status = BDS.Tasks.status_snapshot() @@ -2011,6 +2066,166 @@ defmodule BDS.Desktop.ShellLive do defp git_history_target(_tab), do: nil + defp handle_chat_surface_action(socket, params) do + surface_id = Map.get(params, "surface-id", "") + + payload = + params + |> Map.get("payload") + |> decode_chat_surface_payload() + |> maybe_put_chat_surface_form_data(socket, surface_id) + + case normalize_chat_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_chat_action_error() + |> open_sidebar_item(%{"route" => "post", "id" => post_id, "title" => post_title(post_id), "subtitle" => post_subtitle(post_id)}, :pin) + + _other -> + ChatEditor.set_action_error(socket, socket.assigns.current_tab.id, "Invalid payload for openPost action", &reload_shell/2) + 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_chat_action_error() + |> open_sidebar_item(%{"route" => "media", "id" => media_id, "title" => media_title(media_id), "subtitle" => media_subtitle(media_id)}, :pin) + + _other -> + ChatEditor.set_action_error(socket, socket.assigns.current_tab.id, "Invalid payload for openMedia action", &reload_shell/2) + end + + :open_settings -> + socket + |> clear_chat_action_error() + |> open_sidebar_item(%{"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_chat_action_error() + |> open_sidebar_item(%{"route" => "chat", "id" => chat_id, "title" => Map.get(payload, "title", "Chat"), "subtitle" => Map.get(payload, "subtitle", "")}, :pin) + + :switch_view -> + case safe_existing_atom(Map.get(payload, "view")) do + nil -> ChatEditor.set_action_error(socket, socket.assigns.current_tab.id, "Invalid payload for switchView action", &reload_shell/2) + view -> + socket + |> clear_chat_action_error() + |> reload_shell(Workbench.click_activity(socket.assigns.workbench, view)) + end + + :toggle_sidebar -> + socket + |> clear_chat_action_error() + |> reload_shell(Workbench.toggle_sidebar(socket.assigns.workbench)) + + :toggle_panel -> + socket + |> clear_chat_action_error() + |> reload_shell(Workbench.toggle_panel(socket.assigns.workbench)) + + :toggle_assistant_sidebar -> + socket + |> clear_chat_action_error() + |> reload_shell(Workbench.toggle_assistant_sidebar(socket.assigns.workbench)) + + :unknown -> + ChatEditor.set_action_error(socket, socket.assigns.current_tab.id, "Unsupported assistant action", &reload_shell/2) + end + end + + defp clear_chat_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 + + defp clear_chat_action_error(socket), do: socket + + defp decode_chat_surface_payload(nil), do: %{} + defp decode_chat_surface_payload(""), do: %{} + + defp decode_chat_surface_payload(payload) when is_binary(payload) do + case Jason.decode(payload) do + {:ok, decoded} when is_map(decoded) -> decoded + _other -> %{} + end + end + + defp decode_chat_surface_payload(_payload), do: %{} + + defp maybe_put_chat_surface_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_chat_surface_form_data(payload, _socket, _surface_id), do: payload + + defp normalize_chat_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 post_title(post_id) do + case Repo.get(Post, post_id) do + %Post{} = post -> post.title || post.slug || post.id + _other -> "Post" + end + end + + defp post_subtitle(post_id) do + case Repo.get(Post, post_id) do + %Post{} = post -> post.slug || "draft" + _other -> "draft" + end + end + + defp media_title(media_id) do + case Repo.get(Media, media_id) do + %Media{} = media -> media.title || media.filename || media.id + _other -> "Media" + end + end + + defp media_subtitle(media_id) do + case Repo.get(Media, media_id) do + %Media{} = media -> media.filename || media.mime_type || "media" + _other -> "media" + end + end + + defp parse_integer(value) when is_integer(value), do: value + + defp parse_integer(value) do + case Integer.parse(to_string(value || "0")) do + {parsed, _rest} -> parsed + :error -> 0 + end + end + defp short_commit_hash(hash) when is_binary(hash), do: String.slice(hash, 0, 7) defp short_commit_hash(_hash), do: "-------" diff --git a/lib/bds/desktop/shell_live/chat_editor.ex b/lib/bds/desktop/shell_live/chat_editor.ex index 6d92b2b..49f38f9 100644 --- a/lib/bds/desktop/shell_live/chat_editor.ex +++ b/lib/bds/desktop/shell_live/chat_editor.ex @@ -2,11 +2,24 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do @moduledoc false use Phoenix.Component + import Phoenix.HTML, only: [raw: 1] alias BDS.{AI, Repo} alias BDS.AI.ChatConversation alias BDS.Desktop.ShellData + @render_tool_names MapSet.new([ + "render_card", + "render_chart", + "render_form", + "render_list", + "render_metric", + "render_mindmap", + "render_table", + "render_tabs" + ]) + @tool_args_max_length 30 + embed_templates "chat_editor_html/*" def assign_socket(socket) do @@ -46,27 +59,137 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do |> reload.(socket.assigns.workbench) end + 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 + |> assign(:chat_editor_surface_data, next_data) + |> reload.(socket.assigns.workbench) + end + + 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) + end + + def current_surface_data(socket, surface_id) when is_binary(surface_id) do + Map.get(socket.assigns.chat_editor_surface_data, surface_id, %{}) + end + + 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 + + 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 + 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 message == "" -> reload.(socket, socket.assigns.workbench) + Map.has_key?(socket.assigns.chat_editor_requests, conversation_id) -> reload.(socket, socket.assigns.workbench) socket.assigns.offline_mode -> socket |> append_output.(translated("Chat"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info") |> reload.(socket.assigns.workbench) + needs_api_key?(false) -> + reload.(socket, socket.assigns.workbench) + true -> - case AI.send_chat_message(conversation_id, message, project_id: socket.assigns.projects.active_project_id) do - {:ok, _result} -> - socket - |> assign(:chat_editor_inputs, Map.put(socket.assigns.chat_editor_inputs, conversation_id, "")) - |> reload.(socket.assigns.workbench) + live_view_pid = self() + + 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 + ) + end) + + :ok = allow_repo_sandbox(task.pid) + + 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, %{ref: task.ref, pid: task.pid, message: message, 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) + end + end + + def abort_message(socket, reload) do + %{id: conversation_id} = socket.assigns.current_tab + + case Map.get(socket.assigns.chat_editor_requests, conversation_id) do + nil -> reload.(socket, socket.assigns.workbench) + + %{ref: ref} = _request -> + :ok = AI.cancel_chat(conversation_id) + + 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) + end + end + + 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 -> + update_in(request.tool_events, &(&1 ++ [%{type: :call, name: tool_call_name(tool_call), arguments: tool_call_arguments(tool_call)}])) + end, reload) + end + + 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 -> + update_in(request.tool_events, &(&1 ++ [%{type: :result, name: name}])) + end, reload) + end + + 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) + end + + 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} -> + socket + + {conversation_id, remaining_refs} -> + socket = + 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} -> + reload.(socket, 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"), inspect(reason), nil, "error") + |> append_output.(translated("Chat"), format_error(reason), nil, "error") |> reload.(socket.assigns.workbench) end end @@ -76,6 +199,9 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do case Repo.get(ChatConversation, conversation_id) do nil -> nil %ChatConversation{} = conversation -> + messages = AI.list_chat_messages(conversation.id) + request = Map.get(assigns.chat_editor_requests, conversation.id) + %{ id: conversation.id, title: conversation.title || translated("chat.newChat"), @@ -83,8 +209,15 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do available_models: AI.available_chat_models(conversation.model), model_selector_open?: Map.get(assigns.chat_model_selectors_open, conversation.id, false), input: Map.get(assigns.chat_editor_inputs, conversation.id, ""), - messages: build_entries(AI.list_chat_messages(conversation.id)), - offline?: Map.get(assigns, :offline_mode, true) + messages: build_entries(messages, assigns), + pending_user_message: pending_user_message(messages, request), + is_streaming: not is_nil(request), + streaming_content: streaming_content(request), + streaming_tool_markers: tool_markers_from_events(request), + offline?: Map.get(assigns, :offline_mode, true), + needs_api_key?: needs_api_key?(Map.get(assigns, :offline_mode, true)), + action_error: Map.get(assigns.chat_editor_action_errors, conversation.id), + send_disabled?: String.trim(Map.get(assigns.chat_editor_inputs, conversation.id, "")) == "" or not is_nil(request) } end end @@ -98,25 +231,288 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do Map.get(tool_call, "name") || Map.get(tool_call, :name) || "tool" end + def tool_call_arguments(tool_call) when is_map(tool_call) do + Map.get(tool_call, "arguments") || Map.get(tool_call, :arguments) || Map.get(tool_call, "args") || Map.get(tool_call, :args) || %{} + end + def tool_surface_type(surface), do: Map.get(surface, :type, "json") - defp build_entries(messages) do - {entries, current_entry} = - Enum.reduce(messages, {[], nil}, fn message, {entries, current_entry} -> + def markdown_html(content) when is_binary(content) do + html = + case Earmark.as_html(content, escape: true) do + {:ok, rendered, _messages} -> rendered + {:error, rendered, _messages} -> rendered + end + |> rewrite_external_images() + + raw(html) + end + + def markdown_html(_content), do: "" + + def payload_json(nil), do: "{}" + def payload_json(payload) when is_map(payload), do: Jason.encode!(payload) + + 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 + value + |> Kernel./(max_value) + |> Kernel.*(100) + |> min(100) + |> max(0) + |> Float.round(2) + end + + def chart_width(_max_value, _value), do: 0 + + def truthy?(value) when value in [true, "true", 1, "1", "on"], do: true + def truthy?(_value), do: false + + attr :markers, :list, required: true + + def chat_tool_markers(assigns) do + ~H""" + <%= if @markers != [] do %> +
+ <%= for marker <- @markers do %> +
+ <%= if marker.complete?, do: "✓", else: "●" %> + <%= marker.name %> + <%= if marker.args_preview not in [nil, ""] do %> + (<%= marker.args_preview %>) + <% end %> +
+ <% end %> +
+ <% end %> + """ + end + + attr :surface, :map, required: true + + def chat_surface(assigns) do + ~H""" +
+ <%= case @surface.type do %> + <% "card" -> %> +
+ <%= if present?(@surface.title) do %> +

<%= @surface.title %>

+ <% end %> + <%= if present?(@surface.subtitle) do %> +

<%= @surface.subtitle %>

+ <% end %> +

<%= @surface.body %>

+ <%= if @surface.actions != [] do %> +
+ <%= for action <- @surface.actions do %> + + <% end %> +
+ <% end %> +
+ + <% "table" -> %> + <%= if present?(@surface.title) do %> +

<%= @surface.title %>

+ <% end %> +
+ + + + <%= for column <- @surface.columns do %> + + <% end %> + + + + <%= for row <- @surface.rows do %> + + <%= for value <- row do %> + + <% end %> + + <% end %> + +
<%= column %>
<%= value %>
+
+ + <% "chart" -> %> + <%= if present?(@surface.title) do %> +

<%= @surface.title %>

+ <% end %> +

<%= @surface.chart_type %>

+
+ <%= for series <- @surface.series do %> +
+
+ <%= series.label %> + <%= series.value %> +
+
+ +
+
+ <% end %> +
+ + <% "metric" -> %> +
+ <%= @surface.label %> + <%= @surface.value %> +
+ + <% "list" -> %> + <%= if present?(@surface.title) do %> +

<%= @surface.title %>

+ <% end %> + + + <% "mindmap" -> %> + <%= if present?(@surface.title) do %> +

<%= @surface.title %>

+ <% end %> + + + <% "tabs" -> %> + <%= if present?(@surface.title) do %> +

<%= @surface.title %>

+ <% end %> +
+
+ <%= for {tab, index} <- Enum.with_index(@surface.tabs) do %> + + <% end %> +
+ + <%= case Enum.at(@surface.tabs, @surface.selected_index || 0) do %> + <% nil -> %> + <% tab -> %> +
+ <%= for content <- tab.content do %> + <.chat_surface surface={content} /> + <% end %> +
+ <% end %> +
+ + <% "form" -> %> + <%= if present?(@surface.title) do %> +

<%= @surface.title %>

+ <% end %> +
+ + + <%= for field <- @surface.fields do %> + + <% end %> +
+ + <%= if present?(@surface.submit_label) do %> +
+ +
+ <% end %> + + <% "text" -> %> +
<%= @surface.body %>
+ + <% _other -> %> +
<%= Jason.encode!(@surface.raw || %{}, pretty: true) %>
+ <% end %> +
+ """ + end + + defp build_entries(messages, assigns) do + {entries, current_entry, _turn_index} = + Enum.reduce(messages, {[], nil, -1}, fn message, {entries, current_entry, turn_index} -> case message.role do :tool -> if current_entry && current_entry.role == :assistant do - {entries, append_tool_surface(current_entry, message)} + {entries, append_tool_surface(current_entry, message), turn_index} else - {entries, current_entry} + {entries, current_entry, turn_index} end :system -> - {entries, current_entry} + {entries, current_entry, turn_index} + + :user -> + entries = finalize_entry(entries, current_entry) + next_turn_index = turn_index + 1 + {entries, start_entry(message, next_turn_index, assigns), next_turn_index} _other -> entries = finalize_entry(entries, current_entry) - {entries, start_entry(message)} + {entries, start_entry(message, turn_index, assigns), turn_index} end end) @@ -128,17 +524,23 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do defp finalize_entry(entries, nil), do: entries defp finalize_entry(entries, entry), do: [entry | entries] - defp start_entry(message) do + defp start_entry(message, turn_index, assigns) do + tool_markers = normalize_tool_calls(message.tool_calls) + %{ id: message.id, role: message.role, content: message.content || "", - tool_markers: normalize_tool_calls(message.tool_calls), + turn_index: turn_index, + tool_markers: tool_markers, + inline_surfaces: build_render_surfaces(tool_markers, message.id, assigns), tool_surfaces: [] } end defp append_tool_surface(entry, message) do + entry = mark_tool_call_completed(entry, message.tool_call_id) + case normalize_tool_surface(message.content) do nil -> entry surface -> update_in(entry.tool_surfaces, &(&1 ++ [surface])) @@ -147,15 +549,254 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do defp normalize_tool_calls(tool_calls) when is_list(tool_calls) do Enum.map(tool_calls, fn tool_call -> + arguments = tool_call_arguments(tool_call) + %{ + id: Map.get(tool_call, "id") || Map.get(tool_call, :id), name: tool_call_name(tool_call), - arguments: Map.get(tool_call, "arguments") || Map.get(tool_call, :arguments) || Map.get(tool_call, "args") || Map.get(tool_call, :args) || %{} + arguments: arguments, + args_preview: tool_arguments_preview(arguments), + complete?: false } end) end defp normalize_tool_calls(_tool_calls), do: [] + defp build_render_surfaces(tool_calls, message_id, assigns) do + tool_calls + |> Enum.with_index() + |> Enum.flat_map(fn {tool_call, index} -> + case build_render_surface(tool_call, "#{message_id}-surface-#{index}", assigns) do + nil -> [] + surface -> [surface] + end + end) + end + + defp build_render_surface(%{name: name, arguments: arguments}, surface_id, assigns) do + if MapSet.member?(@render_tool_names, name) do + do_build_render_surface(name, arguments || %{}, surface_id, assigns) + end + end + + defp do_build_render_surface("render_card", arguments, surface_id, _assigns) do + %{ + id: surface_id, + type: "card", + title: map_value(arguments, "title"), + subtitle: map_value(arguments, "subtitle"), + body: map_value(arguments, "body", ""), + actions: decode_surface_actions(map_value(arguments, "actions", [])) + } + end + + defp do_build_render_surface("render_table", arguments, surface_id, _assigns) do + %{ + id: surface_id, + type: "table", + title: map_value(arguments, "title"), + columns: stringify_list(map_value(arguments, "columns", [])), + rows: Enum.map(List.wrap(map_value(arguments, "rows", [])), &stringify_list/1) + } + end + + defp do_build_render_surface("render_chart", arguments, surface_id, _assigns) do + series = + map_value(arguments, "series", []) + |> List.wrap() + |> Enum.map(fn entry -> + %{ + label: map_value(entry, "label", translated("chat.role.assistant")), + value: numeric_value(map_value(entry, "value", 0)), + segments: List.wrap(map_value(entry, "segments", [])) + } + end) + + %{ + id: surface_id, + type: "chart", + title: map_value(arguments, "title"), + chart_type: map_value(arguments, "chart_type", "bar"), + series: series, + max_value: Enum.max([0 | Enum.map(series, & &1.value)]) + } + end + + defp do_build_render_surface("render_metric", arguments, surface_id, _assigns) do + %{ + id: surface_id, + type: "metric", + label: map_value(arguments, "label", "Metric"), + value: map_value(arguments, "value", "") + } + end + + defp do_build_render_surface("render_list", arguments, surface_id, _assigns) do + %{ + id: surface_id, + type: "list", + title: map_value(arguments, "title"), + items: stringify_list(map_value(arguments, "items", [])) + } + end + + defp do_build_render_surface("render_mindmap", arguments, surface_id, _assigns) do + nodes = + arguments + |> map_value("nodes", []) + |> List.wrap() + |> Enum.map(fn node -> + %{ + id: map_value(node, "id"), + label: map_value(node, "label", "Node"), + children: stringify_list(map_value(node, "children", [])) + } + end) + + %{ + id: surface_id, + type: "mindmap", + title: map_value(arguments, "title"), + nodes: nodes + } + end + + defp do_build_render_surface("render_form", arguments, surface_id, assigns) do + stored_fields = Map.get(assigns.chat_editor_surface_data, surface_id, %{}) + + fields = + arguments + |> map_value("fields", []) + |> List.wrap() + |> Enum.map(fn field -> + key = map_value(field, "key", "field") + + %{ + key: key, + label: map_value(field, "label", key), + input_type: map_value(field, "inputType") || map_value(field, "input_type", "text"), + placeholder: map_value(field, "placeholder"), + value: Map.get(stored_fields, key, map_value(field, "defaultValue") || map_value(field, "default_value")), + options: decode_surface_options(map_value(field, "options", [])), + required?: truthy?(map_value(field, "required", false)) + } + end) + + %{ + id: surface_id, + type: "form", + title: map_value(arguments, "title"), + fields: fields, + submit_label: map_value(arguments, "submitLabel") || map_value(arguments, "submit_label", translated("chat.stop")), + submit_action: map_value(arguments, "submitAction") || map_value(arguments, "submit_action", "submitForm") + } + end + + defp do_build_render_surface("render_tabs", arguments, surface_id, assigns) do + tabs = + arguments + |> map_value("tabs", []) + |> List.wrap() + |> Enum.with_index() + |> Enum.map(fn {tab, tab_index} -> + %{ + label: map_value(tab, "label", "Tab #{tab_index + 1}"), + content: + tab + |> map_value("content", []) + |> List.wrap() + |> Enum.with_index() + |> Enum.map(fn {content, content_index} -> + build_tab_surface(content, "#{surface_id}-tab-#{tab_index}-#{content_index}", assigns) + end) + } + end) + + %{ + id: surface_id, + type: "tabs", + title: map_value(arguments, "title"), + tabs: tabs, + selected_index: Map.get(assigns.chat_editor_surface_tabs, surface_id, 0) + } + end + + defp do_build_render_surface(_name, arguments, surface_id, _assigns) do + %{id: surface_id, type: "json", raw: arguments} + end + + defp build_tab_surface(%{} = content, surface_id, assigns) do + type = map_value(content, "type", "text") + + case type do + render_type when render_type in ["card", "chart", "form", "list", "metric", "mindmap", "table", "tabs"] -> + do_build_render_surface("render_#{render_type}", Map.delete(content, "type"), surface_id, assigns) + + "text" -> + %{id: surface_id, type: "text", body: map_value(content, "body") || map_value(content, "text", "")} + + _other -> + %{id: surface_id, type: "json", raw: content} + end + end + + defp build_tab_surface(content, surface_id, _assigns) do + %{id: surface_id, type: "text", body: to_string(content || "")} + end + + defp mark_tool_call_completed(entry, tool_call_id) when is_binary(tool_call_id) do + update_in(entry.tool_markers, fn markers -> + Enum.map(markers, fn marker -> + if marker.id == tool_call_id do + %{marker | complete?: true} + else + marker + end + end) + end) + end + + defp mark_tool_call_completed(entry, _tool_call_id), do: entry + + defp decode_surface_actions(actions) when is_list(actions) do + Enum.map(actions, fn action -> + %{ + label: map_value(action, "label", translated("chat.openSettings")), + action: map_value(action, "action", "openSettings"), + payload: map_value(action, "payload", %{}) + } + end) + end + + defp decode_surface_actions(_actions), do: [] + + defp decode_surface_options(options) when is_list(options) do + Enum.map(options, fn option -> + %{ + label: map_value(option, "label", ""), + value: map_value(option, "value", "") + } + end) + end + + defp decode_surface_options(_options), do: [] + + defp tool_arguments_preview(arguments) when is_map(arguments) do + arguments + |> Enum.map(fn {key, value} -> "#{key}: #{preview_value(value)}" end) + |> Enum.join(", ") + end + + defp tool_arguments_preview(_arguments), do: "" + + defp preview_value(value) when is_binary(value) do + quoted = if String.length(value) > @tool_args_max_length, do: String.slice(value, 0, @tool_args_max_length) <> "...", else: value + inspect(quoted) + end + + defp preview_value(value), do: inspect(value) + defp normalize_tool_surface(content) when is_binary(content) do case Jason.decode(content) do {:ok, %{"type" => type} = decoded} -> @@ -175,5 +816,138 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do defp normalize_tool_surface(_content), do: nil + defp pending_user_message(_messages, nil), do: nil + + defp pending_user_message(messages, %{message: message}) when is_binary(message) do + case messages |> Enum.reverse() |> Enum.find(&(&1.role not in [:system, :tool])) do + %{role: :user, content: ^message} -> nil + _other -> message + end + end + + defp pending_user_message(_messages, _request), do: nil + + defp streaming_content(nil), do: "" + defp streaming_content(%{content: content}) when is_binary(content), do: content + defp streaming_content(_request), do: "" + + defp tool_markers_from_events(nil), do: [] + + defp tool_markers_from_events(%{tool_events: tool_events}) do + Enum.reduce(tool_events || [], [], fn event, markers -> + case event.type do + :call -> markers ++ [%{id: nil, name: event.name, arguments: event.arguments, args_preview: tool_arguments_preview(event.arguments || %{}), complete?: false}] + + :result -> + Enum.reverse(markers) + |> mark_last_matching_complete(event.name) + |> Enum.reverse() + end + end) + end + + defp mark_last_matching_complete(markers, name) do + {updated, found?} = + Enum.map_reduce(markers, false, fn marker, found? -> + cond do + found? -> {marker, true} + marker.name == name and not marker.complete? -> {%{marker | complete?: true}, true} + true -> {marker, false} + end + end) + + if found?, do: updated, else: updated + end + + 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) + end + end + + defp allow_repo_sandbox(pid) when is_pid(pid) do + if Code.ensure_loaded?(Ecto.Adapters.SQL.Sandbox) do + try do + Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid) + rescue + _error -> :ok + end + else + :ok + end + + :ok + end + + defp needs_api_key?(true), do: false + + defp needs_api_key?(false) do + case AI.get_endpoint(:online) do + {:ok, %{url: url, model: model, api_key: api_key}} -> blank?(url) or blank?(model) or blank?(api_key) + _other -> true + end + end + + defp blank?(value) when is_binary(value), do: String.trim(value) == "" + defp blank?(nil), do: true + defp blank?(_value), do: false + + defp rewrite_external_images(html) do + html = + Regex.replace(~r/]*\bsrc="(https?:\/\/[^\"]+)")(?=[^>]*\balt="([^\"]*)")[^>]*\/?>/i, html, fn _match, src, alt -> + external_image_link(src, alt) + end) + + Regex.replace(~r/]*\bsrc="(https?:\/\/[^\"]+)")[^>]*\/?>/i, html, fn _match, src -> + external_image_link(src, src) + end) + end + + defp external_image_link(src, text) do + escaped_src = src |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string() + escaped_text = (text || src) |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string() + ~s(#{escaped_text}) + end + + defp surface_input_type("number"), do: "number" + defp surface_input_type("date"), do: "date" + defp surface_input_type(_type), do: "text" + + defp present?(value) when is_binary(value), do: String.trim(value) != "" + defp present?(value), do: not is_nil(value) + + defp stringify_list(values) when is_list(values), do: Enum.map(values, &to_string/1) + defp stringify_list(value), do: List.wrap(value) |> Enum.map(&to_string/1) + + defp numeric_value(value) when is_integer(value), do: value + defp numeric_value(value) when is_float(value), do: value + + defp numeric_value(value) when is_binary(value) do + case Float.parse(value) do + {parsed, ""} -> parsed + _other -> 0 + end + end + + defp numeric_value(_value), do: 0 + + defp map_value(map, key, default \\ nil) + + defp map_value(map, key, default) when is_map(map) and is_binary(key) do + Map.get(map, key, Map.get(map, String.to_atom(key), default)) + rescue + ArgumentError -> Map.get(map, key, default) + end + + defp map_value(_map, _key, default), do: default + + defp format_error(%{kind: :endpoint_not_configured}), do: translated("chat.apiKeyRequiredDescription") + defp format_error(reason), do: inspect(reason) + def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) end diff --git a/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex b/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex index 35674b9..0776e6a 100644 --- a/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex +++ b/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex @@ -1,40 +1,58 @@ -
+
-
<%= @chat_editor.title %>
- -
- - - <%= if @chat_editor.model_selector_open? and @chat_editor.available_models != [] do %> -
- <%= for model <- @chat_editor.available_models do %> - - <% end %> -
+
+ <%= if @chat_editor.needs_api_key? do %> + <%= translated("chat.setupTitle") %> + <% else %> + <%= @chat_editor.title %> <% end %>
+ + <%= unless @chat_editor.needs_api_key? do %> +
+ + + <%= if @chat_editor.model_selector_open? and @chat_editor.available_models != [] do %> +
+ <%= for model <- @chat_editor.available_models do %> + + <% end %> +
+ <% end %> +
+ <% end %>
-
- <%= if Enum.empty?(@chat_editor.messages) do %> +
+ <%= if @chat_editor.needs_api_key? do %> +
+
🔑
+

<%= translated("chat.apiKeyRequiredTitle") %>

+

<%= translated("chat.apiKeyRequiredDescription") %>

+
+ +
+
+ <% else %> + <%= if Enum.empty?(@chat_editor.messages) and not @chat_editor.is_streaming do %>
🤖

<%= translated("chat.welcomeTitle") %>

@@ -48,24 +66,38 @@
<% else %> - <%= for message <- @chat_editor.messages do %> -
+ <%= if @chat_editor.pending_user_message do %> +
+
👤
-
<%= message_role_label(message.role) %>
- - <%= if message.tool_markers != [] do %> -
- <%= for tool_call <- message.tool_markers do %> -
- <%= tool_call_name(tool_call) %> -
- <% end %> -
- <% end %> - -
<%= message.content || "" %>
+
+ <%= message_role_label(:user) %> +
+
<%= @chat_editor.pending_user_message %>
+ <% end %> + + <%= for message <- @chat_editor.messages do %> +
+
<%= if message.role == :user, do: "👤", else: "🤖" %>
+
+
<%= message_role_label(message.role) %>
+ <.chat_tool_markers markers={message.tool_markers} /> + +
+ <%= if message.role == :assistant do %> + <%= markdown_html(message.content || "") %> + <% else %> + <%= message.content || "" %> + <% end %> +
+
+
+ + <%= for surface <- message.inline_surfaces do %> + <.chat_surface surface={surface} /> + <% end %> <%= for surface <- message.tool_surfaces do %>
@@ -102,13 +134,52 @@
<% end %> <% end %> + + <%= if @chat_editor.is_streaming and (@chat_editor.streaming_content != "" or @chat_editor.streaming_tool_markers != []) do %> +
+
🤖
+
+
+ <%= message_role_label(:assistant) %> + +
+ <.chat_tool_markers markers={@chat_editor.streaming_tool_markers} /> + + <%= if @chat_editor.streaming_content != "" do %> +
<%= markdown_html(@chat_editor.streaming_content) %>
+ <% end %> +
+
+ <% end %> + + <%= if @chat_editor.is_streaming and @chat_editor.streaming_content == "" and @chat_editor.streaming_tool_markers == [] do %> +
+
🤖
+
+
+ +
+
+
+ <% end %> + <% end %> <% end %>
-
-
- - -
-
+ <%= unless @chat_editor.needs_api_key? do %> +
+ <%= if @chat_editor.is_streaming do %> + + <% end %> + +
+ + +
+ + <%= if @chat_editor.action_error do %> +

<%= @chat_editor.action_error %>

+ <% end %> +
+ <% end %>
\ No newline at end of file diff --git a/priv/i18n/locales/de.json b/priv/i18n/locales/de.json index f500af9..d66798e 100644 --- a/priv/i18n/locales/de.json +++ b/priv/i18n/locales/de.json @@ -65,6 +65,10 @@ "translationValidation.fix": "Probleme beheben", "translationValidation.toast.fixSuccess": "%{dbRows} DB-Zeilen und %{files} Dateien gelöscht, %{flushed} Übersetzungen auf Datenträger geschrieben", "chat.newChat": "Neuer Chat", + "chat.setupTitle": "KI-Chat-Einrichtung", + "chat.apiKeyRequiredTitle": "API-Schlüssel erforderlich", + "chat.apiKeyRequiredDescription": "Konfiguriere einen API-Schlüssel in den Einstellungen, um den KI-Chat zu aktivieren.", + "chat.openSettings": "Einstellungen öffnen", "chat.welcomeTitle": "Willkommen beim KI-Assistenten", "chat.welcomeDescription": "Ich kann dir mit interaktiven Visualisierungen bei deinem Blog helfen. Frag mich zum Beispiel nach:", "chat.welcomeTipSearch": "Beiträgen zu einem bestimmten Thema", @@ -75,6 +79,8 @@ "chat.role.you": "Du", "chat.role.assistant": "Assistent", "chat.inputPlaceholder": "Nachricht eingeben...", + "chat.stop": "Stopp", + "chat.cancelledSuffix": "(abgebrochen)", "gitDiff.changedFiles": "Geänderte Dateien", "sidebar.tags": "Schlagwörter", "sidebar.categories": "Kategorien", diff --git a/priv/i18n/locales/en.json b/priv/i18n/locales/en.json index a1f8d8d..43b2e12 100644 --- a/priv/i18n/locales/en.json +++ b/priv/i18n/locales/en.json @@ -65,6 +65,10 @@ "translationValidation.fix": "Fix Issues", "translationValidation.toast.fixSuccess": "Deleted %{dbRows} DB rows and %{files} files, flushed %{flushed} translations to disk", "chat.newChat": "New Chat", + "chat.setupTitle": "AI Chat Setup", + "chat.apiKeyRequiredTitle": "API Key Required", + "chat.apiKeyRequiredDescription": "Configure an API key in Settings to enable AI chat.", + "chat.openSettings": "Open Settings", "chat.welcomeTitle": "Welcome to the AI Assistant", "chat.welcomeDescription": "I can help you manage your blog with rich visualizations. Try asking me to:", "chat.welcomeTipSearch": "Search for posts about a specific topic", @@ -75,6 +79,8 @@ "chat.role.you": "You", "chat.role.assistant": "Assistant", "chat.inputPlaceholder": "Type a message...", + "chat.stop": "Stop", + "chat.cancelledSuffix": "(cancelled)", "gitDiff.changedFiles": "Changed files", "sidebar.tags": "Tags", "sidebar.categories": "Categories", diff --git a/priv/i18n/locales/es.json b/priv/i18n/locales/es.json index db08386..c5000f3 100644 --- a/priv/i18n/locales/es.json +++ b/priv/i18n/locales/es.json @@ -65,6 +65,10 @@ "translationValidation.fix": "Corregir problemas", "translationValidation.toast.fixSuccess": "%{dbRows} filas de BD y %{files} archivos eliminados, %{flushed} traducciones escritas a disco", "chat.newChat": "Nuevo chat", + "chat.setupTitle": "Configuración de chat IA", + "chat.apiKeyRequiredTitle": "Clave API requerida", + "chat.apiKeyRequiredDescription": "Configura una clave API en Ajustes para habilitar el chat de IA.", + "chat.openSettings": "Abrir Ajustes", "chat.welcomeTitle": "Bienvenido al asistente de IA", "chat.welcomeDescription": "Puedo ayudarte a gestionar tu blog con visualizaciones interactivas. Prueba a pedirme que:", "chat.welcomeTipSearch": "Busque entradas sobre un tema específico", @@ -75,6 +79,8 @@ "chat.role.you": "Tú", "chat.role.assistant": "Asistente", "chat.inputPlaceholder": "Escribe un mensaje...", + "chat.stop": "Detener", + "chat.cancelledSuffix": "(cancelado)", "gitDiff.changedFiles": "Archivos modificados", "sidebar.tags": "Etiquetas", "sidebar.categories": "Categorías", diff --git a/priv/i18n/locales/fr.json b/priv/i18n/locales/fr.json index 77964a2..b3eeb09 100644 --- a/priv/i18n/locales/fr.json +++ b/priv/i18n/locales/fr.json @@ -67,6 +67,10 @@ "chat.newChat": "Nouveau chat", "chat.welcomeTitle": "Bienvenue dans l’assistant IA", "chat.welcomeDescription": "Je peux vous aider à gérer votre blog avec des visualisations riches. Essayez par exemple :", + "chat.setupTitle": "Configuration du chat IA", + "chat.apiKeyRequiredTitle": "Clé API requise", + "chat.apiKeyRequiredDescription": "Configurez une clé API dans les Réglages pour activer le chat IA.", + "chat.openSettings": "Ouvrir les Réglages", "chat.welcomeTipSearch": "Rechercher des articles sur un sujet précis", "chat.welcomeTipChart": "Afficher un graphique des articles publiés par mois", "chat.welcomeTipTable": "Comparer mes derniers articles dans un tableau", @@ -75,6 +79,8 @@ "chat.role.you": "Vous", "chat.role.assistant": "Assistant IA", "chat.inputPlaceholder": "Saisissez un message...", + "chat.stop": "Arrêter", + "chat.cancelledSuffix": "(annulé)", "gitDiff.changedFiles": "Fichiers modifiés", "sidebar.tags": "Étiquettes", "sidebar.categories": "Catégories", diff --git a/priv/i18n/locales/it.json b/priv/i18n/locales/it.json index 775e1ca..d0a623f 100644 --- a/priv/i18n/locales/it.json +++ b/priv/i18n/locales/it.json @@ -65,6 +65,10 @@ "translationValidation.fix": "Correggi problemi", "translationValidation.toast.fixSuccess": "%{dbRows} righe DB e %{files} file eliminati, %{flushed} traduzioni scritte su disco", "chat.newChat": "Nuova chat", + "chat.setupTitle": "Configurazione chat IA", + "chat.apiKeyRequiredTitle": "Chiave API richiesta", + "chat.apiKeyRequiredDescription": "Configura una chiave API nelle Impostazioni per abilitare la chat IA.", + "chat.openSettings": "Apri Impostazioni", "chat.welcomeTitle": "Benvenuto nell’assistente IA", "chat.welcomeDescription": "Posso aiutarti a gestire il tuo blog con visualizzazioni interattive. Prova a chiedermi di:", "chat.welcomeTipSearch": "Cercare post su un argomento specifico", @@ -75,6 +79,8 @@ "chat.role.you": "Tu", "chat.role.assistant": "Assistente", "chat.inputPlaceholder": "Scrivi un messaggio...", + "chat.stop": "Ferma", + "chat.cancelledSuffix": "(annullato)", "gitDiff.changedFiles": "File modificati", "sidebar.tags": "Tag", "sidebar.categories": "Categorie", diff --git a/priv/ui/app.css b/priv/ui/app.css index 154ebbf..9af33e2 100644 --- a/priv/ui/app.css +++ b/priv/ui/app.css @@ -3264,16 +3264,27 @@ button svg * { .chat-panel { height: 100%; - display: grid; - grid-template-rows: auto minmax(0, 1fr) auto; + min-height: 0; + display: flex; + flex-direction: column; + background: var(--vscode-editor-background, var(--panel-1, #1e1e1e)); } .chat-panel-title { - font-weight: 700; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + font-weight: 500; + color: var(--vscode-foreground, inherit); } .chat-panel-header { position: relative; + padding: 12px 16px; + border-bottom: 1px solid var(--vscode-editorGroup-border, var(--line, #3c3c3c)); + background: var(--vscode-sideBar-background, var(--panel-1, #1e1e1e)); } .chat-panel-header-actions { @@ -3295,102 +3306,266 @@ button svg * { align-items: center; gap: 8px; padding: 8px 12px; -} + color: inherit; + border: 1px solid var(--vscode-input-border, var(--line, #3c3c3c)); + border-radius: 4px; + background: transparent; + cursor: pointer; + } -.chat-model-selector-menu { + .chat-model-selector-button:hover, + .chat-model-selector-option:hover { + background: var(--vscode-list-hoverBackground, rgba(255, 255, 255, 0.06)); + } + + .chat-model-selector-caret { + font-size: 10px; position: absolute; top: calc(100% - 4px); right: 20px; min-width: 220px; display: flex; flex-direction: column; - gap: 6px; + padding: 4px 8px; + font-size: 12px; + color: var(--vscode-descriptionForeground, inherit); padding: 10px; border: 1px solid var(--line, #3c3c3c); border-radius: 12px; background: var(--panel-1, #1e1e1e); - box-shadow: 0 14px 36px rgba(0, 0, 0, 0.28); - z-index: 2; -} + top: calc(100% + 4px); + right: 16px; + min-width: 180px; + max-height: 300px; + overflow-y: auto; .chat-model-selector-option { - width: 100%; - padding: 8px 10px; - text-align: left; -} - -.chat-model-selector-option.active { - border-color: var(--accent-color); + gap: 4px; + padding: 6px; + border: 1px solid var(--vscode-dropdown-border, var(--line, #3c3c3c)); + border-radius: 4px; + background: var(--vscode-dropdown-background, var(--panel-1, #1e1e1e)); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + z-index: 100; } .chat-messages { padding: 20px; - overflow: auto; + padding: 8px 12px; display: flex; + font-size: 12px; flex-direction: column; gap: 16px; } - + background: var(--vscode-list-activeSelectionBackground, rgba(0, 122, 204, 0.18)); + color: var(--vscode-list-activeSelectionForeground, inherit); .chat-message { display: flex; } - -.chat-message.user { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 16px; justify-content: flex-end; } .chat-message-content { max-width: min(760px, 100%); + .chat-surface-scroll { + min-height: 0; + overflow-y: auto; + } + border: 1px solid var(--line, #3c3c3c); border-radius: 14px; + gap: 12px; padding: 14px 16px; background: var(--panel-2, #252526); } + flex-direction: row-reverse; + } + .chat-message-avatar { + flex-shrink: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + border-radius: 50%; + background: var(--vscode-input-background, var(--panel-2, #252526)); .chat-message.user .chat-message-content { background: rgba(0, 122, 204, 0.15); -} - -.chat-tool-markers { - display: flex; - flex-wrap: wrap; - gap: 8px; + .chat-message.user .chat-message-avatar { + background: var(--vscode-button-background, var(--accent-color)); margin-bottom: 10px; } - -.chat-tool-marker { + .chat-message-content { + max-width: 80%; + min-width: 100px; display: inline-flex; align-items: center; - gap: 6px; - padding: 6px 10px; - border-radius: 999px; + .chat-message.user .chat-message-content { + text-align: right; + } + + .chat-message-header { + display: flex; + align-items: center; border: 1px solid var(--line, #3c3c3c); - background: rgba(255, 255, 255, 0.03); + margin-bottom: 4px; font-size: 12px; } + .chat-message.user .chat-message-header { + justify-content: flex-end; + } -.chat-tool-surface { - max-width: min(820px, 100%); - margin-left: auto; - margin-right: auto; - border: 1px solid var(--line, #3c3c3c); - border-radius: 14px; - background: var(--panel-2, #252526); + .chat-message-role { padding: 16px; + font-weight: 500; + color: var(--vscode-descriptionForeground, inherit); } -.chat-tool-surface h3 { - margin: 0 0 12px; -} + .streaming-indicator { + color: var(--vscode-button-background, var(--accent-color)); + animation: pulse 1s infinite; + } -.chat-tool-surface-table { - width: 100%; - border-collapse: collapse; -} + @keyframes pulse { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.3; + } .chat-tool-surface-table th, -.chat-tool-surface-table td { - padding: 8px 10px; + .chat-message-text { + padding: 10px 14px; + border-radius: 12px 12px 12px 2px; + background: var(--vscode-input-background, var(--panel-2, #252526)); + color: var(--vscode-foreground, inherit); + font-size: 14px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + user-select: text; + cursor: text; + } + + .chat-message.user .chat-message-text { + border-radius: 12px 12px 2px 12px; + background: var(--vscode-button-background, var(--accent-color)); + color: var(--vscode-button-foreground, #ffffff); + } + + .chat-message.assistant .chat-message-text { + white-space: normal; + } + + .chat-message.streaming .chat-message-text { + background: linear-gradient( + 90deg, + var(--vscode-input-background, var(--panel-2, #252526)) 0%, + var(--vscode-list-hoverBackground, rgba(255, 255, 255, 0.06)) 50%, + var(--vscode-input-background, var(--panel-2, #252526)) 100% + ); + background-size: 200% 100%; + animation: shimmer 2s infinite; + } + + @keyframes shimmer { + 0% { + background-position: 200% 0; + } + + 100% { + background-position: -200% 0; + } + } + + .chat-thinking-indicator { + display: flex; + gap: 4px; + padding: 12px 16px; + } + + .chat-thinking-indicator span { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--vscode-descriptionForeground, #8a8a8a); + animation: bounce 1.4s infinite ease-in-out both; + } + + .chat-thinking-indicator span:nth-child(1) { + animation-delay: -0.32s; + } + + .chat-thinking-indicator span:nth-child(2) { + animation-delay: -0.16s; + } + + @keyframes bounce { + 0%, + 80%, + 100% { + transform: scale(0); + } + + 40% { + transform: scale(1); + } + } + + .chat-tool-markers { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 8px; + padding: 8px 10px; + background: var(--vscode-textBlockQuote-background, rgba(127, 127, 127, 0.1)); + border-left: 3px solid var(--vscode-textLink-foreground, #3794ff); + border-radius: 0 4px 4px 0; + } + + .chat-tool-marker { + display: flex; + align-items: center; + gap: 6px; + color: var(--vscode-descriptionForeground, inherit); + font-size: 12px; + } + + .chat-tool-marker.completed .chat-tool-marker-icon { + color: var(--vscode-testing-iconPassed, #89d185); + } + + .chat-tool-marker.pending .chat-tool-marker-icon { + color: var(--vscode-textLink-foreground, #3794ff); + } + + .chat-tool-marker-args { + opacity: 0.85; + } + + .chat-inline-surface, + .chat-tool-surface { + width: min(720px, calc(100% - 44px)); + margin-left: 44px; + padding: 14px; + border: 1px solid var(--vscode-editorGroup-border, var(--line, #3c3c3c)); + border-radius: 12px; + background: var(--vscode-sideBar-background, var(--panel-2, #252526)); + box-sizing: border-box; + } + + .chat-inline-surface h3, + .chat-tool-surface h3 { + margin: 0 0 12px; border-bottom: 1px solid var(--line, #3c3c3c); text-align: left; } @@ -3400,52 +3575,498 @@ button svg * { white-space: pre-wrap; font: 12px/1.5 "SFMono-Regular", Menlo, Monaco, Consolas, monospace; } - -.chat-input-container { + padding: 8px 12px; + border: 1px solid var(--vscode-editorGroup-border, var(--line, #3c3c3c)); padding: 16px 20px 20px; border-top: 1px solid var(--line, #3c3c3c); + + .chat-tool-surface-table th { + font-weight: 600; + background: var(--vscode-list-hoverBackground, rgba(255, 255, 255, 0.06)); + } + + .chat-tool-surface-table tr:nth-child(even) { + background: var(--vscode-list-hoverBackground, rgba(255, 255, 255, 0.04)); + } } .chat-input-wrapper { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; + + .chat-surface-card { + display: flex; + flex-direction: column; + gap: 10px; + } + + .chat-surface-subtitle { + margin: -4px 0 0; + color: var(--vscode-descriptionForeground, inherit); + } + + .chat-surface-body, + .chat-surface-text { + margin: 0; + line-height: 1.5; + white-space: pre-wrap; + } + + .chat-surface-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .chat-surface-action-button, + .chat-surface-tab-button, + .api-key-submit { + padding: 8px 12px; + border: none; + border-radius: 6px; + background: var(--vscode-button-background, var(--accent-color)); + color: var(--vscode-button-foreground, #ffffff); + cursor: pointer; + transition: background-color 0.15s; + } + + .chat-surface-action-button:hover, + .chat-surface-tab-button:hover, + .api-key-submit:hover { + background: var(--vscode-button-hoverBackground, var(--accent-color)); + } + + .chat-surface-chart-type { + margin: -6px 0 12px; + color: var(--vscode-descriptionForeground, inherit); + text-transform: capitalize; + } + + .chat-surface-chart-list { + display: flex; + flex-direction: column; + gap: 10px; + } + + .chat-surface-chart-meta { + display: flex; + justify-content: space-between; + gap: 12px; + margin-bottom: 4px; + } + + .chat-surface-chart-bar { + height: 10px; + border-radius: 999px; + background: var(--vscode-input-background, rgba(255, 255, 255, 0.08)); + overflow: hidden; + } + + .chat-surface-chart-bar span { + display: block; + height: 100%; + border-radius: inherit; + background: var(--vscode-button-background, var(--accent-color)); + } + + .chat-surface-metric { + display: flex; + flex-direction: column; + gap: 6px; + } + + .chat-surface-metric-label { + color: var(--vscode-descriptionForeground, inherit); + } + + .chat-surface-metric-value { + font-size: 28px; + line-height: 1.1; + } + + .chat-surface-list, + .chat-surface-mindmap { + margin: 0; + padding-left: 18px; + } + + .chat-surface-list li, + .chat-surface-mindmap li { + margin: 6px 0; + } + + .chat-surface-mindmap-children { + margin-left: 8px; + color: var(--vscode-descriptionForeground, inherit); + } + + .chat-surface-tabs { + display: flex; + flex-direction: column; + gap: 12px; + } + + .chat-surface-tab-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .chat-surface-tab-button { + background: transparent; + border: 1px solid var(--vscode-input-border, var(--line, #3c3c3c)); + color: var(--vscode-foreground, inherit); + } + + .chat-surface-tab-button.active { + background: var(--vscode-list-activeSelectionBackground, rgba(0, 122, 204, 0.18)); + border-color: var(--vscode-list-activeSelectionBackground, rgba(0, 122, 204, 0.18)); + color: var(--vscode-list-activeSelectionForeground, inherit); + } + + .chat-surface-tab-panel { + display: flex; + flex-direction: column; + gap: 12px; + } + + .chat-surface-tab-panel .chat-inline-surface { + width: 100%; + margin-left: 0; + } + + .chat-surface-form { + display: grid; + gap: 12px; + } + + .chat-surface-form-field { + display: grid; + gap: 6px; + } + + .chat-surface-form input, + .chat-surface-form textarea, + .chat-surface-form select { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--vscode-input-border, var(--line, #3c3c3c)); + border-radius: 6px; + background: var(--vscode-input-background, var(--panel-1, #1e1e1e)); + color: var(--vscode-input-foreground, inherit); + font: inherit; + box-sizing: border-box; + } + + .chat-surface-form textarea { + min-height: 84px; + resize: vertical; + } + + .chat-surface-form-checkbox { + display: inline-flex; + align-items: center; + min-height: 40px; + } } -.chat-input { - min-height: 48px; + padding: 16px; + border-top: 1px solid var(--vscode-editorGroup-border, var(--line, #3c3c3c)); + background: var(--vscode-sideBar-background, var(--panel-1, #1e1e1e)); + } + + .chat-abort-button { + display: block; + width: 100%; + margin-bottom: 8px; + padding: 8px; + font-size: 13px; + color: var(--vscode-errorForeground, #f48771); + background: transparent; + border: 1px solid var(--vscode-errorForeground, #f48771); + border-radius: 4px; + cursor: pointer; + } + + .chat-abort-button:hover { + background: var(--vscode-inputValidation-errorBackground, rgba(244, 135, 113, 0.12)); resize: vertical; } -.chat-send-button { - width: 44px; + display: flex; + align-items: flex-end; height: 44px; + padding: 8px; + border: 1px solid var(--vscode-input-border, var(--line, #3c3c3c)); + border-radius: 8px; + background: var(--vscode-input-background, var(--panel-2, #252526)); + } + + .chat-input-wrapper:focus-within { + border-color: var(--vscode-focusBorder, var(--accent-color)); border-radius: 999px; } -.chat-welcome { - margin: auto; + flex: 1; + min-height: 24px; + max-height: 200px; + padding: 0; + border: none; + outline: none; + background: transparent; + color: var(--vscode-input-foreground, inherit); + font: inherit; + line-height: 1.5; + resize: none; + overflow-y: auto; + } + + .chat-input::placeholder { + color: var(--vscode-input-placeholderForeground, rgba(255, 255, 255, 0.45)); + } + + .chat-surface-input { + border-radius: 6px; + } + + .chat-surface-error { + margin: 12px 0 0; + color: var(--vscode-errorForeground, #f48771); + font-size: 12px; max-width: 560px; text-align: center; color: var(--vscode-descriptionForeground); -} + flex-shrink: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + color: var(--vscode-button-foreground, #ffffff); + background: var(--vscode-button-background, var(--accent-color)); + border: none; + border-radius: 50%; + cursor: pointer; + transition: background-color 0.15s; + } -.chat-welcome ul { + .chat-send-button:hover:not(:disabled) { + background: var(--vscode-button-hoverBackground, var(--accent-color)); + } + + .chat-send-button:disabled, + .api-key-submit:disabled { + opacity: 0.5; + cursor: not-allowed; list-style: none; padding: 0; margin: 18px 0 0; - display: grid; - gap: 8px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100%; + padding: 32px; + box-sizing: border-box; + margin: 0 auto; + max-width: 560px; } .misc-editor-shell { height: 100%; - display: flex; - flex-direction: column; - background: var(--panel-1, #1e1e1e); -} + .chat-welcome-icon { + margin-bottom: 16px; + font-size: 48px; + } -.misc-editor-header { + .chat-welcome h2 { + margin: 0 0 12px; + font-size: 18px; + font-weight: 500; + color: var(--vscode-foreground, inherit); + } + + .chat-welcome p { + margin: 0 0 12px; + font-size: 14px; + } + + display: flex; + margin: 0; + padding: 0; + list-style: none; + text-align: left; + } + + .chat-welcome li { + padding: 4px 0; + font-size: 13px; + } + + .chat-welcome li::before { + content: "•"; + margin-right: 8px; + color: var(--vscode-textLink-foreground, #3794ff); + } + + .api-key-form { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 16px; + width: 100%; + max-width: 400px; + } + + .chat-message-text p { + margin: 0 0 0.5em; + } + + .chat-message-text p:last-child { + margin-bottom: 0; + } + + .chat-message-text h1, + .chat-message-text h2, + .chat-message-text h3, + .chat-message-text h4 { + margin: 0.75em 0 0.5em; + font-weight: 600; + color: var(--vscode-foreground, inherit); + } + + .chat-message-text h1:first-child, + .chat-message-text h2:first-child, + .chat-message-text h3:first-child { + margin-top: 0; + } + + .chat-message-text h1 { + font-size: 1.3em; + } + + .chat-message-text h2 { + font-size: 1.2em; + } + + .chat-message-text h3 { + font-size: 1.1em; + } + + .chat-message-text h4 { + font-size: 1em; + } + + .chat-message-text ul, + .chat-message-text ol { + margin: 0.5em 0; + padding-left: 1.5em; + } + + .chat-message-text li { + margin: 0.25em 0; + } + + .chat-message-text code { + padding: 0.15em 0.4em; + border-radius: 3px; + background: var(--vscode-textCodeBlock-background, rgba(0, 0, 0, 0.2)); + font: 0.9em/1.4 "SFMono-Regular", Menlo, Monaco, Consolas, monospace; + } + + .chat-message-text pre { + margin: 0.75em 0; + padding: 12px; + overflow-x: auto; + border-radius: 6px; + background: var(--vscode-textCodeBlock-background, rgba(0, 0, 0, 0.2)); + } + + .chat-message-text pre code { + padding: 0; + background: transparent; + font-size: 0.85em; + } + + .chat-message-text table { + width: 100%; + margin: 0.75em 0; + border-collapse: collapse; + font-size: 0.9em; + } + + .chat-message-text th, + .chat-message-text td { + padding: 8px 12px; + border: 1px solid var(--vscode-editorGroup-border, var(--line, #3c3c3c)); + text-align: left; + } + + .chat-message-text th { + font-weight: 600; + background: var(--vscode-list-hoverBackground, rgba(255, 255, 255, 0.06)); + } + + .chat-message-text tr:nth-child(even) { + background: var(--vscode-list-hoverBackground, rgba(255, 255, 255, 0.04)); + } + + .chat-message-text blockquote { + margin: 0.75em 0; + padding: 0.5em 1em; + border-left: 3px solid var(--vscode-textLink-foreground, #3794ff); + background: var(--vscode-textBlockQuote-background, rgba(0, 0, 0, 0.1)); + color: var(--vscode-descriptionForeground, inherit); + } + + .chat-message-text blockquote p { + margin: 0; + } + + .chat-message-text a { + color: var(--vscode-textLink-foreground, #3794ff); + text-decoration: none; + } + + .chat-message-text a:hover { + text-decoration: underline; + } + + .chat-message-text hr { + margin: 1em 0; + border: none; + border-top: 1px solid var(--vscode-editorGroup-border, var(--line, #3c3c3c)); + } + + .chat-message-text strong { + font-weight: 600; + } + + .chat-message-text em { + font-style: italic; + } + + @media (max-width: 760px) { + .chat-inline-surface, + .chat-tool-surface { + width: 100%; + margin-left: 0; + } + + .chat-message-content { + max-width: calc(100% - 44px); + } + + .chat-panel-header { + padding: 12px; + } + + .chat-messages, + .chat-input-container { + padding: 12px; + } padding: 18px 20px; border-bottom: 1px solid var(--line, #3c3c3c); display: flex; @@ -4202,6 +4823,821 @@ button svg * { border: 1px solid rgba(255, 255, 255, 0.18); } +.chat-panel { + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; + background-color: var(--vscode-editor-background, var(--panel-1, #1e1e1e)); +} + +.chat-panel-header { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 16px; + border-bottom: 1px solid var(--vscode-editorGroup-border, var(--line, #3c3c3c)); + background-color: var(--vscode-sideBar-background, var(--panel-1, #1e1e1e)); +} + +.chat-panel-title { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + font-weight: 500; + color: var(--vscode-foreground, inherit); +} + +.chat-panel-header-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.chat-model-selector-button, +.chat-model-selector-option { + color: inherit; + border: 1px solid var(--vscode-input-border, var(--line, #3c3c3c)); + border-radius: 4px; + background: transparent; + cursor: pointer; +} + +.chat-model-selector-button { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + font-size: 12px; + color: var(--vscode-descriptionForeground, inherit); +} + +.chat-model-selector-button:hover, +.chat-model-selector-option:hover { + background-color: var(--vscode-list-hoverBackground, rgba(255, 255, 255, 0.06)); +} + +.chat-model-selector-caret { + font-size: 10px; +} + +.chat-model-selector-menu { + position: absolute; + top: calc(100% + 4px); + right: 16px; + min-width: 180px; + max-height: 300px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 4px; + padding: 6px; + border: 1px solid var(--vscode-dropdown-border, var(--line, #3c3c3c)); + border-radius: 4px; + background-color: var(--vscode-dropdown-background, var(--panel-1, #1e1e1e)); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + z-index: 100; +} + +.chat-model-selector-option { + width: 100%; + padding: 8px 12px; + font-size: 12px; + text-align: left; +} + +.chat-model-selector-option.active { + background-color: var(--vscode-list-activeSelectionBackground, rgba(0, 122, 204, 0.18)); + color: var(--vscode-list-activeSelectionForeground, inherit); +} + +.chat-messages, +.chat-surface-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; +} + +.chat-messages { + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; +} + +.chat-welcome { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100%; + margin: 0 auto; + max-width: 560px; + box-sizing: border-box; + padding: 32px; + text-align: center; + color: var(--vscode-descriptionForeground, inherit); +} + +.chat-welcome-icon { + margin-bottom: 16px; + font-size: 48px; +} + +.chat-welcome h2 { + margin: 0 0 12px; + font-size: 18px; + font-weight: 500; + color: var(--vscode-foreground, inherit); +} + +.chat-welcome p { + margin: 0 0 12px; + font-size: 14px; +} + +.chat-welcome ul { + margin: 0; + padding: 0; + list-style: none; + text-align: left; +} + +.chat-welcome li { + padding: 4px 0; + font-size: 13px; +} + +.chat-welcome li::before { + content: "•"; + margin-right: 8px; + color: var(--vscode-textLink-foreground, #3794ff); +} + +.chat-message { + display: flex; + gap: 12px; +} + +.chat-message.user { + flex-direction: row-reverse; +} + +.chat-message-avatar { + flex-shrink: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-size: 18px; + background-color: var(--vscode-input-background, var(--panel-2, #252526)); +} + +.chat-message.user .chat-message-avatar { + background-color: var(--vscode-button-background, var(--accent-color)); +} + +.chat-message-content { + max-width: 80%; + min-width: 100px; +} + +.chat-message.user .chat-message-content { + text-align: right; +} + +.chat-message-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.chat-message.user .chat-message-header { + justify-content: flex-end; +} + +.chat-message-role { + font-size: 12px; + font-weight: 500; + color: var(--vscode-descriptionForeground, inherit); +} + +.streaming-indicator { + color: var(--vscode-button-background, var(--accent-color)); + animation: pulse 1s infinite; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.3; + } +} + +.chat-message-text { + padding: 10px 14px; + border-radius: 12px 12px 12px 2px; + background-color: var(--vscode-input-background, var(--panel-2, #252526)); + color: var(--vscode-foreground, inherit); + font-size: 14px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + user-select: text; + cursor: text; +} + +.chat-message.user .chat-message-text { + border-radius: 12px 12px 2px 12px; + background-color: var(--vscode-button-background, var(--accent-color)); + color: var(--vscode-button-foreground, #ffffff); +} + +.chat-message.assistant .chat-message-text { + white-space: normal; +} + +.chat-message.streaming .chat-message-text { + background: linear-gradient( + 90deg, + var(--vscode-input-background, var(--panel-2, #252526)) 0%, + var(--vscode-list-hoverBackground, rgba(255, 255, 255, 0.06)) 50%, + var(--vscode-input-background, var(--panel-2, #252526)) 100% + ); + background-size: 200% 100%; + animation: shimmer 2s infinite; +} + +@keyframes shimmer { + 0% { + background-position: 200% 0; + } + + 100% { + background-position: -200% 0; + } +} + +.chat-thinking-indicator { + display: flex; + gap: 4px; + padding: 12px 16px; +} + +.chat-thinking-indicator span { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--vscode-descriptionForeground, #8a8a8a); + animation: bounce 1.4s infinite ease-in-out both; +} + +.chat-thinking-indicator span:nth-child(1) { + animation-delay: -0.32s; +} + +.chat-thinking-indicator span:nth-child(2) { + animation-delay: -0.16s; +} + +@keyframes bounce { + 0%, + 80%, + 100% { + transform: scale(0); + } + + 40% { + transform: scale(1); + } +} + +.chat-tool-markers { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 8px; + padding: 8px 10px; + border-left: 3px solid var(--vscode-textLink-foreground, #3794ff); + border-radius: 0 4px 4px 0; + background-color: var(--vscode-textBlockQuote-background, rgba(127, 127, 127, 0.1)); +} + +.chat-tool-marker { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--vscode-descriptionForeground, inherit); +} + +.chat-tool-marker.completed .chat-tool-marker-icon { + color: var(--vscode-testing-iconPassed, #89d185); +} + +.chat-tool-marker.pending .chat-tool-marker-icon { + color: var(--vscode-textLink-foreground, #3794ff); +} + +.chat-tool-marker-args { + opacity: 0.85; +} + +.chat-inline-surface, +.chat-tool-surface { + width: min(720px, calc(100% - 44px)); + margin-left: 44px; + padding: 14px; + box-sizing: border-box; + border: 1px solid var(--vscode-editorGroup-border, var(--line, #3c3c3c)); + border-radius: 12px; + background-color: var(--vscode-sideBar-background, var(--panel-2, #252526)); +} + +.chat-inline-surface h3, +.chat-tool-surface h3 { + margin: 0 0 12px; +} + +.chat-tool-surface-table { + width: 100%; + border-collapse: collapse; +} + +.chat-tool-surface-table th, +.chat-tool-surface-table td { + padding: 8px 12px; + border: 1px solid var(--vscode-editorGroup-border, var(--line, #3c3c3c)); + text-align: left; +} + +.chat-tool-surface-table th { + font-weight: 600; + background-color: var(--vscode-list-hoverBackground, rgba(255, 255, 255, 0.06)); +} + +.chat-tool-surface-table tr:nth-child(even) { + background-color: var(--vscode-list-hoverBackground, rgba(255, 255, 255, 0.04)); +} + +.chat-tool-surface-json { + margin: 0; + white-space: pre-wrap; + font: 12px/1.5 "SFMono-Regular", Menlo, Monaco, Consolas, monospace; +} + +.chat-surface-card { + display: flex; + flex-direction: column; + gap: 10px; +} + +.chat-surface-subtitle { + margin: -4px 0 0; + color: var(--vscode-descriptionForeground, inherit); +} + +.chat-surface-body, +.chat-surface-text { + margin: 0; + line-height: 1.5; + white-space: pre-wrap; +} + +.chat-surface-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.chat-surface-action-button, +.chat-surface-tab-button, +.api-key-submit { + padding: 8px 12px; + border: none; + border-radius: 6px; + background-color: var(--vscode-button-background, var(--accent-color)); + color: var(--vscode-button-foreground, #ffffff); + cursor: pointer; + transition: background-color 0.15s; +} + +.chat-surface-action-button:hover, +.chat-surface-tab-button:hover, +.api-key-submit:hover { + background-color: var(--vscode-button-hoverBackground, var(--accent-color)); +} + +.chat-surface-chart-type { + margin: -6px 0 12px; + color: var(--vscode-descriptionForeground, inherit); + text-transform: capitalize; +} + +.chat-surface-chart-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.chat-surface-chart-meta { + display: flex; + justify-content: space-between; + gap: 12px; + margin-bottom: 4px; +} + +.chat-surface-chart-bar { + height: 10px; + overflow: hidden; + border-radius: 999px; + background-color: var(--vscode-input-background, rgba(255, 255, 255, 0.08)); +} + +.chat-surface-chart-bar span { + display: block; + height: 100%; + border-radius: inherit; + background-color: var(--vscode-button-background, var(--accent-color)); +} + +.chat-surface-metric { + display: flex; + flex-direction: column; + gap: 6px; +} + +.chat-surface-metric-label { + color: var(--vscode-descriptionForeground, inherit); +} + +.chat-surface-metric-value { + font-size: 28px; + line-height: 1.1; +} + +.chat-surface-list, +.chat-surface-mindmap { + margin: 0; + padding-left: 18px; +} + +.chat-surface-list li, +.chat-surface-mindmap li { + margin: 6px 0; +} + +.chat-surface-mindmap-children { + margin-left: 8px; + color: var(--vscode-descriptionForeground, inherit); +} + +.chat-surface-tabs { + display: flex; + flex-direction: column; + gap: 12px; +} + +.chat-surface-tab-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.chat-surface-tab-button { + border: 1px solid var(--vscode-input-border, var(--line, #3c3c3c)); + background: transparent; + color: var(--vscode-foreground, inherit); +} + +.chat-surface-tab-button.active { + border-color: var(--vscode-list-activeSelectionBackground, rgba(0, 122, 204, 0.18)); + background-color: var(--vscode-list-activeSelectionBackground, rgba(0, 122, 204, 0.18)); + color: var(--vscode-list-activeSelectionForeground, inherit); +} + +.chat-surface-tab-panel { + display: flex; + flex-direction: column; + gap: 12px; +} + +.chat-surface-tab-panel .chat-inline-surface { + width: 100%; + margin-left: 0; +} + +.chat-surface-form { + display: grid; + gap: 12px; +} + +.chat-surface-form-field { + display: grid; + gap: 6px; +} + +.chat-surface-form input, +.chat-surface-form textarea, +.chat-surface-form select { + width: 100%; + box-sizing: border-box; + padding: 10px 12px; + border: 1px solid var(--vscode-input-border, var(--line, #3c3c3c)); + border-radius: 6px; + background-color: var(--vscode-input-background, var(--panel-1, #1e1e1e)); + color: var(--vscode-input-foreground, inherit); + font: inherit; +} + +.chat-surface-form textarea { + min-height: 84px; + resize: vertical; +} + +.chat-surface-form-checkbox { + display: inline-flex; + align-items: center; + min-height: 40px; +} + +.chat-input-container { + padding: 16px; + border-top: 1px solid var(--vscode-editorGroup-border, var(--line, #3c3c3c)); + background-color: var(--vscode-sideBar-background, var(--panel-1, #1e1e1e)); +} + +.chat-abort-button { + display: block; + width: 100%; + margin-bottom: 8px; + padding: 8px; + border: 1px solid var(--vscode-errorForeground, #f48771); + border-radius: 4px; + background: transparent; + color: var(--vscode-errorForeground, #f48771); + font-size: 13px; + cursor: pointer; +} + +.chat-abort-button:hover { + background-color: var(--vscode-inputValidation-errorBackground, rgba(244, 135, 113, 0.12)); +} + +.chat-input-wrapper { + display: flex; + align-items: flex-end; + gap: 8px; + padding: 8px; + border: 1px solid var(--vscode-input-border, var(--line, #3c3c3c)); + border-radius: 8px; + background-color: var(--vscode-input-background, var(--panel-2, #252526)); +} + +.chat-input-wrapper:focus-within { + border-color: var(--vscode-focusBorder, var(--accent-color)); +} + +.chat-input { + flex: 1; + min-height: 24px; + max-height: 200px; + padding: 0; + border: none; + outline: none; + background: transparent; + color: var(--vscode-input-foreground, inherit); + font: inherit; + line-height: 1.5; + resize: none; + overflow-y: auto; +} + +.chat-input::placeholder { + color: var(--vscode-input-placeholderForeground, rgba(255, 255, 255, 0.45)); +} + +.chat-surface-input { + border-radius: 6px; +} + +.chat-surface-error { + margin: 12px 0 0; + color: var(--vscode-errorForeground, #f48771); + font-size: 12px; +} + +.chat-send-button { + flex-shrink: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 50%; + background-color: var(--vscode-button-background, var(--accent-color)); + color: var(--vscode-button-foreground, #ffffff); + font-size: 18px; + cursor: pointer; + transition: background-color 0.15s; +} + +.chat-send-button:hover:not(:disabled) { + background-color: var(--vscode-button-hoverBackground, var(--accent-color)); +} + +.chat-send-button:disabled, +.api-key-submit:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.api-key-form { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 16px; + width: 100%; + max-width: 400px; +} + +.chat-message-text p { + margin: 0 0 0.5em; +} + +.chat-message-text p:last-child { + margin-bottom: 0; +} + +.chat-message-text h1, +.chat-message-text h2, +.chat-message-text h3, +.chat-message-text h4 { + margin: 0.75em 0 0.5em; + font-weight: 600; + color: var(--vscode-foreground, inherit); +} + +.chat-message-text h1:first-child, +.chat-message-text h2:first-child, +.chat-message-text h3:first-child { + margin-top: 0; +} + +.chat-message-text h1 { + font-size: 1.3em; +} + +.chat-message-text h2 { + font-size: 1.2em; +} + +.chat-message-text h3 { + font-size: 1.1em; +} + +.chat-message-text h4 { + font-size: 1em; +} + +.chat-message-text ul, +.chat-message-text ol { + margin: 0.5em 0; + padding-left: 1.5em; +} + +.chat-message-text li { + margin: 0.25em 0; +} + +.chat-message-text code { + padding: 0.15em 0.4em; + border-radius: 3px; + background-color: var(--vscode-textCodeBlock-background, rgba(0, 0, 0, 0.2)); + font: 0.9em/1.4 "SFMono-Regular", Menlo, Monaco, Consolas, monospace; +} + +.chat-message-text pre { + margin: 0.75em 0; + padding: 12px; + overflow-x: auto; + border-radius: 6px; + background-color: var(--vscode-textCodeBlock-background, rgba(0, 0, 0, 0.2)); +} + +.chat-message-text pre code { + padding: 0; + background: transparent; + font-size: 0.85em; +} + +.chat-message-text table { + width: 100%; + margin: 0.75em 0; + border-collapse: collapse; + font-size: 0.9em; +} + +.chat-message-text th, +.chat-message-text td { + padding: 8px 12px; + border: 1px solid var(--vscode-editorGroup-border, var(--line, #3c3c3c)); + text-align: left; +} + +.chat-message-text th { + font-weight: 600; + background-color: var(--vscode-list-hoverBackground, rgba(255, 255, 255, 0.06)); +} + +.chat-message-text tr:nth-child(even) { + background-color: var(--vscode-list-hoverBackground, rgba(255, 255, 255, 0.04)); +} + +.chat-message-text blockquote { + margin: 0.75em 0; + padding: 0.5em 1em; + border-left: 3px solid var(--vscode-textLink-foreground, #3794ff); + background-color: var(--vscode-textBlockQuote-background, rgba(0, 0, 0, 0.1)); + color: var(--vscode-descriptionForeground, inherit); +} + +.chat-message-text blockquote p { + margin: 0; +} + +.chat-message-text a { + color: var(--vscode-textLink-foreground, #3794ff); + text-decoration: none; +} + +.chat-message-text a:hover { + text-decoration: underline; +} + +.chat-message-text hr { + margin: 1em 0; + border: none; + border-top: 1px solid var(--vscode-editorGroup-border, var(--line, #3c3c3c)); +} + +.chat-message-text strong { + font-weight: 600; +} + +.chat-message-text em { + font-style: italic; +} + +.misc-editor-shell { + height: 100%; + display: flex; + flex-direction: column; + background: var(--panel-1, #1e1e1e); +} + +.misc-editor-header { + padding: 18px 20px; + border-bottom: 1px solid var(--line, #3c3c3c); + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +@media (max-width: 760px) { + .chat-inline-surface, + .chat-tool-surface { + width: 100%; + margin-left: 0; + } + + .chat-message-content { + max-width: calc(100% - 44px); + } + + .chat-panel-header { + padding: 12px; + } + + .chat-messages, + .chat-input-container { + padding: 12px; + } +} + @media (max-width: 720px) { .insert-media-grid, .gallery-overlay-grid { diff --git a/priv/ui/live.js b/priv/ui/live.js index 1c8edb1..0f7db45 100644 --- a/priv/ui/live.js +++ b/priv/ui/live.js @@ -683,6 +683,113 @@ document.addEventListener("DOMContentLoaded", () => { } }, + ChatSurface: { + mounted() { + this.stickToBottom = true; + this.scrollContainer = null; + + this.autoResize = () => { + const textarea = this.el.querySelector(".chat-input"); + + if (!textarea) { + return; + } + + textarea.style.height = "auto"; + textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; + }; + + this.syncScrollContainer = () => { + const nextContainer = this.el.querySelector(".chat-messages"); + + if (nextContainer === this.scrollContainer) { + return; + } + + if (this.scrollContainer) { + this.scrollContainer.removeEventListener("scroll", this.handleScroll); + } + + this.scrollContainer = nextContainer; + + if (this.scrollContainer) { + this.scrollContainer.addEventListener("scroll", this.handleScroll); + } + }; + + this.scrollToBottom = (force = false) => { + if (!this.scrollContainer) { + return; + } + + if (force || this.stickToBottom) { + this.scrollContainer.scrollTop = this.scrollContainer.scrollHeight; + } + }; + + this.handleScroll = () => { + if (!this.scrollContainer) { + this.stickToBottom = true; + return; + } + + const distanceFromBottom = + this.scrollContainer.scrollHeight - + this.scrollContainer.scrollTop - + this.scrollContainer.clientHeight; + + this.stickToBottom = distanceFromBottom < 48; + }; + + this.handleInput = (event) => { + if (!event.target.closest(".chat-input")) { + return; + } + + this.stickToBottom = true; + this.autoResize(); + }; + + this.handleKeyDown = (event) => { + if (!event.target.closest(".chat-input")) { + return; + } + + if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { + event.preventDefault(); + + const sendButton = this.el.querySelector("[data-testid='chat-send-button']"); + + if (sendButton && !sendButton.disabled) { + sendButton.click(); + } + } + }; + + this.el.addEventListener("input", this.handleInput); + this.el.addEventListener("keydown", this.handleKeyDown); + + this.syncScrollContainer(); + this.autoResize(); + window.requestAnimationFrame(() => this.scrollToBottom(true)); + }, + + updated() { + this.syncScrollContainer(); + this.autoResize(); + window.requestAnimationFrame(() => this.scrollToBottom()); + }, + + destroyed() { + this.el.removeEventListener("input", this.handleInput); + this.el.removeEventListener("keydown", this.handleKeyDown); + + if (this.scrollContainer) { + this.scrollContainer.removeEventListener("scroll", this.handleScroll); + } + } + }, + MonacoEditor: { mounted() { this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea"); diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs index 949ae18..6a7dfbe 100644 --- a/test/bds/desktop/shell_live_test.exs +++ b/test/bds/desktop/shell_live_test.exs @@ -32,6 +32,32 @@ defmodule BDS.Desktop.ShellLiveTest do def get(_url, _headers), do: {:error, :not_found} end + defmodule DelayedChatServer do + use Plug.Router + import Phoenix.ConnTest, except: [post: 2] + + plug :match + plug :dispatch + + post "/v1/chat/completions" do + Process.sleep(300) + + body = + Jason.encode!(%{ + "choices" => [%{"message" => %{"content" => "Delayed **response**"}}], + "usage" => %{"prompt_tokens" => 8, "completion_tokens" => 5} + }) + + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> send_resp(200, body) + end + + match _ do + send_resp(conn, 404, "not found") + end + end + @endpoint BDS.Desktop.Endpoint setup do @@ -1803,6 +1829,155 @@ defmodule BDS.Desktop.ShellLiveTest do assert html =~ "Posts" end + test "chat editor renders API-key-required state when online chat is not configured" do + assert :ok = AI.set_airplane_mode(false) + + assert {:ok, _endpoint} = + AI.put_endpoint(:online, %{ + url: "https://api.example.test/v1", + api_key: nil, + model: "gpt-4.1" + }) + + assert {:ok, conversation} = AI.start_chat(%{title: "Needs Setup", model: "gpt-4.1"}) + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + + html = + render_click(view, "pin_sidebar_item", %{ + "route" => "chat", + "id" => conversation.id, + "title" => conversation.title, + "subtitle" => conversation.model || "chat" + }) + + assert html =~ "AI Chat Setup" + assert html =~ "API Key Required" + assert html =~ "Open Settings" + refute html =~ ~s(data-testid="chat-input-container") + end + + test "chat editor renders assistant markdown and dispatches assistant navigation actions", %{project: project} do + assert {:ok, post} = + Posts.create_post(%{ + project_id: project.id, + title: "Action Post", + content: "Body", + language: "en" + }) + + assert {:ok, conversation} = AI.start_chat(%{title: "Action Chat", model: "gpt-4.1"}) + + now = Persistence.now_ms() + + Repo.insert!( + BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{ + conversation_id: conversation.id, + role: :user, + content: "Open the post", + created_at: now + }) + ) + + Repo.insert!( + BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{ + conversation_id: conversation.id, + role: :assistant, + content: "Use **markdown** to jump to the editor.", + tool_calls: + Jason.encode!([ + %{ + "id" => "call-card", + "name" => "render_card", + "arguments" => %{ + "title" => "Quick Action", + "body" => "Open the related post editor.", + "actions" => [ + %{ + "label" => "Open Post", + "action" => "openPost", + "payload" => %{"postId" => post.id} + } + ] + } + } + ]), + created_at: now + 1 + }) + ) + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + + html = + render_click(view, "pin_sidebar_item", %{ + "route" => "chat", + "id" => conversation.id, + "title" => conversation.title, + "subtitle" => conversation.model || "chat" + }) + + assert html =~ "markdown" + assert html =~ ~s(data-testid="chat-inline-surface") + assert html =~ "Quick Action" + assert html =~ "Open Post" + + html = + view + |> element("[data-testid='chat-surface-action'][data-action='openPost']") + |> render_click() + + assert html =~ ~s(data-tab-type="post") + assert html =~ ~s(data-tab-id="#{post.id}") + end + + test "chat editor shows in-flight stop state and can abort a running turn" do + assert :ok = AI.set_airplane_mode(false) + + server = + start_supervised!({Bandit, plug: DelayedChatServer, port: 0, startup_log: false}) + + {:ok, {_address, port}} = ThousandIsland.listener_info(server) + + assert {:ok, _endpoint} = + AI.put_endpoint(:online, %{ + url: "http://127.0.0.1:#{port}/v1", + api_key: "online-secret", + model: "gpt-4.1" + }) + + assert {:ok, conversation} = AI.start_chat(%{title: "Slow Chat", model: "gpt-4.1"}) + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + + _html = + render_click(view, "pin_sidebar_item", %{ + "route" => "chat", + "id" => conversation.id, + "title" => conversation.title, + "subtitle" => conversation.model || "chat" + }) + + _html = render_change(view, "change_chat_editor_input", %{"message" => "Please wait"}) + + html = + view + |> element("[data-testid='chat-send-button']") + |> render_click() + + assert html =~ ~s(data-testid="chat-abort-button") + assert html =~ ~s(data-testid="chat-streaming-thinking") + + html = + view + |> element("[data-testid='chat-abort-button']") + |> render_click() + + refute html =~ ~s(data-testid="chat-abort-button") + + Process.sleep(350) + refute render(view) =~ "Delayed response" + end + test "translation validation route renders dedicated cards and fix controls", %{project: project, temp_dir: temp_dir} do assert {:ok, _metadata} = BDS.Metadata.update_project_metadata(project.id, %{