fix: more work on A2UI

This commit is contained in:
2026-05-01 22:22:59 +02:00
parent 391a7f216f
commit a17c549817
12 changed files with 228 additions and 59 deletions

View File

@@ -135,6 +135,7 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:chat_editor_request_refs, %{})
|> assign(:chat_editor_surface_data, %{})
|> assign(:chat_editor_surface_tabs, %{})
|> assign(:chat_editor_dismissed_surfaces, MapSet.new())
|> assign(:chat_editor_action_errors, %{})
|> assign(:import_editor_analysis_states, %{})
|> assign(:import_editor_analysis_task_refs, %{})
@@ -948,6 +949,10 @@ defmodule BDS.Desktop.ShellLive do
ChatEditor.select_surface_tab(socket, surface_id, parse_integer(index), &reload_shell/2)}
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
{:noreply,
ChatSurface.handle_action(socket, params, %{

View File

@@ -59,6 +59,16 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|> reload.(socket.assigns.workbench)
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()
def current_surface_data(socket, surface_id) when is_binary(surface_id) do
Map.get(socket.assigns.chat_editor_surface_data, surface_id, %{})
@@ -314,13 +324,23 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
<%= if @markers != [] do %>
<div class="chat-tool-markers">
<%= for marker <- @markers do %>
<div 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>
<span class="chat-tool-marker-name"><%= marker.name %></span>
<%= if marker.args_preview not in [nil, ""] do %>
<span class="chat-tool-marker-args">(<%= marker.args_preview %>)</span>
<% end %>
</div>
<details class={["chat-tool-marker", if(marker.complete?, do: "completed", else: "pending")]} data-testid="chat-tool-marker">
<summary>
<span class="chat-tool-marker-icon"><%= if marker.complete?, do: "✓", else: "●" %></span>
<span class="chat-tool-marker-name"><%= marker.name %></span>
<%= if marker.args_preview not in [nil, ""] do %>
<span class="chat-tool-marker-args">(<%= marker.args_preview %>)</span>
<% 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 %>
</div>
<% end %>
@@ -332,7 +352,13 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
@spec chat_surface(term()) :: term()
def chat_surface(assigns) do
~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" 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 %>
<% "card" -> %>
<div class="chat-surface-card">
@@ -526,10 +552,28 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
<% _other -> %>
<pre class="chat-tool-surface-json"><%= Jason.encode!(@surface.raw || %{}, pretty: true) %></pre>
<% end %>
</article>
</div>
</details>
"""
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 ───────────────────────────────────────────────────────
defp update_request(socket, conversation_id, updater, reload) do

View File

@@ -31,6 +31,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
is_streaming: not is_nil(request),
streaming_content: streaming_content(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),
needs_api_key?: ModelSelection.needs_api_key?(Map.get(assigns, :offline_mode, true)),
action_error: Map.get(assigns.chat_editor_action_errors, conversation.id),
@@ -49,7 +50,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
case message.role do
:tool ->
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
{entries, current_entry, turn_index}
end
@@ -95,18 +96,15 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
content: message.content || "",
turn_index: turn_index,
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_latest_surface_expanded(assigns),
tool_surfaces: []
}
end
defp append_tool_surface(entry, message) do
entry = ToolTracking.mark_tool_call_completed(entry, message.tool_call_id)
case ToolSurfaces.normalize_tool_surface(message.content) do
nil -> entry
surface -> update_in(entry.tool_surfaces, &(&1 ++ [surface]))
end
defp append_tool_result(entry, message) do
ToolTracking.mark_tool_call_completed(entry, message.tool_call_id, message.content)
end
defp tool_only_assistant_entry?(%{role: :assistant, content: content} = entry) do
@@ -125,6 +123,17 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
}
end
defp mark_latest_surface_expanded([], _assigns), do: []
defp mark_latest_surface_expanded(surfaces, assigns) do
dismissed = Map.get(assigns, :chat_editor_dismissed_surfaces, MapSet.new())
surfaces
|> Enum.reject(&MapSet.member?(dismissed, &1.id))
|> Enum.with_index()
|> Enum.map(fn {surface, index} -> Map.put(surface, :expanded?, index == length(surfaces) - 1) end)
end
defp pending_user_message(_messages, nil), do: nil
defp pending_user_message(messages, %{message: message}) when is_binary(message) do
@@ -140,6 +149,15 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
defp streaming_content(%{content: content}) when is_binary(content), do: content
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_latest_surface_expanded(assigns)
end
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end

View File

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

View File

@@ -109,40 +109,6 @@
<.chat_surface surface={surface} />
<% 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 %>
<%= if @chat_editor.is_streaming and (@chat_editor.streaming_content != "" or @chat_editor.streaming_tool_markers != []) do %>
@@ -160,6 +126,10 @@
<% end %>
</div>
</div>
<%= for surface <- @chat_editor.streaming_inline_surfaces do %>
<.chat_surface surface={surface} />
<% end %>
<% end %>
<%= if @chat_editor.is_streaming and @chat_editor.streaming_content == "" and @chat_editor.streaming_tool_markers == [] do %>

View File

@@ -109,6 +109,9 @@
"chat.role.assistant": "Assistent",
"chat.inputPlaceholder": "Nachricht eingeben...",
"chat.stop": "Stopp",
"chat.toolArguments": "Argumente",
"chat.toolResult": "Ergebnis",
"chat.dismissSurface": "Ansicht schließen",
"chat.cancelledSuffix": "(abgebrochen)",
"gitDiff.changedFiles": "Geänderte Dateien",
"sidebar.tags": "Schlagwörter",

View File

@@ -109,6 +109,9 @@
"chat.role.assistant": "Assistant",
"chat.inputPlaceholder": "Type a message...",
"chat.stop": "Stop",
"chat.toolArguments": "Arguments",
"chat.toolResult": "Result",
"chat.dismissSurface": "Dismiss surface",
"chat.cancelledSuffix": "(cancelled)",
"gitDiff.changedFiles": "Changed files",
"sidebar.tags": "Tags",

View File

@@ -109,6 +109,9 @@
"chat.role.assistant": "Asistente",
"chat.inputPlaceholder": "Escribe un mensaje...",
"chat.stop": "Detener",
"chat.toolArguments": "Argumentos",
"chat.toolResult": "Resultado",
"chat.dismissSurface": "Cerrar superficie",
"chat.cancelledSuffix": "(cancelado)",
"gitDiff.changedFiles": "Archivos modificados",
"sidebar.tags": "Etiquetas",

View File

@@ -109,6 +109,9 @@
"chat.role.assistant": "Assistant IA",
"chat.inputPlaceholder": "Saisissez un message...",
"chat.stop": "Arrêter",
"chat.toolArguments": "Arguments",
"chat.toolResult": "Résultat",
"chat.dismissSurface": "Fermer la surface",
"chat.cancelledSuffix": "(annulé)",
"gitDiff.changedFiles": "Fichiers modifiés",
"sidebar.tags": "Étiquettes",

View File

@@ -109,6 +109,9 @@
"chat.role.assistant": "Assistente",
"chat.inputPlaceholder": "Scrivi un messaggio...",
"chat.stop": "Ferma",
"chat.toolArguments": "Argomenti",
"chat.toolResult": "Risultato",
"chat.dismissSurface": "Chiudi superficie",
"chat.cancelledSuffix": "(annullato)",
"gitDiff.changedFiles": "File modificati",
"sidebar.tags": "Tag",

View File

@@ -5558,13 +5558,45 @@ button svg * {
}
.chat-tool-marker {
font-size: 12px;
color: var(--vscode-descriptionForeground, inherit);
}
.chat-tool-marker summary {
display: flex;
align-items: center;
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);
}
.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 {
color: var(--vscode-testing-iconPassed, #89d185);
}
@@ -5581,13 +5613,60 @@ button svg * {
.chat-tool-surface {
width: min(720px, calc(100% - 44px));
margin-left: 44px;
padding: 14px;
box-sizing: border-box;
border: 1px solid var(--vscode-editorGroup-border, var(--line, #3c3c3c));
border-radius: 12px;
background-color: var(--vscode-sideBar-background, var(--panel-2, #252526));
}
.chat-inline-surface {
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-tool-surface h3 {
margin: 0 0 12px;

View File

@@ -2149,7 +2149,7 @@ defmodule BDS.Desktop.ShellLiveTest do
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, collapsed tool pills, and dismissible A2UI surfaces" do
assert {:ok, conversation} = AI.start_chat(%{title: "Editor Chat", model: "gpt-4.1"})
now = Persistence.now_ms()
@@ -2209,11 +2209,21 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-testid="chat-model-selector-button")
assert html =~ "gpt-4.1"
assert html =~ ~s(data-testid="chat-tool-marker")
assert html =~ ~s(data-testid="chat-tool-marker-details")
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 =~ "Metric"
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 folds tool-only assistant steps into the final assistant answer" do
@@ -2647,6 +2657,25 @@ defmodule BDS.Desktop.ShellLiveTest do
assert assistant_index < user_index
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 =
view
|> element("[data-testid='chat-abort-button']")