improve(ui): unify sidebar footer into a full-width identity card (#113156)

* feat(ui): unify sidebar footer into full-width identity card

Footer becomes one card (avatar + name + chevron) mirroring the top agent
card; identity menu gains email header, Settings (with platform shortcut
hint), and Usage; Usage leaves default sidebar pins; settings shortcut now
also binds Ctrl+Shift+Comma; offline state lives in the card subtitle and
menu retry action and the card persists while disconnected.

* test(ui): align persisted-sidebar fallback default with usage removal
This commit is contained in:
Peter Steinberger
2026-07-23 17:40:30 -07:00
committed by GitHub
parent 0dd9dcda2f
commit 1603781bb0
19 changed files with 332 additions and 186 deletions

View File

@@ -217,7 +217,7 @@ and [Linux](/platforms/linux) desktop apps, the
## Sidebar navigation
The sidebar organizes everything around the agent. The identity row at the top is the active agent; below it, the **Pages** section starts with **Home** — the agent's rolling main session, badged with its unread or running state — followed by the pinned destinations (**Usage**, **Automations**, and **Plugins** by default). The customize control on the Pages header opens a menu with every other destination, including plugin-provided tabs, plus **Edit pinned items**; right-clicking the navigation area opens the pin editor directly. The session list below splits into zones: **Threads** for the agent's chat sessions (the main session stays behind Home; sessions it spawned appear here as top-level threads, and named threads show without a type prefix), **Groups** for group and room conversations, and **Coding** for sessions bound to a managed worktree or exec node (rows show a `repo ⎇ branch` line plus the node host), ACP-backed harness sessions, and the Codex/Claude CLI catalogs. Coding starts collapsed on first run and remembers your choice; its collapsed header keeps the true count and shows a running indicator while contained sessions work. Custom groups (the session `category`) and **Pinned** rows sit above Threads, and assigning a session to a custom group always wins over the automatic zone classification. The Threads header holds the sort control (Created or Last updated, Group by, and a persisted **Status** filter for Active, Archived, or All) and the **+** that opens the New session page. Archived rows stay inline, dimmed with an archive glyph; they do not contribute unread or attention state and stay outside lineage promotion. Opening a session moves the selection highlight without reordering rows. Parent sessions with recent child runs show a disclosure and child count; expand it to inspect nested child sessions, live or terminal status, and runtime without leaving the sidebar. Selecting a child opens its chat and automatically reveals its ancestor path. Child rows stay outside root grouping, pinning, dragging, multi-select, and pagination; collapsed zones do not consume the visible page budget. Sessions with new activity since they were last read show an unread dot, and opening one marks it read. An agent can also publish a short expiring status line and optionally request attention with a curated amber icon; that declaration clears when you open the session, send the next message, clear it explicitly, or its TTL expires. Cloud-worker lifecycle states use a globe badge; local and reclaimed sessions omit a placement badge because local execution is the default. Each root session row has a context menu (kebab button or right-click) with Pin/Unpin, Mark as unread/read, Rename, Fork, Move to group (including New group and Remove from group), Archive or Unarchive, and Delete; touch layouts keep the direct pin and menu controls visible. Cmd/Ctrl-click toggles root rows into a multi-select and Shift-click extends it across the visible order; opening the menu on a selected row then offers batch actions (Mark N as unread/read, Move N to group, Archive N, Delete N) that apply to every selected session, with a single confirmation for batch delete. Drag a root session onto **Pinned** to pin it, or onto a custom group to move it. Custom group headers can be collapsed, expanded, or dragged to reorder them; group names and their order live in the gateway (`sessions.groups.*`), so they follow you across browsers, while collapsed state stays in the browser profile. Group headers also have a menu (kebab button or right-click) with Rename group, New group, and Delete group; renaming or deleting a group updates every member session server-side, including archived ones, and deleting a group keeps its sessions and moves them back to Threads.
The sidebar organizes everything around the agent. The identity row at the top is the active agent; below it, the **Pages** section starts with **Home** — the agent's rolling main session, badged with its unread or running state — followed by the pinned destinations (**Automations** and **Plugins** by default). The customize control on the Pages header opens a menu with every other destination, including **Usage** and plugin-provided tabs, plus **Edit pinned items**; right-clicking the navigation area opens the pin editor directly. The session list below splits into zones: **Threads** for the agent's chat sessions (the main session stays behind Home; sessions it spawned appear here as top-level threads, and named threads show without a type prefix), **Groups** for group and room conversations, and **Coding** for sessions bound to a managed worktree or exec node (rows show a `repo ⎇ branch` line plus the node host), ACP-backed harness sessions, and the Codex/Claude CLI catalogs. Coding starts collapsed on first run and remembers your choice; its collapsed header keeps the true count and shows a running indicator while contained sessions work. Custom groups (the session `category`) and **Pinned** rows sit above Threads, and assigning a session to a custom group always wins over the automatic zone classification. The Threads header holds the sort control (Created or Last updated, Group by, and a persisted **Status** filter for Active, Archived, or All) and the **+** that opens the New session page. Archived rows stay inline, dimmed with an archive glyph; they do not contribute unread or attention state and stay outside lineage promotion. Opening a session moves the selection highlight without reordering rows. Parent sessions with recent child runs show a disclosure and child count; expand it to inspect nested child sessions, live or terminal status, and runtime without leaving the sidebar. Selecting a child opens its chat and automatically reveals its ancestor path. Child rows stay outside root grouping, pinning, dragging, multi-select, and pagination; collapsed zones do not consume the visible page budget. Sessions with new activity since they were last read show an unread dot, and opening one marks it read. An agent can also publish a short expiring status line and optionally request attention with a curated amber icon; that declaration clears when you open the session, send the next message, clear it explicitly, or its TTL expires. Cloud-worker lifecycle states use a globe badge; local and reclaimed sessions omit a placement badge because local execution is the default. Each root session row has a context menu (kebab button or right-click) with Pin/Unpin, Mark as unread/read, Rename, Fork, Move to group (including New group and Remove from group), Archive or Unarchive, and Delete; touch layouts keep the direct pin and menu controls visible. Cmd/Ctrl-click toggles root rows into a multi-select and Shift-click extends it across the visible order; opening the menu on a selected row then offers batch actions (Mark N as unread/read, Move N to group, Archive N, Delete N) that apply to every selected session, with a single confirmation for batch delete. Drag a root session onto **Pinned** to pin it, or onto a custom group to move it. Custom group headers can be collapsed, expanded, or dragged to reorder them; group names and their order live in the gateway (`sessions.groups.*`), so they follow you across browsers, while collapsed state stays in the browser profile. Group headers also have a menu (kebab button or right-click) with Rename group, New group, and Delete group; renaming or deleting a group updates every member session server-side, including archived ones, and deleting a group keeps its sessions and moves them back to Threads.
## New session page
@@ -229,7 +229,7 @@ On multi-user gateways, only admin-scope connections can create or view incognit
Inside **Settings**, the dedicated sidebar includes **Ask OpenClaw** and starts with a **Search settings** field for quickly finding settings sections.
On desktop web, a fixed control cluster at the top-left of the content area — the web counterpart of the macOS titlebar strip — holds the sidebar collapse toggle (⌘B) and the command-palette search button (⌘K). Clicking the agent identity row at the top of the sidebar opens the agent menu; **Home** opens the main session. When something needs action — failed or overdue cron jobs, expiring or expired model auth — compact attention chips appear above the sidebar footer and click through to the owning page. The identity row shows the agent's avatar (identity image or emoji), name, connection dot, and a live subtitle. Its agent-scoped menu contains the inline agent switcher (multi-agent setups), **New agent**, "What can this agent do?", and **Agent settings**. Rosters above ten agents get a filter field and list pinned agents first; pin or unpin agents from the Agents settings page, with the pinned set stored in the browser profile. Choosing an agent scopes Chat plus Usage, Automations, Tasks, Workboard, and Sessions to that agent. Each scoped page exposes an **Agent** control with **All agents** as an escape; this widens the shared page scope without changing the concrete chat agent, while direct session links still open their target. The Agents settings page keeps its own `?agent=` selection and does not follow the shared page scope. The footer bar holds your identity (avatar and name) and a one-click Settings shortcut; clicking your identity opens the app/account menu with **Profile**, mobile pairing, **Get the apps**, **Help** (help, Discord, Docs, and the changelog), the version/build chip, and the color-mode toggle. A connection status pill appears only while the gateway is offline. The build chip opens the About page. When the gateway runs from a source checkout on a branch other than `main`, the footer also shows that branch name in red so a non-release gateway is obvious at a glance (release installs never show it). Shift-Command-Comma opens **Settings** without overriding the browser's Command-Comma shortcut. Collapsing the sidebar (⌘B or the cluster's toggle) hides it entirely for a full-width workspace; while collapsed, the top-left cluster keeps the expand toggle and search and gains a new-thread button — mirroring what the macOS app hosts natively in its titlebar. The sidebar is the only navigation chrome on desktop, with no top bar. Narrow viewports swap the sidebar for a slide-over drawer behind a compact header row holding the drawer toggle, brand, and command-palette search; on phones, Chat absorbs that navigation row into its title bar, with the menu and search controls beside the session title. In the macOS app the separate header row folds the titlebar clearance into a single compact strip beside the window controls. Navigation uses regular browser history, so the browser's back/forward buttons traverse it; the macOS app adds a native sidebar toggle next to the window controls plus trackpad swipe gestures, with back/forward buttons at the sidebar's right edge while it is expanded and native search (command palette) and new-session buttons while it is collapsed.
On desktop web, a fixed control cluster at the top-left of the content area — the web counterpart of the macOS titlebar strip — holds the sidebar collapse toggle (⌘B) and the command-palette search button (⌘K). Clicking the agent identity row at the top of the sidebar opens the agent menu; **Home** opens the main session. When something needs action — failed or overdue cron jobs, expiring or expired model auth — compact attention chips appear above the sidebar footer and click through to the owning page. The identity row shows the agent's avatar (identity image or emoji), name, connection dot, and a live subtitle. Its agent-scoped menu contains the inline agent switcher (multi-agent setups), **New agent**, "What can this agent do?", and **Agent settings**. Rosters above ten agents get a filter field and list pinned agents first; pin or unpin agents from the Agents settings page, with the pinned set stored in the browser profile. Choosing an agent scopes Chat plus Usage, Automations, Tasks, Workboard, and Sessions to that agent. Each scoped page exposes an **Agent** control with **All agents** as an escape; this widens the shared page scope without changing the concrete chat agent, while direct session links still open their target. The Agents settings page keeps its own `?agent=` selection and does not follow the shared page scope. The footer is one full-width identity card that remains available offline and shows **Reconnecting…** beneath the last-known account name. It opens the app/account menu, whose profile identity header is followed by **Settings**, **Usage**, mobile pairing, **Get the apps**, **Help** (help, Discord, Docs, and the changelog), an offline retry action when needed, the version/build chip, and the color-mode toggle. The build chip opens the About page. When the gateway runs from a source checkout on a branch other than `main`, the footer also shows that branch name in red so a non-release gateway is obvious at a glance (release installs never show it). Shift-Command-Comma on Apple platforms or Ctrl-Shift-Comma elsewhere opens **Settings** without overriding the browser's plain Command-Comma shortcut. Collapsing the sidebar (⌘B or the cluster's toggle) hides it entirely for a full-width workspace; while collapsed, the top-left cluster keeps the expand toggle and search and gains a new-thread button — mirroring what the macOS app hosts natively in its titlebar. The sidebar is the only navigation chrome on desktop, with no top bar. Narrow viewports swap the sidebar for a slide-over drawer behind a compact header row holding the drawer toggle, brand, and command-palette search; on phones, Chat absorbs that navigation row into its title bar, with the menu and search controls beside the session title. In the macOS app the separate header row folds the titlebar clearance into a single compact strip beside the window controls. Navigation uses regular browser history, so the browser's back/forward buttons traverse it; the macOS app adds a native sidebar toggle next to the window controls plus trackpad swipe gestures, with back/forward buttons at the sidebar's right edge while it is expanded and native search (command palette) and new-session buttons while it is collapsed.
Pending approvals also contribute an attention chip above the sidebar footer;
select it to open the owning Approvals page.

View File

@@ -16,7 +16,8 @@ const settingsRoutes = SETTINGS_NAVIGATION_GROUPS.flatMap((group) => group.route
describe("sidebar entries", () => {
it("keeps operational destinations visible by default", () => {
expect(DEFAULT_SIDEBAR_ENTRIES).toEqual(["route:usage", "route:cron", "route:plugins"]);
expect(DEFAULT_SIDEBAR_ENTRIES).toEqual(["route:cron", "route:plugins"]);
expect(DEFAULT_SIDEBAR_ENTRIES).not.toContain("route:usage");
});
it("drops retired routes from persisted entries", () => {

View File

@@ -56,7 +56,7 @@ export type SidebarZoneEntry =
// Keep the highest-value operational destinations visible on first use. Users
// can still replace this route set through the customize menu.
export const DEFAULT_SIDEBAR_ENTRIES = ["usage", "cron", "plugins"].map((route) =>
export const DEFAULT_SIDEBAR_ENTRIES = ["cron", "plugins"].map((route) =>
serializeSidebarEntry({ type: "route", route: route as SidebarNavRoute }),
);

View File

@@ -848,6 +848,28 @@ describe("OpenClaw shell keyboard shortcuts", () => {
expect(navigate).toHaveBeenCalledWith("config", undefined);
});
it("opens Settings with Ctrl-Shift-Comma", () => {
const navigate = vi.fn();
const shell = document.createElement("openclaw-app-shell") as unknown as ShellKeyboardState;
shell.runtime = {
context: {
navigate,
} as unknown as ApplicationContext,
};
const event = new KeyboardEvent("keydown", {
key: "<",
code: "Comma",
ctrlKey: true,
shiftKey: true,
cancelable: true,
});
shell.handleDocumentKeydown(event);
expect(event.defaultPrevented).toBe(true);
expect(navigate).toHaveBeenCalledWith("config", undefined);
});
it("toggles the navigation sidebar when the native macOS titlebar button fires", () => {
const snapshot = { navCollapsed: false };
const update = vi.fn((next: { navCollapsed: boolean }) => {

View File

@@ -1161,12 +1161,13 @@ class OpenClawShell extends OpenClawLightDomElement {
this.exitSettings();
return;
}
const commandKey = event.metaKey && !event.ctrlKey && !event.altKey;
if (commandKey && event.shiftKey && event.code === "Comma") {
const settingsModifier = event.metaKey !== event.ctrlKey && !event.altKey;
if (settingsModifier && event.shiftKey && event.code === "Comma") {
event.preventDefault();
this.navigate("config");
return;
}
const commandKey = event.metaKey && !event.ctrlKey && !event.altKey;
if (!commandKey || event.shiftKey || event.key.toLowerCase() !== "b") {
return;
}
@@ -1818,8 +1819,6 @@ class OpenClawShell extends OpenClawLightDomElement {
.connected=${gatewayConnected}
.offline=${gatewaySnapshot.offlineStable}
.outboxCountForSession=${outboxCountForSession}
.queuedOutboxCount=${storedOutboxes?.total ?? 0}
.lastError=${gatewaySnapshot.lastError}
.terminalAvailable=${terminalAvailable}
.catalogOpenTarget=${normalizeCatalogOpenTarget(uiSettings.catalogOpenTarget)}
.canPairDevice=${gatewayConnected &&

View File

@@ -402,7 +402,7 @@ describe("loadSettings default gateway URL derivation", () => {
persisted.navWidth = 220;
localStorage.setItem(scopedKey, JSON.stringify(persisted));
expect(loadSettings().sidebarEntries).toEqual(["route:usage", "route:cron", "route:plugins"]);
expect(loadSettings().sidebarEntries).toEqual(["route:cron", "route:plugins"]);
expect(loadSettings().navWidth).toBe(258);
});

View File

@@ -71,17 +71,26 @@ type SidebarAgentMenuParams = {
};
type SidebarIdentityMenuParams = {
position: { x: number; bottom: number } | null;
position: { x: number; bottom: number; width: number } | null;
canPairDevice: boolean;
basePath: string;
gatewayVersion: string | null;
selfName?: string;
selfEmail?: string;
offline: boolean;
themeMode: ThemeMode;
triggerWidth: number;
onTabAway: () => void;
onClose: (restoreFocus?: boolean) => void;
onNavigate: (routeId: NavigationRouteId, options?: ApplicationNavigationOptions) => void;
onPairMobile: () => void;
onRetryConnect?: () => void;
};
function isApplePlatform(): boolean {
return /Mac|iPhone|iPad|iPod/u.test(globalThis.navigator?.platform ?? "");
}
/** Rows for the chip switcher. Small rosters list everything; past
QUICK_SWITCH_AGENT_LIMIT the menu shows pinned agents (plus the active
one) and the filter searches the full roster. */
@@ -356,10 +365,12 @@ export function renderSidebarIdentityMenu(params: SidebarIdentityMenuParams) {
if (!position) {
return nothing;
}
const profileLabel = params.selfEmail ?? params.selfName;
return html`
<openclaw-menu-surface>
<wa-dropdown
class="sidebar-customize-menu sidebar-identity-menu"
style=${`--sidebar-identity-menu-min-width: ${params.triggerWidth}px`}
.open=${true}
placement="top-start"
.distance=${0}
@@ -385,12 +396,21 @@ export function renderSidebarIdentityMenu(params: SidebarIdentityMenuParams) {
case `${COMMAND_VALUE_PREFIX}profile`:
params.onNavigate("profile", { hash: "#settings-profile-identity" });
break;
case `${COMMAND_VALUE_PREFIX}settings`:
params.onNavigate("config");
break;
case `${COMMAND_VALUE_PREFIX}usage`:
params.onNavigate("usage");
break;
case `${COMMAND_VALUE_PREFIX}pair-mobile`:
params.onPairMobile();
break;
case `${COMMAND_VALUE_PREFIX}apps`:
params.onNavigate("apps");
break;
case `${COMMAND_VALUE_PREFIX}retry-connect`:
params.onRetryConnect?.();
break;
}
}}
@keydown=${(event: KeyboardEvent) =>
@@ -405,9 +425,22 @@ export function renderSidebarIdentityMenu(params: SidebarIdentityMenuParams) {
aria-label=${t("profilePage.identity.menuLabel")}
style="position: fixed; left: ${position.x}px; bottom: ${position.bottom}px; width: 1px; height: 1px; opacity: 0; pointer-events: none;"
></button>
<wa-dropdown-item class="sidebar-customize-menu__item" value="command:profile">
<span slot="icon" class="nav-item__icon" aria-hidden="true">${icons.users}</span>
<span class="sidebar-customize-menu__text">${titleForRoute("profile")}</span>
${profileLabel
? html`<wa-dropdown-item class="sidebar-identity-menu__header" value="command:profile">
${profileLabel}
</wa-dropdown-item>
<div class="sidebar-customize-menu__separator" role="separator"></div>`
: nothing}
<wa-dropdown-item class="sidebar-customize-menu__item" value="command:settings">
<span slot="icon" class="nav-item__icon" aria-hidden="true">${icons.settings}</span>
<span class="sidebar-customize-menu__text">${t("nav.settings")}</span>
<span slot="details" class="session-menu__shortcut" aria-hidden="true"
>${isApplePlatform() ? "⌘⇧," : "Ctrl+Shift+,"}</span
>
</wa-dropdown-item>
<wa-dropdown-item class="sidebar-customize-menu__item" value="command:usage">
<span slot="icon" class="nav-item__icon" aria-hidden="true">${icons.coins}</span>
<span class="sidebar-customize-menu__text">${titleForRoute("usage")}</span>
</wa-dropdown-item>
<wa-dropdown-item
class="sidebar-customize-menu__item sidebar-pair-mobile"
@@ -432,6 +465,15 @@ export function renderSidebarIdentityMenu(params: SidebarIdentityMenuParams) {
<span class="sidebar-customize-menu__text">${t("agentChip.help")}</span>
${renderIdentityMenuHelpSubmenu()}
</wa-dropdown-item>
${params.offline
? html`<div class="sidebar-customize-menu__separator" role="separator"></div>
<wa-dropdown-item
class="sidebar-customize-menu__item sidebar-identity-menu__retry"
value="command:retry-connect"
>
<span class="sidebar-customize-menu__text">${t("connection.retryNow")}</span>
</wa-dropdown-item>`
: nothing}
<div class="sidebar-customize-menu__separator" role="separator"></div>
<div class="sidebar-identity-menu__footer">
<openclaw-sidebar-build-chip

View File

@@ -24,8 +24,6 @@ export abstract class AppSidebarBase extends OpenClawLightDomContentsElement {
@property({ attribute: false }) connected = false;
@property({ attribute: false }) offline = false;
@property({ attribute: false }) outboxCountForSession: (sessionKey: string) => number = () => 0;
@property({ attribute: false }) queuedOutboxCount = 0;
@property({ attribute: false }) lastError: string | null = null;
@property({ attribute: false }) terminalAvailable = false;
@property({ attribute: false }) catalogOpenTarget: CatalogOpenTarget = "viewer";
@property({ attribute: false }) canPairDevice = false;

View File

@@ -20,17 +20,13 @@ import type { AppSidebarSessionNavigationElement } from "./app-sidebar-session-n
import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
import type { SidebarWorkboardBoard } from "./app-sidebar-workboard.ts";
import { icons } from "./icons.ts";
import { redactLoginFailureError } from "./login-gate.ts";
import { renderOfflineSidebarStatus, renderSessionRowBadges } from "./session-row-badges.ts";
import { renderSessionRowBadges } from "./session-row-badges.ts";
type AppSidebarRenderHost = AppSidebarSessionNavigationElement & {
activePluginTabId: string;
activeWorkboardBoardId: string;
offline: boolean;
queuedOutboxCount: number;
lastError: string | null;
onOpenApprovals?: () => void;
onRetryConnect?: () => void;
getRouteSessionKey(): string;
renderPinnedSidebarSession(session: SidebarRecentSession): unknown;
};
@@ -175,55 +171,45 @@ export function renderAppSidebarPagesHead(host: AppSidebarRenderHost) {
/** Zone 5: product chrome recedes to one slim footer bar. */
export function renderAppSidebarFooterBar(host: AppSidebarRenderHost) {
const reconnecting = t("connection.reconnecting");
const selfUser = host.connected
? resolveCurrentSelfUser({
snapshotUser: host.sessionDataContext?.gateway.snapshot.selfUser,
presenceEntries: readPresenceEntries(host.sessionData.presencePayload),
presenceInstanceId: host.sessionData.presenceInstanceId,
})
: null;
const selfLabel = selfUser?.name ?? selfUser?.email ?? selfUser?.id;
const selfUser = resolveCurrentSelfUser({
snapshotUser: host.sessionDataContext?.gateway.snapshot.selfUser,
presenceEntries: readPresenceEntries(host.sessionData.presencePayload),
presenceInstanceId: host.sessionData.presenceInstanceId,
});
const selfLabel = selfUser?.name ?? selfUser?.email ?? t("nav.account");
const avatarUser = {
...(selfUser ?? { id: "account", name: selfLabel }),
watchedSessions: [],
};
return html`
<div class="sidebar-footer-bar">
${selfUser && selfLabel
? html`<openclaw-tooltip .content=${selfLabel}>
<button
type="button"
class="sidebar-footer-bar__identity"
aria-haspopup="menu"
aria-expanded=${String(host.sidebarMenus.identityMenuPosition !== null)}
aria-label=${t("profilePage.identity.menuButtonLabel", { name: selfLabel })}
@click=${(event: MouseEvent) =>
host.sidebarMenus.toggleIdentityMenu(event.currentTarget as HTMLElement)}
>
<openclaw-viewer-avatar
.user=${{ ...selfUser, watchedSessions: [] }}
variant="footer"
></openclaw-viewer-avatar>
<span class="sidebar-footer-bar__identity-name">${selfLabel}</span>
</button>
</openclaw-tooltip>`
: nothing}
<span class="sidebar-footer-bar__spacer" aria-hidden="true"></span>
${host.offline
? renderOfflineSidebarStatus({
queuedOutboxCount: host.queuedOutboxCount,
reconnecting,
title: host.lastError ? redactLoginFailureError(host.lastError) : reconnecting,
onRetry: () => host.onRetryConnect?.(),
})
: nothing}
<openclaw-tooltip .content=${t("nav.settings")}>
<openclaw-tooltip .content=${selfLabel}>
<button
type="button"
class="sidebar-footer-bar__settings"
aria-label=${t("nav.settings")}
@click=${() => host.onNavigate?.("config")}
class="sidebar-identity-card"
aria-haspopup="menu"
aria-expanded=${String(host.sidebarMenus.identityMenuPosition !== null)}
aria-label=${t("profilePage.identity.menuButtonLabel", { name: selfLabel })}
@click=${(event: MouseEvent) =>
host.sidebarMenus.toggleIdentityMenu(event.currentTarget as HTMLElement)}
>
${icons.settings}
<openclaw-viewer-avatar .user=${avatarUser} variant="footer"></openclaw-viewer-avatar>
<span class="sidebar-identity-card__text">
<span class="sidebar-identity-card__name">${selfLabel}</span>
${host.offline
? html`<span class="sidebar-identity-card__subtitle" aria-hidden="true"
>${t("connection.reconnecting")}</span
>`
: nothing}
</span>
<span class="sidebar-identity-card__chevron" aria-hidden="true"
>${icons.chevronDown}</span
>
</button>
</openclaw-tooltip>
<span class="sidebar-identity-card__status" role="status" aria-live="polite"
>${host.offline ? t("connection.reconnecting") : ""}</span
>
</div>
`;
}

View File

@@ -43,7 +43,7 @@ interface SidebarMenusControllerState {
sessionSortMenuPosition: { x: number; y: number } | null;
agentMenuPosition: { x: number; bottom: number } | null;
agentMenuFilter: string;
identityMenuPosition: { x: number; bottom: number } | null;
identityMenuPosition: { x: number; bottom: number; width: number } | null;
}
type SidebarMenusRenderer = {
@@ -63,6 +63,7 @@ interface SidebarMenusControllerHost
readonly basePath: string;
readonly canPairDevice: boolean;
readonly connected: boolean;
readonly offline: boolean;
readonly enabledRouteIds?: readonly NavigationRouteId[];
readonly gatewayVersion: string | null;
readonly onNavigate?: (
@@ -70,11 +71,15 @@ interface SidebarMenusControllerHost
options?: ApplicationNavigationOptions,
) => void;
readonly onPairMobile?: () => void;
readonly onRetryConnect?: () => void;
readonly onPreloadRoute?: (routeId: NavigationRouteId) => Promise<void>;
readonly pinnedAgentIds: readonly string[];
readonly selectedSessionKeys: ReadonlySet<string>;
readonly sessionData: SessionOrganizerControllerHost["sessionData"] &
Pick<SessionDataController, "approvalBadgeSnapshot" | "sessionsLoading">;
Pick<
SessionDataController,
"approvalBadgeSnapshot" | "presenceInstanceId" | "presencePayload" | "sessionsLoading"
>;
readonly sessionDataContext: ApplicationContext<RouteId> | undefined;
readonly sessionOrganizer: SessionOrganizerController;
readonly sessionCreatorFilterActive: boolean;
@@ -115,7 +120,7 @@ export class SidebarMenusController implements ReactiveController, SidebarMenusC
agentMenuPosition: { x: number; bottom: number } | null = null;
agentMenuFilter = "";
// Anchored by its bottom edge so the footer menu grows upward regardless of height.
identityMenuPosition: { x: number; bottom: number } | null = null;
identityMenuPosition: { x: number; bottom: number; width: number } | null = null;
customizeMenuTrigger: HTMLElement | null = null;
moreMenuTrigger: HTMLElement | null = null;
@@ -435,13 +440,14 @@ export class SidebarMenusController implements ReactiveController, SidebarMenusC
return;
}
this.loadMenuRenderer();
const menuWidth = 240;
const rect = trigger.getBoundingClientRect();
const menuWidth = Math.max(240, rect.width);
this.dismissTransientMenus();
this.identityMenuTrigger = trigger;
this.updateState("identityMenuPosition", {
x: Math.max(8, Math.min(rect.right - menuWidth, window.innerWidth - menuWidth - 8)),
bottom: Math.max(8, window.innerHeight - rect.top + 4),
width: rect.width,
});
}

View File

@@ -9,6 +9,7 @@ import {
import type { RouteId } from "../app-route-paths.ts";
import type { ApplicationContext, ApplicationNavigationOptions } from "../app/context.ts";
import type { ThemeMode } from "../app/theme.ts";
import { readPresenceEntries, resolveCurrentSelfUser } from "../app/user-profile.ts";
import { normalizeAgentLabel } from "../lib/agents/display.ts";
import { openEditor } from "../lib/editor-links.ts";
import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts";
@@ -49,15 +50,20 @@ interface SidebarMenusRenderHost extends ReactiveControllerHost, SessionOrganize
readonly basePath: string;
readonly canPairDevice: boolean;
readonly connected: boolean;
readonly offline: boolean;
readonly gatewayVersion: string | null;
readonly onNavigate?: (
routeId: NavigationRouteId,
options?: ApplicationNavigationOptions,
) => void;
readonly onPairMobile?: () => void;
readonly onRetryConnect?: () => void;
readonly pinnedAgentIds: readonly string[];
readonly sessionData: SessionOrganizerControllerHost["sessionData"] &
Pick<SessionDataController, "approvalBadgeSnapshot" | "sessionsLoading">;
Pick<
SessionDataController,
"approvalBadgeSnapshot" | "presenceInstanceId" | "presencePayload" | "sessionsLoading"
>;
readonly sessionDataContext: ApplicationContext<RouteId> | undefined;
readonly sessionOrganizer: SessionOrganizerController;
readonly sessionCreatorFilterActive: boolean;
@@ -92,7 +98,7 @@ interface SidebarMenusRenderController {
readonly agentMenuTrigger: HTMLElement | null;
readonly customizeMenuPosition: { x: number; y: number } | null;
readonly customizeMenuTrigger: HTMLElement | null;
readonly identityMenuPosition: { x: number; bottom: number } | null;
readonly identityMenuPosition: { x: number; bottom: number; width: number } | null;
readonly identityMenuTrigger: HTMLElement | null;
readonly moreMenuPosition: { x: number; y: number } | null;
readonly moreMenuTrigger: HTMLElement | null;
@@ -196,12 +202,21 @@ export function renderSidebarIdentityMenuForController(controller: SidebarMenusR
const { host } = controller;
const position = controller.identityMenuPosition;
const trigger = controller.identityMenuTrigger;
const selfUser = resolveCurrentSelfUser({
snapshotUser: host.sessionDataContext?.gateway.snapshot.selfUser,
presenceEntries: readPresenceEntries(host.sessionData.presencePayload),
presenceInstanceId: host.sessionData.presenceInstanceId,
});
return renderSidebarIdentityMenu({
position,
canPairDevice: host.canPairDevice,
basePath: host.basePath,
gatewayVersion: host.gatewayVersion,
selfName: selfUser?.name ?? undefined,
selfEmail: selfUser?.email ?? undefined,
offline: host.offline,
themeMode: host.themeMode,
triggerWidth: position?.width ?? 0,
onTabAway: () => trigger?.focus(),
onClose: (restoreFocus) => {
if (controller.identityMenuPosition !== position) {
@@ -211,6 +226,7 @@ export function renderSidebarIdentityMenuForController(controller: SidebarMenusR
},
onNavigate: (routeId, options) => host.onNavigate?.(routeId, options),
onPairMobile: () => host.onPairMobile?.(),
onRetryConnect: host.onRetryConnect,
});
}

View File

@@ -126,7 +126,12 @@ describeControlUiE2e("Control UI agent page scope", () => {
)
.toBe("Writer");
await sidebar.getByRole("link", { name: "Usage" }).click();
await sidebar.getByRole("link", { name: "Home" }).click();
await expect.poll(() => new URL(page.url()).pathname).toBe("/chat");
await sidebar.locator(".sidebar-identity-card").click();
await sidebar
.locator('wa-dropdown.sidebar-identity-menu wa-dropdown-item[value="command:usage"]')
.click();
await expect.poll(() => new URL(page.url()).pathname).toBe("/usage");
await waitForRequest(gateway, "sessions.usage", (params) => params.agentId === "writer");
const pageScope = page.locator(".agent-scope-control openclaw-agent-select");

View File

@@ -2302,7 +2302,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
await page.goto(`${server.baseUrl}new?agent=research`);
await page.getByRole("heading", { name: "Research" }).waitFor();
await gateway.setOnline(false);
await page.locator(".sidebar-footer-bar__status").waitFor({ timeout: 10_000 });
await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 });
await page.evaluate(() => {
history.pushState(null, "", "new?agent=research&catalog=claude");
@@ -2416,7 +2416,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
const branchRequestsBefore = (await gateway.getRequests("worktrees.branches")).length;
await gateway.setOnline(false);
await page.locator(".sidebar-footer-bar__status").waitFor({ timeout: 10_000 });
await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 });
await gateway.setMethodResponse("agents.list", {
agents: [
{
@@ -2473,7 +2473,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
const branchesBeforeSameWorkspaceReconnect = (await gateway.getRequests("worktrees.branches"))
.length;
await gateway.setOnline(false);
await page.locator(".sidebar-footer-bar__status").waitFor({ timeout: 10_000 });
await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 });
await gateway.setOnline(true);
await expect
@@ -2537,7 +2537,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
const branchRequests = (await gateway.getRequests("worktrees.branches")).length;
await gateway.deferNext("worktrees.branches");
await gateway.setOnline(false);
await page.locator(".sidebar-footer-bar__status").waitFor({ timeout: 10_000 });
await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 });
await gateway.setOnline(true);
await expect
.poll(async () => (await gateway.getRequests("worktrees.branches")).length)
@@ -2606,7 +2606,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
});
const branchRequests = (await gateway.getRequests("worktrees.branches")).length;
await gateway.setOnline(false);
await page.locator(".sidebar-footer-bar__status").waitFor({ timeout: 10_000 });
await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 });
await gateway.setOnline(true);
await expect
.poll(async () => (await gateway.getRequests("worktrees.branches")).length)
@@ -2664,7 +2664,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
});
const branchRequests = (await gateway.getRequests("worktrees.branches")).length;
await gateway.setOnline(false);
await page.locator(".sidebar-footer-bar__status").waitFor({ timeout: 10_000 });
await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 });
await gateway.setOnline(true);
await expect
.poll(async () => (await gateway.getRequests("worktrees.branches")).length)
@@ -2729,7 +2729,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
});
const branchRequests = (await gateway.getRequests("worktrees.branches")).length;
await gateway.setOnline(false);
await page.locator(".sidebar-footer-bar__status").waitFor({ timeout: 10_000 });
await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 });
await gateway.setOnline(true);
await expect
.poll(async () => (await gateway.getRequests("worktrees.branches")).length)
@@ -2805,7 +2805,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
const nodeRequestsBefore = (await gateway.getRequests("node.list")).length;
await gateway.setOnline(false);
await page.locator(".sidebar-footer-bar__status").waitFor({ timeout: 10_000 });
await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 });
await gateway.deferNext("node.list");
await gateway.setOnline(true);
await expect
@@ -3034,7 +3034,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
} else {
const agentRequestsBefore = (await gateway.getRequests("agents.list")).length;
await gateway.setOnline(false);
await page.locator(".sidebar-footer-bar__status").waitFor({ timeout: 10_000 });
await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 });
await gateway.setOnline(true);
await expect
.poll(async () => (await gateway.getRequests("agents.list")).length)

View File

@@ -137,7 +137,7 @@ async function proxyReconnect(
): Promise<void> {
await gateway.closeLatest(1001, "proxy idle timeout");
await expect.poll(() => gateway.getSocketCount(), { timeout: 10_000 }).toBe(expectedSocketCount);
expect(await page.locator(".sidebar-footer-bar__status").count()).toBe(0);
expect(await page.locator(".sidebar-identity-card__subtitle").count()).toBe(0);
}
async function captureProof(page: Page, name: string): Promise<void> {
@@ -179,8 +179,14 @@ describeControlUiE2e("Control UI usage proxy reconnect lifecycle", () => {
});
try {
const response = await page.goto(`${server.baseUrl}usage`);
const response = await page.goto(`${server.baseUrl}chat`);
expect(response?.status()).toBe(200);
const sidebar = page.locator("openclaw-app-sidebar");
await sidebar.locator(".sidebar-identity-card").click();
await sidebar
.locator('wa-dropdown.sidebar-identity-menu wa-dropdown-item[value="command:usage"]')
.click();
await expect.poll(() => new URL(page.url()).pathname).toBe("/usage");
await waitForRequestCount(gateway, "sessions.usage", 1);
await waitForRequestCount(gateway, "usage.cost", 1);
await page.locator(".daily-chart-compact").waitFor({ timeout: 10_000 });
@@ -226,7 +232,10 @@ describeControlUiE2e("Control UI usage proxy reconnect lifecycle", () => {
await gateway.deferNext("sessions.usage");
await gateway.deferNext("usage.cost");
await page.getByRole("button", { name: "Refresh", exact: true }).click();
await page
.locator("openclaw-usage-page")
.getByRole("button", { name: "Refresh", exact: true })
.click();
await waitForRequestCount(gateway, "sessions.usage", 3);
await waitForRequestCount(gateway, "usage.cost", 3);

View File

@@ -1651,6 +1651,7 @@ export const en: TranslationMap = {
blockedAgentFilter: "blocked by agent filter",
},
nav: {
account: "Account",
back: "Back",
forward: "Forward",
chat: "Chat",

View File

@@ -3020,10 +3020,100 @@ wa-dropdown.sidebar-session-sort-menu::part(menu) {
.sidebar-footer-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
}
.sidebar-identity-card {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
padding: 5px 6px;
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--text);
font: inherit;
text-align: left;
cursor: default;
transition: background var(--duration-fast) ease;
}
.sidebar-identity-card:hover,
.sidebar-identity-card:focus-visible,
.sidebar-identity-card[aria-expanded="true"] {
background: color-mix(in srgb, var(--bg-hover) 84%, transparent);
}
.sidebar-identity-card:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 48%, transparent);
outline-offset: 1px;
}
.sidebar-identity-card .viewer-avatar--footer {
width: 26px;
height: 26px;
flex: none;
font-size: 10px;
}
.sidebar-identity-card__text {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-width: 0;
gap: 1px;
}
.sidebar-identity-card__name,
.sidebar-identity-card__subtitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-identity-card__name {
color: var(--text-strong);
font-size: 13px;
font-weight: 600;
line-height: 1.2;
}
.sidebar-identity-card__subtitle {
color: var(--muted);
font-size: 11px;
line-height: 1.2;
}
.sidebar-identity-card__status {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.sidebar-identity-card__chevron {
display: inline-flex;
flex: none;
color: var(--muted);
}
.sidebar-identity-card__chevron svg {
width: 13px;
height: 13px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
openclaw-tooltip.sidebar-hover-tooltip {
--openclaw-tooltip-max-width: min(340px, calc(100vw - 24px));
}
@@ -3149,40 +3239,6 @@ openclaw-tooltip.sidebar-hover-tooltip {
font-size: 11px;
}
.sidebar-footer-bar__identity {
display: inline-flex;
align-items: center;
gap: var(--space-1);
min-width: 0;
max-width: 132px;
padding: 2px 5px 2px 2px;
border: none;
border-radius: var(--radius-full);
background: transparent;
color: var(--text);
font: inherit;
cursor: pointer;
transition: background var(--duration-fast) ease;
}
.sidebar-footer-bar__identity:hover,
.sidebar-footer-bar__identity:focus-visible {
background: var(--bg-hover);
}
.sidebar-footer-bar__identity-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
font-weight: 550;
}
.sidebar-footer-bar__spacer {
flex: 1 1 auto;
}
.sidebar-footer-bar__status {
display: inline-flex;
align-items: center;
@@ -3222,39 +3278,6 @@ openclaw-tooltip.sidebar-hover-tooltip {
background: var(--danger);
}
.sidebar-footer-bar__settings {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex: none;
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--muted);
cursor: default;
transition:
color var(--duration-fast) ease,
background var(--duration-fast) ease;
}
.sidebar-footer-bar__settings:hover,
.sidebar-footer-bar__settings:focus-visible {
color: var(--text);
background: color-mix(in srgb, var(--bg-hover) 84%, transparent);
}
.sidebar-footer-bar__settings svg {
width: 17px;
height: 17px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Groups zone: quiet compact rows. Coding zone: muted until expanded. */
.sidebar-recent-sessions__group--zone-groups .sidebar-recent-session__link {
padding-top: 3px;
@@ -3300,6 +3323,18 @@ openclaw-tooltip.sidebar-hover-tooltip {
overflow: visible;
}
wa-dropdown.sidebar-identity-menu::part(menu) {
min-width: max(224px, var(--sidebar-identity-menu-min-width, 224px));
}
.sidebar-identity-menu__header {
min-height: 24px;
padding: 0 8px;
color: var(--muted);
font-size: 11px;
font-weight: 500;
}
.sidebar-agent-menu__filter {
padding: 2px 8px 6px;
}

View File

@@ -77,7 +77,7 @@ describe("AppSidebar viewer presence", () => {
await vi.waitFor(() => {
const avatar = sidebar.querySelector<HTMLImageElement>(
".sidebar-footer-bar__identity openclaw-viewer-avatar img",
".sidebar-identity-card openclaw-viewer-avatar img",
);
expect(avatar?.getAttribute("src")).toBe("/api/users/00-self/avatar?v=7");
});
@@ -166,21 +166,22 @@ describe("AppSidebar viewer presence", () => {
),
).toEqual(["Alice", "bob@example.test", "Carol", "Dave\nErin\nFrank"]);
const identityChip = sidebar.querySelector<HTMLButtonElement>(".sidebar-footer-bar__identity");
expect(identityChip?.querySelector(".sidebar-footer-bar__identity-name")?.textContent).toBe(
const identityCard = sidebar.querySelector<HTMLButtonElement>(".sidebar-identity-card");
expect(identityCard?.querySelector(".sidebar-identity-card__name")?.textContent).toBe(
"Self User",
);
expect(identityChip?.querySelector('[data-viewer-id="00-self"]')).not.toBeNull();
expect(identityCard?.querySelector('[data-viewer-id="00-self"]')).not.toBeNull();
const avatar = identityChip?.querySelector<HTMLImageElement>("openclaw-viewer-avatar img");
const avatar = identityCard?.querySelector<HTMLImageElement>("openclaw-viewer-avatar img");
expect(avatar?.getAttribute("src")).toBe("/api/users/00-self/avatar?v=1");
const footer = sidebar.querySelector(".sidebar-footer-bar");
expect(footer?.querySelector("openclaw-viewer-facepile")).toBeNull();
expect(footer?.querySelector("openclaw-sidebar-build-chip")).toBeNull();
expect(footer?.querySelector(".sidebar-brand__logo-slot")).toBeNull();
expect(
[...(footer?.children ?? [])].map((element) => element.className || element.localName),
).toEqual(["openclaw-tooltip", "sidebar-footer-bar__spacer", "openclaw-tooltip"]);
expect([...(footer?.children ?? [])].map((element) => element.localName)).toEqual([
"openclaw-tooltip",
"span",
]);
gatewayHarness.gateway.updateSelfUser?.({
name: "Augusta Ada",
avatarUrl: "/api/users/00-self/avatar?v=4",
@@ -188,17 +189,17 @@ describe("AppSidebar viewer presence", () => {
await sidebar.updateComplete;
// Profile mutations update gateway state directly; no presence event follows them.
expect(identityChip?.querySelector(".sidebar-footer-bar__identity-name")?.textContent).toBe(
expect(identityCard?.querySelector(".sidebar-identity-card__name")?.textContent).toBe(
"Augusta Ada",
);
expect(avatar?.getAttribute("src")).toBe("/api/users/00-self/avatar?v=4");
sidebar.connected = false;
await sidebar.updateComplete;
expect(sidebar.querySelector(".sidebar-footer-bar__identity")).toBeNull();
expect(sidebar.querySelector(".sidebar-identity-card__name")?.textContent).toBe("Augusta Ada");
});
it("leaves the footer identity chip absent for an unidentified connection", async () => {
it("renders an Account fallback for an unidentified connection", async () => {
const client = { instanceId: "anonymous-self" } as GatewayBrowserClient;
const gatewayHarness = createGatewayHarness(client);
const { sidebar } = await mountSidebar(
@@ -214,8 +215,11 @@ describe("AppSidebar viewer presence", () => {
});
await sidebar.updateComplete;
expect(sidebar.querySelector(".sidebar-footer-bar__identity")).toBeNull();
expect(sidebar.querySelector(".sidebar-footer-bar")?.textContent).not.toContain("Sign in");
const identityCard = sidebar.querySelector(".sidebar-identity-card");
expect(identityCard?.querySelector(".sidebar-identity-card__name")?.textContent).toBe(
"Account",
);
expect(identityCard?.querySelector('[data-viewer-id="account"]')?.textContent).toContain("A");
});
});
@@ -333,7 +337,7 @@ describe("AppSidebar agent chip", () => {
expect(onNavigate).not.toHaveBeenCalledWith("config");
});
it("renders the canonical offline retry button only for a stable disconnect", async () => {
it("keeps the identity card available offline with reconnect and retry actions", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
const onRetryConnect = vi.fn();
@@ -341,40 +345,42 @@ describe("AppSidebar agent chip", () => {
sidebar.connected = true;
await sidebar.updateComplete;
expect(sidebar.querySelector(".sidebar-footer-bar__status")).toBeNull();
expect(sidebar.querySelector(".sidebar-identity-card__subtitle")).toBeNull();
expect(
sidebar.querySelector(".sidebar-agent-card__main")?.getAttribute("aria-label"),
).not.toContain("Online");
sidebar.connected = false;
sidebar.offline = true;
sidebar.queuedOutboxCount = 3;
sidebar.lastError = "gateway unavailable?token=sidebar-secret";
await sidebar.updateComplete;
const button = sidebar.querySelector<HTMLButtonElement>(".sidebar-footer-bar__status");
expect(button?.textContent).toContain("Offline");
expect(button?.textContent).toContain("Reconnecting…");
expect(button?.textContent).toContain("3 queued");
expect(button?.getAttribute("aria-label")).toBe("Offline — Retry now — 3 queued");
expect(button?.getAttribute("aria-live")).toBe("polite");
// The redacted error detail moved from a native title to the shared
// tooltip's accessible description.
expect(button?.hasAttribute("title")).toBe(false);
const descriptionId = button?.getAttribute("aria-describedby") ?? "";
expect(document.getElementById(descriptionId)?.textContent).toBe(
"gateway unavailable?[redacted-credential]",
const card = sidebar.querySelector<HTMLButtonElement>(".sidebar-identity-card");
expect(card?.querySelector(".sidebar-identity-card__name")?.textContent).toBe("Account");
expect(card?.querySelector(".sidebar-identity-card__subtitle")?.textContent).toBe(
"Reconnecting…",
);
expect(button?.querySelector(".sidebar-footer-bar__status-dot")).not.toBeNull();
expect(
card?.querySelector(".sidebar-identity-card__subtitle")?.getAttribute("aria-hidden"),
).toBe("true");
const connectionStatus = sidebar.querySelector(".sidebar-identity-card__status");
expect(connectionStatus?.getAttribute("role")).toBe("status");
expect(connectionStatus?.getAttribute("aria-live")).toBe("polite");
expect(connectionStatus?.textContent).toBe("Reconnecting…");
expect(sidebar.querySelector(".sidebar-footer-bar__status")).toBeNull();
expect(sidebar.querySelector(".sidebar-agent-card__subtitle")?.textContent).not.toContain(
"Offline",
);
button?.click();
card?.click();
await sidebar.updateComplete;
const menu = sidebar.querySelector<HTMLElement>(".sidebar-identity-menu");
const retry = menu?.querySelector('wa-dropdown-item[value="command:retry-connect"]');
menu?.dispatchEvent(new CustomEvent("wa-select", { detail: { item: retry }, bubbles: true }));
expect(onRetryConnect).toHaveBeenCalledOnce();
sidebar.offline = false;
await sidebar.updateComplete;
expect(sidebar.querySelector(".sidebar-footer-bar__status")).toBeNull();
expect(sidebar.querySelector(".sidebar-identity-card__subtitle")).toBeNull();
expect(sidebar.querySelector(".sidebar-identity-card__status")?.textContent).toBe("");
});
it("shows a working subtitle while the agent has an active run", async () => {

View File

@@ -20,14 +20,20 @@ describe("AppSidebar footer identity menu", () => {
presence: [
{
instanceId: "self-instance",
user: { id: "self", name: "Ada" },
user: { id: "self", name: "Ada", email: "ada@example.test" },
},
],
});
await sidebar.updateComplete;
const identity = sidebar.querySelector<HTMLButtonElement>(".sidebar-footer-bar__identity");
const identity = sidebar.querySelector<HTMLButtonElement>(".sidebar-identity-card");
expect(identity?.getAttribute("aria-haspopup")).toBe("menu");
vi.spyOn(identity!, "getBoundingClientRect").mockReturnValue({
left: 12,
right: 224,
top: 700,
width: 212,
} as DOMRect);
identity?.click();
await sidebar.updateComplete;
@@ -37,7 +43,23 @@ describe("AppSidebar footer identity menu", () => {
[...(menu?.children ?? [])]
.filter((element) => element.localName === "wa-dropdown-item")
.map((element) => element.getAttribute("value")),
).toEqual(["command:profile", "command:pair-mobile", "command:apps", "command:help"]);
).toEqual([
"command:profile",
"command:settings",
"command:usage",
"command:pair-mobile",
"command:apps",
"command:help",
]);
expect(menu?.querySelector(".sidebar-identity-menu__header")?.textContent?.trim()).toBe(
"ada@example.test",
);
expect(
menu
?.querySelector('wa-dropdown-item[value="command:settings"] .session-menu__shortcut')
?.textContent?.trim(),
).toMatch(/^(,|Ctrl\+Shift\+,)$/u);
expect(menu?.style.getPropertyValue("--sidebar-identity-menu-min-width")).toBe("212px");
expect(menu?.querySelector(".sidebar-pair-mobile")?.hasAttribute("disabled")).toBe(true);
expect(menu?.querySelector("openclaw-sidebar-build-chip")).not.toBeNull();
expect(menu?.querySelector("openclaw-theme-mode-toggle")).not.toBeNull();

View File

@@ -39,8 +39,6 @@ export type SidebarLifecycleState = HTMLElement & {
connected: boolean;
offline: boolean;
outboxCountForSession: (sessionKey: string) => number;
queuedOutboxCount: number;
lastError: string | null;
terminalAvailable: boolean;
catalogOpenTarget: "viewer" | "terminal";
canPairDevice: boolean;