diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index 3128503..43f21f4 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -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, %{ diff --git a/lib/bds/desktop/shell_live/chat_editor.ex b/lib/bds/desktop/shell_live/chat_editor.ex index 5273a85..eddd6fe 100644 --- a/lib/bds/desktop/shell_live/chat_editor.ex +++ b/lib/bds/desktop/shell_live/chat_editor.ex @@ -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 %>
<%= for marker <- @markers do %> -
- <%= if marker.complete?, do: "✓", else: "●" %> - <%= marker.name %> - <%= if marker.args_preview not in [nil, ""] do %> - (<%= marker.args_preview %>) - <% end %> -
+
+ + <%= if marker.complete?, do: "✓", else: "●" %> + <%= marker.name %> + <%= if marker.args_preview not in [nil, ""] do %> + (<%= marker.args_preview %>) + <% end %> + +
+
<%= translated("chat.toolArguments") %>
+
<%= Jason.encode!(marker.arguments || %{}, pretty: true) %>
+ <%= if marker.result not in [nil, ""] do %> +
<%= translated("chat.toolResult") %>
+
<%= marker.result %>
+ <% end %> +
+
<% end %>
<% end %> @@ -332,7 +352,13 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do @spec chat_surface(term()) :: term() def chat_surface(assigns) do ~H""" -
+
+ + <%= surface_icon(@surface.type) %> + <%= surface_title(@surface) %> + + +
<%= 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" -> %> -
- - - - <%= for column <- surface.columns do %> - - <% end %> - - - - <%= for row <- surface.rows do %> - - <%= for value <- row do %> - - <% end %> - - <% end %> - -
<%= column %>
<%= value %>
-
- - <% _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/]*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']")