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
@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

View File

@@ -1,54 +1,56 @@
<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-title">
<%= if @chat_editor.needs_api_key? do %>
<%= translated("chat.setupTitle") %>
<% else %>
<%= @chat_editor.title %>
<span class="chat-panel-title-main">
<%= if @chat_editor.needs_api_key? do %>
<%= translated("chat.setupTitle") %>
<% 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 %>
</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 class="chat-messages chat-surface-scroll">
@@ -83,7 +85,7 @@
<div class="chat-message-header">
<span class="chat-message-role"><%= message_role_label(:user) %></span>
</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>
<% end %>
@@ -95,13 +97,11 @@
<div class="chat-message-header"><span class="chat-message-role"><%= message_role_label(message.role) %></span></div>
<.chat_tool_markers markers={message.tool_markers} />
<div class="chat-message-text">
<%= if message.role == :assistant do %>
<%= markdown_html(message.content || "") %>
<% else %>
<%= message.content || "" %>
<% end %>
</div>
<%= if message.role == :assistant do %>
<div class="chat-message-text"><%= markdown_html(message.content || "") %></div>
<% else %>
<div class="chat-message-text chat-user-message-text" data-testid="chat-user-message-text"><%= message.content || "" %></div>
<% end %>
</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-header">
<h2 data-testid="editor-title"><%= translated("Settings") %></h2>

View File

@@ -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

View File

@@ -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,

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

View File

@@ -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

View File

@@ -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(<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
updated_at = Persistence.now_ms()