fix(ui): show enabled plugins in sidebar (#111995)

* fix(ui): show enabled plugins in sidebar

* test(ui): capture enabled plugin sidebar

* fix(ui): keep sidebar insertion within startup budget
This commit is contained in:
Peter Steinberger
2026-07-20 20:37:02 -07:00
committed by GitHub
parent 3c10513c72
commit 922d54917b
7 changed files with 145 additions and 67 deletions
-7
View File
@@ -29,7 +29,6 @@ import {
renderSidebarMoreRow,
renderSidebarNavRoute,
sidebarMoreMenuHoldsActiveRoute,
sidebarPluginTabs,
} from "./app-sidebar-nav-menus.ts";
import { AppSidebarSessionGroupsElement } from "./app-sidebar-session-groups.ts";
import {
@@ -592,9 +591,7 @@ export abstract class AppSidebarMenusElement extends AppSidebarSessionGroupsElem
position,
basePath: this.basePath,
activeRouteId: this.activeRouteId,
activePluginTabId: this.activePluginTabId,
sidebarEntries: this.sidebarEntries,
pluginTabs: sidebarPluginTabs(this.context?.gateway.snapshot.hello?.controlUiTabs),
isRouteEnabled: (routeId) => this.isRouteEnabled(routeId),
onTabAway: () => trigger?.focus(),
onClose: (restoreFocus) => {
@@ -607,10 +604,6 @@ export abstract class AppSidebarMenusElement extends AppSidebarSessionGroupsElem
this.closeMoreMenu({ restoreFocus: true });
this.onNavigate?.(routeId);
},
onNavigatePluginTab: (search) => {
this.closeMoreMenu({ restoreFocus: true });
this.onNavigate?.("plugin", { search });
},
onPreloadRoute: (routeId, event) => this.preloadRoute(routeId, event),
onCancelPreload: this.cancelPreload,
onEditPinnedItems: () => {
+35 -50
View File
@@ -16,7 +16,7 @@ import {
} from "../app-navigation.ts";
import { pathForRoute } from "../app-route-paths.ts";
import { t } from "../i18n/index.ts";
import { pluginTabKey, pluginTabSearch } from "../pages/plugin/route.ts";
import { pluginTabSearch } from "../pages/plugin/route.ts";
import { icons, type IconName } from "./icons.ts";
import { consumeDropdownKeyboardDismissal, trackDropdownKeyboardDismissal } from "./web-awesome.ts";
@@ -54,7 +54,7 @@ export function isSidebarRouteActive(
return activeRouteId === routeId;
}
/** Dynamic plugin tabs stay in the More menu; only stable static route ids can be persisted as pins. */
/** Stable ordering for plugin-provided sidebar tabs. */
export function sidebarPluginTabs(
tabs: readonly GatewayControlUiPluginTab[] | undefined,
): GatewayControlUiPluginTab[] {
@@ -99,7 +99,36 @@ export function renderSidebarNavRoute(params: SidebarNavRouteParams) {
`;
}
/** Unpinned routes, plugin tabs, and the pin editor live in a popup behind this row. */
export function renderSidebarPluginTab(params: {
tab: GatewayControlUiPluginTab;
basePath: string;
active: boolean;
onNavigate: (search: string) => void;
}) {
const search = pluginTabSearch({ pluginId: params.tab.pluginId, id: params.tab.id });
const iconName = Object.hasOwn(icons, params.tab.icon!)
? (params.tab.icon as IconName)
: "puzzle";
return html`
<a
href=${`${pathForRoute("plugin", params.basePath)}${search}`}
class="nav-item ${params.active ? "nav-item--active" : ""}"
aria-current=${params.active ? "page" : nothing}
@click=${(event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
return;
}
event.preventDefault();
params.onNavigate(search);
}}
>
<span class="nav-item__icon" aria-hidden="true">${icons[iconName]}</span>
<span class="nav-item__text">${params.tab.label}</span>
</a>
`;
}
/** Unpinned routes and the pin editor live in a popup behind this row. */
export function renderSidebarMoreRow(params: {
open: boolean;
active: boolean;
@@ -121,7 +150,6 @@ export function renderSidebarMoreRow(params: {
type SidebarMenuNavigationHandlers = {
onNavigateRoute: (routeId: SidebarNavRoute) => void;
onNavigatePluginTab: (search: string) => void;
onPreloadRoute: (routeId: SidebarNavRoute, event: Event) => void;
onCancelPreload: (event: Event) => void;
};
@@ -130,9 +158,7 @@ type SidebarMoreMenuParams = SidebarMenuNavigationHandlers & {
position: SidebarMenuPosition | null;
basePath: string;
activeRouteId: NavigationRouteId | undefined;
activePluginTabId: string;
sidebarEntries: readonly string[];
pluginTabs: readonly GatewayControlUiPluginTab[];
isRouteEnabled: (routeId: NavigationRouteId) => boolean;
onEditPinnedItems: () => void;
onTabAway: () => void;
@@ -166,33 +192,6 @@ function renderMoreMenuRoute(params: SidebarMoreMenuParams, routeId: SidebarNavR
`;
}
function renderMoreMenuPluginTab(params: SidebarMoreMenuParams, tab: GatewayControlUiPluginTab) {
const ref = { pluginId: tab.pluginId, id: tab.id };
const search = pluginTabSearch(ref);
const active =
params.activeRouteId === "plugin" && params.activePluginTabId === pluginTabKey(ref);
const iconName = tab.icon && Object.hasOwn(icons, tab.icon) ? (tab.icon as IconName) : "puzzle";
return html`
<wa-dropdown-item
value=${`plugin:${pluginTabKey(ref)}`}
class="sidebar-customize-menu__item ${active ? "sidebar-customize-menu__item--active" : ""}"
aria-current=${active ? "page" : nothing}
@click=${(event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
(event.currentTarget as HTMLElement).dataset.nativeNavigation = "true";
return;
}
event.preventDefault();
}}
>
<a href=${`${pathForRoute("plugin", params.basePath)}${search}`} tabindex="-1">
<span class="nav-item__icon" aria-hidden="true">${icons[iconName]}</span>
<span class="sidebar-customize-menu__text">${tab.label}</span>
</a>
</wa-dropdown-item>
`;
}
export function renderSidebarMoreMenu(params: SidebarMoreMenuParams) {
const position = params.position;
if (!position) {
@@ -221,16 +220,6 @@ export function renderSidebarMoreMenu(params: SidebarMoreMenuParams) {
params.onEditPinnedItems();
return;
}
if (value?.startsWith("plugin:")) {
const tab = params.pluginTabs.find((candidate) => {
const ref = { pluginId: candidate.pluginId, id: candidate.id };
return `plugin:${pluginTabKey(ref)}` === value;
});
if (tab) {
params.onNavigatePluginTab(pluginTabSearch({ pluginId: tab.pluginId, id: tab.id }));
}
return;
}
if (value && moreRoutes.includes(value as SidebarNavRoute)) {
params.onNavigateRoute(value as SidebarNavRoute);
}
@@ -248,7 +237,6 @@ export function renderSidebarMoreMenu(params: SidebarMoreMenuParams) {
style="position: fixed; left: ${position.x}px; top: ${position.y}px; width: 1px; height: 1px; opacity: 0; pointer-events: none;"
></button>
${moreRoutes.map((routeId) => renderMoreMenuRoute(params, routeId))}
${params.pluginTabs.map((tab) => renderMoreMenuPluginTab(params, tab))}
<div class="sidebar-customize-menu__separator" role="separator"></div>
<wa-dropdown-item class="sidebar-customize-menu__item" value="customize">
<span slot="icon" class="nav-item__icon" aria-hidden="true">${icons.penLine}</span>
@@ -338,11 +326,8 @@ export function sidebarMoreMenuHoldsActiveRoute(params: {
sidebarEntries: readonly string[];
isRouteEnabled: (routeId: NavigationRouteId) => boolean;
}): boolean {
return (
params.activeRouteId === "plugin" ||
sidebarMoreRoutes(params.sidebarEntries).some(
(routeId) =>
params.isRouteEnabled(routeId) && isSidebarRouteActive(params.activeRouteId, routeId),
)
return sidebarMoreRoutes(params.sidebarEntries).some(
(routeId) =>
params.isRouteEnabled(routeId) && isSidebarRouteActive(params.activeRouteId, routeId),
);
}
+29 -1
View File
@@ -1,5 +1,6 @@
import { html, nothing, type PropertyValues } from "lit";
import { state } from "lit/decorators.js";
import type { GatewayControlUiPluginTab } from "../api/gateway.ts";
import {
serializeSidebarEntry,
type NavigationRouteId,
@@ -26,7 +27,12 @@ import { sessionHasBoard } from "../lib/board/provider.ts";
import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts";
import { searchForSession } from "../lib/sessions/index.ts";
import { areUiSessionKeysEquivalent, normalizeAgentId } from "../lib/sessions/session-key.ts";
import { shouldHandleNavigationClick } from "./app-sidebar-nav-menus.ts";
import { pluginTabKey } from "../pages/plugin/route.ts";
import {
renderSidebarPluginTab,
shouldHandleNavigationClick,
sidebarPluginTabs,
} from "./app-sidebar-nav-menus.ts";
import { AppSidebarSessionListElement } from "./app-sidebar-session-list.ts";
import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
import { icons } from "./icons.ts";
@@ -141,6 +147,10 @@ class AppSidebar extends AppSidebarSessionListElement {
}
}
protected override firstUpdated() {
requestAnimationFrame(() => requestAnimationFrame(() => this.classList.add("sidebar-r")));
}
private syncOfflineIndicator(schedule = !this.connected) {
if (this.offlineIndicatorTimer !== null) {
globalThis.clearTimeout(this.offlineIndicatorTimer);
@@ -441,6 +451,21 @@ class AppSidebar extends AppSidebarSessionListElement {
`;
}
private renderPluginTabEntry(tab: GatewayControlUiPluginTab) {
const ref = { pluginId: tab.pluginId, id: tab.id };
const key = pluginTabKey(ref);
return html`
<div class="sidebar-zone-entry" data-sidebar-entry=${`plugin:${key}`}>
${renderSidebarPluginTab({
tab,
basePath: this.basePath,
active: this.activeRouteId === "plugin" && this.activePluginTabId === key,
onNavigate: (search) => this.onNavigate?.("plugin", { search }),
})}
</div>
`;
}
override render() {
const sidebarZone = this.reconciledSidebarZone();
return html`
@@ -464,6 +489,9 @@ class AppSidebar extends AppSidebarSessionListElement {
${sidebarZone.entries.map((entry) =>
this.renderSidebarZoneEntry(entry, sidebarZone.sessionRows),
)}
${sidebarPluginTabs(this.context?.gateway.snapshot.hello?.controlUiTabs).map(
(tab) => this.renderPluginTabEntry(tab),
)}
</div>
</nav>
${this.renderSessions()}
+21 -1
View File
@@ -2,7 +2,7 @@ import { consume } from "@lit/context";
import { html, type PropertyValues } from "lit";
import { property, state } from "lit/decorators.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { titleForRoute } from "../../app-navigation.ts";
import { serializeSidebarEntry, titleForRoute } from "../../app-navigation.ts";
import { pathForRoute } from "../../app-route-paths.ts";
import {
applicationContext,
@@ -654,6 +654,18 @@ class PluginsPage extends OpenClawLightDomElement {
this.replaceResult(withPlugin(this.result, result.plugin), true);
}
private pinEnabledPluginRoute(pluginId: string) {
const navigation = this.context.navigation;
if (pluginId !== "workboard" || !navigation) {
return;
}
const entry = serializeSidebarEntry({ type: "route", route: "workboard" });
const current = navigation.snapshot.sidebarEntries;
if (!current.includes(entry)) {
navigation.update({ sidebarEntries: [...current, entry] });
}
}
/** Plugin changes can affect both catalog state and route visibility (for example Workboard). */
private async refreshAfterMutation(
client: GatewayBrowserClient,
@@ -760,7 +772,15 @@ class PluginsPage extends OpenClawLightDomElement {
kind: "success",
text: mutationSuccessMessage(enabled ? "enabled" : "disabled", result),
});
if (enabled) {
this.pinEnabledPluginRoute(pluginId);
}
await this.refreshAfterMutation(client, sourceGeneration);
if (isCurrent() && !result.restartRequired) {
// Plugin-provided tabs are projected in the connection hello. Re-handshake
// after the registry refresh so sidebar navigation reflects this mutation.
this.context.gateway.connect();
}
} catch (error) {
if (isCurrent()) {
this.setMessage(key, { kind: "error", text: errorMessage(error) });
+16 -8
View File
@@ -482,6 +482,7 @@ describeControlUiE2e("Control UI Plugins mocked Gateway E2E", () => {
await workboardCard.waitFor({ state: "visible" });
const listCountBeforeEnable = (await gateway.getRequests("plugins.list")).length;
const configCountBeforeEnable = (await gateway.getRequests("config.get")).length;
const connectCountBeforeEnable = (await gateway.getRequests("connect")).length;
const enableCountBefore = (await gateway.getRequests("plugins.setEnabled")).length;
await gateway.deferNext("plugins.list");
await gateway.deferNext("config.get");
@@ -505,8 +506,11 @@ describeControlUiE2e("Control UI Plugins mocked Gateway E2E", () => {
);
expect(requestParams(postEnableListRequest)).toEqual({});
expect(requestParams(postEnableConfigRequest)).toEqual({});
await gateway.setMethodResponse("plugins.list", finalInventory);
await gateway.setMethodResponse("config.get", configSnapshot(true));
await gateway.resolveDeferred("plugins.list", finalInventory);
await gateway.resolveDeferred("config.get", configSnapshot(true));
await waitForNextRequest(gateway, "connect", connectCountBeforeEnable);
await expect.poll(() => workboardCard.getAttribute("aria-busy")).toBe("false");
await page
@@ -566,15 +570,19 @@ describeControlUiE2e("Control UI Plugins mocked Gateway E2E", () => {
}
const sidebar = page.locator("openclaw-app-sidebar");
await sidebar.waitFor({ state: "visible" });
const pagesButton = sidebar.locator(".sidebar-nav__head-action");
if ((await pagesButton.getAttribute("aria-expanded")) !== "true") {
await pagesButton.click();
const workboardSidebarItem = sidebar.locator(
'.sidebar-zone-entry[data-sidebar-entry="route:workboard"] > .nav-item',
);
await workboardSidebarItem.waitFor({ state: "visible" });
expect(await workboardSidebarItem.getAttribute("href")).toBe("/workboard");
if (updateScreenshots) {
await mkdir(artifactDir, { recursive: true });
await page.screenshot({
animations: "disabled",
fullPage: true,
path: path.join(artifactDir, "07-workboard-sidebar.png"),
});
}
const workboardMenuItem = sidebar
.locator("wa-dropdown.sidebar-more-menu")
.locator('wa-dropdown-item[value="workboard"] a');
await workboardMenuItem.waitFor({ state: "visible" });
expect(await workboardMenuItem.getAttribute("href")).toBe("/workboard");
} finally {
await context.close();
}
+10
View File
@@ -2243,9 +2243,19 @@ html.openclaw-native-macos
border-radius: var(--radius-md);
transition:
opacity var(--duration-fast) ease,
transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1),
box-shadow var(--duration-fast) ease;
}
@media (prefers-reduced-motion: no-preference) {
@starting-style {
.sidebar-r .sidebar-zone-entry {
opacity: 0;
transform: translateX(-8px) scale(0.98);
}
}
}
.sidebar-zone-entry--dragging {
opacity: 0.45;
}
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import {
createGateway,
createGatewayHarness,
createSessionsHarness,
mountSidebar,
type SidebarLifecycleState,
@@ -183,6 +184,39 @@ describe("AppSidebar interleaved zone", () => {
expect(sidebar.querySelector(".nav-item--home")?.hasAttribute("draggable")).toBe(false);
});
it("renders plugin tabs as sidebar entries", async () => {
const gateway = createGatewayHarness({} as GatewayBrowserClient);
const sessions = createSessionsHarness("main", ["agent:main:main"]);
const { sidebar } = await mountSidebar(gateway.gateway, sessions.sessions);
gateway.publish({
hello: {
type: "hello-ok",
protocol: 1,
auth: { role: "operator", scopes: ["operator.read"] },
controlUiTabs: [{ group: "control", id: "logbook", label: "Logbook", pluginId: "logbook" }],
},
});
await sidebar.updateComplete;
const entry = sidebar.querySelector<HTMLAnchorElement>(
'[data-sidebar-entry="plugin:logbook/logbook"] > .nav-item',
);
expect(entry?.textContent).toContain("Logbook");
expect(entry?.getAttribute("href")).toBe("/plugin?plugin=logbook&id=logbook");
gateway.publish({
hello: {
type: "hello-ok",
protocol: 1,
auth: { role: "operator", scopes: ["operator.read"] },
controlUiTabs: [],
},
});
await sidebar.updateComplete;
expect(sidebar.querySelector('[data-sidebar-entry="plugin:logbook/logbook"]')).toBeNull();
});
it("writes reordered entries after a route drop", async () => {
const { sidebar } = await mountZone();
sidebar.sidebarEntries = ["route:usage", "route:plugins", "route:tasks"];