feat(ui): make the sidebar session-first and minimal (#100386)

* feat(ui): bolder sidebar minimalism — pinned/chats session groups, compact icon footer, no brand header

* test(ui): align fixtures with single default pinned route; document pinned-cap invariant

* fix(ui): preserve sidebar session groups

* fix(ui): polish minimal sidebar behavior

* fix(ui): avoid duplicate pinned chat fallback

* docs(changelog): note sidebar layout refresh

* chore(i18n): preserve sidebar locale metadata

* fix(ui): reuse localized sessions label

* test(ui): follow localized sessions label

* chore(ui): trim sidebar landing churn

* docs(ui): update minimal sidebar guidance
This commit is contained in:
Peter Steinberger
2026-07-06 02:08:02 +01:00
committed by GitHub
parent b170c08e6d
commit 60cf7eaa00
18 changed files with 348 additions and 468 deletions
+2 -2
View File
@@ -113,9 +113,9 @@ Appearance also has a browser-local Text size setting, stored with the rest of C
## Sidebar navigation
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.
The sidebar keeps sessions first, split into **Pinned** and **Sessions** groups. Every pinned session stays visible, while unpinned sessions keep an independent nine-item recent budget. **Overview** is the only destination 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.
**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.
The compact footer keeps connection status, **Settings**, **Docs**, and mobile pairing together. 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.
## What it can do (today)
+2 -2
View File
@@ -29,10 +29,10 @@ export const SIDEBAR_NAV_ROUTES = [
export type SidebarNavRoute = (typeof SIDEBAR_NAV_ROUTES)[number];
// Sessions are the sidebar's core content; Overview is the only page pinned by
// default. Users pin more via the customize menu.
export const DEFAULT_SIDEBAR_PINNED_ROUTES = [
"overview",
"workboard",
"agents",
] as const satisfies readonly SidebarNavRoute[];
/**
-7
View File
@@ -306,7 +306,6 @@ class OpenClawShell extends LitElement {
@state() private navCollapsed = false;
@state() private sidebarPinnedRoutes: readonly SidebarNavRoute[] = [];
@state() private sidebarMoreExpanded = false;
@state() private recentSessionsCollapsed = false;
@state() private navDrawerOpen = false;
@state() private gatewayConnected = false;
@state() private terminalAvailable = false;
@@ -599,7 +598,6 @@ class OpenClawShell extends LitElement {
this.navCollapsed = snapshot.navCollapsed;
this.sidebarPinnedRoutes = snapshot.sidebarPinnedRoutes;
this.sidebarMoreExpanded = snapshot.sidebarMoreExpanded;
this.recentSessionsCollapsed = snapshot.recentSessionsCollapsed;
};
override render() {
@@ -673,7 +671,6 @@ class OpenClawShell extends LitElement {
hasOperatorAdminAccess(context.gateway.snapshot.hello?.auth ?? null)}
.sidebarPinnedRoutes=${this.sidebarPinnedRoutes}
.sidebarMoreExpanded=${this.sidebarMoreExpanded}
.recentSessionsCollapsed=${this.recentSessionsCollapsed}
.themeMode=${context.theme.mode}
.onToggleMore=${() =>
context.navigation.update({
@@ -681,10 +678,6 @@ class OpenClawShell extends LitElement {
})}
.onUpdatePinnedRoutes=${(routes: SidebarNavRoute[]) =>
context.navigation.update({ sidebarPinnedRoutes: routes })}
.onToggleRecentSessions=${() =>
context.navigation.update({
recentSessionsCollapsed: !context.navigation.snapshot.recentSessionsCollapsed,
})}
.onPairMobile=${() => void context.overlays.openDevicePairSetup()}
.onNavigate=${(routeId: string, options?: ApplicationNavigationOptions) =>
this.navigate(routeId, options)}
-3
View File
@@ -171,7 +171,6 @@ function createApplicationNavigationPreferences(
navCollapsed: settings.navCollapsed,
sidebarPinnedRoutes: settings.sidebarPinnedRoutes,
sidebarMoreExpanded: settings.sidebarMoreExpanded,
recentSessionsCollapsed: settings.recentSessionsCollapsed ?? false,
};
const listeners = new Set<(next: ApplicationNavigationPreferencesSnapshot) => void>();
@@ -183,7 +182,6 @@ function createApplicationNavigationPreferences(
const nextSnapshot = { ...snapshot, ...patch };
if (
nextSnapshot.navCollapsed === snapshot.navCollapsed &&
nextSnapshot.recentSessionsCollapsed === snapshot.recentSessionsCollapsed &&
nextSnapshot.sidebarPinnedRoutes === snapshot.sidebarPinnedRoutes &&
nextSnapshot.sidebarMoreExpanded === snapshot.sidebarMoreExpanded
) {
@@ -193,7 +191,6 @@ function createApplicationNavigationPreferences(
navCollapsed: nextSnapshot.navCollapsed,
sidebarPinnedRoutes: [...nextSnapshot.sidebarPinnedRoutes],
sidebarMoreExpanded: nextSnapshot.sidebarMoreExpanded,
recentSessionsCollapsed: nextSnapshot.recentSessionsCollapsed,
});
snapshot = nextSnapshot;
for (const listener of listeners) {
-1
View File
@@ -34,7 +34,6 @@ export type ApplicationNavigationPreferencesSnapshot = {
navCollapsed: boolean;
sidebarPinnedRoutes: readonly SidebarNavRoute[];
sidebarMoreExpanded: boolean;
recentSessionsCollapsed: boolean;
};
export type ApplicationNavigationPreferences = {
+14 -76
View File
@@ -146,9 +146,8 @@ describe("loadSettings default gateway URL derivation", () => {
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
sidebarPinnedRoutes: ["overview", "workboard", "agents"],
sidebarPinnedRoutes: ["overview"],
sidebarMoreExpanded: false,
recentSessionsCollapsed: false,
borderRadius: 50,
textScale: 100,
sessionsByGateway: {
@@ -182,7 +181,7 @@ describe("loadSettings default gateway URL derivation", () => {
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
sidebarPinnedRoutes: ["overview", "workboard", "agents"],
sidebarPinnedRoutes: ["overview"],
sidebarMoreExpanded: false,
borderRadius: 50,
textScale: 100,
@@ -215,7 +214,7 @@ describe("loadSettings default gateway URL derivation", () => {
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
sidebarPinnedRoutes: ["overview", "workboard", "agents"],
sidebarPinnedRoutes: ["overview"],
sidebarMoreExpanded: false,
borderRadius: 50,
});
@@ -233,7 +232,7 @@ describe("loadSettings default gateway URL derivation", () => {
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
sidebarPinnedRoutes: ["overview", "workboard", "agents"],
sidebarPinnedRoutes: ["overview"],
sidebarMoreExpanded: false,
borderRadius: 50,
});
@@ -263,7 +262,7 @@ describe("loadSettings default gateway URL derivation", () => {
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
sidebarPinnedRoutes: ["overview", "workboard", "agents"],
sidebarPinnedRoutes: ["overview"],
sidebarMoreExpanded: false,
borderRadius: 50,
});
@@ -283,9 +282,8 @@ describe("loadSettings default gateway URL derivation", () => {
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
sidebarPinnedRoutes: ["overview", "workboard", "agents"],
sidebarPinnedRoutes: ["overview"],
sidebarMoreExpanded: false,
recentSessionsCollapsed: false,
borderRadius: 50,
textScale: 100,
sessionsByGateway: {
@@ -298,65 +296,6 @@ describe("loadSettings default gateway URL derivation", () => {
expect(sessionStorage.length).toBe(1);
});
it("persists recent sessions collapse state across save and load", () => {
setTestLocation({
protocol: "https:",
host: "gateway.example:8443",
pathname: "/",
});
const gwUrl = expectedGatewayUrl("");
saveSettings({
gatewayUrl: gwUrl,
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "system",
chatShowThinking: true,
chatShowToolCalls: true,
chatAutoScroll: "near-bottom",
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
sidebarPinnedRoutes: ["overview", "workboard", "agents"],
sidebarMoreExpanded: false,
recentSessionsCollapsed: true,
borderRadius: 50,
textScale: 100,
});
expect(loadSettings().recentSessionsCollapsed).toBe(true);
saveSettings({
gatewayUrl: gwUrl,
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "system",
chatShowThinking: true,
chatShowToolCalls: true,
chatAutoScroll: "near-bottom",
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
sidebarPinnedRoutes: ["overview", "workboard", "agents"],
sidebarMoreExpanded: false,
recentSessionsCollapsed: false,
borderRadius: 50,
textScale: 100,
});
const scopedKey = `openclaw.control.settings.v1:${gwUrl}`;
const persisted = JSON.parse(localStorage.getItem(scopedKey) ?? "{}") as Record<
string,
unknown
>;
expect(persisted.recentSessionsCollapsed).toBe(false);
expect(loadSettings().recentSessionsCollapsed).toBe(false);
});
it("persists sidebar customization across save and load, normalizing bad values", () => {
setTestLocation({
protocol: "https:",
@@ -380,7 +319,6 @@ describe("loadSettings default gateway URL derivation", () => {
navWidth: 220,
sidebarPinnedRoutes: ["sessions", "cron"],
sidebarMoreExpanded: true,
recentSessionsCollapsed: false,
borderRadius: 50,
textScale: 100,
});
@@ -398,7 +336,7 @@ describe("loadSettings default gateway URL derivation", () => {
persisted.sidebarMoreExpanded = "yes";
localStorage.setItem(scopedKey, JSON.stringify(persisted));
expect(loadSettings().sidebarPinnedRoutes).toEqual(["overview", "workboard", "agents"]);
expect(loadSettings().sidebarPinnedRoutes).toEqual(["overview"]);
expect(loadSettings().sidebarMoreExpanded).toBe(false);
});
@@ -468,7 +406,7 @@ describe("loadSettings default gateway URL derivation", () => {
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
sidebarPinnedRoutes: ["overview", "workboard", "agents"],
sidebarPinnedRoutes: ["overview"],
sidebarMoreExpanded: false,
borderRadius: 50,
});
@@ -484,7 +422,7 @@ describe("loadSettings default gateway URL derivation", () => {
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
sidebarPinnedRoutes: ["overview", "workboard", "agents"],
sidebarPinnedRoutes: ["overview"],
sidebarMoreExpanded: false,
borderRadius: 50,
});
@@ -513,7 +451,7 @@ describe("loadSettings default gateway URL derivation", () => {
splitRatio: 0.6,
navCollapsed: false,
navWidth: 320,
sidebarPinnedRoutes: ["overview", "workboard", "agents"],
sidebarPinnedRoutes: ["overview"],
sidebarMoreExpanded: false,
borderRadius: 50,
});
@@ -549,7 +487,7 @@ describe("loadSettings default gateway URL derivation", () => {
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
sidebarPinnedRoutes: ["overview", "workboard", "agents"],
sidebarPinnedRoutes: ["overview"],
sidebarMoreExpanded: false,
borderRadius: 50,
customTheme,
@@ -580,7 +518,7 @@ describe("loadSettings default gateway URL derivation", () => {
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
sidebarPinnedRoutes: ["overview", "workboard", "agents"],
sidebarPinnedRoutes: ["overview"],
sidebarMoreExpanded: false,
borderRadius: 50,
customTheme: {
@@ -625,7 +563,7 @@ describe("loadSettings default gateway URL derivation", () => {
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
sidebarPinnedRoutes: ["overview", "workboard", "agents"],
sidebarPinnedRoutes: ["overview"],
sidebarMoreExpanded: false,
borderRadius: 50,
});
@@ -669,7 +607,7 @@ describe("loadSettings default gateway URL derivation", () => {
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
sidebarPinnedRoutes: ["overview", "workboard", "agents"],
sidebarPinnedRoutes: ["overview"],
sidebarMoreExpanded: false,
borderRadius: 50,
});
-7
View File
@@ -104,7 +104,6 @@ export type UiSettings = {
navWidth: number; // Sidebar width when expanded (240400px)
sidebarPinnedRoutes: SidebarNavRoute[]; // Nav routes shown above the "More" section
sidebarMoreExpanded: boolean; // Whether the sidebar "More" section is expanded
recentSessionsCollapsed?: boolean; // Collapse recent sessions list in sidebar
borderRadius: number; // Corner roundness (0100, default 50)
textScale?: TextScaleStop; // Browser-local text scale percentage
customTheme?: ImportedCustomTheme;
@@ -467,7 +466,6 @@ export function loadSettings(): UiSettings {
navWidth: 220,
sidebarPinnedRoutes: [...DEFAULT_SIDEBAR_PINNED_ROUTES],
sidebarMoreExpanded: false,
recentSessionsCollapsed: false,
borderRadius: 50,
textScale: 100,
};
@@ -530,10 +528,6 @@ export function loadSettings(): UiSettings {
typeof parsed.sidebarMoreExpanded === "boolean"
? parsed.sidebarMoreExpanded
: defaults.sidebarMoreExpanded,
recentSessionsCollapsed:
typeof parsed.recentSessionsCollapsed === "boolean"
? parsed.recentSessionsCollapsed
: defaults.recentSessionsCollapsed,
borderRadius:
typeof parsed.borderRadius === "number" &&
parsed.borderRadius >= 0 &&
@@ -637,7 +631,6 @@ function persistSettings(next: UiSettings) {
navWidth: next.navWidth,
sidebarPinnedRoutes: next.sidebarPinnedRoutes,
sidebarMoreExpanded: next.sidebarMoreExpanded,
recentSessionsCollapsed: next.recentSessionsCollapsed ?? false,
borderRadius: next.borderRadius,
textScale: normalizeTextScale(next.textScale),
...(next.customTheme ? { customTheme: next.customTheme } : {}),
+125 -147
View File
@@ -22,7 +22,6 @@ import {
type ApplicationContext,
type ApplicationNavigationOptions,
} from "../app/context.ts";
import { controlUiPublicAssetPath } from "../app/public-assets.ts";
import "./theme-mode-toggle.ts";
import "./session-picker.ts";
import "./tooltip.ts";
@@ -32,11 +31,7 @@ import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../lib/external-link
import { formatRelativeTimestamp } from "../lib/format.ts";
import { startHoverMarquee, stopHoverMarquee } from "../lib/hover-marquee.ts";
import { resolveSessionDisplayName } from "../lib/session-display.ts";
import {
compareSessionRowsByUpdatedAt,
resolveSessionNavigation,
searchForSession,
} from "../lib/sessions/index.ts";
import { resolveSessionNavigation, searchForSession } from "../lib/sessions/index.ts";
import {
buildAgentMainSessionKey,
canArchiveSessionRow,
@@ -60,7 +55,6 @@ type SidebarRecentSession = {
hasActiveRun: boolean;
kind?: string;
pinned: boolean;
pinnedAt?: number | null;
};
function shouldHandleNavigationClick(event: MouseEvent): boolean {
@@ -90,11 +84,9 @@ export class AppSidebar extends LitElement {
@property({ attribute: false }) sidebarPinnedRoutes: readonly SidebarNavRoute[] =
DEFAULT_SIDEBAR_PINNED_ROUTES;
@property({ attribute: false }) sidebarMoreExpanded = false;
@property({ attribute: false }) recentSessionsCollapsed = false;
@property({ attribute: false }) themeMode: ThemeMode = "system";
@property({ attribute: false }) onToggleMore?: () => void;
@property({ attribute: false }) onUpdatePinnedRoutes?: (routes: SidebarNavRoute[]) => void;
@property({ attribute: false }) onToggleRecentSessions?: () => void;
@property({ attribute: false }) onPairMobile?: () => void;
@property({ attribute: false })
onNavigate?: (routeId: NavigationRouteId, options?: ApplicationNavigationOptions) => void;
@@ -224,14 +216,12 @@ export class AppSidebar extends LitElement {
hasActiveRun: Boolean(row.hasActiveRun),
kind: row.kind,
pinned: row.pinned === true,
pinnedAt: row.pinnedAt,
});
const activeSession = navigation.selectedSession
? toSidebarSession(navigation.selectedSession)
: null;
const recentSessions = navigation.recentSessions
.slice(activeSession ? 1 : 0)
.toSorted(compareSessionRowsByUpdatedAt)
.map(toSidebarSession);
const newSessionDisabled =
!this.connected || this.sessionsLoading || Boolean(navigation.selectedSession?.hasActiveRun);
@@ -673,6 +663,11 @@ export class AppSidebar extends LitElement {
>`}
</button>
`;
// Pinned rows stay separate from the recency-capped chat list; the active
// session leads whichever group owns it.
const allRows = [...(activeSession ? [activeSession] : []), ...recentSessions];
const pinnedRows = allRows.filter((session) => session.pinned);
const chatRows = allRows.filter((session) => !session.pinned);
return html`
<section class="sidebar-sessions ${this.collapsed ? "sidebar-sessions--collapsed" : ""}">
${this.collapsed
@@ -683,66 +678,64 @@ export class AppSidebar extends LitElement {
${this.collapsed
? nothing
: html`
<div
class="sidebar-recent-sessions ${this.recentSessionsCollapsed
? "sidebar-recent-sessions--collapsed"
: ""}"
aria-label=${t("overview.cards.recentSessions")}
>
<div class="sidebar-recent-sessions__head">
<button
class="sidebar-recent-sessions__label"
type="button"
aria-expanded=${String(!this.recentSessionsCollapsed)}
@click=${() => this.onToggleRecentSessions?.()}
>
<span class="sidebar-recent-sessions__label-text"
>${t("usage.sessions.recentShort")}</span
>
<span class="sidebar-recent-sessions__chevron"> ${icons.chevronDown} </span>
</button>
<openclaw-session-picker
.sessions=${context?.sessions}
.sessionsResult=${this.sessionsResult}
.currentSessionKey=${routeSessionKey}
.agentId=${selectedAgentId}
.defaultAgentId=${defaultAgentId}
.mainKey=${resolveUiConfiguredMainKey({
agentsList: context?.agents.state.agentsList,
hello: context?.gateway.snapshot.hello,
})}
.connected=${this.connected}
.onSelectSession=${this.selectSession}
.onReplaceCurrentSession=${this.replaceCurrentSession}
></openclaw-session-picker>
</div>
${this.renderAgentFilter(routeSessionKey, selectedAgentId)}
${activeSession
? this.renderRecentSession(activeSession)
: this.renderChatFallback()}
${recentSessions.length === 0
<div class="sidebar-recent-sessions" aria-label=${titleForRoute("sessions")}>
${pinnedRows.length === 0
? nothing
: html`
<div class="sidebar-recent-sessions__list">
${recentSessions.map((session) => this.renderRecentSession(session))}
<div class="sidebar-recent-sessions__group">
<div class="sidebar-recent-sessions__head">
<span class="sidebar-recent-sessions__label-text"
>${t("sessionsView.pinned")}</span
>
</div>
<div class="sidebar-recent-sessions__list">
${pinnedRows.map((session) => this.renderRecentSession(session))}
</div>
</div>
`}
<a
href=${pathForRoute("sessions", this.basePath)}
class="sidebar-recent-sessions__all"
@click=${(event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
return;
}
event.preventDefault();
this.onNavigate?.("sessions");
}}
>
<span>${t("chat.sidebar.allSessions")}</span>
<span class="sidebar-recent-sessions__all-icon" aria-hidden="true"
>${icons.chevronRight}</span
<div class="sidebar-recent-sessions__group">
<div class="sidebar-recent-sessions__head">
<span class="sidebar-recent-sessions__label-text"
>${t("sessionsView.title")}</span
>
<openclaw-session-picker
.sessions=${context?.sessions}
.sessionsResult=${this.sessionsResult}
.currentSessionKey=${routeSessionKey}
.agentId=${selectedAgentId}
.defaultAgentId=${defaultAgentId}
.mainKey=${resolveUiConfiguredMainKey({
agentsList: context?.agents.state.agentsList,
hello: context?.gateway.snapshot.hello,
})}
.connected=${this.connected}
.onSelectSession=${this.selectSession}
.onReplaceCurrentSession=${this.replaceCurrentSession}
></openclaw-session-picker>
</div>
${this.renderAgentFilter(routeSessionKey, selectedAgentId)}
<div class="sidebar-recent-sessions__list">
${allRows.length === 0
? this.renderChatFallback()
: chatRows.map((session) => this.renderRecentSession(session))}
</div>
<a
href=${pathForRoute("sessions", this.basePath)}
class="sidebar-recent-sessions__all"
@click=${(event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
return;
}
event.preventDefault();
this.onNavigate?.("sessions");
}}
>
</a>
<span>${t("chat.sidebar.allSessions")}</span>
<span class="sidebar-recent-sessions__all-icon" aria-hidden="true"
>${icons.chevronRight}</span
>
</a>
</div>
</div>
`}
</section>
@@ -846,25 +839,11 @@ export class AppSidebar extends LitElement {
const gatewayStatus = t("chat.gatewayStatus", {
status: this.connected ? t("common.online") : t("common.offline"),
});
const settingsActive =
this.activeRouteId !== undefined && isSettingsNavigationRoute(this.activeRouteId);
return html`
<aside class="sidebar ${this.collapsed ? "sidebar--collapsed" : ""}">
<div class="sidebar-shell">
<div class="sidebar-shell__header">
<div class="sidebar-brand">
<img
class="sidebar-brand__logo"
src="${controlUiPublicAssetPath("favicon.svg", this.basePath)}"
alt="OpenClaw"
/>
${this.collapsed
? nothing
: html`
<span class="sidebar-brand__copy">
<span class="sidebar-brand__title">OpenClaw</span>
</span>
`}
</div>
</div>
<div class="sidebar-shell__body">
${this.renderSessions()}
<nav class="sidebar-nav" @contextmenu=${this.openCustomizeMenuFromContext}>
@@ -876,72 +855,71 @@ export class AppSidebar extends LitElement {
</nav>
</div>
<div class="sidebar-shell__footer">
<div class="sidebar-utility-group">
${this.renderRoute("config")}
${this.collapsed
? html`
<openclaw-tooltip
.content=${t("chat.docsOpensInNewTab", { label: t("common.docs") })}
>
<a
class="nav-item nav-item--external sidebar-utility-link"
href="https://docs.openclaw.ai"
target=${EXTERNAL_LINK_TARGET}
rel=${buildExternalLinkRel()}
>
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span>
</a>
</openclaw-tooltip>
`
: html`
<a
class="nav-item nav-item--external sidebar-utility-link"
href="https://docs.openclaw.ai"
target=${EXTERNAL_LINK_TARGET}
rel=${buildExternalLinkRel()}
>
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span>
<span class="nav-item__text">${t("common.docs")}</span>
<span class="nav-item__external-icon">${icons.externalLink}</span>
</a>
`}
<div class="sidebar-mode-switch">
<openclaw-theme-mode-toggle .mode=${this.themeMode}></openclaw-theme-mode-toggle>
</div>
<div class="sidebar-footer-row">
<div class="sidebar-status">
<openclaw-tooltip .content=${gatewayStatus}>
<span
class="sidebar-status__dot ${this.connected
? "sidebar-connection-status--online"
: "sidebar-connection-status--offline"}"
role="img"
aria-live="polite"
aria-label=${gatewayStatus}
></span>
</openclaw-tooltip>
${this.collapsed
? nothing
: html`<span class="sidebar-status__text"
>${this.connected ? t("common.online") : t("common.offline")}</span
>`}
</div>
<openclaw-tooltip
.content=${this.canPairDevice
? t("nodes.pairing.button")
: t("nodes.pairing.adminRequired")}
<div class="sidebar-footer-bar">
<openclaw-tooltip .content=${gatewayStatus}>
<span
class="sidebar-status__dot ${this.connected
? "sidebar-connection-status--online"
: "sidebar-connection-status--offline"}"
role="img"
aria-live="polite"
aria-label=${gatewayStatus}
></span>
</openclaw-tooltip>
<span class="sidebar-footer-bar__spacer"></span>
<openclaw-tooltip .content=${titleForRoute("config")}>
<a
href=${pathForRoute("config", this.basePath)}
class="sidebar-footer-icon ${settingsActive ? "sidebar-footer-icon--active" : ""}"
aria-label=${titleForRoute("config")}
aria-current=${settingsActive ? "page" : nothing}
@focus=${(event: Event) => this.preloadRoute("config", event)}
@blur=${this.cancelPreload}
@pointerenter=${(event: Event) => this.preloadRoute("config", event)}
@pointerleave=${this.cancelPreload}
@touchstart=${(event: TouchEvent) => this.preloadRoute("config", event, true)}
@click=${(event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
return;
}
event.preventDefault();
this.onNavigate?.("config");
}}
>
<button
class="sidebar-pair-mobile"
type="button"
aria-label=${t("nodes.pairing.button")}
?disabled=${!this.canPairDevice}
@click=${() => this.onPairMobile?.()}
>
<span aria-hidden="true">${icons.smartphone}</span>
</button>
</openclaw-tooltip>
</div>
${icons.settings}
</a>
</openclaw-tooltip>
<openclaw-tooltip
.content=${t("chat.docsOpensInNewTab", { label: t("common.docs") })}
>
<a
class="sidebar-footer-icon"
href="https://docs.openclaw.ai"
target=${EXTERNAL_LINK_TARGET}
rel=${buildExternalLinkRel()}
aria-label=${t("common.docs")}
>
${icons.book}
</a>
</openclaw-tooltip>
<openclaw-tooltip
.content=${this.canPairDevice
? t("nodes.pairing.button")
: t("nodes.pairing.adminRequired")}
>
<button
class="sidebar-footer-icon sidebar-pair-mobile"
type="button"
aria-label=${t("nodes.pairing.button")}
?disabled=${!this.canPairDevice}
@click=${() => this.onPairMobile?.()}
>
${icons.smartphone}
</button>
</openclaw-tooltip>
<span class="sidebar-mode-switch">
<openclaw-theme-mode-toggle .mode=${this.themeMode}></openclaw-theme-mode-toggle>
</span>
</div>
</div>
</div>
+47
View File
@@ -92,6 +92,10 @@ function actionOpacity(button: Locator): Promise<string> {
return button.evaluate((element) => globalThis.getComputedStyle(element).opacity);
}
async function trimmedTextContents(locator: Locator): Promise<string[]> {
return (await locator.allTextContents()).map((text) => text.trim());
}
async function captureUiProof(page: Page, fileName: string) {
if (process.env.OPENCLAW_CAPTURE_UI_PROOF !== "1") {
return;
@@ -151,6 +155,15 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
const sidebarRows = page.locator(".sidebar-recent-sessions__list .sidebar-recent-session");
await sidebarRows.first().waitFor({ state: "visible", timeout: 10_000 });
await expect.poll(() => sidebarRows.first().textContent()).toContain("Release planning");
const sessionGroups = page.locator(".sidebar-recent-sessions__group");
const pinnedGroup = sessionGroups.filter({ hasText: "Pinned" });
const chatsGroup = sessionGroups.filter({ hasText: "Sessions" });
await expect
.poll(() => trimmedTextContents(pinnedGroup.locator(".sidebar-recent-session__name")))
.toEqual(["Release planning"]);
await expect
.poll(() => trimmedTextContents(chatsGroup.locator(".sidebar-recent-session__name")))
.toEqual(["Main", "Data migration", "Research notes"]);
const sidebarMigration = sidebarRows.filter({ hasText: "Data migration" });
await expect
.poll(() => sidebarMigration.locator(".session-run-spinner").isVisible())
@@ -237,4 +250,38 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
await context.close();
}
});
it("does not duplicate the active chat when its only session is pinned", async () => {
const context = await browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
await installMockGateway(page, {
methodResponses: {
"sessions.list": sessionsListResponse([
sessionRow("agent:main:pinned", "Pinned only", Date.parse("2026-07-01T16:00:00.000Z"), {
pinned: true,
}),
]),
},
sessionKey: "agent:main:pinned",
});
try {
await page.goto(`${server.baseUrl}chat`);
const sessionGroups = page.locator(".sidebar-recent-sessions__group");
const pinnedGroup = sessionGroups.filter({ hasText: "Pinned" });
const chatsGroup = sessionGroups.filter({ hasText: "Sessions" });
await expect
.poll(() => trimmedTextContents(pinnedGroup.locator(".sidebar-recent-session__name")))
.toEqual(["Pinned only"]);
await expect.poll(() => chatsGroup.locator(".sidebar-recent-session").count()).toBe(0);
await expect.poll(() => page.locator(".sidebar-recent-session--active").count()).toBe(1);
} finally {
await context.close();
}
});
});
+17 -8
View File
@@ -69,9 +69,15 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () =
const sidebar = page.locator("openclaw-app-sidebar");
const pinnedItems = sidebar.locator(".sidebar-nav > .nav-section__items > .nav-item");
await expect
.poll(() => trimmedTextContents(pinnedItems))
.toEqual(["Overview", "Workboard", "Agents"]);
await expect.poll(() => trimmedTextContents(pinnedItems)).toEqual(["Overview"]);
await expect.poll(() => sidebar.locator(".sidebar-brand").count()).toBe(0);
const settingsLink = sidebar.getByRole("link", { name: "Settings" });
await expect.poll(() => settingsLink.isVisible()).toBe(true);
await settingsLink.click();
await expect.poll(() => new URL(page.url()).pathname).toBe("/config");
await expect.poll(() => settingsLink.getAttribute("aria-current")).toBe("page");
await sidebar.getByRole("link", { name: "Overview" }).click();
await expect.poll(() => new URL(page.url()).pathname).toBe("/overview");
await captureUiProof(page, "01-default-pinned.png");
const moreButton = sidebar.getByRole("button", { name: "More" });
@@ -98,9 +104,9 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () =
await captureUiProof(page, "02-customize-menu.png");
await overviewItem.click();
await expect.poll(() => trimmedTextContents(pinnedItems)).toEqual(["Workboard", "Agents"]);
await expect.poll(() => trimmedTextContents(pinnedItems)).toEqual([]);
await page.reload();
await expect.poll(() => trimmedTextContents(pinnedItems)).toEqual(["Workboard", "Agents"]);
await expect.poll(() => trimmedTextContents(pinnedItems)).toEqual([]);
await expect.poll(() => moreButton.getAttribute("aria-expanded")).toBe("true");
await expect
.poll(() =>
@@ -113,9 +119,7 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () =
await customizeButton.click();
await menu.getByRole("menuitem", { name: "Reset to defaults" }).click();
await expect
.poll(() => trimmedTextContents(pinnedItems))
.toEqual(["Overview", "Workboard", "Agents"]);
await expect.poll(() => trimmedTextContents(pinnedItems)).toEqual(["Overview"]);
const collapseButton = page.getByRole("button", { name: "Collapse sidebar" });
await collapseButton.click();
@@ -143,6 +147,11 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () =
)
.toBe(false);
await expect.poll(() => moreButton.isVisible()).toBe(true);
await expect
.poll(() =>
page.locator(".shell-nav").evaluate((element) => element.getBoundingClientRect().left),
)
.toBe(0);
await captureUiProof(page, "05-expanded-tablet-drawer.png");
} finally {
await context.close();
-7
View File
@@ -8,13 +8,6 @@
"path": "ui/src/app/app-host.ts",
"text": "Close navigation"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/components/app-sidebar.ts",
"text": "OpenClaw"
},
{
"count": 1,
"kind": "html-text",
+46
View File
@@ -36,4 +36,50 @@ describe("resolveSessionNavigation", () => {
Array.from({ length: 9 }, (_, index) => `agent:main:recent-${index}`),
);
});
it("keeps every pinned session when pins exceed the recent-session cap", () => {
const pinnedSessions = Array.from({ length: 10 }, (_, index) => ({
key: `agent:main:pinned-${index}`,
kind: "direct" as const,
pinned: true,
updatedAt: 100 - index,
}));
const navigation = resolveSessionNavigation({
result: sessionsResult([
{ key: "agent:main:recent", kind: "direct", updatedAt: 1_000 },
...pinnedSessions,
]),
resultAgentId: "main",
sessionKey: "unknown",
});
expect(navigation.recentSessions.map((row) => row.key)).toEqual([
...pinnedSessions.map((row) => row.key),
"agent:main:recent",
]);
});
it("keeps nine recent chats in addition to pinned sessions", () => {
const pinnedSessions = Array.from({ length: 3 }, (_, index) => ({
key: `agent:main:pinned-${index}`,
kind: "direct" as const,
pinned: true,
updatedAt: 100 - index,
}));
const recentSessions = Array.from({ length: 10 }, (_, index) => ({
key: `agent:main:recent-${index}`,
kind: "direct" as const,
updatedAt: 1_000 - index,
}));
const navigation = resolveSessionNavigation({
result: sessionsResult([...recentSessions, ...pinnedSessions]),
resultAgentId: "main",
sessionKey: "unknown",
});
expect(navigation.recentSessions.map((row) => row.key)).toEqual([
...pinnedSessions.map((row) => row.key),
...recentSessions.slice(0, 9).map((row) => row.key),
]);
});
});
+13 -3
View File
@@ -214,6 +214,10 @@ export function getVisibleSessionRows(
}
export function compareSessionRowsByUpdatedAt(a: GatewaySessionRow, b: GatewaySessionRow): number {
const pinnedStateDiff = Number(b.pinned === true) - Number(a.pinned === true);
if (pinnedStateDiff !== 0) {
return pinnedStateDiff;
}
const pinnedDiff = (b.pinnedAt ?? 0) - (a.pinnedAt ?? 0);
return pinnedDiff !== 0 ? pinnedDiff : (b.updatedAt ?? 0) - (a.updatedAt ?? 0);
}
@@ -237,15 +241,21 @@ export function resolveSessionNavigation(input: SessionNavigationInput): Session
currentSessionKey && currentSessionKey.toLowerCase() !== "unknown"
? { ...(selectedSession ?? { kind: "direct", updatedAt: null }), key: currentSessionKey }
: undefined;
const recentSessions = getVisibleSessionRows(input.result, {
const sortedSessions = getVisibleSessionRows(input.result, {
currentSessionKey: currentSessionKey || undefined,
agentId: selectedAgentId,
defaultAgentId,
filterByAgent: shouldFilterByAgent,
})
.filter((row) => !matchesCurrentSession(row))
.toSorted(compareSessionRowsByUpdatedAt)
.slice(0, 9);
.toSorted(compareSessionRowsByUpdatedAt);
// Pinned chats are explicit user choices, so the recent-chat cap only
// trims unpinned rows. Otherwise a tenth pin silently vanishes.
const pinnedSessions = sortedSessions.filter((row) => row.pinned === true);
const recentSessions = [
...pinnedSessions,
...sortedSessions.filter((row) => row.pinned !== true).slice(0, 9),
];
return {
currentSessionKey,
selectedAgentId,
+1 -1
View File
@@ -422,7 +422,7 @@ function createChatHeaderState(
splitRatio: 0.6,
navCollapsed: false,
navWidth: 280,
sidebarPinnedRoutes: ["overview", "workboard", "agents"],
sidebarPinnedRoutes: ["overview"],
sidebarMoreExpanded: false,
borderRadius: 50,
chatShowThinking: false,
-1
View File
@@ -248,7 +248,6 @@ export class OverviewPage extends LitElement {
navCollapsed: navigation.navCollapsed,
sidebarPinnedRoutes: [...navigation.sidebarPinnedRoutes],
sidebarMoreExpanded: navigation.sidebarMoreExpanded,
recentSessionsCollapsed: navigation.recentSessionsCollapsed,
locale,
};
this.settings = nextDraft;
+1 -1
View File
@@ -22,7 +22,7 @@ function createOverviewProps(overrides: Partial<OverviewProps> = {}): OverviewPr
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
sidebarPinnedRoutes: ["overview", "workboard", "agents"],
sidebarPinnedRoutes: ["overview"],
sidebarMoreExpanded: false,
borderRadius: 50,
locale: "en",
+77 -193
View File
@@ -457,20 +457,10 @@
flex: 0 0 var(--shell-nav-rail-width);
}
.sidebar-shell__header,
.sidebar-shell__footer {
flex-shrink: 0;
}
.sidebar-shell__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 0;
padding: 0 8px 18px;
}
.sidebar-shell__body {
min-height: 0;
flex: 1;
@@ -483,39 +473,6 @@
border-top: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.sidebar-brand__logo {
width: 32px;
height: 32px;
flex-shrink: 0;
border-radius: var(--radius-md);
box-shadow: 0 8px 18px color-mix(in srgb, black 12%, transparent);
}
.sidebar-brand__copy {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.sidebar-brand__title {
font-size: 15px;
line-height: 1.1;
font-weight: 700;
letter-spacing: -0.03em;
color: var(--text-strong);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-nav {
flex: 1;
overflow-y: auto;
@@ -588,19 +545,26 @@
.sidebar-recent-sessions {
display: grid;
gap: 6px;
gap: 10px;
margin: 0 -8px;
}
.sidebar-recent-sessions__group {
display: grid;
gap: 2px;
}
/* Anchors the session-picker popover to the full header width. */
.sidebar-recent-sessions__head {
position: relative;
display: flex;
align-items: center;
gap: 2px;
min-height: 24px;
padding: 0 10px;
}
.sidebar-recent-sessions__head .sidebar-recent-sessions__label {
.sidebar-recent-sessions__head .sidebar-recent-sessions__label-text {
flex: 1 1 auto;
}
@@ -671,64 +635,15 @@
stroke-linejoin: round;
}
.sidebar-recent-sessions__label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 0 10px;
min-height: 28px;
background: transparent;
border: none;
border-radius: var(--radius-md);
color: color-mix(in srgb, var(--muted) 72%, var(--text) 28%);
cursor: pointer;
text-align: left;
transition:
color var(--duration-fast) ease,
background var(--duration-fast) ease;
}
.sidebar-recent-sessions__label:hover {
color: var(--text);
background: color-mix(in srgb, var(--bg-hover) 72%, transparent);
}
.sidebar-recent-sessions__label-text {
font-size: 11px;
font-weight: 650;
letter-spacing: 0.07em;
text-transform: uppercase;
color: color-mix(in srgb, var(--muted) 72%, var(--text) 28%);
opacity: 0.85;
}
.sidebar-recent-sessions__chevron {
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 0.5;
transition: transform var(--duration-fast) ease;
}
.sidebar-recent-sessions__chevron svg {
width: 12px;
height: 12px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
.sidebar-recent-sessions--collapsed .sidebar-recent-sessions__chevron {
transform: rotate(-90deg);
}
.sidebar-recent-sessions--collapsed .sidebar-recent-sessions__list {
display: none;
}
.sidebar-recent-sessions__list {
display: grid;
gap: 2px;
@@ -1126,13 +1041,6 @@
padding: 12px 8px 10px;
}
.sidebar--collapsed .sidebar-shell__header {
justify-content: center;
align-items: center;
gap: 0;
padding: 0 2px 16px;
}
.sidebar--collapsed .sidebar-nav {
padding: 0;
}
@@ -1208,44 +1116,67 @@
0 10px 20px color-mix(in srgb, black 18%, transparent);
}
.sidebar--collapsed .sidebar-brand__logo {
width: 34px;
height: 34px;
/* Single compact icon row: status dot, then utility icons right-aligned.
The footer signals connectivity only; gateway version lives in Settings. */
.sidebar-footer-bar {
display: flex;
align-items: center;
gap: 2px;
min-height: 34px;
padding: 0 10px;
}
.sidebar-footer-bar__spacer {
flex: 1 1 auto;
}
.sidebar-footer-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
flex: 0 0 auto;
border: none;
border-radius: var(--radius-md);
box-shadow:
0 10px 20px color-mix(in srgb, black 20%, transparent),
inset 0 1px 0 color-mix(in srgb, white 10%, transparent);
}
.sidebar-utility-group {
display: grid;
gap: 8px;
}
.sidebar-utility-link {
min-height: 42px;
}
.sidebar-footer-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
/* Borderless status line: the sidebar footer signals connectivity only;
the gateway version moved into Settings (Quick Settings footer). */
.sidebar-status {
display: flex;
align-items: center;
gap: 8px;
min-height: 28px;
padding: 0 12px;
}
.sidebar-status__text {
font-size: 12px;
background: transparent;
color: var(--muted);
cursor: pointer;
text-decoration: none;
transition:
color var(--duration-fast) ease,
background var(--duration-fast) ease;
}
.sidebar-footer-icon:hover:not(:disabled),
.sidebar-footer-icon:focus-visible {
color: var(--text);
background: color-mix(in srgb, var(--bg-hover) 84%, transparent);
text-decoration: none;
}
.sidebar-footer-icon:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 48%, transparent);
outline-offset: 1px;
}
.sidebar-footer-icon:disabled {
cursor: not-allowed;
opacity: 0.42;
}
.sidebar-footer-icon--active {
color: var(--accent);
}
.sidebar-footer-icon svg {
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
.sidebar-status__dot {
@@ -1265,48 +1196,6 @@
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 14%, transparent);
}
.sidebar-pair-mobile {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
flex: 0 0 auto;
border: 1px solid transparent;
border-radius: var(--radius-md);
background: transparent;
color: var(--muted);
cursor: pointer;
transition:
color var(--duration-fast) ease,
border-color var(--duration-fast) ease,
background var(--duration-fast) ease;
}
.sidebar-pair-mobile:hover:not(:disabled),
.sidebar-pair-mobile:focus-visible {
color: var(--text);
border-color: color-mix(in srgb, var(--border) 76%, transparent);
background: var(--bg-hover);
}
.sidebar-pair-mobile:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 48%, transparent);
outline-offset: 1px;
}
.sidebar-pair-mobile:disabled {
cursor: not-allowed;
opacity: 0.42;
}
.sidebar-pair-mobile svg {
width: 17px;
height: 17px;
stroke: currentColor;
fill: none;
}
.sidebar-pair-mobile svg * {
stroke: currentColor;
fill: none;
@@ -1316,20 +1205,15 @@
padding: 8px 0 2px;
}
.sidebar--collapsed .sidebar-utility-group {
justify-items: center;
gap: 6px;
}
.sidebar--collapsed .sidebar-status {
width: 44px;
min-height: 36px;
padding: 0;
justify-content: center;
}
.sidebar--collapsed .sidebar-footer-row {
/* Rail mode stacks the footer icons vertically. */
.sidebar--collapsed .sidebar-footer-bar {
flex-direction: column-reverse;
gap: 6px;
padding: 0;
}
.sidebar--collapsed .sidebar-footer-bar__spacer {
display: none;
}
/* Mode switch in sidebar — hidden on desktop, shown on mobile */
+3 -9
View File
@@ -173,11 +173,6 @@
border-radius: 0;
}
.sidebar-shell__header {
min-height: 0;
padding: 0 4px 16px;
}
.sidebar-nav,
.sidebar--collapsed .sidebar-nav {
flex: 1 1 auto;
@@ -230,10 +225,9 @@
padding: 12px 8px 0;
}
.sidebar--collapsed .sidebar-status {
width: auto;
min-height: 32px;
padding: 0 12px;
.sidebar--collapsed .sidebar-footer-bar {
flex-direction: row;
padding: 0 10px;
}
}