Skip to content

Commit 51771c3

Browse files
authored
feat(ui): declutter the Control UI sidebar with customizable pinned nav and a More section (#100296)
* feat(ui): declutter sidebar with pinned nav, More section, and customize menu * test(ui): normalize sidebar e2e labels * docs: refresh generated docs map * fix(ui): keep tablet navigation drawer expandable * chore(ui): refresh i18n raw-copy baseline
1 parent 664464c commit 51771c3

62 files changed

Lines changed: 856 additions & 536 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
1010
- **Logbook work journal:** add a disabled-by-default bundled plugin that turns paired-node screen snapshots into a private timeline, daily standup, and timeline-grounded Q&A in a plugin-contributed Control UI tab. (#99930)
1111
- **Control UI message context:** reveal per-message token, context, and model details from the timestamp on hover or activation instead of showing a separate Context button.
1212
- **Control UI session titles:** reveal truncated recent-session names with a reduced-motion-safe hover animation.
13+
- **Control UI sidebar navigation:** show a small customizable pinned destination set, keep the remaining pages under More, move Settings to the footer, and persist sidebar customization in the browser. (#100296)
1314
- **Control UI sidebar usage:** remove the provider usage quota row from the expanded sidebar while keeping usage details available in the chat composer and Usage page. Thanks @shakkernerd.
1415

1516
### Fixes

docs/docs_map.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9780,6 +9780,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
97809780
- H2: Runtime config endpoint
97819781
- H2: Language support
97829782
- H2: Appearance themes
9783+
- H2: Sidebar navigation
97839784
- H2: What it can do (today)
97849785
- H2: MCP page
97859786
- H2: Activity tab

docs/web/control-ui.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,12 @@ Imported themes are stored only in the current browser profile; they are not wri
111111

112112
Appearance also has a browser-local Text size setting, stored with the rest of Control UI preferences. It applies to chat text, composer text, tool cards, and chat sidebars, and keeps text inputs at least 16px so mobile Safari does not auto-zoom on focus.
113113

114+
## Sidebar navigation
115+
116+
The sidebar keeps sessions first, followed by a small pinned destination set. **Overview**, **Workboard**, and **Agents** are pinned by default; expand **More** to reach every other destination. Select **Customize sidebar** under More, or right-click the navigation area, to pin or unpin destinations and restore the defaults. The pinned set and More expansion state are stored in the current browser profile and survive reloads.
117+
118+
**Settings** stays available in the sidebar footer next to **Docs**. On desktop, use the topbar button next to the terminal control to collapse or expand the sidebar. At drawer breakpoints, the hamburger button replaces that control.
119+
114120
## What it can do (today)
115121

116122
<AccordionGroup>

ui/src/app-navigation-groups.test.ts

Lines changed: 33 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,51 @@
1-
// Control UI tests cover navigation groups behavior.
1+
// Control UI tests cover sidebar pinned-route customization behavior.
22
import { describe, expect, it } from "vitest";
33
import {
4+
DEFAULT_SIDEBAR_PINNED_ROUTES,
45
SETTINGS_NAVIGATION_ROUTES,
5-
SIDEBAR_SECTIONS,
6+
SIDEBAR_NAV_ROUTES,
67
isSettingsNavigationRoute,
7-
isRouteInSidebarSection,
8+
normalizeSidebarPinnedRoutes,
9+
sidebarMoreRoutes,
810
} from "./app-navigation.ts";
911
import { routeIdFromPath } from "./app-routes.ts";
1012

11-
describe("SIDEBAR_SECTIONS", () => {
12-
it("collapses detailed settings slices into one sidebar entry", () => {
13-
const settings = SIDEBAR_SECTIONS.find((group) => group.label === "settings");
14-
expect(settings?.routes).toEqual(["config"]);
13+
describe("sidebar pinned routes", () => {
14+
it("defaults to a small pinned set drawn from the customizable routes", () => {
15+
expect(DEFAULT_SIDEBAR_PINNED_ROUTES.length).toBeLessThan(SIDEBAR_NAV_ROUTES.length);
16+
for (const routeId of DEFAULT_SIDEBAR_PINNED_ROUTES) {
17+
expect(SIDEBAR_NAV_ROUTES).toContain(routeId);
18+
}
19+
});
20+
21+
it("keeps channel management and settings slices out of the customizable sidebar", () => {
22+
expect(SIDEBAR_NAV_ROUTES).not.toContain("channels");
23+
expect(SIDEBAR_NAV_ROUTES).not.toContain("config");
24+
expect(SETTINGS_NAVIGATION_ROUTES).toContain("channels");
1525
expect(SETTINGS_NAVIGATION_ROUTES.every((routeId) => isSettingsNavigationRoute(routeId))).toBe(
1626
true,
1727
);
1828
});
1929

20-
it("keeps channel management out of the primary control sidebar", () => {
21-
const control = SIDEBAR_SECTIONS.find((group) => group.label === "control");
22-
expect(control?.routes).toEqual([
23-
"overview",
24-
"activity",
25-
"workboard",
26-
"instances",
27-
"sessions",
28-
"usage",
29-
"cron",
30-
]);
31-
expect(SETTINGS_NAVIGATION_ROUTES).toContain("channels");
30+
it("normalizes persisted pinned routes, dropping unknown and duplicate entries", () => {
31+
expect(
32+
normalizeSidebarPinnedRoutes(["workboard", "overview", "workboard", "bogus", 7]),
33+
).toEqual(["workboard", "overview"]);
34+
expect(normalizeSidebarPinnedRoutes([])).toEqual([]);
3235
});
3336

34-
it("keeps the settings group active for nested settings routes", () => {
35-
const settings = SIDEBAR_SECTIONS.find((group) => group.label === "settings");
36-
if (!settings) {
37-
throw new Error("Expected settings group");
38-
}
37+
it("falls back to null for non-list values so callers use defaults", () => {
38+
expect(normalizeSidebarPinnedRoutes(undefined)).toBeNull();
39+
expect(normalizeSidebarPinnedRoutes({ overview: true })).toBeNull();
40+
expect(normalizeSidebarPinnedRoutes("overview")).toBeNull();
41+
});
3942

40-
expect(isRouteInSidebarSection(settings, "appearance")).toBe(true);
41-
expect(isRouteInSidebarSection(settings, "channels")).toBe(true);
42-
expect(isRouteInSidebarSection(settings, "debug")).toBe(true);
43-
expect(isRouteInSidebarSection(settings, "chat")).toBe(false);
43+
it("puts every unpinned nav route into the More section", () => {
44+
const pinned = ["overview", "agents"] as const;
45+
const more = sidebarMoreRoutes(pinned);
46+
expect(more).not.toContain("overview");
47+
expect(more).not.toContain("agents");
48+
expect(new Set([...pinned, ...more])).toEqual(new Set(SIDEBAR_NAV_ROUTES));
4449
});
4550

4651
it("routes every published settings slice", () => {

ui/src/app-navigation.test.ts

Lines changed: 9 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { describe, expect, it } from "vitest";
33
import {
44
SETTINGS_NAVIGATION_ROUTES,
5-
SIDEBAR_SECTIONS,
5+
SIDEBAR_NAV_ROUTES,
66
navigationIconForRoute,
77
subtitleForRoute,
88
titleForRoute,
@@ -17,12 +17,9 @@ import {
1717
} from "./app-routes.ts";
1818
import { pluginTabKey, pluginTabRefFromSearch, pluginTabSearch } from "./pages/plugin/route.ts";
1919

20-
/** All route identifiers derived from visible groups plus routed settings slices. */
20+
/** All route identifiers derived from sidebar nav routes plus routed settings slices. */
2121
const ALL_ROUTES: RouteId[] = Array.from(
22-
new Set<RouteId>([
23-
...(SIDEBAR_SECTIONS.flatMap((group) => group.routes) as RouteId[]),
24-
...SETTINGS_NAVIGATION_ROUTES,
25-
]),
22+
new Set<RouteId>(["chat", ...SIDEBAR_NAV_ROUTES, ...SETTINGS_NAVIGATION_ROUTES]),
2623
);
2724

2825
const leadingSlashNormalizerCases = [
@@ -261,25 +258,18 @@ describe("plugin tabs route", () => {
261258
expect(pluginTabKey({ pluginId: "other", id: "logbook" })).not.toBe(pluginTabKey(ref));
262259
});
263260

264-
it("stays out of the static sidebar sections", () => {
265-
expect(SIDEBAR_SECTIONS.flatMap((g) => g.routes)).not.toContain("plugin");
261+
it("stays out of the customizable static sidebar routes", () => {
262+
expect(SIDEBAR_NAV_ROUTES).not.toContain("plugin");
266263
});
267264
});
268265

269-
describe("SIDEBAR_SECTIONS", () => {
270-
it("contains all expected groups", () => {
271-
expect(SIDEBAR_SECTIONS.map((g) => g.label)).toEqual(["chat", "control", "agent", "settings"]);
272-
});
273-
266+
describe("SIDEBAR_NAV_ROUTES", () => {
274267
it("all routes are unique", () => {
275-
const allRoutes = SIDEBAR_SECTIONS.flatMap((g) => g.routes);
276-
const uniqueRoutes = new Set(allRoutes);
277-
expect(uniqueRoutes.size).toBe(allRoutes.length);
268+
expect(new Set(SIDEBAR_NAV_ROUTES).size).toBe(SIDEBAR_NAV_ROUTES.length);
278269
});
279270

280-
it("keeps detailed settings slices routed but out of the root sidebar", () => {
281-
const settings = SIDEBAR_SECTIONS.find((group) => group.label === "settings");
282-
expect(settings?.routes).toEqual(["config"]);
271+
it("keeps detailed settings slices routed but out of the customizable sidebar", () => {
272+
expect(SIDEBAR_NAV_ROUTES).not.toContain("config");
283273
expect(SETTINGS_NAVIGATION_ROUTES).toEqual([
284274
"config",
285275
"channels",

ui/src/app-navigation.ts

Lines changed: 51 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,61 @@ import { t } from "./i18n/index.ts";
55

66
export type NavigationRouteId = RouteId;
77

8-
type SidebarSection = {
9-
label: string;
10-
routes: readonly NavigationRouteId[];
11-
};
12-
138
type NavigationItem = {
149
[TRouteId in NavigationRouteId]: IconName;
1510
};
1611

17-
export const SIDEBAR_SECTIONS = [
18-
{ label: "chat", routes: ["chat"] },
19-
{
20-
label: "control",
21-
routes: ["overview", "activity", "workboard", "instances", "sessions", "usage", "cron"],
22-
},
23-
{ label: "agent", routes: ["agents", "skills", "skill-workshop", "nodes", "dreams"] },
24-
{ label: "settings", routes: ["config"] },
25-
] as const satisfies readonly SidebarSection[];
12+
// The sidebar shows a small user-customizable pinned set; every other nav route
13+
// lives in the collapsed "More" section. Chat is reachable through the session
14+
// list and Settings/Docs live in the sidebar footer, so neither is listed here.
15+
export const SIDEBAR_NAV_ROUTES = [
16+
"overview",
17+
"activity",
18+
"workboard",
19+
"instances",
20+
"sessions",
21+
"usage",
22+
"cron",
23+
"agents",
24+
"skills",
25+
"skill-workshop",
26+
"nodes",
27+
"dreams",
28+
] as const satisfies readonly NavigationRouteId[];
29+
30+
export type SidebarNavRoute = (typeof SIDEBAR_NAV_ROUTES)[number];
31+
32+
export const DEFAULT_SIDEBAR_PINNED_ROUTES = [
33+
"overview",
34+
"workboard",
35+
"agents",
36+
] as const satisfies readonly SidebarNavRoute[];
37+
38+
/**
39+
* Normalize a persisted pinned-route list. Returns null when the value is not a
40+
* list (caller falls back to defaults); unknown or duplicate entries are dropped
41+
* so prefs survive route renames/removals without a migration.
42+
*/
43+
export function normalizeSidebarPinnedRoutes(value: unknown): SidebarNavRoute[] | null {
44+
if (!Array.isArray(value)) {
45+
return null;
46+
}
47+
const pinned: SidebarNavRoute[] = [];
48+
for (const entry of value) {
49+
if (
50+
typeof entry === "string" &&
51+
(SIDEBAR_NAV_ROUTES as readonly string[]).includes(entry) &&
52+
!pinned.includes(entry as SidebarNavRoute)
53+
) {
54+
pinned.push(entry as SidebarNavRoute);
55+
}
56+
}
57+
return pinned;
58+
}
59+
60+
export function sidebarMoreRoutes(pinned: readonly SidebarNavRoute[]): SidebarNavRoute[] {
61+
return SIDEBAR_NAV_ROUTES.filter((routeId) => !pinned.includes(routeId));
62+
}
2663

2764
export const SETTINGS_NAVIGATION_ROUTES = [
2865
"config",
@@ -68,16 +105,6 @@ export function isSettingsNavigationRoute(routeId: NavigationRouteId): boolean {
68105
return (SETTINGS_NAVIGATION_ROUTES as readonly NavigationRouteId[]).includes(routeId);
69106
}
70107

71-
export function isRouteInSidebarSection(
72-
section: SidebarSection,
73-
routeId: NavigationRouteId,
74-
): boolean {
75-
if (section.label === "settings") {
76-
return isSettingsNavigationRoute(routeId);
77-
}
78-
return section.routes.includes(routeId);
79-
}
80-
81108
export function navigationIconForRoute(routeId: NavigationRouteId): IconName {
82109
return NAVIGATION_ICONS[routeId] ?? "folder";
83110
}

ui/src/app/app-host.ts

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import "../components/login-gate.ts";
1212
import "../components/terminal/terminal-panel.ts";
1313
import "../components/tooltip.ts";
1414
import "../components/update-banner.ts";
15+
import type { SidebarNavRoute } from "../app-navigation.ts";
1516
import { APP_ROUTE_IDS, isRouteId, pathForRoute, type RouteId } from "../app-routes.ts";
1617
import {
1718
COMMAND_PALETTE_TARGET_EVENT,
@@ -303,7 +304,8 @@ class OpenClawShell extends LitElement {
303304
private context?: ApplicationContext<RouteId>;
304305

305306
@state() private navCollapsed = false;
306-
@state() private navGroupsCollapsed: Record<string, boolean> = {};
307+
@state() private sidebarPinnedRoutes: readonly SidebarNavRoute[] = [];
308+
@state() private sidebarMoreExpanded = false;
307309
@state() private recentSessionsCollapsed = false;
308310
@state() private navDrawerOpen = false;
309311
@state() private gatewayConnected = false;
@@ -595,7 +597,8 @@ class OpenClawShell extends LitElement {
595597
snapshot: ApplicationRuntime["context"]["navigation"]["snapshot"],
596598
) => {
597599
this.navCollapsed = snapshot.navCollapsed;
598-
this.navGroupsCollapsed = snapshot.navGroupsCollapsed;
600+
this.sidebarPinnedRoutes = snapshot.sidebarPinnedRoutes;
601+
this.sidebarMoreExpanded = snapshot.sidebarMoreExpanded;
599602
this.recentSessionsCollapsed = snapshot.recentSessionsCollapsed;
600603
};
601604

@@ -612,6 +615,8 @@ class OpenClawShell extends LitElement {
612615
? pluginTabKey(pluginTabRefFromSearch(this.routeState.location?.search ?? ""))
613616
: "";
614617
const navDrawerOpen = this.navDrawerOpen && !this.onboarding;
618+
// Drawer navigation always opens expanded; the desktop collapse preference
619+
// stays persisted for when the viewport returns to the desktop layout.
615620
const navCollapsed = this.navCollapsed && !navDrawerOpen;
616621
return html`
617622
<openclaw-command-palette
@@ -643,6 +648,11 @@ class OpenClawShell extends LitElement {
643648
.themeMode=${context.theme.mode}
644649
.onboarding=${this.onboarding}
645650
.onOpenPalette=${this.openPalette}
651+
.navCollapsed=${navCollapsed}
652+
.onToggleSidebar=${() =>
653+
context.navigation.update({
654+
navCollapsed: !navCollapsed,
655+
})}
646656
.terminalAvailable=${this.terminalAvailable}
647657
.onToggleTerminal=${() =>
648658
window.dispatchEvent(new CustomEvent("openclaw:terminal-toggle"))}
@@ -661,27 +671,16 @@ class OpenClawShell extends LitElement {
661671
.connected=${this.gatewayConnected}
662672
.canPairDevice=${this.gatewayConnected &&
663673
hasOperatorAdminAccess(context.gateway.snapshot.hello?.auth ?? null)}
664-
.navGroupsCollapsed=${this.navGroupsCollapsed}
674+
.sidebarPinnedRoutes=${this.sidebarPinnedRoutes}
675+
.sidebarMoreExpanded=${this.sidebarMoreExpanded}
665676
.recentSessionsCollapsed=${this.recentSessionsCollapsed}
666677
.themeMode=${context.theme.mode}
667-
.onToggleCollapsed=${() => {
668-
if (navDrawerOpen) {
669-
this.closeNavDrawer({ restoreFocus: true });
670-
return;
671-
}
678+
.onToggleMore=${() =>
672679
context.navigation.update({
673-
navCollapsed: !navCollapsed,
674-
});
675-
}}
676-
.onToggleGroup=${(label: string) => {
677-
const current = context.navigation.snapshot.navGroupsCollapsed[label] ?? false;
678-
context.navigation.update({
679-
navGroupsCollapsed: {
680-
...context.navigation.snapshot.navGroupsCollapsed,
681-
[label]: !current,
682-
},
683-
});
684-
}}
680+
sidebarMoreExpanded: !context.navigation.snapshot.sidebarMoreExpanded,
681+
})}
682+
.onUpdatePinnedRoutes=${(routes: SidebarNavRoute[]) =>
683+
context.navigation.update({ sidebarPinnedRoutes: routes })}
685684
.onToggleRecentSessions=${() =>
686685
context.navigation.update({
687686
recentSessionsCollapsed: !context.navigation.snapshot.recentSessionsCollapsed,

ui/src/app/bootstrap.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,8 @@ function createApplicationNavigationPreferences(
169169
let settings = initialSettings;
170170
let snapshot: ApplicationNavigationPreferencesSnapshot = {
171171
navCollapsed: settings.navCollapsed,
172-
navGroupsCollapsed: settings.navGroupsCollapsed,
172+
sidebarPinnedRoutes: settings.sidebarPinnedRoutes,
173+
sidebarMoreExpanded: settings.sidebarMoreExpanded,
173174
recentSessionsCollapsed: settings.recentSessionsCollapsed ?? false,
174175
};
175176
const listeners = new Set<(next: ApplicationNavigationPreferencesSnapshot) => void>();
@@ -183,13 +184,15 @@ function createApplicationNavigationPreferences(
183184
if (
184185
nextSnapshot.navCollapsed === snapshot.navCollapsed &&
185186
nextSnapshot.recentSessionsCollapsed === snapshot.recentSessionsCollapsed &&
186-
nextSnapshot.navGroupsCollapsed === snapshot.navGroupsCollapsed
187+
nextSnapshot.sidebarPinnedRoutes === snapshot.sidebarPinnedRoutes &&
188+
nextSnapshot.sidebarMoreExpanded === snapshot.sidebarMoreExpanded
187189
) {
188190
return;
189191
}
190192
settings = patchSettings({
191193
navCollapsed: nextSnapshot.navCollapsed,
192-
navGroupsCollapsed: nextSnapshot.navGroupsCollapsed,
194+
sidebarPinnedRoutes: [...nextSnapshot.sidebarPinnedRoutes],
195+
sidebarMoreExpanded: nextSnapshot.sidebarMoreExpanded,
193196
recentSessionsCollapsed: nextSnapshot.recentSessionsCollapsed,
194197
});
195198
snapshot = nextSnapshot;

ui/src/app/context.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createContext } from "@lit/context";
22
import type { RouteLocation } from "@openclaw/uirouter";
3+
import type { SidebarNavRoute } from "../app-navigation.ts";
34
import type { RouteId } from "../app-route-paths.ts";
45
import type { AgentIdentityCapability } from "../lib/agents/identity.ts";
56
import type { AgentCapability } from "../lib/agents/index.ts";
@@ -31,7 +32,8 @@ export type ApplicationTheme = {
3132

3233
export type ApplicationNavigationPreferencesSnapshot = {
3334
navCollapsed: boolean;
34-
navGroupsCollapsed: Record<string, boolean>;
35+
sidebarPinnedRoutes: readonly SidebarNavRoute[];
36+
sidebarMoreExpanded: boolean;
3537
recentSessionsCollapsed: boolean;
3638
};
3739

0 commit comments

Comments
 (0)