Skip to content

Commit e946431

Browse files
authored
feat(ui): polish sidebar rail and hide disabled Workboard nav (#101123)
1 parent 806a116 commit e946431

11 files changed

Lines changed: 127 additions & 43 deletions

File tree

docs/plugins/workboard.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@ openclaw gateway restart
2525
openclaw dashboard
2626
```
2727

28-
The Workboard tab appears in the dashboard nav. If the tab is visible but the
29-
plugin is disabled or blocked by `plugins.allow`/`plugins.deny`, the tab shows
30-
a plugin-unavailable state instead of card data.
28+
The Workboard tab appears in the dashboard nav once the plugin is enabled;
29+
while it is disabled the tab stays hidden from navigation. Opening the
30+
`/workboard` route directly while the plugin is disabled or blocked by
31+
`plugins.allow`/`plugins.deny` shows a plugin-unavailable state instead of
32+
card data.
3133

3234
## Configuration
3335

ui/src/app-navigation.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,15 @@ describe("navigationIconForRoute", () => {
3535
chat: "messageSquare",
3636
overview: "barChart",
3737
activity: "activity",
38-
workboard: "folder",
38+
workboard: "kanban",
3939
worktrees: "folder",
4040
channels: "link",
4141
instances: "radio",
4242
sessions: "fileText",
4343
usage: "barChart",
4444
cron: "loader",
4545
tasks: "loader",
46-
agents: "folder",
46+
agents: "bot",
4747
skills: "zap",
4848
"skill-workshop": "wrench",
4949
nodes: "monitor",

ui/src/app-navigation.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,10 @@ export const SETTINGS_NAVIGATION_ROUTES = [
7777
] as const satisfies readonly NavigationRouteId[];
7878

7979
const NAVIGATION_ICONS: NavigationItem = {
80-
agents: "folder",
80+
agents: "bot",
8181
activity: "activity",
8282
overview: "barChart",
83-
workboard: "folder",
83+
workboard: "kanban",
8484
worktrees: "folder",
8585
channels: "link",
8686
instances: "radio",

ui/src/app/app-host.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import type { ThemeModeChangeDetail } from "../components/theme-mode-toggle.ts";
2525
import { t } from "../i18n/index.ts";
2626
import { copyToClipboard } from "../lib/clipboard.ts";
2727
import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts";
28+
import { isWorkboardEnabledInConfigSnapshot } from "../lib/plugin-activation.ts";
2829
import { searchForSession } from "../lib/sessions/index.ts";
2930
import { resolveAgentIdFromSessionKey } from "../lib/sessions/session-key.ts";
3031
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../lib/string-coerce.ts";
@@ -45,6 +46,10 @@ type ShellRouteState = {
4546
location?: RouteLocation;
4647
};
4748

49+
// Stable references so the sidebar's enabledRouteIds property does not churn
50+
// on every shell render.
51+
const ROUTE_IDS_WITHOUT_WORKBOARD = APP_ROUTE_IDS.filter((routeId) => routeId !== "workboard");
52+
4853
function selectShellRouteState(routerState: RouterState<RouteId>): ShellRouteState {
4954
const match = selectRenderedRouteMatch(routerState.matches[0], routerState.pendingMatches[0]);
5055
return match
@@ -360,6 +365,7 @@ class OpenClawShell extends LitElement {
360365
private stopNavigationSubscription: (() => void) | undefined;
361366
private stopRouteSubscription: (() => void) | undefined;
362367
private stopOverlaySubscription: (() => void) | undefined;
368+
private stopRuntimeConfigSubscription: (() => void) | undefined;
363369
private stopThemeSubscription: (() => void) | undefined;
364370

365371
override createRenderRoot() {
@@ -388,6 +394,7 @@ class OpenClawShell extends LitElement {
388394
this.stopNavigationSubscription ||
389395
this.stopRouteSubscription ||
390396
this.stopOverlaySubscription ||
397+
this.stopRuntimeConfigSubscription ||
391398
this.stopThemeSubscription
392399
) {
393400
return;
@@ -400,12 +407,14 @@ class OpenClawShell extends LitElement {
400407
this.updateGatewayStatus(context.gateway.snapshot);
401408
this.updateTerminalSurface(context.gateway.snapshot);
402409
this.updateAgentLabel();
410+
this.ensureRuntimeConfig(context.gateway.snapshot);
403411
this.stopGatewaySubscription = context.gateway.subscribe((snapshot) => {
404412
this.updateGatewaySessionKey(snapshot);
405413
this.updateGatewayStatus(snapshot);
406414
this.updateTerminalSurface(snapshot);
407415
this.updateAgentLabel();
408416
this.ensureAgentsList(snapshot);
417+
this.ensureRuntimeConfig(snapshot);
409418
});
410419
this.stopConfigSubscription = context.config.subscribe(() => {
411420
this.updateTerminalSurface(context.gateway.snapshot);
@@ -426,6 +435,10 @@ class OpenClawShell extends LitElement {
426435
this.stopOverlaySubscription = context.overlays.subscribe((snapshot) => {
427436
this.overlaySnapshot = snapshot;
428437
});
438+
this.stopRuntimeConfigSubscription = context.runtimeConfig.subscribe(() => {
439+
// Route enablement (e.g. Workboard) derives from the config snapshot.
440+
this.requestUpdate();
441+
});
429442
}
430443

431444
override disconnectedCallback() {
@@ -442,6 +455,8 @@ class OpenClawShell extends LitElement {
442455
this.stopRouteSubscription = undefined;
443456
this.stopOverlaySubscription?.();
444457
this.stopOverlaySubscription = undefined;
458+
this.stopRuntimeConfigSubscription?.();
459+
this.stopRuntimeConfigSubscription = undefined;
445460
this.stopThemeSubscription?.();
446461
this.stopThemeSubscription = undefined;
447462
this.agentsListClient = null;
@@ -565,6 +580,23 @@ class OpenClawShell extends LitElement {
565580
);
566581
}
567582

583+
private ensureRuntimeConfig(snapshot: {
584+
client: GatewayBrowserClient | null;
585+
connected: boolean;
586+
}) {
587+
// The sidebar hides config-gated routes (Workboard), so the snapshot must
588+
// load eagerly instead of waiting for a page that happens to fetch it.
589+
if (snapshot.connected && snapshot.client) {
590+
void this.context?.runtimeConfig.ensureLoaded();
591+
}
592+
}
593+
594+
private enabledRouteIds(): readonly RouteId[] {
595+
return isWorkboardEnabledInConfigSnapshot(this.context?.runtimeConfig.state.configSnapshot)
596+
? APP_ROUTE_IDS
597+
: ROUTE_IDS_WITHOUT_WORKBOARD;
598+
}
599+
568600
private ensureAgentsList(snapshot: { client: GatewayBrowserClient | null; connected: boolean }) {
569601
if (!snapshot.connected || !snapshot.client) {
570602
this.agentsListClient = null;
@@ -692,7 +724,7 @@ class OpenClawShell extends LitElement {
692724
.basePath=${context.basePath}
693725
.activeRouteId=${activeRoute}
694726
.activePluginTabId=${activePluginTabId}
695-
.enabledRouteIds=${APP_ROUTE_IDS}
727+
.enabledRouteIds=${this.enabledRouteIds()}
696728
.sessionKey=${this.activeSessionKey}
697729
.collapsed=${navCollapsed}
698730
.connected=${this.gatewayConnected}

ui/src/components/app-sidebar.ts

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -939,18 +939,10 @@ export class AppSidebar extends LitElement {
939939
routeId === "config"
940940
? this.activeRouteId !== undefined && isSettingsNavigationRoute(this.activeRouteId)
941941
: this.activeRouteId === routeId;
942-
const enabled = this.isRouteEnabled(routeId);
943-
if (!enabled) {
944-
return html`
945-
<span class="nav-item nav-item--disabled" aria-disabled="true">
946-
<span class="nav-item__icon" aria-hidden="true"
947-
>${icons[navigationIconForRoute(routeId)]}</span
948-
>
949-
${!this.collapsed
950-
? html`<span class="nav-item__text">${titleForRoute(routeId)}</span>`
951-
: nothing}
952-
</span>
953-
`;
942+
// Disabled routes (e.g. Workboard with the plugin off) stay hidden rather
943+
// than rendering an inert nav item.
944+
if (!this.isRouteEnabled(routeId)) {
945+
return nothing;
954946
}
955947
const routeSessionKey = routeId === "chat" ? this.getRouteSessionKey() : "";
956948
const href =

ui/src/components/icons.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,24 @@ export const icons = {
118118
/>
119119
</svg>
120120
`,
121+
kanban: html`
122+
<svg viewBox="0 0 24 24">
123+
<rect width="18" height="18" x="3" y="3" rx="2" />
124+
<path d="M8 7v7" />
125+
<path d="M12 7v4" />
126+
<path d="M16 7v9" />
127+
</svg>
128+
`,
129+
bot: html`
130+
<svg viewBox="0 0 24 24">
131+
<path d="M12 8V4H8" />
132+
<rect width="16" height="12" x="4" y="8" rx="2" />
133+
<path d="M2 14h2" />
134+
<path d="M20 14h2" />
135+
<path d="M15 13v2" />
136+
<path d="M9 13v2" />
137+
</svg>
138+
`,
121139

122140
// UI icons
123141
menu: html`

ui/src/e2e/sidebar-customization.e2e.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,21 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () =
9292
)
9393
.toContain("Logbook");
9494
await expect.poll(() => trimmedTextContents(pinnedItems)).not.toContain("Logbook");
95+
// Workboard ships disabled, so it stays hidden from navigation entirely.
96+
await expect
97+
.poll(() =>
98+
trimmedTextContents(
99+
sidebar.locator(".nav-section--more .nav-section__items > .nav-item"),
100+
),
101+
)
102+
.not.toContain("Workboard");
95103

96104
const customizeButton = sidebar.getByRole("button", { name: "Customize sidebar" });
97105
await customizeButton.click();
98106
const menu = sidebar.getByRole("menu", { name: "Customize sidebar" });
107+
await expect
108+
.poll(() => trimmedTextContents(menu.getByRole("menuitemcheckbox")))
109+
.not.toContain("Workboard");
99110
const overviewItem = menu.getByRole("menuitemcheckbox", { name: "Overview" });
100111
await expect.poll(() => overviewItem.getAttribute("aria-checked")).toBe("true");
101112
const usageItem = menu.getByRole("menuitemcheckbox", { name: "Usage" });
@@ -161,4 +172,35 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () =
161172
await context.close();
162173
}
163174
});
175+
176+
it("shows the Workboard route when the plugin is enabled in config", async () => {
177+
const context = await browser.newContext({
178+
locale: "en-US",
179+
serviceWorkers: "block",
180+
viewport: { height: 900, width: 1440 },
181+
});
182+
const page = await context.newPage();
183+
await installMockGateway(page, {
184+
methodResponses: {
185+
"config.get": {
186+
config: { plugins: { entries: { workboard: { enabled: true } } } },
187+
},
188+
},
189+
});
190+
191+
try {
192+
await page.goto(`${server.baseUrl}overview`);
193+
const sidebar = page.locator("openclaw-app-sidebar");
194+
await sidebar.getByRole("button", { name: "More" }).click();
195+
await expect
196+
.poll(() =>
197+
trimmedTextContents(
198+
sidebar.locator(".nav-section--more .nav-section__items > .nav-item"),
199+
),
200+
)
201+
.toContain("Workboard");
202+
} finally {
203+
await context.close();
204+
}
205+
});
164206
});

ui/src/lib/plugin-activation.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,10 @@ export function isPluginEnabledInConfigSnapshot(
5252
const enabled = (entry as { enabled?: unknown }).enabled;
5353
return typeof enabled === "boolean" ? enabled : enabledByDefault;
5454
}
55+
56+
/** Workboard ships disabled; an unloaded snapshot therefore reads as disabled. */
57+
export function isWorkboardEnabledInConfigSnapshot(
58+
configSnapshot: ConfigSnapshot | null | undefined,
59+
): boolean {
60+
return isPluginEnabledInConfigSnapshot(configSnapshot, "workboard", { enabledByDefault: false });
61+
}

ui/src/pages/sessions/sessions-page.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { subtitleForRoute, titleForRoute } from "../../app-navigation.ts";
1212
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
1313
import { hasOperatorWriteAccess } from "../../app/operator-access.ts";
1414
import { t } from "../../i18n/index.ts";
15-
import { isPluginEnabledInConfigSnapshot } from "../../lib/plugin-activation.ts";
15+
import { isWorkboardEnabledInConfigSnapshot } from "../../lib/plugin-activation.ts";
1616
import {
1717
loadStoredSessionCustomGroups,
1818
saveStoredSessionCustomGroups,
@@ -738,10 +738,8 @@ class SessionsPage extends LitElement {
738738
return html``;
739739
}
740740
const gateway = context.gateway.snapshot;
741-
const workboardEnabled = isPluginEnabledInConfigSnapshot(
741+
const workboardEnabled = isWorkboardEnabledInConfigSnapshot(
742742
context.runtimeConfig.state.configSnapshot,
743-
"workboard",
744-
{ enabledByDefault: false },
745743
);
746744
const canCapture = workboardEnabled && hasOperatorWriteAccess(gateway.hello?.auth ?? null);
747745
const workboardState = context.workboard.state;

ui/src/pages/workboard/workboard-page.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { html, LitElement, nothing } from "lit";
33
import { subtitleForRoute, titleForRoute } from "../../app-navigation.ts";
44
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
55
import { hasOperatorAdminAccess, hasOperatorWriteAccess } from "../../app/operator-access.ts";
6-
import { isPluginEnabledInConfigSnapshot } from "../../lib/plugin-activation.ts";
6+
import { isWorkboardEnabledInConfigSnapshot } from "../../lib/plugin-activation.ts";
77
import { searchForSession } from "../../lib/sessions/index.ts";
88
import {
99
configureWorkboardPolling,
@@ -106,9 +106,7 @@ class WorkboardPage extends LitElement {
106106

107107
private pluginEnabled(): boolean | null {
108108
const snapshot = this.context?.runtimeConfig.state.configSnapshot;
109-
return snapshot
110-
? isPluginEnabledInConfigSnapshot(snapshot, "workboard", { enabledByDefault: false })
111-
: null;
109+
return snapshot ? isWorkboardEnabledInConfigSnapshot(snapshot) : null;
112110
}
113111

114112
private syncWorkboardRuntime() {

0 commit comments

Comments
 (0)