diff --git a/lib/bds/desktop/shell_commands.ex b/lib/bds/desktop/shell_commands.ex
index 8528dd2..87146df 100644
--- a/lib/bds/desktop/shell_commands.ex
+++ b/lib/bds/desktop/shell_commands.ex
@@ -249,6 +249,13 @@ defmodule BDS.Desktop.ShellCommands do
nil -> {:error, %{message: "No active project selected"}}
project -> {:ok, project}
end
+ rescue
+ error in [Exqlite.Error] ->
+ if String.contains?(Exception.message(error), "no such table: projects") do
+ {:error, %{message: "Project database is not initialized"}}
+ else
+ reraise error, __STACKTRACE__
+ end
end
defp preview_url(server) do
diff --git a/priv/ui/app.css b/priv/ui/app.css
index 910c842..7851c69 100644
--- a/priv/ui/app.css
+++ b/priv/ui/app.css
@@ -537,8 +537,33 @@ button {
.tab-close {
margin-left: auto;
- font-size: 11px;
+ width: 18px;
+ height: 18px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 15px;
+ line-height: 1;
color: var(--vscode-descriptionForeground);
+ border-radius: 4px;
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+.tab-close:hover {
+ background-color: var(--vscode-toolbar-hoverBackground);
+ color: var(--vscode-tab-activeForeground);
+}
+
+.output-item-details {
+ margin: 4px 0 0;
+ padding: 8px;
+ border-radius: 4px;
+ background: rgba(255, 255, 255, 0.03);
+ color: inherit;
+ font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
+ white-space: pre-wrap;
+ user-select: text;
}
.editor-shell {
@@ -936,16 +961,31 @@ button {
gap: 8px;
}
+.panel-tabs {
+ display: flex;
+ gap: 2px;
+}
+
.panel-tab {
- padding: 8px 10px;
- border-radius: 999px;
background: transparent;
- color: var(--muted);
+ border: none;
+ padding: 6px 12px;
+ font-size: 12px;
+ color: var(--vscode-tab-inactiveForeground);
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+ border-radius: 0;
+}
+
+.panel-tab:hover {
+ color: var(--vscode-tab-activeForeground);
+ background: transparent;
}
.panel-tab.active {
- background: var(--accent-soft);
- color: var(--ink);
+ color: var(--vscode-tab-activeForeground);
+ border-bottom-color: var(--vscode-focusBorder);
+ background: transparent;
}
.assistant-content {
@@ -956,29 +996,42 @@ button {
}
.status-bar {
- height: 34px;
+ height: 22px;
display: flex;
align-items: center;
justify-content: space-between;
- gap: 12px;
- padding: 0 12px;
- background: var(--status);
- border-top: 1px solid var(--line);
+ background-color: var(--vscode-statusBar-background);
+ color: var(--vscode-statusBar-foreground);
+ font-size: 12px;
+ padding: 0 8px;
+ user-select: none;
+ flex-wrap: nowrap;
+ gap: 0;
+ border-top: none;
}
.status-bar-left,
.status-bar-right {
display: flex;
- gap: 8px;
+ align-items: center;
+ gap: 4px;
+ flex-shrink: 0;
min-width: 0;
}
.status-bar-item {
- max-width: 180px;
- padding: 4px 8px;
- border-radius: 999px;
- background: rgba(255, 255, 255, 0.06);
- font-size: 11px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 0 8px;
+ height: 100%;
+ max-width: none;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ border-radius: 0;
+ background: transparent;
+ font-size: 12px;
}
@media (max-width: 1100px) {
diff --git a/priv/ui/app.js b/priv/ui/app.js
index 66fd788..d3ea3bd 100644
--- a/priv/ui/app.js
+++ b/priv/ui/app.js
@@ -64,7 +64,7 @@ function renderTitlebar() {
`)}
- ${renderTitlebarAction("toggle-assistant", "toggle-assistant", "Toggle assistant", `
+ ${renderTitlebarAction("toggle-assistant-sidebar", "toggle-assistant", "Toggle assistant", `
@@ -184,7 +184,7 @@ function renderTab(tab) {
`;
}
@@ -419,11 +419,13 @@ function applyVisibility() {
}
function bindEvents() {
- root.querySelectorAll("[data-command]").forEach((button) => {
+ root.querySelectorAll("button[data-command]").forEach((button) => {
button.onclick = () => {
const command = button.dataset.command;
if (command === "open-tasks-panel") {
openTasksPanel();
+ render();
+ return;
}
if (command === "toggle-offline-mode") {
executeShellCommand("toggle_offline_mode");
@@ -471,6 +473,15 @@ function bindEvents() {
};
});
+ root.querySelectorAll("[data-close-tab]").forEach((button) => {
+ button.onclick = (event) => {
+ event.stopPropagation();
+ const [type, id] = button.dataset.closeTab.split(":");
+ closeSpecificTab(type, id);
+ render();
+ };
+ });
+
root.querySelectorAll("[data-panel-tab]").forEach((button) => {
button.onclick = () => {
state.session.panel.active_tab = button.dataset.panelTab;
@@ -790,6 +801,30 @@ function closeActiveTab() {
}
}
+function closeSpecificTab(type, id) {
+ const index = state.session.tabs.findIndex((tab) => tab.type === type && tab.id === id);
+
+ if (index < 0) {
+ return;
+ }
+
+ const wasActive = state.session.active_tab?.type === type && state.session.active_tab?.id === id;
+ state.session.tabs.splice(index, 1);
+ delete state.tabMeta[`${type}:${id}`];
+
+ if (!state.session.tabs.length) {
+ state.session.active_tab = null;
+ return;
+ }
+
+ if (!wasActive) {
+ return;
+ }
+
+ const next = state.session.tabs[Math.min(index, state.session.tabs.length - 1)];
+ state.session.active_tab = { type: next.type, id: next.id };
+}
+
function openTab(type, id, title, transient, meta = {}) {
const existingIndex = state.session.tabs.findIndex((tab) => tab.type === type && tab.id === id);
@@ -1187,7 +1222,7 @@ function renderLanguageOptions() {
return state.supportedUiLanguages
.map((language) => {
const selected = language.code === state.uiLanguage ? " selected" : "";
- return ``;
+ return ``;
})
.join("");
}
diff --git a/scripts/desktop_automation_runner.mjs b/scripts/desktop_automation_runner.mjs
index 731b945..c82e67a 100644
--- a/scripts/desktop_automation_runner.mjs
+++ b/scripts/desktop_automation_runner.mjs
@@ -43,6 +43,8 @@ for await (const line of rl) {
window_title: text("[data-testid='window-title']"),
active_view: document.querySelector("[data-testid='activity-button'][data-active='true']")?.dataset.view ?? null,
sidebar_visible: !hasClass("[data-testid='sidebar-shell']", "is-hidden"),
+ assistant_visible: !hasClass("[data-testid='assistant-shell']", "is-hidden"),
+ panel_visible: !hasClass(".panel-shell", "is-hidden"),
editor_title: text("[data-testid='editor-title']"),
activity_labels: texts("[data-testid='activity-button']", (node) => node.getAttribute("aria-label")),
sidebar_sections: texts("[data-testid='sidebar-section-title']", (node) => node.textContent.trim()),
diff --git a/test/bds/desktop/automation_test.exs b/test/bds/desktop/automation_test.exs
index 7caa2c6..8383bef 100644
--- a/test/bds/desktop/automation_test.exs
+++ b/test/bds/desktop/automation_test.exs
@@ -22,6 +22,8 @@ defmodule BDS.Desktop.AutomationTest do
assert snapshot.window_title == "Blogging Desktop Server"
assert snapshot.active_view == "posts"
assert snapshot.sidebar_visible == true
+ assert snapshot.assistant_visible == false
+ assert snapshot.panel_visible == false
assert snapshot.editor_title == "Dashboard"
assert snapshot.activity_labels == [
"Posts",
@@ -43,6 +45,12 @@ defmodule BDS.Desktop.AutomationTest do
snapshot = Automation.snapshot(session)
assert snapshot.sidebar_visible == false
+ assert :ok = Automation.click(session, "[data-testid='toggle-assistant']")
+
+ snapshot = Automation.snapshot(session)
+ assert snapshot.assistant_visible == true
+ assert snapshot.panel_visible == false
+
screenshot_path = Path.join(screenshot_dir, "main-window.png")
assert Automation.capture_screenshot(session, screenshot_path) == screenshot_path
assert File.exists?(screenshot_path)
diff --git a/test/bds/desktop/shell_commands_test.exs b/test/bds/desktop/shell_commands_test.exs
index ce2bb67..a04ad9e 100644
--- a/test/bds/desktop/shell_commands_test.exs
+++ b/test/bds/desktop/shell_commands_test.exs
@@ -73,6 +73,13 @@ defmodule BDS.Desktop.ShellCommandsTest do
assert wait_for_task(result.task_id, &(&1.status in [:completed, :failed])).status == :completed
end
+ test "missing project schema returns a command error instead of raising" do
+ BDS.Repo.query!("DROP TABLE projects", [])
+
+ assert {:error, %{message: message}} = ShellCommands.execute("open_in_browser")
+ assert message =~ "Project database is not initialized"
+ end
+
defp wait_for_task(task_id, matcher, timeout \\ 2_000)
defp wait_for_task(task_id, _matcher, timeout) when timeout <= 0 do
diff --git a/test/bds/ui/shell_test.exs b/test/bds/ui/shell_test.exs
index d802752..ce5b7aa 100644
--- a/test/bds/ui/shell_test.exs
+++ b/test/bds/ui/shell_test.exs
@@ -130,6 +130,7 @@ defmodule BDS.UI.ShellTest do
assert js =~ "window-titlebar-sidebar-icon"
assert js =~ "window-titlebar-panel-icon"
assert js =~ "window-titlebar-assistant-icon"
+ assert js =~ "toggle-assistant-sidebar"
assert js =~ "activity-bar-top"
assert js =~ "activity-bar-bottom"
end
@@ -148,6 +149,7 @@ defmodule BDS.UI.ShellTest do
assert css =~ "gap: 4px"
assert css =~ "padding: 0 8px"
assert css =~ "height: 100%"
+ refute css =~ "background: var(--status)"
assert css =~ ".status-bar-language-select"
assert css =~ ".status-bar-item.language-badge"
assert css =~ ".status-bar-item.offline-badge"
@@ -166,6 +168,9 @@ defmodule BDS.UI.ShellTest do
assert js =~ "executeBackendShellCommand"
assert js =~ "applyShellCommandResult"
assert js =~ "openTasksPanel"
+ assert js =~ "command === \"open-tasks-panel\")"
+ assert js =~ "openTasksPanel();"
+ assert js =~ "return;"
assert js =~ "No background tasks running"
assert js =~ "task-list"
assert js =~ "output-list"
@@ -189,5 +194,8 @@ defmodule BDS.UI.ShellTest do
assert js =~ "case \"metadata_diff\""
assert js =~ "case \"regenerate_calendar\""
assert js =~ "case \"fill_missing_translations\""
+ assert js =~ "root.querySelectorAll(\"button[data-command]\")"
+ assert js =~ "[data-close-tab]"
+ assert js =~ "language.flag"
end
end