diff --git a/ui/src/components/app-sidebar-menus.ts b/ui/src/components/app-sidebar-menus.ts index 19ef55c635b5..3bd97d745be1 100644 --- a/ui/src/components/app-sidebar-menus.ts +++ b/ui/src/components/app-sidebar-menus.ts @@ -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: () => { diff --git a/ui/src/components/app-sidebar-nav-menus.ts b/ui/src/components/app-sidebar-nav-menus.ts index ec78b03b3cb0..990c1dd87a94 100644 --- a/ui/src/components/app-sidebar-nav-menus.ts +++ b/ui/src/components/app-sidebar-nav-menus.ts @@ -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` + { + if (!shouldHandleNavigationClick(event)) { + return; + } + event.preventDefault(); + params.onNavigate(search); + }} + > + + ${params.tab.label} + + `; +} + +/** 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` - { - if (!shouldHandleNavigationClick(event)) { - (event.currentTarget as HTMLElement).dataset.nativeNavigation = "true"; - return; - } - event.preventDefault(); - }} - > - - - ${tab.label} - - - `; -} - 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;" > ${moreRoutes.map((routeId) => renderMoreMenuRoute(params, routeId))} - ${params.pluginTabs.map((tab) => renderMoreMenuPluginTab(params, tab))} @@ -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), ); } diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts index cd2a449b0d43..c82bfc66ae09 100644 --- a/ui/src/components/app-sidebar.ts +++ b/ui/src/components/app-sidebar.ts @@ -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` +
+ ${renderSidebarPluginTab({ + tab, + basePath: this.basePath, + active: this.activeRouteId === "plugin" && this.activePluginTabId === key, + onNavigate: (search) => this.onNavigate?.("plugin", { search }), + })} +
+ `; + } + 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), + )} ${this.renderSessions()} diff --git a/ui/src/pages/plugins/plugins-page.ts b/ui/src/pages/plugins/plugins-page.ts index 9f960194768f..84ab152a7a36 100644 --- a/ui/src/pages/plugins/plugins-page.ts +++ b/ui/src/pages/plugins/plugins-page.ts @@ -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) }); diff --git a/ui/src/pages/plugins/plugins.e2e.test.ts b/ui/src/pages/plugins/plugins.e2e.test.ts index c6162aced170..9edfbb88605e 100644 --- a/ui/src/pages/plugins/plugins.e2e.test.ts +++ b/ui/src/pages/plugins/plugins.e2e.test.ts @@ -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(); } diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index d7af6b64d8cd..5aac9509ea58 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -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; } diff --git a/ui/src/test-helpers/app-sidebar-cases/sidebar-zone.ts b/ui/src/test-helpers/app-sidebar-cases/sidebar-zone.ts index 8531537bc9b1..bab8d9426842 100644 --- a/ui/src/test-helpers/app-sidebar-cases/sidebar-zone.ts +++ b/ui/src/test-helpers/app-sidebar-cases/sidebar-zone.ts @@ -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( + '[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"];