<%= case @surface.type do %>
<% "card" -> %>
@@ -526,10 +552,28 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
<% _other -> %>
<%= Jason.encode!(@surface.raw || %{}, pretty: true) %>
<% 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 ───────────────────────────────────────────────────────
defp update_request(socket, conversation_id, updater, reload) do
diff --git a/lib/bds/desktop/shell_live/chat_editor/message_build.ex b/lib/bds/desktop/shell_live/chat_editor/message_build.ex
index 50bac29..3ef3348 100644
--- a/lib/bds/desktop/shell_live/chat_editor/message_build.ex
+++ b/lib/bds/desktop/shell_live/chat_editor/message_build.ex
@@ -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
diff --git a/lib/bds/desktop/shell_live/chat_editor/tool_tracking.ex b/lib/bds/desktop/shell_live/chat_editor/tool_tracking.ex
index f581955..9285ab8 100644
--- a/lib/bds/desktop/shell_live/chat_editor/tool_tracking.ex
+++ b/lib/bds/desktop/shell_live/chat_editor/tool_tracking.ex
@@ -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
}
]
diff --git a/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex b/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex
index e13c5ab..abcdd5c 100644
--- a/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex
+++ b/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex
@@ -109,40 +109,6 @@
<.chat_surface surface={surface} />
<% end %>
- <%= for surface <- message.tool_surfaces do %>
-
- <%= if surface.title do %>
- <%= surface.title %>
- <% end %>
-
- <%= case tool_surface_type(surface) do %>
- <% "table" -> %>
-
-
- <% _other -> %>
- <%= Jason.encode!(surface.data, pretty: true) %>
- <% end %>
-
- <% end %>
<% end %>
<%= if @chat_editor.is_streaming and (@chat_editor.streaming_content != "" or @chat_editor.streaming_tool_markers != []) do %>
@@ -160,6 +126,10 @@
<% end %>
+
+ <%= 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 %>
diff --git a/priv/i18n/locales/de.json b/priv/i18n/locales/de.json
index d057c6f..d2158d4 100644
--- a/priv/i18n/locales/de.json
+++ b/priv/i18n/locales/de.json
@@ -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",
diff --git a/priv/i18n/locales/en.json b/priv/i18n/locales/en.json
index 57de8e3..781d435 100644
--- a/priv/i18n/locales/en.json
+++ b/priv/i18n/locales/en.json
@@ -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",
diff --git a/priv/i18n/locales/es.json b/priv/i18n/locales/es.json
index 74d9419..3f5050b 100644
--- a/priv/i18n/locales/es.json
+++ b/priv/i18n/locales/es.json
@@ -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",
diff --git a/priv/i18n/locales/fr.json b/priv/i18n/locales/fr.json
index 394444e..fe53d34 100644
--- a/priv/i18n/locales/fr.json
+++ b/priv/i18n/locales/fr.json
@@ -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",
diff --git a/priv/i18n/locales/it.json b/priv/i18n/locales/it.json
index 3e80e2e..eb9627a 100644
--- a/priv/i18n/locales/it.json
+++ b/priv/i18n/locales/it.json
@@ -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",
diff --git a/priv/ui/app.css b/priv/ui/app.css
index a51155f..1031567 100644
--- a/priv/ui/app.css
+++ b/priv/ui/app.css
@@ -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;
diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs
index 6b10b5a..2c9eca5 100644
--- a/test/bds/desktop/shell_live_test.exs
+++ b/test/bds/desktop/shell_live_test.exs
@@ -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/