feat: preliminary work on assistant

This commit is contained in:
2026-04-26 09:48:53 +02:00
parent 50d8e88ce8
commit 8e8a2e2cd2
10 changed files with 313 additions and 13 deletions

View File

@@ -47,6 +47,8 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:page_language, ShellData.ui_language()) |> assign(:page_language, ShellData.ui_language())
|> assign(:client_shortcuts, Commands.client_shortcuts()) |> assign(:client_shortcuts, Commands.client_shortcuts())
|> assign(:offline_mode, true) |> assign(:offline_mode, true)
|> assign(:assistant_prompt, "")
|> assign(:assistant_messages, [])
|> assign(:is_mac_ui, mac_ui?()) |> assign(:is_mac_ui, mac_ui?())
|> assign(:menu_groups, titlebar_menu_groups()) |> assign(:menu_groups, titlebar_menu_groups())
|> assign(:titlebar_menu_group, nil) |> assign(:titlebar_menu_group, nil)
@@ -275,6 +277,25 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, reload_shell(socket, socket.assigns.workbench)} {:noreply, reload_shell(socket, socket.assigns.workbench)}
end end
def handle_event("update_assistant_prompt", %{"assistant" => %{"prompt" => prompt}}, socket) do
{:noreply, assign(socket, :assistant_prompt, prompt)}
end
def handle_event("submit_assistant_prompt", %{"assistant" => %{"prompt" => prompt}}, socket) do
prompt = prompt |> to_string() |> String.trim()
socket =
if prompt == "" do
assign(socket, :assistant_prompt, "")
else
socket
|> assign(:assistant_prompt, "")
|> assign(:assistant_messages, socket.assigns.assistant_messages ++ assistant_turn(prompt, socket))
end
{:noreply, socket}
end
def handle_event("open_tasks_panel", _params, socket) do def handle_event("open_tasks_panel", _params, socket) do
workbench = workbench =
socket.assigns.workbench socket.assigns.workbench
@@ -1506,6 +1527,34 @@ defmodule BDS.Desktop.ShellLive do
defp tab_icon_id(%{type: :style}), do: "settings" defp tab_icon_id(%{type: :style}), do: "settings"
defp tab_icon_id(%{type: type}), do: Atom.to_string(type) defp tab_icon_id(%{type: type}), do: Atom.to_string(type)
defp assistant_turn(prompt, socket) do
[
%{role: "user", content: prompt},
%{role: "assistant", content: assistant_reply(socket)}
]
end
defp assistant_reply(socket) do
if socket.assigns.offline_mode do
ShellData.translate("Automatic AI actions stay gated by airplane mode.", %{}, socket.assigns.page_language)
else
ShellData.translate(
"The assistant sidebar chat surface is ready, but model execution is not connected yet.",
%{},
socket.assigns.page_language
)
end
end
defp assistant_project_name(nil), do: translated("Projects")
defp assistant_project_name(project), do: project.name
defp assistant_message_label("assistant"), do: translated("Assistant")
defp assistant_message_label("user"), do: translated("You")
defp assistant_message_label(_role), do: translated("Assistant")
defp assistant_message_testid(role), do: "assistant-message-#{role}"
defp media_thumbnail_glyph(mime_type) do defp media_thumbnail_glyph(mime_type) do
case String.split(to_string(mime_type || ""), "/", parts: 2) do case String.split(to_string(mime_type || ""), "/", parts: 2) do
["image", _rest] -> "IMG" ["image", _rest] -> "IMG"

View File

@@ -416,15 +416,77 @@
> >
<div class="resizable-panel-divider assistant-divider" data-resize="assistant" data-role="resize-handle"></div> <div class="resizable-panel-divider assistant-divider" data-resize="assistant" data-role="resize-handle"></div>
<aside class="assistant-sidebar" data-region="assistant-sidebar"> <aside class="assistant-sidebar" data-region="assistant-sidebar">
<div class="assistant-header">
<strong><%= translated("Assistant") %></strong>
</div>
<div class="assistant-content"> <div class="assistant-content">
<%= for card <- @assistant_cards do %> <header class="assistant-sidebar-header">
<section class="assistant-card"> <div class="assistant-sidebar-heading">
<strong><%= translated(card.label) %></strong> <strong><%= translated("AI Assistant") %></strong>
<span><%= translated(card.text) %></span> <span class="assistant-sidebar-description"><%= translated("AI conversations") %></span>
</section> </div>
<span class={[
"assistant-sidebar-status",
if(@offline_mode, do: "is-offline", else: "is-online")
]}>
<%= if @offline_mode, do: translated("Offline"), else: translated("Chat") %>
</span>
</header>
<section class="assistant-sidebar-context" data-testid="assistant-context">
<div class="assistant-sidebar-context-row">
<span class="assistant-sidebar-context-label"><%= translated("Project") %></span>
<span class="assistant-sidebar-context-value"><%= assistant_project_name(@current_project) %></span>
</div>
<div class="assistant-sidebar-context-row">
<span class="assistant-sidebar-context-label"><%= translated("Editor") %></span>
<span class="assistant-sidebar-context-value"><%= tab_title(@current_tab, @tab_meta) %></span>
</div>
<p class="assistant-sidebar-context-text"><%= tab_subtitle(@current_tab, @tab_meta) %></p>
</section>
<form
class="assistant-sidebar-prompt-form"
data-testid="assistant-prompt-form"
phx-change="update_assistant_prompt"
phx-submit="submit_assistant_prompt"
>
<textarea
class="assistant-sidebar-prompt"
data-testid="assistant-prompt-input"
name="assistant[prompt]"
rows="6"
placeholder={translated("Ask the assistant about the active project or editor.")}
><%= @assistant_prompt %></textarea>
<button
class="assistant-sidebar-start-button"
data-testid="assistant-start-button"
type="submit"
disabled={String.trim(@assistant_prompt || "") == ""}
>
<%= translated("Start chat") %>
</button>
</form>
<%= if Enum.empty?(@assistant_messages) do %>
<div class="assistant-sidebar-welcome">
<%= for card <- @assistant_cards do %>
<section class="assistant-card">
<strong><%= translated(card.label) %></strong>
<span><%= translated(card.text) %></span>
</section>
<% end %>
</div>
<% else %>
<div class="assistant-sidebar-transcript">
<%= for message <- @assistant_messages do %>
<article
class={["assistant-sidebar-message", message.role]}
data-testid={assistant_message_testid(message.role)}
>
<span class="assistant-sidebar-message-role"><%= assistant_message_label(message.role) %></span>
<p class="assistant-sidebar-message-content"><%= message.content %></p>
</article>
<% end %>
</div>
<% end %> <% end %>
</div> </div>
</aside> </aside>

View File

@@ -221,5 +221,9 @@
"View": "Ansicht", "View": "Ansicht",
"Welcome to bDS2": "Willkommen bei bDS2", "Welcome to bDS2": "Willkommen bei bDS2",
"Workbench Notes": "Workbench-Hinweise", "Workbench Notes": "Workbench-Hinweise",
"Working tree integration is not wired yet in the shell, but the tab is selectable and ready for command output.": "Die Integration des Arbeitsverzeichnisses ist in der Shell noch nicht verdrahtet, aber der Tab ist auswählbar und bereit für Befehlsausgaben." "Working tree integration is not wired yet in the shell, but the tab is selectable and ready for command output.": "Die Integration des Arbeitsverzeichnisses ist in der Shell noch nicht verdrahtet, aber der Tab ist auswählbar und bereit für Befehlsausgaben.",
"Ask the assistant about the active project or editor.": "Frage den Assistenten zum aktiven Projekt oder Editor.",
"Start chat": "Chat starten",
"You": "Du",
"The assistant sidebar chat surface is ready, but model execution is not connected yet.": "Die Chat-Oberfläche der Assistenten-Seitenleiste ist bereit, aber die Modellausführung ist noch nicht verbunden."
} }

View File

@@ -221,5 +221,9 @@
"View": "View", "View": "View",
"Welcome to bDS2": "Welcome to bDS2", "Welcome to bDS2": "Welcome to bDS2",
"Workbench Notes": "Workbench Notes", "Workbench Notes": "Workbench Notes",
"Working tree integration is not wired yet in the shell, but the tab is selectable and ready for command output.": "Working tree integration is not wired yet in the shell, but the tab is selectable and ready for command output." "Working tree integration is not wired yet in the shell, but the tab is selectable and ready for command output.": "Working tree integration is not wired yet in the shell, but the tab is selectable and ready for command output.",
"Ask the assistant about the active project or editor.": "Ask the assistant about the active project or editor.",
"Start chat": "Start chat",
"You": "You",
"The assistant sidebar chat surface is ready, but model execution is not connected yet.": "The assistant sidebar chat surface is ready, but model execution is not connected yet."
} }

View File

@@ -221,5 +221,9 @@
"View": "Ver", "View": "Ver",
"Welcome to bDS2": "Bienvenido a bDS2", "Welcome to bDS2": "Bienvenido a bDS2",
"Workbench Notes": "Notas del área de trabajo", "Workbench Notes": "Notas del área de trabajo",
"Working tree integration is not wired yet in the shell, but the tab is selectable and ready for command output.": "La integración del árbol de trabajo aún no está conectada en el shell, pero la pestaña es seleccionable y está lista para la salida de comandos." "Working tree integration is not wired yet in the shell, but the tab is selectable and ready for command output.": "La integración del árbol de trabajo aún no está conectada en el shell, pero la pestaña es seleccionable y está lista para la salida de comandos.",
"Ask the assistant about the active project or editor.": "Pregunta al asistente sobre el proyecto o editor activo.",
"Start chat": "Iniciar chat",
"You": "Tú",
"The assistant sidebar chat surface is ready, but model execution is not connected yet.": "La superficie de chat de la barra lateral del asistente está lista, pero la ejecución del modelo aún no está conectada."
} }

View File

@@ -221,5 +221,9 @@
"View": "Affichage", "View": "Affichage",
"Welcome to bDS2": "Bienvenue dans bDS2", "Welcome to bDS2": "Bienvenue dans bDS2",
"Workbench Notes": "Notes datelier", "Workbench Notes": "Notes datelier",
"Working tree integration is not wired yet in the shell, but the tab is selectable and ready for command output.": "Lintégration de larbre de travail nest pas encore câblée dans le shell, mais longlet est sélectionnable et prêt pour la sortie des commandes." "Working tree integration is not wired yet in the shell, but the tab is selectable and ready for command output.": "Lintégration de larbre de travail nest pas encore câblée dans le shell, mais longlet est sélectionnable et prêt pour la sortie des commandes.",
"Ask the assistant about the active project or editor.": "Interrogez lassistant sur le projet ou léditeur actif.",
"Start chat": "Démarrer la conversation",
"You": "Vous",
"The assistant sidebar chat surface is ready, but model execution is not connected yet.": "La surface de discussion de la barre latérale de lassistant est prête, mais lexécution du modèle nest pas encore connectée."
} }

View File

@@ -221,5 +221,9 @@
"View": "Vista", "View": "Vista",
"Welcome to bDS2": "Benvenuto in bDS2", "Welcome to bDS2": "Benvenuto in bDS2",
"Workbench Notes": "Note del banco di lavoro", "Workbench Notes": "Note del banco di lavoro",
"Working tree integration is not wired yet in the shell, but the tab is selectable and ready for command output.": "Lintegrazione del working tree non è ancora collegata nella shell, ma la scheda è selezionabile e pronta per loutput dei comandi." "Working tree integration is not wired yet in the shell, but the tab is selectable and ready for command output.": "Lintegrazione del working tree non è ancora collegata nella shell, ma la scheda è selezionabile e pronta per loutput dei comandi.",
"Ask the assistant about the active project or editor.": "Chiedi allassistente del progetto o editor attivo.",
"Start chat": "Avvia chat",
"You": "Tu",
"The assistant sidebar chat surface is ready, but model execution is not connected yet.": "La superficie chat della barra laterale assistente è pronta, ma lesecuzione del modello non è ancora collegata."
} }

View File

@@ -2010,6 +2010,134 @@ button {
padding: 14px; padding: 14px;
} }
.assistant-sidebar-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.assistant-sidebar-heading {
display: flex;
flex-direction: column;
gap: 4px;
}
.assistant-sidebar-description,
.assistant-sidebar-context-text,
.assistant-sidebar-message-content {
color: var(--vscode-descriptionForeground);
}
.assistant-sidebar-status {
border-radius: 999px;
border: 1px solid var(--vscode-panel-border);
padding: 2px 8px;
font-size: 11px;
line-height: 1.4;
}
.assistant-sidebar-status.is-offline {
background: rgba(255, 196, 0, 0.18);
border-color: rgba(255, 196, 0, 0.35);
color: var(--vscode-editor-foreground);
}
.assistant-sidebar-context {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
border: 1px solid var(--vscode-panel-border);
border-radius: 10px;
background: color-mix(in srgb, var(--vscode-sideBar-background) 78%, var(--vscode-editor-background));
}
.assistant-sidebar-context-row {
display: flex;
justify-content: space-between;
gap: 12px;
}
.assistant-sidebar-context-label,
.assistant-sidebar-message-role {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--vscode-descriptionForeground);
}
.assistant-sidebar-context-value {
text-align: right;
color: var(--vscode-editor-foreground);
}
.assistant-sidebar-context-text,
.assistant-sidebar-message-content {
margin: 0;
white-space: pre-wrap;
}
.assistant-sidebar-prompt-form,
.assistant-sidebar-welcome,
.assistant-sidebar-transcript {
display: flex;
flex-direction: column;
gap: 10px;
}
.assistant-sidebar-prompt {
width: 100%;
min-height: 112px;
resize: vertical;
border: 1px solid var(--vscode-input-border);
border-radius: 10px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
padding: 10px 12px;
font: inherit;
}
.assistant-sidebar-prompt:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 1px;
}
.assistant-sidebar-start-button {
align-self: flex-start;
border: 1px solid var(--vscode-button-border, transparent);
border-radius: 999px;
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
padding: 7px 14px;
cursor: pointer;
}
.assistant-sidebar-start-button:disabled {
cursor: default;
opacity: 0.55;
}
.assistant-card,
.assistant-sidebar-message {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
border: 1px solid var(--vscode-panel-border);
border-radius: 10px;
border-bottom-width: 1px;
background: color-mix(in srgb, var(--vscode-sideBar-background) 82%, var(--vscode-editor-background));
}
.assistant-sidebar-message.user {
background: color-mix(in srgb, var(--vscode-list-hoverBackground) 76%, transparent);
}
.assistant-sidebar-message.assistant {
background: color-mix(in srgb, var(--vscode-sideBar-background) 70%, var(--vscode-editor-background));
}
.status-bar { .status-bar {
height: 22px; height: 22px;
display: flex; display: flex;

View File

@@ -177,6 +177,32 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ">7<" assert html =~ ">7<"
end end
test "assistant sidebar exposes context, prompt, and offline-gated transcript" do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
html =
view
|> element("[data-testid='toggle-assistant']")
|> render_click()
assert html =~ ~s(data-testid="assistant-shell")
assert html =~ ~s(data-testid="assistant-context")
assert html =~ ~s(data-testid="assistant-prompt-form")
assert html =~ ~s(data-testid="assistant-prompt-input")
assert html =~ ~s(data-testid="assistant-start-button")
assert html =~ ~s(>Dashboard<)
html =
render_submit(view, "submit_assistant_prompt", %{
"assistant" => %{"prompt" => "Summarize the current project"}
})
assert html =~ ~s(data-testid="assistant-message-user")
assert html =~ ~s(data-testid="assistant-message-assistant")
assert html =~ "Summarize the current project"
assert html =~ "Automatic AI actions stay gated by airplane mode."
end
test "sidebar open supports preview and pin intents for entity tabs" do test "sidebar open supports preview and pin intents for entity tabs" do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)

View File

@@ -160,4 +160,19 @@ defmodule BDS.UI.ShellTest do
assert template =~ "tab-actions" assert template =~ "tab-actions"
assert template =~ "tab-dirty-indicator" assert template =~ "tab-dirty-indicator"
end end
test "desktop shell assets keep the assistant sidebar chat surface contract" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/index.html.heex")
assert css =~ ".assistant-sidebar-context"
assert css =~ ".assistant-sidebar-prompt"
assert css =~ ".assistant-sidebar-start-button"
assert css =~ ".assistant-sidebar-message"
assert template =~ "data-testid=\"assistant-context\""
assert template =~ "data-testid=\"assistant-prompt-form\""
assert template =~ "data-testid=\"assistant-prompt-input\""
assert template =~ "data-testid=\"assistant-start-button\""
assert template =~ "assistant-sidebar-transcript"
end
end end