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
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:
827
ui/src/components/app-sidebar.ts
Normal file
827
ui/src/components/app-sidebar.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user