diff --git a/lib/bds/desktop/main_window.ex b/lib/bds/desktop/main_window.ex index 94b59db..65241c6 100644 --- a/lib/bds/desktop/main_window.ex +++ b/lib/bds/desktop/main_window.ex @@ -90,8 +90,8 @@ defmodule BDS.Desktop.MainWindow do end @impl true - def terminate(_reason, %{frame: frame, last_bounds: last_bounds}) do - if bounds = current_bounds(frame) || last_bounds do + def terminate(_reason, %{last_bounds: last_bounds}) do + if bounds = last_bounds do _ = persist_bounds(bounds) 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 a97f0fa..e13c5ab 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,54 +1,56 @@
- <%= if @chat_editor.needs_api_key? do %> - <%= translated("chat.setupTitle") %> - <% else %> - <%= @chat_editor.title %> + + <%= 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 group <- @chat_editor.available_model_groups do %> +
+ <%= if length(@chat_editor.available_model_groups) > 1 do %> +
<%= group.label %>
+ <% end %> + + <%= for model <- group.models do %> + + <% end %> +
+ <% end %> +
+ <% end %> +
<% end %>
- - <%= unless @chat_editor.needs_api_key? do %> -
- - - <%= if @chat_editor.model_selector_open? and @chat_editor.available_models != [] do %> -
- <%= for group <- @chat_editor.available_model_groups do %> -
- <%= if length(@chat_editor.available_model_groups) > 1 do %> -
<%= group.label %>
- <% end %> - - <%= for model <- group.models do %> - - <% end %> -
- <% end %> -
- <% end %> -
- <% end %>
@@ -83,7 +85,7 @@
<%= message_role_label(:user) %>
-
<%= @chat_editor.pending_user_message %>
+
<%= @chat_editor.pending_user_message %>
<% end %> @@ -95,13 +97,11 @@
<%= 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 %> -
+ <%= if message.role == :assistant do %> +
<%= markdown_html(message.content || "") %>
+ <% else %> +
<%= message.content || "" %>
+ <% end %> diff --git a/lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex b/lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex index 6db8fca..1b4bed7 100644 --- a/lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex +++ b/lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex @@ -1,4 +1,11 @@ -
+

<%= translated("Settings") %>

diff --git a/lib/bds/desktop/shell_live/sidebar_create.ex b/lib/bds/desktop/shell_live/sidebar_create.ex index b0c6676..83d9e7a 100644 --- a/lib/bds/desktop/shell_live/sidebar_create.ex +++ b/lib/bds/desktop/shell_live/sidebar_create.ex @@ -2,6 +2,7 @@ defmodule BDS.Desktop.ShellLive.SidebarCreate do @moduledoc false alias BDS.Desktop.{FilePicker, ShellData} + alias BDS.AI alias BDS.ImportDefinitions alias BDS.Scripts alias BDS.Templates @@ -132,6 +133,27 @@ defmodule BDS.Desktop.ShellLive.SidebarCreate do end end + def create(socket, _project_id, "chat", callbacks) do + case AI.start_chat(%{}) do + {:ok, conversation} -> + callbacks.open_sidebar.( + socket, + %{ + "route" => "chat", + "id" => conversation.id, + "title" => conversation.title, + "subtitle" => "AI conversations" + }, + :pin + ) + + {:error, reason} -> + socket + |> callbacks.append_output.(translated("chat.newChat"), inspect(reason), nil, "error") + |> callbacks.reload.(socket.assigns.workbench) + end + end + def create(socket, project_id, "import", callbacks) do case ImportDefinitions.create_definition(%{ project_id: project_id, @@ -168,6 +190,7 @@ defmodule BDS.Desktop.ShellLive.SidebarCreate do def action(:media), do: %{kind: "media", label: "sidebar.importMedia"} def action(:scripts), do: %{kind: "script", label: "sidebar.scripts.newScript"} def action(:templates), do: %{kind: "template", label: "sidebar.templates.newTemplate"} + def action(:chat), do: %{kind: "chat", label: "chat.newChat"} def action(:import), do: %{kind: "import", label: "sidebar.import.newDefinition"} def action(_view), do: nil diff --git a/priv/ui/app.css b/priv/ui/app.css index f83ce52..4193daf 100644 --- a/priv/ui/app.css +++ b/priv/ui/app.css @@ -3561,14 +3561,22 @@ button svg * { .chat-panel-title { flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 10px; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; font-size: 14px; font-weight: 500; color: var(--vscode-foreground, inherit); } +.chat-panel-title-main { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .chat-panel-header { position: relative; padding: 12px 16px; @@ -5133,14 +5141,22 @@ button svg * { .chat-panel-title { flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 10px; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; font-size: 14px; font-weight: 500; color: var(--vscode-foreground, inherit); } +.chat-panel-title-main { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .chat-panel-header-actions { display: flex; align-items: center; @@ -5160,9 +5176,29 @@ button svg * { display: inline-flex; align-items: center; gap: 8px; + flex: 0 1 auto; + max-width: min(40vw, 240px); padding: 4px 8px; font-size: 12px; color: var(--vscode-descriptionForeground, inherit); + white-space: nowrap; +} + +.chat-model-selector-inline { + min-width: 0; + background-color: var(--vscode-input-background, transparent); +} + +.chat-model-selector-inline span:first-child { + overflow: hidden; + text-overflow: ellipsis; +} + +.chat-model-selector-wrap { + position: relative; + display: inline-flex; + min-width: 0; + flex: 0 1 auto; } .chat-model-selector-button:hover, @@ -5177,7 +5213,7 @@ button svg * { .chat-model-selector-menu { position: absolute; top: calc(100% + 4px); - right: 16px; + left: 0; min-width: 180px; max-height: 300px; overflow-y: auto; @@ -5318,6 +5354,13 @@ button svg * { } .chat-message.user .chat-message-content { + width: fit-content; + min-width: 0; + max-width: min(80%, 720px); + display: flex; + flex-direction: column; + align-items: flex-end; + margin-left: auto; text-align: right; } @@ -5368,6 +5411,9 @@ button svg * { } .chat-message.user .chat-message-text { + width: fit-content; + max-width: 100%; + display: inline-block; border-radius: 12px 12px 2px 12px; background-color: var(--vscode-button-background, var(--accent-color)); color: var(--vscode-button-foreground, #ffffff); @@ -5377,6 +5423,74 @@ button svg * { white-space: normal; } +.chat-panel .chat-model-selector-button.chat-model-selector-inline { + width: auto; + min-width: 0; + max-width: 240px; + height: auto; + flex: 0 1 auto; + padding: 4px 8px; + border: 1px solid var(--vscode-input-border, var(--line, #3c3c3c)); + border-radius: 4px; + background: transparent; + color: var(--vscode-descriptionForeground, inherit); +} + +.chat-panel .chat-model-selector-caret { + position: static; + inset: auto; + width: auto; + min-width: 0; + max-width: none; + height: auto; + display: inline; + flex: 0 0 auto; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + color: inherit; + font-size: 10px; + line-height: 1; +} + +.chat-panel .chat-model-selector-menu { + left: 0; + right: auto; + width: max-content; + min-width: 180px; + max-width: min(360px, calc(100vw - 48px)); + height: auto; + 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); +} + +.chat-panel .chat-message.user .chat-message-content { + width: max-content; + min-width: 0; + max-width: min(72%, 720px); + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.chat-panel .chat-message.user .chat-message-text.chat-user-message-text { + width: auto; + min-width: 0; + max-width: 100%; + display: inline-block; + box-sizing: border-box; + padding: 6px 12px; + line-height: 1.35; + white-space: pre-wrap; +} + .chat-message.streaming .chat-message-text { background: linear-gradient( 90deg, diff --git a/priv/ui/live.js b/priv/ui/live.js index d1b03f2..2766c83 100644 --- a/priv/ui/live.js +++ b/priv/ui/live.js @@ -690,6 +690,35 @@ document.addEventListener("DOMContentLoaded", () => { } }, + SettingsSectionScroll: { + mounted() { + this.lastTargetId = null; + this.scrollToSelectedSection(); + }, + + updated() { + this.scrollToSelectedSection(); + }, + + scrollToSelectedSection() { + const targetId = this.el.dataset.settingsScrollTarget; + + if (!targetId || targetId === this.lastTargetId) { + return; + } + + this.lastTargetId = targetId; + + window.requestAnimationFrame(() => { + const target = document.getElementById(targetId); + + if (target && this.el.contains(target)) { + target.scrollIntoView({ block: "start", behavior: "smooth" }); + } + }); + } + }, + ChatSurface: { mounted() { this.stickToBottom = true; diff --git a/test/bds/desktop/main_window_test.exs b/test/bds/desktop/main_window_test.exs index accb698..a78e13f 100644 --- a/test/bds/desktop/main_window_test.exs +++ b/test/bds/desktop/main_window_test.exs @@ -63,4 +63,19 @@ defmodule BDS.Desktop.MainWindowTest do assert opts[:size] == {1200, 700} end + + test "terminate persists the last known bounds without querying wx during shutdown", %{ + path: path + } do + bounds = %{x: 33, y: 44, width: 900, height: 700} + + assert :ok = MainWindow.terminate(:shutdown, %{frame: :invalid_wx_frame, last_bounds: bounds}) + + assert Jason.decode!(File.read!(path)) == %{ + "x" => 33, + "y" => 44, + "width" => 900, + "height" => 700 + } + end end diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs index df8478e..6533586 100644 --- a/test/bds/desktop/shell_live_test.exs +++ b/test/bds/desktop/shell_live_test.exs @@ -136,7 +136,7 @@ defmodule BDS.Desktop.ShellLiveTest do %{project: project, temp_dir: temp_dir} end - test "sidebar headers expose old-app create actions for posts, media, scripts, templates, and imports" do + test "sidebar headers expose old-app create actions for posts, media, scripts, templates, chat, and imports" do {:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) assert html =~ ~s(data-testid="sidebar-create-action") @@ -162,6 +162,13 @@ defmodule BDS.Desktop.ShellLiveTest do assert html =~ ~s(data-sidebar-action="template") + html = + view + |> element("[data-testid='activity-button'][data-view='chat']") + |> render_click() + + assert html =~ ~s(data-sidebar-action="chat") + html = view |> element("[data-testid='activity-button'][data-view='import']") @@ -170,13 +177,15 @@ defmodule BDS.Desktop.ShellLiveTest do assert html =~ ~s(data-sidebar-action="import") end - test "sidebar create actions follow the old-app post, script, template, and import flows", %{ - project: project - } do + test "sidebar create actions follow the old-app post, script, template, chat, and import flows", + %{ + project: project + } do {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) post_count_before = Repo.aggregate(Post, :count, :id) script_count_before = Repo.aggregate(BDS.Scripts.Script, :count, :id) template_count_before = Repo.aggregate(BDS.Templates.Template, :count, :id) + chat_count_before = Repo.aggregate(BDS.AI.ChatConversation, :count, :id) import_count_before = Repo.aggregate(ImportDefinitions.ImportDefinition, :count, :id) html = @@ -225,6 +234,20 @@ defmodule BDS.Desktop.ShellLiveTest do assert html =~ ~s(data-tab-type="templates") assert html =~ ~s(data-tab-id="#{created_template.id}") + _html = render_click(view, "select_view", %{"view" => "chat"}) + + html = + view + |> element("[data-testid='sidebar-create-action'][data-sidebar-action='chat']") + |> render_click() + + assert Repo.aggregate(BDS.AI.ChatConversation, :count, :id) == chat_count_before + 1 + + created_chat = Repo.one!(BDS.AI.ChatConversation) + assert created_chat.title == "New Chat" + assert html =~ ~s(data-tab-type="chat") + assert html =~ ~s(data-tab-id="#{created_chat.id}") + _html = render_click(view, "select_view", %{"view" => "import"}) html = @@ -242,6 +265,21 @@ defmodule BDS.Desktop.ShellLiveTest do assert html =~ ~s(data-tab-id="#{created_definition.id}") end + test "settings sidebar selections expose a scroll target for the preferences editor" do + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + + _html = render_click(view, "select_view", %{"view" => "settings"}) + + html = + view + |> element("[data-testid='sidebar-open-item'][data-item-id='settings-ai']") + |> render_click() + + assert html =~ ~s(phx-hook="SettingsSectionScroll") + assert html =~ ~s(data-selected-settings-section="ai") + assert html =~ ~s(data-settings-scroll-target="settings-section-ai") + end + test "shell live refreshes the posts sidebar when the CLI watcher broadcasts an entity change", %{project: project} do {:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) @@ -2074,6 +2112,38 @@ defmodule BDS.Desktop.ShellLiveTest do refute chat_html =~ "Desktop workbench content routed through the Elixir shell." end + test "chat editor uses the model name itself as the selector" do + assert {:ok, conversation} = AI.start_chat(%{title: "Selector Chat", model: "qwen3.5-122b"}) + + {: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 =~ ~s(data-testid="chat-model-selector-button") + assert html =~ ~s(class="chat-panel-title-main") + assert html =~ ~s(class="chat-model-selector-wrap") + assert html =~ ~s(class="chat-model-selector-button chat-model-selector-inline") + refute html =~ ~s(class="chat-panel-header-actions") + + css = File.read!(Path.expand("../../../priv/ui/app.css", __DIR__)) + assert css =~ ".chat-model-selector-wrap" + assert css =~ "left: 0;" + assert css =~ "right: auto;" + + refute css =~ + ".chat-model-selector-menu {\n position: absolute;\n top: calc(100% + 4px);\n right: 16px;" + + assert css =~ ".chat-panel .chat-model-selector-button.chat-model-selector-inline" + assert css =~ ".chat-panel .chat-model-selector-caret" + assert css =~ "position: static;" + end + test "chat editor renders legacy model controls, tool markers, and structured tool surfaces" do assert {:ok, conversation} = AI.start_chat(%{title: "Editor Chat", model: "gpt-4.1"}) @@ -2141,6 +2211,42 @@ defmodule BDS.Desktop.ShellLiveTest do assert html =~ "Posts" end + test "chat editor marks user message text as compact" do + assert {:ok, conversation} = AI.start_chat(%{title: "Compact Chat", model: "gpt-4.1"}) + + Repo.insert!( + BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{ + conversation_id: conversation.id, + role: :user, + content: "wie viele Posts sind im Blog?", + created_at: Persistence.now_ms() + }) + ) + + {: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 =~ ~s(data-testid="chat-user-message-text") + assert html =~ ~s(class="chat-message-text chat-user-message-text") + + assert html =~ + ~s(
wie viele Posts sind im Blog?
) + + css = File.read!(Path.expand("../../../priv/ui/app.css", __DIR__)) + assert css =~ ".chat-panel .chat-message.user .chat-message-content" + assert css =~ "background: transparent;" + assert css =~ "border: 0;" + assert css =~ "padding: 6px 12px;" + assert css =~ "line-height: 1.35;" + end + test "chat editor groups selector models by provider and uses catalog labels" do updated_at = Persistence.now_ms()