fix: fixes for AI chat

This commit is contained in:
2026-05-01 20:22:12 +02:00
parent 8a582ee6c7
commit dd0c05b785
8 changed files with 360 additions and 66 deletions

View File

@@ -90,8 +90,8 @@ defmodule BDS.Desktop.MainWindow do
end end
@impl true @impl true
def terminate(_reason, %{frame: frame, last_bounds: last_bounds}) do def terminate(_reason, %{last_bounds: last_bounds}) do
if bounds = current_bounds(frame) || last_bounds do if bounds = last_bounds do
_ = persist_bounds(bounds) _ = persist_bounds(bounds)
end end

View File

@@ -1,54 +1,56 @@
<div id={"chat-editor-#{@chat_editor.id}"} class="chat-panel" data-testid="chat-editor" phx-hook="ChatSurface"> <div id={"chat-editor-#{@chat_editor.id}"} class="chat-panel" data-testid="chat-editor" phx-hook="ChatSurface">
<div class="chat-panel-header"> <div class="chat-panel-header">
<div class="chat-panel-title"> <div class="chat-panel-title">
<%= if @chat_editor.needs_api_key? do %> <span class="chat-panel-title-main">
<%= translated("chat.setupTitle") %> <%= if @chat_editor.needs_api_key? do %>
<% else %> <%= translated("chat.setupTitle") %>
<%= @chat_editor.title %> <% else %>
<%= @chat_editor.title %>
<% end %>
</span>
<%= unless @chat_editor.needs_api_key? do %>
<span class="chat-model-selector-wrap">
<button
class="chat-model-selector-button chat-model-selector-inline"
type="button"
phx-click="toggle_chat_model_selector"
data-testid="chat-model-selector-button"
>
<span><%= @chat_editor.model || translated("chat.newChat") %></span>
<span class="chat-model-selector-caret">▾</span>
</button>
<%= if @chat_editor.model_selector_open? and @chat_editor.available_models != [] do %>
<div class="chat-model-selector-menu">
<%= for group <- @chat_editor.available_model_groups do %>
<section class="chat-model-provider-group" data-testid="chat-model-provider-group" data-provider={group.provider}>
<%= if length(@chat_editor.available_model_groups) > 1 do %>
<div class="chat-model-provider-header"><%= group.label %></div>
<% end %>
<%= for model <- group.models do %>
<button
class={[
"chat-model-selector-option",
if(model.id == @chat_editor.model, do: "active")
]}
type="button"
phx-click="select_chat_model"
phx-value-model={model.id}
data-testid="chat-model-selector-option"
data-provider={group.provider}
>
<span class="chat-model-selector-option-name"><%= model.name || model.id %></span>
</button>
<% end %>
</section>
<% end %>
</div>
<% end %>
</span>
<% end %> <% end %>
</div> </div>
<%= unless @chat_editor.needs_api_key? do %>
<div class="chat-panel-header-actions">
<button
class="chat-model-selector-button"
type="button"
phx-click="toggle_chat_model_selector"
data-testid="chat-model-selector-button"
>
<span><%= @chat_editor.model || translated("chat.newChat") %></span>
<span class="chat-model-selector-caret">▾</span>
</button>
<%= if @chat_editor.model_selector_open? and @chat_editor.available_models != [] do %>
<div class="chat-model-selector-menu">
<%= for group <- @chat_editor.available_model_groups do %>
<section class="chat-model-provider-group" data-testid="chat-model-provider-group" data-provider={group.provider}>
<%= if length(@chat_editor.available_model_groups) > 1 do %>
<div class="chat-model-provider-header"><%= group.label %></div>
<% end %>
<%= for model <- group.models do %>
<button
class={[
"chat-model-selector-option",
if(model.id == @chat_editor.model, do: "active")
]}
type="button"
phx-click="select_chat_model"
phx-value-model={model.id}
data-testid="chat-model-selector-option"
data-provider={group.provider}
>
<span class="chat-model-selector-option-name"><%= model.name || model.id %></span>
</button>
<% end %>
</section>
<% end %>
</div>
<% end %>
</div>
<% end %>
</div> </div>
<div class="chat-messages chat-surface-scroll"> <div class="chat-messages chat-surface-scroll">
@@ -83,7 +85,7 @@
<div class="chat-message-header"> <div class="chat-message-header">
<span class="chat-message-role"><%= message_role_label(:user) %></span> <span class="chat-message-role"><%= message_role_label(:user) %></span>
</div> </div>
<div class="chat-message-text"><%= @chat_editor.pending_user_message %></div> <div class="chat-message-text chat-user-message-text" data-testid="chat-user-message-text"><%= @chat_editor.pending_user_message %></div>
</div> </div>
</div> </div>
<% end %> <% end %>
@@ -95,13 +97,11 @@
<div class="chat-message-header"><span class="chat-message-role"><%= message_role_label(message.role) %></span></div> <div class="chat-message-header"><span class="chat-message-role"><%= message_role_label(message.role) %></span></div>
<.chat_tool_markers markers={message.tool_markers} /> <.chat_tool_markers markers={message.tool_markers} />
<div class="chat-message-text"> <%= if message.role == :assistant do %>
<%= if message.role == :assistant do %> <div class="chat-message-text"><%= markdown_html(message.content || "") %></div>
<%= markdown_html(message.content || "") %> <% else %>
<% else %> <div class="chat-message-text chat-user-message-text" data-testid="chat-user-message-text"><%= message.content || "" %></div>
<%= message.content || "" %> <% end %>
<% end %>
</div>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,11 @@
<div class="settings-view-shell" data-testid="settings-editor" data-selected-settings-section={@settings_editor.selected_section}> <div
id="settings-editor-shell"
class="settings-view-shell"
data-testid="settings-editor"
phx-hook="SettingsSectionScroll"
data-selected-settings-section={@settings_editor.selected_section}
data-settings-scroll-target={"settings-section-#{@settings_editor.selected_section}"}
>
<div class="settings-view"> <div class="settings-view">
<div class="settings-header"> <div class="settings-header">
<h2 data-testid="editor-title"><%= translated("Settings") %></h2> <h2 data-testid="editor-title"><%= translated("Settings") %></h2>

View File

@@ -2,6 +2,7 @@ defmodule BDS.Desktop.ShellLive.SidebarCreate do
@moduledoc false @moduledoc false
alias BDS.Desktop.{FilePicker, ShellData} alias BDS.Desktop.{FilePicker, ShellData}
alias BDS.AI
alias BDS.ImportDefinitions alias BDS.ImportDefinitions
alias BDS.Scripts alias BDS.Scripts
alias BDS.Templates alias BDS.Templates
@@ -132,6 +133,27 @@ defmodule BDS.Desktop.ShellLive.SidebarCreate do
end end
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 def create(socket, project_id, "import", callbacks) do
case ImportDefinitions.create_definition(%{ case ImportDefinitions.create_definition(%{
project_id: project_id, 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(:media), do: %{kind: "media", label: "sidebar.importMedia"}
def action(:scripts), do: %{kind: "script", label: "sidebar.scripts.newScript"} def action(:scripts), do: %{kind: "script", label: "sidebar.scripts.newScript"}
def action(:templates), do: %{kind: "template", label: "sidebar.templates.newTemplate"} 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(:import), do: %{kind: "import", label: "sidebar.import.newDefinition"}
def action(_view), do: nil def action(_view), do: nil

View File

@@ -3561,14 +3561,22 @@ button svg * {
.chat-panel-title { .chat-panel-title {
flex: 1; flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
color: var(--vscode-foreground, inherit); color: var(--vscode-foreground, inherit);
} }
.chat-panel-title-main {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-panel-header { .chat-panel-header {
position: relative; position: relative;
padding: 12px 16px; padding: 12px 16px;
@@ -5133,14 +5141,22 @@ button svg * {
.chat-panel-title { .chat-panel-title {
flex: 1; flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
color: var(--vscode-foreground, inherit); color: var(--vscode-foreground, inherit);
} }
.chat-panel-title-main {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-panel-header-actions { .chat-panel-header-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -5160,9 +5176,29 @@ button svg * {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
flex: 0 1 auto;
max-width: min(40vw, 240px);
padding: 4px 8px; padding: 4px 8px;
font-size: 12px; font-size: 12px;
color: var(--vscode-descriptionForeground, inherit); 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, .chat-model-selector-button:hover,
@@ -5177,7 +5213,7 @@ button svg * {
.chat-model-selector-menu { .chat-model-selector-menu {
position: absolute; position: absolute;
top: calc(100% + 4px); top: calc(100% + 4px);
right: 16px; left: 0;
min-width: 180px; min-width: 180px;
max-height: 300px; max-height: 300px;
overflow-y: auto; overflow-y: auto;
@@ -5318,6 +5354,13 @@ button svg * {
} }
.chat-message.user .chat-message-content { .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; text-align: right;
} }
@@ -5368,6 +5411,9 @@ button svg * {
} }
.chat-message.user .chat-message-text { .chat-message.user .chat-message-text {
width: fit-content;
max-width: 100%;
display: inline-block;
border-radius: 12px 12px 2px 12px; border-radius: 12px 12px 2px 12px;
background-color: var(--vscode-button-background, var(--accent-color)); background-color: var(--vscode-button-background, var(--accent-color));
color: var(--vscode-button-foreground, #ffffff); color: var(--vscode-button-foreground, #ffffff);
@@ -5377,6 +5423,74 @@ button svg * {
white-space: normal; 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 { .chat-message.streaming .chat-message-text {
background: linear-gradient( background: linear-gradient(
90deg, 90deg,

View File

@@ -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: { ChatSurface: {
mounted() { mounted() {
this.stickToBottom = true; this.stickToBottom = true;

View File

@@ -63,4 +63,19 @@ defmodule BDS.Desktop.MainWindowTest do
assert opts[:size] == {1200, 700} assert opts[:size] == {1200, 700}
end 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 end

View File

@@ -136,7 +136,7 @@ defmodule BDS.Desktop.ShellLiveTest do
%{project: project, temp_dir: temp_dir} %{project: project, temp_dir: temp_dir}
end 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) {:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
assert html =~ ~s(data-testid="sidebar-create-action") assert html =~ ~s(data-testid="sidebar-create-action")
@@ -162,6 +162,13 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-sidebar-action="template") 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 = html =
view view
|> element("[data-testid='activity-button'][data-view='import']") |> element("[data-testid='activity-button'][data-view='import']")
@@ -170,13 +177,15 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-sidebar-action="import") assert html =~ ~s(data-sidebar-action="import")
end end
test "sidebar create actions follow the old-app post, script, template, and import flows", %{ test "sidebar create actions follow the old-app post, script, template, chat, and import flows",
project: project %{
} do project: project
} do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
post_count_before = Repo.aggregate(Post, :count, :id) post_count_before = Repo.aggregate(Post, :count, :id)
script_count_before = Repo.aggregate(BDS.Scripts.Script, :count, :id) script_count_before = Repo.aggregate(BDS.Scripts.Script, :count, :id)
template_count_before = Repo.aggregate(BDS.Templates.Template, :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) import_count_before = Repo.aggregate(ImportDefinitions.ImportDefinition, :count, :id)
html = html =
@@ -225,6 +234,20 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-tab-type="templates") assert html =~ ~s(data-tab-type="templates")
assert html =~ ~s(data-tab-id="#{created_template.id}") 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 = render_click(view, "select_view", %{"view" => "import"})
html = html =
@@ -242,6 +265,21 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-tab-id="#{created_definition.id}") assert html =~ ~s(data-tab-id="#{created_definition.id}")
end 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", test "shell live refreshes the posts sidebar when the CLI watcher broadcasts an entity change",
%{project: project} do %{project: project} do
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) {: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." refute chat_html =~ "Desktop workbench content routed through the Elixir shell."
end 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 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"}) 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" assert html =~ "Posts"
end 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(<div class="chat-message-text chat-user-message-text" data-testid="chat-user-message-text">wie viele Posts sind im Blog?</div>)
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 test "chat editor groups selector models by provider and uses catalog labels" do
updated_at = Persistence.now_ms() updated_at = Persistence.now_ms()