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 = {}; @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; @consume({ context: applicationContext, subscribe: false }) private context?: ApplicationContext; @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 = {}; private modelAuthClient: GatewayBrowserClient | null = null; private readonly routePreloadTimers = new Map< EventTarget, ReturnType >(); 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` ${!this.collapsed ? html`${titleForRoute(routeId)}` : nothing} `; } 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` 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, ); }} > ${!this.collapsed ? html`${label}` : nothing} `; return this.collapsed ? html`${link}` : 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`
{ if (!shouldHandleNavigationClick(event)) { return; } event.preventDefault(); this.selectSession(session.key); }} > ${session.label} ${session.hasActiveRun ? html`` : session.meta}
`; } private renderSessions() { const context = this.context; const { routeSessionKey, selectedAgentId, defaultAgentId, activeSession, recentSessions, newSessionDisabled, newSessionTitle, } = this.getSessionNavigationState(); const newSessionButton = html` `; return html` `; } 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` `; } private renderChatFallback() { return html` { if (!shouldHandleNavigationClick(event)) { return; } event.preventDefault(); this.onNavigate?.("chat"); }} > ${t("nav.chat")} `; } 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` `; } } if (!customElements.get("openclaw-app-sidebar")) { customElements.define("openclaw-app-sidebar", AppSidebar); }