Compare commits

..

4 Commits

17 changed files with 599 additions and 70 deletions

View File

@@ -163,9 +163,15 @@ defmodule BDS.AI do
@spec get_chat_conversation(String.t()) :: BDS.AI.ChatConversation.t() | nil @spec get_chat_conversation(String.t()) :: BDS.AI.ChatConversation.t() | nil
defdelegate get_chat_conversation(conversation_id), to: Chat defdelegate get_chat_conversation(conversation_id), to: Chat
@spec delete_chat_conversation(String.t()) :: {:ok, :deleted} | {:error, :not_found | term()}
defdelegate delete_chat_conversation(conversation_id), to: Chat
@spec available_chat_models(String.t() | nil) :: [map()] @spec available_chat_models(String.t() | nil) :: [map()]
defdelegate available_chat_models(current_model \\ nil), to: Chat defdelegate available_chat_models(current_model \\ nil), to: Chat
@spec effective_chat_model(BDS.AI.ChatConversation.t() | map() | nil) :: String.t() | nil
defdelegate effective_chat_model(conversation), to: Chat
@spec set_conversation_model(String.t(), String.t()) :: @spec set_conversation_model(String.t(), String.t()) ::
{:ok, map()} | {:error, :not_found | Ecto.Changeset.t()} {:ok, map()} | {:error, :not_found | Ecto.Changeset.t()}
defdelegate set_conversation_model(conversation_id, model_id), to: Chat defdelegate set_conversation_model(conversation_id, model_id), to: Chat

View File

@@ -23,6 +23,8 @@ defmodule BDS.AI.Chat do
@default_system_prompt "You are the bDS AI backend. Be precise, prefer structured JSON when asked, and avoid inventing blog facts." @default_system_prompt "You are the bDS AI backend. Be precise, prefer structured JSON when asked, and avoid inventing blog facts."
@default_max_output_tokens 16_384 @default_max_output_tokens 16_384
@title_max_output_tokens 20
@chat_title_max_length 30
@chat_max_tool_rounds 10 @chat_max_tool_rounds 10
@default_context_window 128_000 @default_context_window 128_000
@@ -59,6 +61,22 @@ defmodule BDS.AI.Chat do
Repo.get(ChatConversation, conversation_id) Repo.get(ChatConversation, conversation_id)
end end
@spec delete_chat_conversation(String.t()) :: {:ok, :deleted} | {:error, :not_found | term()}
def delete_chat_conversation(conversation_id) when is_binary(conversation_id) do
case Repo.get(ChatConversation, conversation_id) do
nil ->
{:error, :not_found}
%ChatConversation{} = conversation ->
Repo.delete_all(from message in ChatMessage, where: message.conversation_id == ^conversation_id)
case Repo.delete(conversation) do
{:ok, _conversation} -> {:ok, :deleted}
{:error, reason} -> {:error, reason}
end
end
end
@spec available_chat_models(String.t() | nil) :: [map()] @spec available_chat_models(String.t() | nil) :: [map()]
def available_chat_models(current_model \\ nil) do def available_chat_models(current_model \\ nil) do
endpoint_models = configured_chat_models() endpoint_models = configured_chat_models()
@@ -87,6 +105,15 @@ defmodule BDS.AI.Chat do
end) end)
end end
@spec effective_chat_model(ChatConversation.t() | map() | nil) :: String.t() | nil
def effective_chat_model(%ChatConversation{} = conversation) do
resolve_effective_chat_model(conversation.model)
end
def effective_chat_model(%{model: model}), do: resolve_effective_chat_model(model)
def effective_chat_model(%{"model" => model}), do: resolve_effective_chat_model(model)
def effective_chat_model(_conversation), do: resolve_effective_chat_model(nil)
@spec set_conversation_model(String.t(), String.t()) :: @spec set_conversation_model(String.t(), String.t()) ::
{:ok, map()} | {:error, :not_found | Ecto.Changeset.t()} {:ok, map()} | {:error, :not_found | Ecto.Changeset.t()}
def set_conversation_model(conversation_id, model_id) def set_conversation_model(conversation_id, model_id)
@@ -264,6 +291,25 @@ defmodule BDS.AI.Chat do
end end
end end
defp resolve_effective_chat_model(model) when is_binary(model) and model != "", do: model
defp resolve_effective_chat_model(_model) do
mode = if AI.airplane_mode?(), do: :airplane, else: :online
preference_key = if mode == :airplane, do: :airplane_chat, else: :chat
case Runtime.model_preference_value(preference_key) do
model when is_binary(model) and model != "" ->
model
_other ->
case AI.get_endpoint(mode) do
{:ok, %{model: model}} when is_binary(model) and model != "" -> model
_other -> nil
end
end
end
defp catalog_provider_name_map do defp catalog_provider_name_map do
Repo.all(from provider in CatalogProvider, select: {provider.id, provider.name}) Repo.all(from provider in CatalogProvider, select: {provider.id, provider.name})
|> Map.new() |> Map.new()
@@ -303,7 +349,7 @@ defmodule BDS.AI.Chat do
defp fallback_provider_name(_provider), do: "Other" defp fallback_provider_name(_provider), do: "Other"
defp do_send_chat_message(conversation, _user_message, opts) do defp do_send_chat_message(conversation, user_message, opts) do
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime) runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
project_id = Keyword.get(opts, :project_id, active_project_id()) project_id = Keyword.get(opts, :project_id, active_project_id())
@@ -327,11 +373,106 @@ defmodule BDS.AI.Chat do
runtime, runtime,
opts, opts,
@chat_max_tool_rounds @chat_max_tool_rounds
) do ),
{:ok, reply} <- maybe_generate_chat_title(conversation.id, user_message.content, reply, opts) do
{:ok, reply} {:ok, reply}
end end
end end
defp maybe_generate_chat_title(conversation_id, user_content, reply, opts) do
conversation = Repo.get!(ChatConversation, conversation_id)
cond do
chat_user_message_count(conversation_id) != 1 ->
{:ok, reply}
not generated_chat_title?(conversation.title, conversation.model) ->
{:ok, reply}
true ->
case generate_chat_title(user_content, opts) do
{:ok, title} when is_binary(title) and title != "" ->
now = Persistence.now_ms()
conversation
|> ChatConversation.changeset(%{title: title, updated_at: now})
|> Repo.update()
|> case do
{:ok, updated_conversation} ->
{:ok, %{reply | conversation: format_conversation(updated_conversation)}}
{:error, _reason} ->
{:ok, reply}
end
_other ->
{:ok, reply}
end
end
end
defp generate_chat_title(user_content, opts) when is_binary(user_content) do
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(:chat_title, opts),
:ok <- Runtime.validate_target(:chat_title, model, mode),
request <- build_chat_title_request(user_content, model),
{:ok, response} <- runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts) do
{:ok, sanitize_chat_title(Map.get(response, :content))}
end
end
defp build_chat_title_request(user_content, model) do
%{
operation: :chat_title,
model: model,
max_output_tokens: @title_max_output_tokens,
messages: [
%{
"role" => "system",
"content" =>
"Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Output ONLY the title text."
},
%{"role" => "user", "content" => "Topic: #{String.slice(user_content, 0, 100)}"}
]
}
end
defp sanitize_chat_title(title) when is_binary(title) do
title =
title
|> String.trim()
|> String.trim_leading("\"")
|> String.trim_leading("'")
|> String.trim_trailing("\"")
|> String.trim_trailing("'")
|> String.trim_trailing(".")
|> String.trim_trailing("!")
|> String.trim_trailing("?")
if String.length(title) > @chat_title_max_length do
String.slice(title, 0, @chat_title_max_length - 3) <> "..."
else
title
end
end
defp sanitize_chat_title(_title), do: ""
defp chat_user_message_count(conversation_id) do
Repo.aggregate(
from(message in ChatMessage,
where: message.conversation_id == ^conversation_id and message.role == :user
),
:count,
:id
)
end
defp generated_chat_title?(title, model) do
title in [generated_chat_title(nil), generated_chat_title(model)]
end
defp chat_round( defp chat_round(
_conversation, _conversation,
_messages, _messages,

View File

@@ -135,6 +135,7 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:chat_editor_request_refs, %{}) |> assign(:chat_editor_request_refs, %{})
|> assign(:chat_editor_surface_data, %{}) |> assign(:chat_editor_surface_data, %{})
|> assign(:chat_editor_surface_tabs, %{}) |> assign(:chat_editor_surface_tabs, %{})
|> assign(:chat_editor_dismissed_surfaces, MapSet.new())
|> assign(:chat_editor_action_errors, %{}) |> assign(:chat_editor_action_errors, %{})
|> assign(:import_editor_analysis_states, %{}) |> assign(:import_editor_analysis_states, %{})
|> assign(:import_editor_analysis_task_refs, %{}) |> assign(:import_editor_analysis_task_refs, %{})
@@ -457,6 +458,30 @@ defmodule BDS.Desktop.ShellLive do
end end
end end
def handle_event("delete_sidebar_chat", %{"id" => conversation_id}, socket) do
case AI.delete_chat_conversation(conversation_id) do
{:ok, :deleted} ->
workbench = Workbench.close_tab(socket.assigns.workbench, :chat, conversation_id)
tab_meta = Map.delete(socket.assigns.tab_meta, {:chat, conversation_id})
{:noreply,
socket
|> assign(:tab_meta, tab_meta)
|> reload_shell(workbench)}
{:error, reason} ->
{:noreply,
socket
|> append_output_entry(
translated("sidebar.chat.deleteConversation"),
inspect(reason),
nil,
"error"
)
|> reload_shell(socket.assigns.workbench)}
end
end
def handle_event("toggle_offline_mode", _params, socket) do def handle_event("toggle_offline_mode", _params, socket) do
next_mode = not socket.assigns.offline_mode next_mode = not socket.assigns.offline_mode
@@ -948,6 +973,10 @@ defmodule BDS.Desktop.ShellLive do
ChatEditor.select_surface_tab(socket, surface_id, parse_integer(index), &reload_shell/2)} ChatEditor.select_surface_tab(socket, surface_id, parse_integer(index), &reload_shell/2)}
end end
def handle_event("dismiss_chat_surface", %{"surface-id" => surface_id}, socket) do
{:noreply, ChatEditor.dismiss_surface(socket, surface_id, &reload_shell/2)}
end
def handle_event("chat_surface_action", params, socket) do def handle_event("chat_surface_action", params, socket) do
{:noreply, {:noreply,
ChatSurface.handle_action(socket, params, %{ ChatSurface.handle_action(socket, params, %{

View File

@@ -59,6 +59,16 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec dismiss_surface(term(), term(), term()) :: term()
def dismiss_surface(socket, surface_id, reload) when is_binary(surface_id) do
socket
|> assign(
:chat_editor_dismissed_surfaces,
MapSet.put(socket.assigns.chat_editor_dismissed_surfaces, surface_id)
)
|> reload.(socket.assigns.workbench)
end
@spec current_surface_data(term(), term()) :: term() @spec current_surface_data(term(), term()) :: term()
def current_surface_data(socket, surface_id) when is_binary(surface_id) do def current_surface_data(socket, surface_id) when is_binary(surface_id) do
Map.get(socket.assigns.chat_editor_surface_data, surface_id, %{}) Map.get(socket.assigns.chat_editor_surface_data, surface_id, %{})
@@ -314,13 +324,23 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
<%= if @markers != [] do %> <%= if @markers != [] do %>
<div class="chat-tool-markers"> <div class="chat-tool-markers">
<%= for marker <- @markers do %> <%= for marker <- @markers do %>
<div class={["chat-tool-marker", if(marker.complete?, do: "completed", else: "pending")]} data-testid="chat-tool-marker"> <details class={["chat-tool-marker", if(marker.complete?, do: "completed", else: "pending")]} data-testid="chat-tool-marker">
<span class="chat-tool-marker-icon"><%= if marker.complete?, do: "✓", else: "●" %></span> <summary>
<span class="chat-tool-marker-name"><%= marker.name %></span> <span class="chat-tool-marker-icon"><%= if marker.complete?, do: "✓", else: "●" %></span>
<%= if marker.args_preview not in [nil, ""] do %> <span class="chat-tool-marker-name"><%= marker.name %></span>
<span class="chat-tool-marker-args">(<%= marker.args_preview %>)</span> <%= if marker.args_preview not in [nil, ""] do %>
<% end %> <span class="chat-tool-marker-args">(<%= marker.args_preview %>)</span>
</div> <% end %>
</summary>
<div class="chat-tool-marker-details" data-testid="chat-tool-marker-details">
<div class="chat-tool-marker-detail-label"><%= translated("chat.toolArguments") %></div>
<pre><%= Jason.encode!(marker.arguments || %{}, pretty: true) %></pre>
<%= if marker.result not in [nil, ""] do %>
<div class="chat-tool-marker-detail-label"><%= translated("chat.toolResult") %></div>
<pre><%= marker.result %></pre>
<% end %>
</div>
</details>
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
@@ -332,7 +352,13 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
@spec chat_surface(term()) :: term() @spec chat_surface(term()) :: term()
def chat_surface(assigns) do def chat_surface(assigns) do
~H""" ~H"""
<article class={["chat-inline-surface", "chat-inline-surface-#{@surface.type}"]} data-testid="chat-inline-surface"> <details id={@surface.id} class={["chat-inline-surface", "chat-inline-surface-#{@surface.type}"]} data-testid="chat-inline-surface" data-expanded={Map.get(@surface, :expanded?, false)} open={Map.get(@surface, :expanded?, false)}>
<summary class="chat-inline-surface-header">
<span class="chat-inline-surface-icon"><%= surface_icon(@surface.type) %></span>
<span class="chat-inline-surface-title"><%= surface_title(@surface) %></span>
<button class="chat-inline-surface-dismiss" type="button" phx-click="dismiss_chat_surface" phx-value-surface-id={@surface.id} aria-label={translated("chat.dismissSurface")} data-testid="chat-inline-surface-dismiss">×</button>
</summary>
<div class="chat-inline-surface-body">
<%= case @surface.type do %> <%= case @surface.type do %>
<% "card" -> %> <% "card" -> %>
<div class="chat-surface-card"> <div class="chat-surface-card">
@@ -526,10 +552,28 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
<% _other -> %> <% _other -> %>
<pre class="chat-tool-surface-json"><%= Jason.encode!(@surface.raw || %{}, pretty: true) %></pre> <pre class="chat-tool-surface-json"><%= Jason.encode!(@surface.raw || %{}, pretty: true) %></pre>
<% end %> <% end %>
</article> </div>
</details>
""" """
end end
defp surface_icon("chart"), do: ""
defp surface_icon("table"), do: ""
defp surface_icon("form"), do: ""
defp surface_icon("card"), do: ""
defp surface_icon("metric"), do: "#"
defp surface_icon("list"), do: ""
defp surface_icon("tabs"), do: ""
defp surface_icon(_type), do: ""
defp surface_title(surface) do
cond do
present?(Map.get(surface, :title)) -> Map.get(surface, :title)
present?(Map.get(surface, :label)) -> Map.get(surface, :label)
true -> surface.type |> to_string() |> String.capitalize()
end
end
# ── Private helpers ─────────────────────────────────────────────────────── # ── Private helpers ───────────────────────────────────────────────────────
defp update_request(socket, conversation_id, updater, reload) do defp update_request(socket, conversation_id, updater, reload) do

View File

@@ -15,12 +15,14 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
%ChatConversation{} = conversation -> %ChatConversation{} = conversation ->
messages = AI.list_chat_messages(conversation.id) messages = AI.list_chat_messages(conversation.id)
request = Map.get(assigns.chat_editor_requests, conversation.id) request = Map.get(assigns.chat_editor_requests, conversation.id)
available_models = AI.available_chat_models(conversation.model) effective_model = AI.effective_chat_model(conversation)
available_models = AI.available_chat_models(effective_model)
%{ %{
id: conversation.id, id: conversation.id,
title: conversation.title || translated("chat.newChat"), title: conversation.title || translated("chat.newChat"),
model: conversation.model, model: conversation.model,
effective_model: effective_model,
available_models: available_models, available_models: available_models,
available_model_groups: ModelSelection.group_available_models(available_models), available_model_groups: ModelSelection.group_available_models(available_models),
model_selector_open?: model_selector_open?:
@@ -31,6 +33,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
is_streaming: not is_nil(request), is_streaming: not is_nil(request),
streaming_content: streaming_content(request), streaming_content: streaming_content(request),
streaming_tool_markers: ToolTracking.tool_markers_from_events(request), streaming_tool_markers: ToolTracking.tool_markers_from_events(request),
streaming_inline_surfaces: streaming_inline_surfaces(conversation.id, request, assigns),
offline?: Map.get(assigns, :offline_mode, true), offline?: Map.get(assigns, :offline_mode, true),
needs_api_key?: ModelSelection.needs_api_key?(Map.get(assigns, :offline_mode, true)), needs_api_key?: ModelSelection.needs_api_key?(Map.get(assigns, :offline_mode, true)),
action_error: Map.get(assigns.chat_editor_action_errors, conversation.id), action_error: Map.get(assigns.chat_editor_action_errors, conversation.id),
@@ -49,7 +52,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
case message.role do case message.role do
:tool -> :tool ->
if current_entry && current_entry.role == :assistant do if current_entry && current_entry.role == :assistant do
{entries, append_tool_surface(current_entry, message), turn_index} {entries, append_tool_result(current_entry, message), turn_index}
else else
{entries, current_entry, turn_index} {entries, current_entry, turn_index}
end end
@@ -95,18 +98,15 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
content: message.content || "", content: message.content || "",
turn_index: turn_index, turn_index: turn_index,
tool_markers: tool_markers, tool_markers: tool_markers,
inline_surfaces: ToolSurfaces.build_render_surfaces(tool_markers, message.id, assigns), inline_surfaces:
ToolSurfaces.build_render_surfaces(tool_markers, message.id, assigns)
|> mark_surfaces_expanded(assigns),
tool_surfaces: [] tool_surfaces: []
} }
end end
defp append_tool_surface(entry, message) do defp append_tool_result(entry, message) do
entry = ToolTracking.mark_tool_call_completed(entry, message.tool_call_id) ToolTracking.mark_tool_call_completed(entry, message.tool_call_id, message.content)
case ToolSurfaces.normalize_tool_surface(message.content) do
nil -> entry
surface -> update_in(entry.tool_surfaces, &(&1 ++ [surface]))
end
end end
defp tool_only_assistant_entry?(%{role: :assistant, content: content} = entry) do defp tool_only_assistant_entry?(%{role: :assistant, content: content} = entry) do
@@ -125,6 +125,16 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
} }
end end
defp mark_surfaces_expanded([], _assigns), do: []
defp mark_surfaces_expanded(surfaces, assigns) do
dismissed = Map.get(assigns, :chat_editor_dismissed_surfaces, MapSet.new())
surfaces
|> Enum.reject(&MapSet.member?(dismissed, &1.id))
|> Enum.map(&Map.put(&1, :expanded?, true))
end
defp pending_user_message(_messages, nil), do: nil defp pending_user_message(_messages, nil), do: nil
defp pending_user_message(messages, %{message: message}) when is_binary(message) do defp pending_user_message(messages, %{message: message}) when is_binary(message) do
@@ -140,6 +150,15 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
defp streaming_content(%{content: content}) when is_binary(content), do: content defp streaming_content(%{content: content}) when is_binary(content), do: content
defp streaming_content(_request), do: "" defp streaming_content(_request), do: ""
defp streaming_inline_surfaces(_conversation_id, nil, _assigns), do: []
defp streaming_inline_surfaces(conversation_id, request, assigns) do
request
|> ToolTracking.tool_markers_from_events()
|> ToolSurfaces.build_render_surfaces("streaming-#{conversation_id}", assigns)
|> mark_surfaces_expanded(assigns)
end
defp translated(text, bindings \\ %{}), defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end end

View File

@@ -22,6 +22,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
name: tool_call_name(tool_call), name: tool_call_name(tool_call),
arguments: arguments, arguments: arguments,
args_preview: tool_arguments_preview(arguments), args_preview: tool_arguments_preview(arguments),
result: nil,
complete?: false complete?: false
} }
end) end)
@@ -39,11 +40,19 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
@spec tool_arguments_preview(term()) :: term() @spec tool_arguments_preview(term()) :: term()
def tool_arguments_preview(_arguments), do: "" def tool_arguments_preview(_arguments), do: ""
@spec mark_tool_call_completed(term(), term()) :: term()
def mark_tool_call_completed(entry, tool_call_id) when is_binary(tool_call_id) do def mark_tool_call_completed(entry, tool_call_id) when is_binary(tool_call_id) do
mark_tool_call_completed(entry, tool_call_id, nil)
end
def mark_tool_call_completed(entry, _tool_call_id), do: entry
@spec mark_tool_call_completed(term(), term(), term()) :: term()
def mark_tool_call_completed(entry, tool_call_id, result) when is_binary(tool_call_id) do
update_in(entry.tool_markers, fn markers -> update_in(entry.tool_markers, fn markers ->
Enum.map(markers, fn marker -> Enum.map(markers, fn marker ->
if marker.id == tool_call_id do if marker.id == tool_call_id do
%{marker | complete?: true} %{marker | complete?: true, result: result}
else else
marker marker
end end
@@ -51,8 +60,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
end) end)
end end
@spec mark_tool_call_completed(term(), term()) :: term() def mark_tool_call_completed(entry, _tool_call_id, _result), do: entry
def mark_tool_call_completed(entry, _tool_call_id), do: entry
@spec tool_markers_from_events(term()) :: term() @spec tool_markers_from_events(term()) :: term()
def tool_markers_from_events(nil), do: [] def tool_markers_from_events(nil), do: []
@@ -68,6 +76,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
name: event.name, name: event.name,
arguments: event.arguments, arguments: event.arguments,
args_preview: tool_arguments_preview(event.arguments || %{}), args_preview: tool_arguments_preview(event.arguments || %{}),
result: nil,
complete?: false complete?: false
} }
] ]

View File

@@ -17,7 +17,7 @@
phx-click="toggle_chat_model_selector" phx-click="toggle_chat_model_selector"
data-testid="chat-model-selector-button" data-testid="chat-model-selector-button"
> >
<span><%= @chat_editor.model || translated("chat.newChat") %></span> <span><%= @chat_editor.effective_model || translated("chat.modelUnavailable") %></span>
<span class="chat-model-selector-caret">▾</span> <span class="chat-model-selector-caret">▾</span>
</button> </button>
@@ -33,7 +33,7 @@
<button <button
class={[ class={[
"chat-model-selector-option", "chat-model-selector-option",
if(model.id == @chat_editor.model, do: "active") if(model.id == @chat_editor.effective_model, do: "active")
]} ]}
type="button" type="button"
phx-click="select_chat_model" phx-click="select_chat_model"
@@ -109,40 +109,6 @@
<.chat_surface surface={surface} /> <.chat_surface surface={surface} />
<% end %> <% end %>
<%= for surface <- message.tool_surfaces do %>
<article class="chat-tool-surface" data-testid="chat-tool-surface">
<%= if surface.title do %>
<h3><%= surface.title %></h3>
<% end %>
<%= case tool_surface_type(surface) do %>
<% "table" -> %>
<div class="chat-tool-surface-table-wrap">
<table class="chat-tool-surface-table">
<thead>
<tr>
<%= for column <- surface.columns do %>
<th><%= column %></th>
<% end %>
</tr>
</thead>
<tbody>
<%= for row <- surface.rows do %>
<tr>
<%= for value <- row do %>
<td><%= value %></td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
</div>
<% _other -> %>
<pre class="chat-tool-surface-json"><%= Jason.encode!(surface.data, pretty: true) %></pre>
<% end %>
</article>
<% end %>
<% end %> <% end %>
<%= if @chat_editor.is_streaming and (@chat_editor.streaming_content != "" or @chat_editor.streaming_tool_markers != []) do %> <%= if @chat_editor.is_streaming and (@chat_editor.streaming_content != "" or @chat_editor.streaming_tool_markers != []) do %>
@@ -160,6 +126,10 @@
<% end %> <% end %>
</div> </div>
</div> </div>
<%= for surface <- @chat_editor.streaming_inline_surfaces do %>
<.chat_surface surface={surface} />
<% end %>
<% end %> <% end %>
<%= if @chat_editor.is_streaming and @chat_editor.streaming_content == "" and @chat_editor.streaming_tool_markers == [] do %> <%= if @chat_editor.is_streaming and @chat_editor.streaming_content == "" and @chat_editor.streaming_tool_markers == [] do %>

View File

@@ -353,7 +353,7 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
<%= if Enum.any?(Map.get(@sidebar_data, :items, [])) do %> <%= if Enum.any?(Map.get(@sidebar_data, :items, [])) do %>
<div class={if(template_sidebar?(@sidebar_data), do: "chat-list-items", else: "settings-nav-list")}> <div class={if(template_sidebar?(@sidebar_data), do: "chat-list-items", else: "settings-nav-list")}>
<%= for item <- Map.get(@sidebar_data, :items, []) do %> <%= for item <- Map.get(@sidebar_data, :items, []) do %>
<%= if item.route == "templates" do %> <%= if item.route in ["templates", "chat"] do %>
<div <div
class={["chat-list-item", if(sidebar_item_selected?(@workbench, item.route, item.id), do: "active")]} class={["chat-list-item", if(sidebar_item_selected?(@workbench, item.route, item.id), do: "active")]}
data-item-id={item.id} data-item-id={item.id}
@@ -379,11 +379,11 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
</button> </button>
<button <button
class="chat-item-delete" class="chat-item-delete"
data-testid="sidebar-delete-template" data-testid={if(item.route == "chat", do: "sidebar-delete-chat", else: "sidebar-delete-template")}
data-item-id={item.id} data-item-id={item.id}
type="button" type="button"
title={translated("Delete") <> " " <> translated("Template")} title={if(item.route == "chat", do: translated("sidebar.chat.deleteConversation"), else: translated("Delete") <> " " <> translated("Template"))}
phx-click="delete_sidebar_template" phx-click={if(item.route == "chat", do: "delete_sidebar_chat", else: "delete_sidebar_template")}
phx-value-id={item.id} phx-value-id={item.id}
> >
× ×

View File

@@ -107,8 +107,12 @@
"chat.welcomeTipTabs": "Beitragsstatistiken pro Jahr in Tabs mit Diagrammen", "chat.welcomeTipTabs": "Beitragsstatistiken pro Jahr in Tabs mit Diagrammen",
"chat.role.you": "Du", "chat.role.you": "Du",
"chat.role.assistant": "Assistent", "chat.role.assistant": "Assistent",
"chat.modelUnavailable": "Kein Modell",
"chat.inputPlaceholder": "Nachricht eingeben...", "chat.inputPlaceholder": "Nachricht eingeben...",
"chat.stop": "Stopp", "chat.stop": "Stopp",
"chat.toolArguments": "Argumente",
"chat.toolResult": "Ergebnis",
"chat.dismissSurface": "Ansicht schließen",
"chat.cancelledSuffix": "(abgebrochen)", "chat.cancelledSuffix": "(abgebrochen)",
"gitDiff.changedFiles": "Geänderte Dateien", "gitDiff.changedFiles": "Geänderte Dateien",
"sidebar.tags": "Schlagwörter", "sidebar.tags": "Schlagwörter",
@@ -118,6 +122,7 @@
"sidebar.noPostsYet": "Noch keine Beiträge", "sidebar.noPostsYet": "Noch keine Beiträge",
"sidebar.noPagesYet": "Noch keine Seiten", "sidebar.noPagesYet": "Noch keine Seiten",
"sidebar.noMediaYet": "Noch keine Medien", "sidebar.noMediaYet": "Noch keine Medien",
"sidebar.chat.deleteConversation": "Unterhaltung löschen",
"sidebar.search": "Suchen", "sidebar.search": "Suchen",
"sidebar.searchPostsPlaceholder": "Beiträge durchsuchen...", "sidebar.searchPostsPlaceholder": "Beiträge durchsuchen...",
"sidebar.searchPagesPlaceholder": "Seiten durchsuchen...", "sidebar.searchPagesPlaceholder": "Seiten durchsuchen...",

View File

@@ -107,8 +107,12 @@
"chat.welcomeTipTabs": "Show post statistics by year in tabs with charts", "chat.welcomeTipTabs": "Show post statistics by year in tabs with charts",
"chat.role.you": "You", "chat.role.you": "You",
"chat.role.assistant": "Assistant", "chat.role.assistant": "Assistant",
"chat.modelUnavailable": "No model",
"chat.inputPlaceholder": "Type a message...", "chat.inputPlaceholder": "Type a message...",
"chat.stop": "Stop", "chat.stop": "Stop",
"chat.toolArguments": "Arguments",
"chat.toolResult": "Result",
"chat.dismissSurface": "Dismiss surface",
"chat.cancelledSuffix": "(cancelled)", "chat.cancelledSuffix": "(cancelled)",
"gitDiff.changedFiles": "Changed files", "gitDiff.changedFiles": "Changed files",
"sidebar.tags": "Tags", "sidebar.tags": "Tags",
@@ -118,6 +122,7 @@
"sidebar.noPostsYet": "No posts yet", "sidebar.noPostsYet": "No posts yet",
"sidebar.noPagesYet": "No pages yet", "sidebar.noPagesYet": "No pages yet",
"sidebar.noMediaYet": "No media yet", "sidebar.noMediaYet": "No media yet",
"sidebar.chat.deleteConversation": "Delete conversation",
"sidebar.search": "Search", "sidebar.search": "Search",
"sidebar.searchPostsPlaceholder": "Search posts...", "sidebar.searchPostsPlaceholder": "Search posts...",
"sidebar.searchPagesPlaceholder": "Search pages...", "sidebar.searchPagesPlaceholder": "Search pages...",

View File

@@ -107,8 +107,12 @@
"chat.welcomeTipTabs": "Muestre estadísticas por año en pestañas con gráficos", "chat.welcomeTipTabs": "Muestre estadísticas por año en pestañas con gráficos",
"chat.role.you": "Tú", "chat.role.you": "Tú",
"chat.role.assistant": "Asistente", "chat.role.assistant": "Asistente",
"chat.modelUnavailable": "Sin modelo",
"chat.inputPlaceholder": "Escribe un mensaje...", "chat.inputPlaceholder": "Escribe un mensaje...",
"chat.stop": "Detener", "chat.stop": "Detener",
"chat.toolArguments": "Argumentos",
"chat.toolResult": "Resultado",
"chat.dismissSurface": "Cerrar superficie",
"chat.cancelledSuffix": "(cancelado)", "chat.cancelledSuffix": "(cancelado)",
"gitDiff.changedFiles": "Archivos modificados", "gitDiff.changedFiles": "Archivos modificados",
"sidebar.tags": "Etiquetas", "sidebar.tags": "Etiquetas",
@@ -118,6 +122,7 @@
"sidebar.noPostsYet": "Aún no hay entradas", "sidebar.noPostsYet": "Aún no hay entradas",
"sidebar.noPagesYet": "Aún no hay páginas", "sidebar.noPagesYet": "Aún no hay páginas",
"sidebar.noMediaYet": "Aún no hay medios", "sidebar.noMediaYet": "Aún no hay medios",
"sidebar.chat.deleteConversation": "Eliminar conversación",
"sidebar.search": "Buscar", "sidebar.search": "Buscar",
"sidebar.searchPostsPlaceholder": "Buscar entradas...", "sidebar.searchPostsPlaceholder": "Buscar entradas...",
"sidebar.searchPagesPlaceholder": "Buscar páginas...", "sidebar.searchPagesPlaceholder": "Buscar páginas...",

View File

@@ -107,8 +107,12 @@
"chat.welcomeTipTabs": "Afficher les statistiques par année dans des onglets avec graphiques", "chat.welcomeTipTabs": "Afficher les statistiques par année dans des onglets avec graphiques",
"chat.role.you": "Vous", "chat.role.you": "Vous",
"chat.role.assistant": "Assistant IA", "chat.role.assistant": "Assistant IA",
"chat.modelUnavailable": "Aucun modèle",
"chat.inputPlaceholder": "Saisissez un message...", "chat.inputPlaceholder": "Saisissez un message...",
"chat.stop": "Arrêter", "chat.stop": "Arrêter",
"chat.toolArguments": "Arguments",
"chat.toolResult": "Résultat",
"chat.dismissSurface": "Fermer la surface",
"chat.cancelledSuffix": "(annulé)", "chat.cancelledSuffix": "(annulé)",
"gitDiff.changedFiles": "Fichiers modifiés", "gitDiff.changedFiles": "Fichiers modifiés",
"sidebar.tags": "Étiquettes", "sidebar.tags": "Étiquettes",
@@ -118,6 +122,7 @@
"sidebar.noPostsYet": "Aucun article pour le moment", "sidebar.noPostsYet": "Aucun article pour le moment",
"sidebar.noPagesYet": "Aucune page pour le moment", "sidebar.noPagesYet": "Aucune page pour le moment",
"sidebar.noMediaYet": "Aucun média pour le moment", "sidebar.noMediaYet": "Aucun média pour le moment",
"sidebar.chat.deleteConversation": "Supprimer la conversation",
"sidebar.search": "Rechercher", "sidebar.search": "Rechercher",
"sidebar.searchPostsPlaceholder": "Rechercher des articles...", "sidebar.searchPostsPlaceholder": "Rechercher des articles...",
"sidebar.searchPagesPlaceholder": "Rechercher des pages...", "sidebar.searchPagesPlaceholder": "Rechercher des pages...",

View File

@@ -107,8 +107,12 @@
"chat.welcomeTipTabs": "Mostrare statistiche per anno in schede con grafici", "chat.welcomeTipTabs": "Mostrare statistiche per anno in schede con grafici",
"chat.role.you": "Tu", "chat.role.you": "Tu",
"chat.role.assistant": "Assistente", "chat.role.assistant": "Assistente",
"chat.modelUnavailable": "Nessun modello",
"chat.inputPlaceholder": "Scrivi un messaggio...", "chat.inputPlaceholder": "Scrivi un messaggio...",
"chat.stop": "Ferma", "chat.stop": "Ferma",
"chat.toolArguments": "Argomenti",
"chat.toolResult": "Risultato",
"chat.dismissSurface": "Chiudi superficie",
"chat.cancelledSuffix": "(annullato)", "chat.cancelledSuffix": "(annullato)",
"gitDiff.changedFiles": "File modificati", "gitDiff.changedFiles": "File modificati",
"sidebar.tags": "Tag", "sidebar.tags": "Tag",
@@ -118,6 +122,7 @@
"sidebar.noPostsYet": "Nessun post", "sidebar.noPostsYet": "Nessun post",
"sidebar.noPagesYet": "Nessuna pagina", "sidebar.noPagesYet": "Nessuna pagina",
"sidebar.noMediaYet": "Nessun media", "sidebar.noMediaYet": "Nessun media",
"sidebar.chat.deleteConversation": "Elimina conversazione",
"sidebar.search": "Cerca", "sidebar.search": "Cerca",
"sidebar.searchPostsPlaceholder": "Cerca post...", "sidebar.searchPostsPlaceholder": "Cerca post...",
"sidebar.searchPagesPlaceholder": "Cerca pagine...", "sidebar.searchPagesPlaceholder": "Cerca pagine...",

View File

@@ -3565,7 +3565,7 @@ button svg * {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
overflow: hidden; overflow: visible;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
color: var(--vscode-foreground, inherit); color: var(--vscode-foreground, inherit);
@@ -5145,7 +5145,7 @@ button svg * {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
overflow: hidden; overflow: visible;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
color: var(--vscode-foreground, inherit); color: var(--vscode-foreground, inherit);
@@ -5558,13 +5558,45 @@ button svg * {
} }
.chat-tool-marker { .chat-tool-marker {
font-size: 12px;
color: var(--vscode-descriptionForeground, inherit);
}
.chat-tool-marker summary {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
font-size: 12px; cursor: pointer;
list-style: none;
}
.chat-tool-marker summary::-webkit-details-marker {
display: none;
}
.chat-tool-marker-details {
margin: 6px 0 2px 20px;
padding: 8px;
border: 1px solid var(--vscode-editorGroup-border, var(--line, #3c3c3c));
border-radius: 6px;
background-color: var(--vscode-editor-background, rgba(0, 0, 0, 0.18));
}
.chat-tool-marker-detail-label {
margin: 0 0 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: var(--vscode-descriptionForeground, inherit); color: var(--vscode-descriptionForeground, inherit);
} }
.chat-tool-marker-details pre {
margin: 0 0 8px;
white-space: pre-wrap;
overflow-wrap: anywhere;
font: 11px/1.45 "SFMono-Regular", Menlo, Monaco, Consolas, monospace;
}
.chat-tool-marker.completed .chat-tool-marker-icon { .chat-tool-marker.completed .chat-tool-marker-icon {
color: var(--vscode-testing-iconPassed, #89d185); color: var(--vscode-testing-iconPassed, #89d185);
} }
@@ -5581,13 +5613,60 @@ button svg * {
.chat-tool-surface { .chat-tool-surface {
width: min(720px, calc(100% - 44px)); width: min(720px, calc(100% - 44px));
margin-left: 44px; margin-left: 44px;
padding: 14px;
box-sizing: border-box; box-sizing: border-box;
border: 1px solid var(--vscode-editorGroup-border, var(--line, #3c3c3c)); border: 1px solid var(--vscode-editorGroup-border, var(--line, #3c3c3c));
border-radius: 12px; border-radius: 12px;
background-color: var(--vscode-sideBar-background, var(--panel-2, #252526)); background-color: var(--vscode-sideBar-background, var(--panel-2, #252526));
} }
.chat-inline-surface {
overflow: hidden;
}
.chat-inline-surface-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
list-style: none;
background-color: var(--vscode-textBlockQuote-background, rgba(127, 127, 127, 0.1));
}
.chat-inline-surface-header::-webkit-details-marker {
display: none;
}
.chat-inline-surface-title {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 600;
}
.chat-inline-surface-dismiss {
border: 0;
border-radius: 4px;
padding: 2px 7px;
background: transparent;
color: var(--vscode-descriptionForeground, inherit);
cursor: pointer;
font-size: 15px;
line-height: 1.2;
}
.chat-inline-surface-dismiss:hover {
background-color: var(--vscode-toolbar-hoverBackground, rgba(255, 255, 255, 0.08));
color: var(--vscode-foreground, inherit);
}
.chat-inline-surface-body {
padding: 14px;
}
.chat-inline-surface h3, .chat-inline-surface h3,
.chat-tool-surface h3 { .chat-tool-surface h3 {
margin: 0 0 12px; margin: 0 0 12px;

View File

@@ -780,6 +780,14 @@ document.addEventListener("DOMContentLoaded", () => {
} }
}; };
this.syncExpandedSurfaces = () => {
this.el
.querySelectorAll(".chat-inline-surface[data-expanded='true']")
.forEach((surface) => {
surface.open = true;
});
};
this.handleScroll = () => { this.handleScroll = () => {
if (!this.scrollContainer) { if (!this.scrollContainer) {
this.stickToBottom = true; this.stickToBottom = true;
@@ -823,12 +831,14 @@ document.addEventListener("DOMContentLoaded", () => {
this.el.addEventListener("keydown", this.handleKeyDown); this.el.addEventListener("keydown", this.handleKeyDown);
this.syncScrollContainer(); this.syncScrollContainer();
this.syncExpandedSurfaces();
this.autoResize(); this.autoResize();
window.requestAnimationFrame(() => this.scrollToBottom(true)); window.requestAnimationFrame(() => this.scrollToBottom(true));
}, },
updated() { updated() {
this.syncScrollContainer(); this.syncScrollContainer();
this.syncExpandedSurfaces();
this.autoResize(); this.autoResize();
window.requestAnimationFrame(() => this.scrollToBottom()); window.requestAnimationFrame(() => this.scrollToBottom());
}, },

View File

@@ -168,6 +168,9 @@ defmodule BDS.AITest do
usage: usage(31, 8, 0, 0) usage: usage(31, 8, 0, 0)
}} }}
end end
:chat_title ->
{:ok, %{content: "Blog Stats", usage: usage(12, 3, 0, 0)}}
end end
end end
@@ -615,6 +618,46 @@ defmodule BDS.AITest do
end) end)
end end
test "chat generates a short title after the first user turn using the title model" do
{:ok, project} = create_project_fixture("Title Chat")
_fixtures = seed_project_content(project.id)
assert {:ok, _endpoint} =
BDS.AI.put_endpoint(
:online,
%{
url: "https://api.example.test/v1",
api_key: "online-secret",
model: "gpt-4o-mini"
},
secret_backend: FakeSecretBackend
)
assert :ok = BDS.AI.set_airplane_mode(false)
assert :ok = BDS.AI.put_model_preference(:title, "title-model")
assert {:ok, conversation} = BDS.AI.start_chat(%{model: "gpt-4o-mini"})
assert {:ok, reply} =
BDS.AI.send_chat_message(conversation.id, "How many items are in the blog?",
runtime: FakeRuntime,
test_pid: self(),
project_id: project.id,
secret_backend: FakeSecretBackend
)
assert reply.conversation.title == "Blog Stats"
assert BDS.AI.get_chat_conversation(conversation.id).title == "Blog Stats"
assert_received {:runtime_request, _endpoint, %{operation: :chat}}
assert_received {:runtime_request, _endpoint, %{operation: :chat}}
assert_received {:runtime_request, _endpoint, title_request}
assert title_request.operation == :chat_title
assert title_request.model == "title-model"
assert Enum.any?(title_request.messages, &(&1["content"] =~ "2-3 words"))
assert Enum.any?(title_request.messages, &(&1["content"] =~ "How many items"))
end
test "chat does not prompt models to emit textual tool calls when tools are unavailable" do test "chat does not prompt models to emit textual tool calls when tools are unavailable" do
{:ok, project} = create_project_fixture("No Tool Chat") {:ok, project} = create_project_fixture("No Tool Chat")
_fixtures = seed_project_content(project.id) _fixtures = seed_project_content(project.id)

View File

@@ -248,6 +248,17 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-tab-type="chat") assert html =~ ~s(data-tab-type="chat")
assert html =~ ~s(data-tab-id="#{created_chat.id}") assert html =~ ~s(data-tab-id="#{created_chat.id}")
html = render_click(view, "select_view", %{"view" => "chat"})
assert html =~ ~s(data-testid="sidebar-delete-chat")
html =
view
|> element("[data-testid='sidebar-delete-chat'][data-item-id='#{created_chat.id}']")
|> render_click()
refute Repo.get(BDS.AI.ChatConversation, created_chat.id)
refute 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 =
@@ -2149,7 +2160,50 @@ defmodule BDS.Desktop.ShellLiveTest do
assert css =~ "position: static;" assert css =~ "position: static;"
end end
test "chat editor renders legacy model controls, tool markers, and structured tool surfaces" do test "chat editor model selector uses effective model for new chats and persists selection" do
assert :ok = AI.set_airplane_mode(true)
assert {:ok, _endpoint} =
AI.put_endpoint(:airplane, %{
url: "http://localhost:11434/v1",
api_key: nil,
model: "llama-default"
})
assert :ok = AI.put_model_preference(:airplane_chat, "llama-current")
assert {:ok, conversation} = AI.start_chat(%{title: "New Chat"})
{: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" => "chat"
})
assert html =~ ~s(data-testid="chat-model-selector-button")
assert html =~ "llama-current"
refute html =~ ~s(<span>New Chat</span><span class="chat-model-selector-caret">▾</span>)
selector_html = render_click(view, "toggle_chat_model_selector", %{})
assert selector_html =~ ~s(class="chat-model-selector-menu")
assert selector_html =~ ~s(data-testid="chat-model-selector-option")
assert selector_html =~ "llama-current"
css = File.read!(Path.expand("../../../priv/ui/app.css", __DIR__))
assert css =~ ".chat-panel-title {"
assert css =~ "overflow: visible;"
refute css =~ ".chat-panel-title {\n flex: 1;\n min-width: 0;\n display: flex;\n align-items: center;\n gap: 10px;\n overflow: hidden;"
render_click(view, "select_chat_model", %{"model" => "llama-next"})
assert AI.get_chat_conversation(conversation.id).model == "llama-next"
assert render(view) =~ "llama-next"
end
test "chat editor renders legacy model controls, collapsed tool pills, and dismissible A2UI 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"})
now = Persistence.now_ms() now = Persistence.now_ms()
@@ -2209,11 +2263,92 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-testid="chat-model-selector-button") assert html =~ ~s(data-testid="chat-model-selector-button")
assert html =~ "gpt-4.1" assert html =~ "gpt-4.1"
assert html =~ ~s(data-testid="chat-tool-marker") assert html =~ ~s(data-testid="chat-tool-marker")
assert html =~ ~s(data-testid="chat-tool-marker-details")
assert html =~ "render_table" assert html =~ "render_table"
assert html =~ ~s(data-testid="chat-tool-surface") refute html =~ ~s(data-testid="chat-tool-surface")
assert html =~ ~s(data-testid="chat-inline-surface")
assert html =~ ~s(data-testid="chat-inline-surface-dismiss")
assert html =~ "Blog Stats" assert html =~ "Blog Stats"
assert html =~ "Metric" assert html =~ "Metric"
assert html =~ "Posts" assert html =~ "Posts"
dismissed_html =
render_click(view, "dismiss_chat_surface", %{
"surface-id" => Regex.run(~r/id="([^"]+-surface-0)"/, html) |> Enum.at(1)
})
refute dismissed_html =~ ~s(data-testid="chat-inline-surface")
end
test "chat editor keeps every non-dismissed A2UI surface expanded" do
assert {:ok, conversation} = AI.start_chat(%{title: "Surface 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: "Show two updates",
created_at: now
})
)
Repo.insert!(
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
conversation_id: conversation.id,
role: :assistant,
content: "Here are the updates.",
tool_calls:
Jason.encode!([
%{
"id" => "call-card-old",
"name" => "render_card",
"arguments" => %{
"title" => "Earlier Missing Data",
"body" => "The first data request needs review."
}
},
%{
"id" => "call-card-new",
"name" => "render_card",
"arguments" => %{
"title" => "Latest Missing Data",
"body" => "The second data request needs review."
}
}
]),
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 length(:binary.matches(html, ~s(data-testid="chat-inline-surface"))) == 2
assert length(:binary.matches(html, "data-expanded")) == 2
assert html =~ "Earlier Missing Data"
assert html =~ "The first data request needs review."
assert html =~ "Latest Missing Data"
assert html =~ "The second data request needs review."
end
test "chat editor hook reopens server-expanded A2UI surfaces after patches" do
live_js = File.read!(Path.expand("../../../priv/ui/live.js", __DIR__))
chat_editor = File.read!(Path.expand("../../../lib/bds/desktop/shell_live/chat_editor.ex", __DIR__))
assert chat_editor =~ "data-expanded={Map.get(@surface, :expanded?, false)}"
assert live_js =~ "this.syncExpandedSurfaces = () =>"
assert live_js =~ "querySelectorAll(\".chat-inline-surface[data-expanded='true']\")"
assert live_js =~ "surface.open = true;"
assert live_js =~ "this.syncExpandedSurfaces();"
end end
test "chat editor folds tool-only assistant steps into the final assistant answer" do test "chat editor folds tool-only assistant steps into the final assistant answer" do
@@ -2647,6 +2782,25 @@ defmodule BDS.Desktop.ShellLiveTest do
assert assistant_index < user_index assert assistant_index < user_index
assert html =~ ~r/<textarea[^>]*class="chat-input chat-surface-input"[^>]*disabled/ assert html =~ ~r/<textarea[^>]*class="chat-input chat-surface-input"[^>]*disabled/
send(view.pid, {
:chat_tool_call,
conversation.id,
%{
id: "call-streaming-chart",
name: "render_chart",
arguments: %{
"title" => "Streaming Chart",
"chartType" => "bar",
"series" => [%{"label" => "Posts", "value" => 3}]
}
}
})
html = render(view)
assert html =~ ~s(data-testid="chat-streaming-message")
assert html =~ ~s(data-testid="chat-inline-surface")
assert html =~ "Streaming Chart"
html = html =
view view
|> element("[data-testid='chat-abort-button']") |> element("[data-testid='chat-abort-button']")