mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
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:
committed by
GitHub
parent
3c10513c72
commit
922d54917b
@@ -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: () => {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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"];
|
||||
|
||||
Reference in New Issue
Block a user