Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,827 @@
import { consume } from "@lit/context";
import { LitElement, html, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { ModelAuthStatusResult, SessionsListResult } from "../api/types.ts";
import {
cancelRoutePreload,
isSettingsNavigationRoute,
navigationIconForRoute,
scheduleRoutePreload,
type NavigationRouteId,
SIDEBAR_SECTIONS,
titleForRoute,
} from "../app-navigation.ts";
import { pathForRoute, type RouteId } from "../app-route-paths.ts";
import {
applicationContext,
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";
import type { ThemeMode } from "../app/theme.ts";
import { t } from "../i18n/index.ts";
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../lib/external-link.ts";
import { formatRelativeTimestamp } from "../lib/format.ts";
import { resolveSessionDisplayName } from "../lib/session-display.ts";
import {
compareSessionRowsByUpdatedAt,
resolveSessionNavigation,
searchForSession,
} from "../lib/sessions/index.ts";
import {
buildAgentMainSessionKey,
canArchiveSessionRow,
normalizeAgentId,
parseAgentSessionKey,
resolveUiConfiguredMainKey,
} from "../lib/sessions/session-key.ts";
import {
resolvePreferredSessionForAgent,
resolveSessionAgentFilterOptions,
} from "../lib/sessions/session-options.ts";
import { icons } from "./icons.ts";
type ProviderQuotaPillRenderer = typeof import("./provider-quota-pill.ts").renderProviderQuotaPill;
type SidebarRecentSession = {
key: string;
label: string;
meta: string;
href: string;
active: boolean;
hasActiveRun: boolean;
kind?: string;
pinned: boolean;
pinnedAt?: number | null;
};
function shouldHandleNavigationClick(event: MouseEvent): boolean {
return (
!event.defaultPrevented &&
event.button === 0 &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
!event.altKey
);
}
export class AppSidebar extends LitElement {
override createRenderRoot() {
return this;
}
@property({ attribute: false }) basePath = "";
@property({ attribute: false }) activeRouteId?: NavigationRouteId;
@property({ attribute: false }) enabledRouteIds?: readonly NavigationRouteId[];
@property({ attribute: false }) collapsed = false;
@property({ attribute: false }) connected = false;
@property({ attribute: false }) canPairDevice = false;
@property({ attribute: false }) sessionKey = "";
@property({ attribute: false }) navGroupsCollapsed: Record<string, boolean> = {};
@property({ attribute: false }) recentSessionsCollapsed = false;
@property({ attribute: false }) themeMode: ThemeMode = "system";
@property({ attribute: false }) onToggleCollapsed?: () => void;
@property({ attribute: false }) onToggleGroup?: (label: string) => void;
@property({ attribute: false }) onToggleRecentSessions?: () => void;
@property({ attribute: false }) onPairMobile?: () => void;
@property({ attribute: false })
onNavigate?: (routeId: NavigationRouteId, options?: ApplicationNavigationOptions) => void;
@property({ attribute: false }) onPreloadRoute?: (routeId: NavigationRouteId) => Promise<void>;
@consume({ context: applicationContext, subscribe: false })
private context?: ApplicationContext<RouteId>;
@state() private sessionsResult: SessionsListResult | null = null;
@state() private sessionsAgentId: string | null = null;
@state() private sessionsLoading = false;
@state() private modelAuthStatusResult: ModelAuthStatusResult | null = null;
@state() private providerQuotaPillRenderer: ProviderQuotaPillRenderer | null = null;
private stopSessionsSubscription: (() => void) | undefined;
private stopAgentsSubscription: (() => void) | undefined;
private stopAgentSelectionSubscription: (() => void) | undefined;
private stopGatewaySubscription: (() => void) | undefined;
private sessionRowsByAgent: Record<string, SessionsListResult["sessions"]> = {};
private modelAuthClient: GatewayBrowserClient | null = null;
private readonly routePreloadTimers = new Map<
EventTarget,
ReturnType<typeof globalThis.setTimeout>
>();
override connectedCallback() {
super.connectedCallback();
this.style.display = "contents";
this.startSubscriptions();
}
override disconnectedCallback() {
this.stopSessionsSubscription?.();
this.stopSessionsSubscription = undefined;
this.stopAgentsSubscription?.();
this.stopAgentsSubscription = undefined;
this.stopAgentSelectionSubscription?.();
this.stopAgentSelectionSubscription = undefined;
this.stopGatewaySubscription?.();
this.stopGatewaySubscription = undefined;
this.modelAuthClient = null;
for (const timer of this.routePreloadTimers.values()) {
globalThis.clearTimeout(timer);
}
this.routePreloadTimers.clear();
super.disconnectedCallback();
}
private startSubscriptions() {
const context = this.context;
if (
!context ||
this.stopSessionsSubscription ||
this.stopAgentsSubscription ||
this.stopAgentSelectionSubscription ||
this.stopGatewaySubscription
) {
return;
}
this.updateModelAuthStatus(context.gateway.snapshot);
this.updateSessions(context.sessions.state);
this.stopSessionsSubscription = context.sessions.subscribe((snapshot) => {
this.updateSessions(snapshot);
});
this.stopAgentsSubscription = context.agents.subscribe(() => {
this.requestUpdate();
});
this.stopAgentSelectionSubscription = context.agentSelection.subscribe(() => {
this.requestUpdate();
});
this.stopGatewaySubscription = context.gateway.subscribe((snapshot) => {
this.updateModelAuthStatus(snapshot);
this.requestUpdate();
});
}
override updated() {
this.startSubscriptions();
}
private readonly updateSessions = (snapshot: {
result: SessionsListResult | null;
agentId: string | null;
loading: boolean;
}) => {
this.sessionsResult = snapshot.result;
this.sessionsAgentId = snapshot.agentId;
this.sessionsLoading = snapshot.loading;
if (snapshot.result && snapshot.agentId) {
this.sessionRowsByAgent[normalizeAgentId(snapshot.agentId)] = snapshot.result.sessions;
}
};
private updateModelAuthStatus(snapshot: {
client: GatewayBrowserClient | null;
connected: boolean;
}) {
const client = snapshot.connected ? snapshot.client : null;
if (client === this.modelAuthClient) {
return;
}
this.sessionRowsByAgent = {};
this.modelAuthClient = client;
this.modelAuthStatusResult = null;
if (!client) {
return;
}
void import("../lib/model-auth.ts")
.then(async ({ isMonitoredAuthProvider, loadModelAuthStatus }) => {
const result = await loadModelAuthStatus(client);
if (this.modelAuthClient !== client) {
return;
}
this.modelAuthStatusResult = result;
const hasQuota = result.providers.some(
(provider) =>
isMonitoredAuthProvider(provider) && Boolean(provider.usage?.windows?.length),
);
if (!hasQuota || this.providerQuotaPillRenderer) {
return;
}
const { renderProviderQuotaPill } = await import("./provider-quota-pill.ts");
if (this.modelAuthClient === client) {
this.providerQuotaPillRenderer = renderProviderQuotaPill;
}
})
.catch(() => {
if (this.modelAuthClient === client) {
this.modelAuthStatusResult = null;
}
});
}
private getRouteSessionKey(): string {
return this.sessionKey.trim() || this.context?.gateway.snapshot.sessionKey.trim() || "";
}
private getSessionNavigationState() {
const context = this.context;
const routeSessionKey = this.getRouteSessionKey();
const navigation = resolveSessionNavigation({
result: this.sessionsResult,
resultAgentId: this.sessionsAgentId,
sessionKey: routeSessionKey,
assistantAgentId:
context?.agentSelection.state.selectedId ?? context?.gateway.snapshot.assistantAgentId,
hello: context?.gateway.snapshot.hello,
});
const toSidebarSession = (row: SessionsListResult["sessions"][number]) => ({
key: row.key,
label: resolveSessionDisplayName(row.key, row),
meta: row.updatedAt ? formatRelativeTimestamp(row.updatedAt) : "",
href: `${pathForRoute("chat", context?.basePath ?? "")}${searchForSession(row.key)}`,
active: row.key === navigation.currentSessionKey,
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);
return {
routeSessionKey: navigation.currentSessionKey,
selectedAgentId: navigation.selectedAgentId,
defaultAgentId: navigation.defaultAgentId,
activeSession,
recentSessions,
newSessionDisabled,
newSessionTitle: !this.connected
? "Connect to create a new session"
: navigation.selectedSession?.hasActiveRun
? "Finish the active run before creating a new session"
: "New session",
};
}
private readonly selectSession = (sessionKey: string) => {
this.context?.gateway.setSessionKey(sessionKey);
this.onNavigate?.("chat", {
search: searchForSession(sessionKey),
});
};
private readonly replaceCurrentSession = (sessionKey: string) => {
this.context?.gateway.setSessionKey(sessionKey);
if (this.activeRouteId === "chat") {
this.onNavigate?.("chat", {
search: searchForSession(sessionKey),
});
}
};
private readonly selectAgent = (agentId: string) => {
const context = this.context;
if (!context) {
return;
}
const { routeSessionKey, selectedAgentId } = this.getSessionNavigationState();
const nextAgentId = normalizeAgentId(agentId);
if (nextAgentId === normalizeAgentId(selectedAgentId)) {
return;
}
const nextSessionKey = resolvePreferredSessionForAgent(
{
agentsList: context.agents.state.agentsList,
chatAgentSessionRowsByAgent: this.sessionRowsByAgent,
sessionsResult: this.sessionsResult,
sessionsResultAgentId: this.sessionsAgentId,
sessionKey: routeSessionKey,
},
nextAgentId,
);
context.agentSelection.set(nextAgentId);
this.selectSession(nextSessionKey);
};
private readonly createSession = async () => {
const context = this.context;
if (!context) {
return;
}
const { routeSessionKey, selectedAgentId, newSessionDisabled } =
this.getSessionNavigationState();
if (newSessionDisabled) {
return;
}
const nextSessionKey = await context.sessions.create({
currentSessionKey: routeSessionKey,
agentId: selectedAgentId,
});
if (nextSessionKey) {
this.selectSession(nextSessionKey);
}
};
private readonly patchSession = async (
session: SidebarRecentSession,
patch: { archived?: boolean; pinned?: boolean },
) => {
const context = this.context;
if (!context || !this.connected) {
return;
}
const { selectedAgentId } = this.getSessionNavigationState();
const agentId = parseAgentSessionKey(session.key)?.agentId;
try {
const patched = await context.sessions.patch(session.key, patch, agentId ? { agentId } : {});
if (!patched || patch.archived !== true || !session.active) {
return;
}
this.replaceCurrentSession(
buildAgentMainSessionKey({
agentId: agentId ?? selectedAgentId,
mainKey: resolveUiConfiguredMainKey({
agentsList: context.agents.state.agentsList,
hello: context.gateway.snapshot.hello,
}),
}),
);
} catch {
// Session capability publishes the actionable error for the owning page.
}
};
private preloadRoute(routeId: NavigationRouteId, event: Event, immediate = false) {
scheduleRoutePreload(
this.routePreloadTimers,
routeId,
event,
(nextRouteId) => this.onPreloadRoute?.(nextRouteId),
routeId === this.activeRouteId || !this.isRouteEnabled(routeId),
immediate,
);
}
private readonly cancelPreload = (event: Event) => {
cancelRoutePreload(this.routePreloadTimers, event);
};
private isRouteEnabled(routeId: NavigationRouteId): boolean {
return this.enabledRouteIds?.includes(routeId) ?? true;
}
private renderRoute(routeId: NavigationRouteId) {
const active =
routeId === "config"
? this.activeRouteId !== undefined && isSettingsNavigationRoute(this.activeRouteId)
: this.activeRouteId === routeId;
const enabled = this.isRouteEnabled(routeId);
if (!enabled) {
return html`
<span class="nav-item nav-item--disabled" aria-disabled="true">
<span class="nav-item__icon" aria-hidden="true"
>${icons[navigationIconForRoute(routeId)]}</span
>
${!this.collapsed
? html`<span class="nav-item__text">${titleForRoute(routeId)}</span>`
: nothing}
</span>
`;
}
const routeSessionKey = routeId === "chat" ? this.getRouteSessionKey() : "";
const href =
routeSessionKey && routeId === "chat"
? `${pathForRoute("chat", this.basePath)}${searchForSession(routeSessionKey)}`
: pathForRoute(routeId, this.basePath);
const label = titleForRoute(routeId);
const link = html`
<a
href=${href}
class="nav-item ${active ? "nav-item--active" : ""}"
@focus=${(event: Event) => this.preloadRoute(routeId, event)}
@blur=${this.cancelPreload}
@pointerenter=${(event: Event) => this.preloadRoute(routeId, event)}
@pointerleave=${this.cancelPreload}
@touchstart=${(event: TouchEvent) => this.preloadRoute(routeId, event, true)}
@click=${(event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
return;
}
event.preventDefault();
this.onNavigate?.(
routeId,
routeId === "chat" && routeSessionKey
? {
search: searchForSession(routeSessionKey),
}
: undefined,
);
}}
>
<span class="nav-item__icon" aria-hidden="true"
>${icons[navigationIconForRoute(routeId)]}</span
>
${!this.collapsed ? html`<span class="nav-item__text">${label}</span>` : nothing}
</a>
`;
return this.collapsed
? html`<openclaw-tooltip .content=${label}>${link}</openclaw-tooltip>`
: link;
}
private renderRecentSession(session: SidebarRecentSession) {
const context = this.context;
const archiveAllowed = canArchiveSessionRow(
session,
resolveUiConfiguredMainKey({
agentsList: context?.agents.state.agentsList,
hello: context?.gateway.snapshot.hello,
}),
);
const rowClass = [
"sidebar-recent-session",
"session-row-host",
session.active ? "sidebar-recent-session--active" : "",
session.pinned ? "session-row-host--pinned" : "",
session.hasActiveRun ? "session-row-host--running" : "",
]
.filter(Boolean)
.join(" ");
return html`
<div class=${rowClass} data-session-key=${session.key}>
<a
href=${session.href}
class="sidebar-recent-session__link"
title=${`${session.label} · ${session.key}`}
@click=${(event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
return;
}
event.preventDefault();
this.selectSession(session.key);
}}
>
<span class="sidebar-recent-session__name">${session.label}</span>
</a>
<span class="sidebar-recent-session__aside session-row-aside">
<span class="session-row-trail">
${session.hasActiveRun
? html`<span
class="session-run-spinner"
role="img"
aria-label=${t("sessionsView.activeRun")}
title=${t("sessionsView.activeRun")}
></span>`
: session.meta}
</span>
<span class="session-row-actions">
<button
class="session-action"
data-sidebar-session-archive="true"
type="button"
title=${t("sessionsView.archiveSession")}
aria-label=${t("sessionsView.archiveSession")}
?disabled=${!this.connected || !archiveAllowed}
@click=${() => void this.patchSession(session, { archived: true })}
>
${icons.archive}
</button>
<button
class="session-action session-action--pin"
data-sidebar-session-pin="true"
type="button"
title=${session.pinned
? t("sessionsView.unpinSession")
: t("sessionsView.pinSession")}
aria-label=${session.pinned
? t("sessionsView.unpinSession")
: t("sessionsView.pinSession")}
?disabled=${!this.connected}
@click=${() => void this.patchSession(session, { pinned: !session.pinned })}
>
${icons.pin}
</button>
</span>
</span>
</div>
`;
}
private renderSessions() {
const context = this.context;
const {
routeSessionKey,
selectedAgentId,
defaultAgentId,
activeSession,
recentSessions,
newSessionDisabled,
newSessionTitle,
} = this.getSessionNavigationState();
const newSessionButton = html`
<button
type="button"
class="sidebar-new-session"
aria-label=${t("chat.runControls.newSession")}
?disabled=${newSessionDisabled}
@click=${this.createSession}
>
<span class="sidebar-new-session__icon" aria-hidden="true">${icons.plus}</span>
${this.collapsed
? nothing
: html`<span class="sidebar-new-session__label"
>${t("chat.runControls.newSession")}</span
>`}
</button>
`;
return html`
<section class="sidebar-sessions ${this.collapsed ? "sidebar-sessions--collapsed" : ""}">
${this.collapsed
? html`<openclaw-tooltip .content=${newSessionTitle}
>${newSessionButton}</openclaw-tooltip
>`
: newSessionButton}
${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
? nothing
: html`
<div class="sidebar-recent-sessions__list">
${recentSessions.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");
}}
>
<span>${t("chat.sidebar.allSessions")}</span>
<span class="sidebar-recent-sessions__all-icon" aria-hidden="true"
>${icons.chevronRight}</span
>
</a>
</div>
`}
</section>
`;
}
private renderAgentFilter(sessionKey: string, selectedAgentId: string) {
const options = resolveSessionAgentFilterOptions({
agentsList: this.context?.agents.state.agentsList,
sessionsResult: this.sessionsResult,
sessionsResultAgentId: this.sessionsAgentId,
sessionKey,
});
if (options.length <= 1) {
return nothing;
}
const selectedLabel =
options.find((option) => option.id === selectedAgentId)?.label ?? selectedAgentId;
return html`
<div class="sidebar-agent-filter">
<label class="field chat-controls__session chat-controls__agent">
<select
data-chat-agent-filter="true"
aria-label=${t("chat.selectors.agentFilter")}
title=${selectedLabel}
.value=${selectedAgentId}
?disabled=${!this.connected}
@change=${(event: Event) => this.selectAgent((event.target as HTMLSelectElement).value)}
>
${options.map(
(option) =>
html`<option value=${option.id} ?selected=${option.id === selectedAgentId}>
${option.label}
</option>`,
)}
</select>
</label>
</div>
`;
}
private renderChatFallback() {
return html`
<a
href=${pathForRoute("chat", this.basePath)}
class="sidebar-recent-session ${this.activeRouteId === "chat"
? "sidebar-recent-session--active"
: ""}"
@click=${(event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
return;
}
event.preventDefault();
this.onNavigate?.("chat");
}}
>
<span class="sidebar-recent-session__body">
<span class="sidebar-recent-session__name">${t("nav.chat")}</span>
</span>
</a>
`;
}
override render() {
const gatewayStatus = t("chat.gatewayStatus", {
status: this.connected ? t("common.online") : t("common.offline"),
});
const quotaPill = this.collapsed
? ""
: (this.providerQuotaPillRenderer?.({
basePath: this.basePath,
modelAuthStatusResult: this.modelAuthStatusResult,
}) ?? "");
return html`
<aside class="sidebar ${this.collapsed ? "sidebar--collapsed" : ""}">
<div class="sidebar-shell">
<div class="sidebar-shell__header">
<div class="sidebar-brand">
${this.collapsed
? nothing
: html`
<img
class="sidebar-brand__logo"
src="${controlUiPublicAssetPath("favicon.svg", this.basePath)}"
alt="OpenClaw"
/>
<span class="sidebar-brand__copy">
<span class="sidebar-brand__title">OpenClaw</span>
</span>
`}
</div>
<openclaw-tooltip .content=${this.collapsed ? t("nav.expand") : t("nav.collapse")}>
<button
type="button"
class="nav-collapse-toggle"
@click=${() => this.onToggleCollapsed?.()}
aria-label=${this.collapsed ? t("nav.expand") : t("nav.collapse")}
>
<span class="nav-collapse-toggle__icon" aria-hidden="true"
>${this.collapsed ? icons.panelLeftOpen : icons.panelLeftClose}</span
>
</button>
</openclaw-tooltip>
</div>
<div class="sidebar-shell__body">
${this.renderSessions()}
<nav class="sidebar-nav">
${SIDEBAR_SECTIONS.filter((group) => this.collapsed || group.label !== "chat").map(
(group) => {
const isGroupCollapsed = this.navGroupsCollapsed[group.label] ?? false;
const showItems = this.collapsed || !isGroupCollapsed;
return html`
<section class="nav-section ${!showItems ? "nav-section--collapsed" : ""}">
${!this.collapsed
? html`
<button
class="nav-section__label"
@click=${() => this.onToggleGroup?.(group.label)}
aria-expanded=${showItems}
>
<span class="nav-section__label-text"
>${t(`nav.${group.label}`)}</span
>
<span class="nav-section__chevron"> ${icons.chevronDown} </span>
</button>
`
: nothing}
<div class="nav-section__items">
${group.routes.map((routeId) => this.renderRoute(routeId))}
</div>
</section>
`;
},
)}
</nav>
</div>
<div class="sidebar-shell__footer">
<div class="sidebar-utility-group">
${quotaPill ? html`<div class="sidebar-quota">${quotaPill}</div>` : nothing}
${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")}
>
<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>
</div>
</div>
</div>
</aside>
`;
}
}
if (!customElements.get("openclaw-app-sidebar")) {
customElements.define("openclaw-app-sidebar", AppSidebar);
}