mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 19:08:22 -06:00
0cb56ace62
* fix(ui): harden browser, session, and gateway lifecycles * fix(ui): satisfy browser lifecycle and control UI CI gates * fix(ui): satisfy type-aware browser and cron lint * fix(ci): isolate model and UI test workloads * fix(ui): reject superseded browser and session page races * fix(ui): settle superseded background-tab refreshes * fix(ui): reconcile committed navigation and terminal pages * fix(ui): preserve canonical cron permission contracts
484 lines
18 KiB
TypeScript
484 lines
18 KiB
TypeScript
import { html, nothing, type PropertyValues, type TemplateResult } from "lit";
|
|
import { state } from "lit/decorators.js";
|
|
import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js";
|
|
import { isSessionRouteId } from "../app-route-paths.ts";
|
|
import { beginNativeWindowDragFromTopInset } from "../app/native-window-drag.ts";
|
|
import { BoardAvailabilityController } from "../lib/board/availability-controller.ts";
|
|
import "./menu-surface.ts";
|
|
import "./session-menu.ts";
|
|
import "./sidebar-agent-card.ts";
|
|
import "./sidebar-attention.ts";
|
|
import "./sidebar-update-card.ts";
|
|
import "./theme-mode-toggle.ts";
|
|
import "./tooltip.ts";
|
|
import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts";
|
|
import { createIdleImport } from "../lib/idle-import.ts";
|
|
import type { CatalogProjectGrouping } from "../lib/sessions/catalog-project-grouping.ts";
|
|
import { normalizeAgentId } from "../lib/sessions/session-key.ts";
|
|
import { SubscriptionsController } from "../lit/subscriptions-controller.ts";
|
|
import { sidebarPluginTabs } from "./app-sidebar-nav-menus.ts";
|
|
import {
|
|
renderAppSidebarAttention,
|
|
renderAppSidebarBrand,
|
|
renderAppSidebarFooterBar,
|
|
renderAppSidebarHomeRow,
|
|
renderAppSidebarPagesHead,
|
|
renderAppSidebarPluginTabEntry,
|
|
renderAppSidebarZoneEntry,
|
|
} from "./app-sidebar-render.ts";
|
|
import type { SessionCatalogGroupsRenderer } from "./app-sidebar-session-catalog-render.ts";
|
|
import type { CatalogSessionMenuRequest } from "./app-sidebar-session-catalogs.ts";
|
|
import { renderSessionList } from "./app-sidebar-session-list-render.ts";
|
|
import type {
|
|
SidebarNarrationSyncInput,
|
|
SidebarSessionNarrationController,
|
|
} from "./app-sidebar-session-narration.ts";
|
|
import { AppSidebarSessionNavigationElement } from "./app-sidebar-session-navigation.ts";
|
|
import {
|
|
renderSessionTree,
|
|
type SessionListHost,
|
|
visibleSessionChildren,
|
|
} from "./app-sidebar-session-row-render.ts";
|
|
import {
|
|
loadStoredSidebarCatalogGrouping,
|
|
storeSidebarCatalogGrouping,
|
|
type SidebarRecentSession,
|
|
} from "./app-sidebar-session-types.ts";
|
|
import { icons } from "./icons.ts";
|
|
import {
|
|
lobsterPetSeed,
|
|
resolveLobsterPetMode,
|
|
resolveLobsterRunOutcome,
|
|
} from "./lobster-pet-contract.ts";
|
|
import { SessionOrganizerController } from "./session-organizer-controller.ts";
|
|
import { SidebarMenusController } from "./sidebar-menus-controller.ts";
|
|
// The shared loader retries transient chunk failures online; a deploy-pruned
|
|
// chunk still stays off until reload when that retry fails, by design.
|
|
const sidebarChromeImport = createIdleImport(() =>
|
|
Promise.all([
|
|
customElements.get("openclaw-lobster-pet") ? undefined : import("./lobster-pet.ts"),
|
|
customElements.get("openclaw-viewer-facepile") ? undefined : import("./viewer-facepile.ts"),
|
|
]),
|
|
);
|
|
|
|
class AppSidebar extends AppSidebarSessionNavigationElement implements SessionListHost {
|
|
@state() sidebarNarrationLines: ReadonlyMap<string, string> = new Map();
|
|
@state() sidebarObserverDigests: ReadonlyMap<string, SessionObserverDigest> = new Map();
|
|
|
|
override readonly sessionOrganizer = new SessionOrganizerController(this);
|
|
override readonly sidebarMenus = new SidebarMenusController(this);
|
|
|
|
// Lazy: the controller pulls core token-suppression modules that must stay
|
|
// out of the startup chunk (QA smoke startup-JS budget). It loads on the
|
|
// first update with the preference enabled; earlier events are safely
|
|
// dropped because the controller aligns from cumulative snapshots.
|
|
private narration: SidebarSessionNarrationController | null = null;
|
|
private narrationLoad: Promise<void> | null = null;
|
|
private readonly narrationSubscriptions = this.createNarrationSubscriptions();
|
|
private readonly nativeGatewaysChanged = () => this.requestUpdate();
|
|
|
|
// Catalog rows are non-startup content. Load their renderer through the same
|
|
// idle boundary as other sidebar chrome, then repaint when the chunk arrives.
|
|
private catalogRenderer: SessionCatalogGroupsRenderer | null = null;
|
|
private readonly catalogRendererImport = createIdleImport(
|
|
() => import("./app-sidebar-session-catalog-render.ts"),
|
|
(module) => {
|
|
this.catalogRenderer = module.renderSessionCatalogGroups;
|
|
if (this.isConnected) {
|
|
this.requestUpdate();
|
|
}
|
|
},
|
|
);
|
|
private readonly agentIdentitySubscriptions = new SubscriptionsController(this).watch(
|
|
() => this.context?.agentIdentity,
|
|
(agentIdentity, notify) => agentIdentity.subscribe(notify),
|
|
);
|
|
|
|
@state() catalogProjectGrouping = loadStoredSidebarCatalogGrouping();
|
|
|
|
constructor() {
|
|
super();
|
|
void this.narrationSubscriptions;
|
|
void this.agentIdentitySubscriptions;
|
|
void new BoardAvailabilityController(
|
|
this,
|
|
() => {
|
|
const mainKey = this.selectedAgentMainSessionKey(this.activeChipAgent().activeId);
|
|
return [
|
|
mainKey,
|
|
...this.visibleSessionRowsInOrder()
|
|
.filter((session) => !session.isChild)
|
|
.map((session) => session.key),
|
|
];
|
|
},
|
|
undefined,
|
|
() => {
|
|
const snapshot = this.context?.gateway.snapshot;
|
|
const client = snapshot?.client;
|
|
const availabilityClient =
|
|
client &&
|
|
typeof client.request === "function" &&
|
|
typeof client.addEventListener === "function"
|
|
? client
|
|
: null;
|
|
return {
|
|
client: availabilityClient,
|
|
connected: snapshot?.phase === "connected",
|
|
available: snapshot ? isGatewayMethodAdvertised(snapshot, "board.get") !== false : false,
|
|
key: `${this.context?.gateway.connection?.gatewayUrl ?? ""}\u0000${
|
|
snapshot?.hello?.server?.version ?? ""
|
|
}`,
|
|
};
|
|
},
|
|
);
|
|
}
|
|
|
|
private createNarrationSubscriptions(): SubscriptionsController {
|
|
const subscriptions = new SubscriptionsController(this);
|
|
subscriptions.effect(
|
|
() => this.context?.gateway,
|
|
(gateway) => gateway.subscribeEvents((event) => this.narration?.handleEvent(event)),
|
|
);
|
|
return subscriptions;
|
|
}
|
|
|
|
override disconnectedCallback() {
|
|
window.removeEventListener("openclaw:native-gateways-changed", this.nativeGatewaysChanged);
|
|
this.narration?.disconnect();
|
|
this.catalogRendererImport.dispose();
|
|
super.disconnectedCallback();
|
|
}
|
|
|
|
protected override willUpdate(changed: PropertyValues<this>) {
|
|
super.willUpdate(changed);
|
|
const chip = this.activeChipAgent();
|
|
// An open switcher tracks roster/reconnect updates; otherwise only hydrate
|
|
// the active card and avoid background RPCs for every configured agent.
|
|
const identityIds =
|
|
this.sidebarMenus.agentMenuPosition === null
|
|
? [chip.activeId]
|
|
: chip.agents.map((agent) => agent.id);
|
|
this.ensureAgentIdentities(identityIds);
|
|
// A fresh draft must be visible where it will live: genuinely expand a
|
|
// collapsed Threads section (persisted) instead of overriding at render
|
|
// time, so the header toggle keeps matching the visible state.
|
|
if (
|
|
changed.has("draftSessionAgentId") &&
|
|
this.draftSessionAgentId &&
|
|
this.collapsedSessionSections.has("ungrouped")
|
|
) {
|
|
this.sessionOrganizer.toggleSection("ungrouped");
|
|
}
|
|
}
|
|
|
|
ensureAgentIdentities(agentIds: readonly string[]): void {
|
|
if (this.connected) {
|
|
void this.context?.agentIdentity.ensure(agentIds);
|
|
}
|
|
}
|
|
|
|
override updated(changedProperties: PropertyValues<this>) {
|
|
super.updated(changedProperties);
|
|
if (!this.narration) {
|
|
if (this.sidebarLiveActivity) {
|
|
this.ensureNarrationController();
|
|
}
|
|
return;
|
|
}
|
|
this.narration.sync(this.narrationSyncInput());
|
|
}
|
|
|
|
private visibleNarrationRowsInOrder(): SidebarRecentSession[] {
|
|
const rows: SidebarRecentSession[] = [];
|
|
const append = (session: SidebarRecentSession) => {
|
|
rows.push(session);
|
|
if (this.isSessionChildrenExpanded(session)) {
|
|
visibleSessionChildren({
|
|
session,
|
|
fullyShownChildSessionKeys: this.fullyShownChildSessionKeys,
|
|
}).forEach(append);
|
|
}
|
|
};
|
|
this.visibleSessionRowsInOrder().forEach(append);
|
|
return rows;
|
|
}
|
|
|
|
private narrationSyncInput(): SidebarNarrationSyncInput {
|
|
const gateway = this.context?.gateway.snapshot;
|
|
return {
|
|
enabled: this.sidebarLiveActivity,
|
|
connected: this.connected && gateway?.phase === "connected",
|
|
connectionIdentity: gateway?.client ?? null,
|
|
source: this.context?.sessions ?? null,
|
|
rows: this.visibleNarrationRowsInOrder(),
|
|
openSessionKey: isSessionRouteId(this.activeRouteId) ? this.getRouteSessionKey() : "",
|
|
agentId: this.selectedAgentIdForSessions(),
|
|
};
|
|
}
|
|
|
|
private ensureNarrationController(): void {
|
|
if (this.narration || this.narrationLoad) {
|
|
return;
|
|
}
|
|
this.narrationLoad = import("./app-sidebar-session-narration.ts").then((module) => {
|
|
this.narrationLoad = null;
|
|
// The element may have left the DOM while the chunk loaded.
|
|
if (!this.isConnected) {
|
|
return;
|
|
}
|
|
this.narration = new module.SidebarSessionNarrationController(
|
|
(lines) => {
|
|
this.sidebarNarrationLines = lines;
|
|
},
|
|
(digests) => {
|
|
this.sidebarObserverDigests = digests;
|
|
},
|
|
);
|
|
this.narration.sync(this.narrationSyncInput());
|
|
});
|
|
}
|
|
|
|
override connectedCallback() {
|
|
super.connectedCallback();
|
|
window.addEventListener("openclaw:native-gateways-changed", this.nativeGatewaysChanged);
|
|
// The decorative pet's large module stays out of startup and upgrades in place.
|
|
// Its first visit is at least 15 seconds after load, so idle loading cannot miss one.
|
|
sidebarChromeImport.schedule();
|
|
this.catalogRendererImport.schedule();
|
|
}
|
|
|
|
protected override firstUpdated() {
|
|
requestAnimationFrame(() => requestAnimationFrame(() => this.classList.add("sidebar-r")));
|
|
}
|
|
|
|
startSessionDrag(session: SidebarRecentSession): void {
|
|
this.sessionOrganizer.startSessionDrag(session);
|
|
}
|
|
|
|
finishSessionDrag(): void {
|
|
this.sessionOrganizer.finishSessionDrag();
|
|
}
|
|
|
|
toggleSessionPin(session: SidebarRecentSession): void {
|
|
void this.sessionOrganizer.patchSession(session, { pinned: !session.pinned });
|
|
}
|
|
|
|
toggleSessionMenu(
|
|
session: SidebarRecentSession,
|
|
menuSession: SidebarRecentSession,
|
|
trigger: HTMLElement,
|
|
): void {
|
|
if (this.sidebarMenus.sessionMenu?.session.key === session.key) {
|
|
this.sidebarMenus.closeSessionMenu();
|
|
return;
|
|
}
|
|
const rect = trigger.getBoundingClientRect();
|
|
this.sidebarMenus.openSessionMenu(menuSession, rect.right, rect.bottom + 4, trigger);
|
|
}
|
|
|
|
startSidebarSectionDrag(sectionId: string): void {
|
|
this.sessionOrganizer.startSidebarSectionDrag(sectionId);
|
|
}
|
|
|
|
finishSidebarSectionDrag(): void {
|
|
this.sessionOrganizer.finishSidebarSectionDrag();
|
|
}
|
|
|
|
sectionDragOver(event: DragEvent, sectionId: string, group?: string): void {
|
|
this.sessionOrganizer.sectionDragOver(event, sectionId, group);
|
|
}
|
|
|
|
sectionDragLeave(event: DragEvent, sectionId: string, group?: string): void {
|
|
this.sessionOrganizer.sectionDragLeave(event, sectionId, group);
|
|
}
|
|
|
|
sectionDrop(event: DragEvent, sectionId: string, group?: string): void {
|
|
this.sessionOrganizer.sectionDrop(event, sectionId, group);
|
|
}
|
|
|
|
toggleSection(sectionId: string): void {
|
|
this.sessionOrganizer.toggleSection(sectionId);
|
|
}
|
|
|
|
handleSessionListDragOver(event: DragEvent): void {
|
|
this.sessionOrganizer.handleSessionListDragOver(event);
|
|
}
|
|
|
|
handleSessionListDragLeave(event: DragEvent): void {
|
|
this.sessionOrganizer.handleSessionListDragLeave(event);
|
|
}
|
|
|
|
handleSessionListDrop(event: DragEvent): void {
|
|
this.sessionOrganizer.handleSessionListDrop(event);
|
|
}
|
|
|
|
openNewSession(): void {
|
|
this.onOpenNewSession?.(this.expandedAgentId());
|
|
}
|
|
|
|
setVisibleSessionLimit(sectionId: string, limit: number): void {
|
|
this.sessionData.setVisibleSessionLimit(sectionId, limit);
|
|
}
|
|
|
|
loadMoreSidebarSessions(): Promise<void> {
|
|
return this.sessionData.loadMoreSidebarSessions();
|
|
}
|
|
|
|
dismissSessionMutationError(): void {
|
|
this.sessionData.dismissSessionMutationError();
|
|
}
|
|
|
|
preloadCatalogRenderer() {
|
|
return this.catalogRendererImport.load();
|
|
}
|
|
|
|
setCatalogProjectGrouping(next: CatalogProjectGrouping): void {
|
|
storeSidebarCatalogGrouping(next);
|
|
this.catalogProjectGrouping = next;
|
|
}
|
|
|
|
openCatalogMenu(
|
|
request: CatalogSessionMenuRequest,
|
|
x: number,
|
|
y: number,
|
|
trigger?: HTMLElement,
|
|
): void {
|
|
this.sidebarMenus.catalogMenu.open(request, x, y, trigger);
|
|
}
|
|
|
|
renderPinnedSidebarSession(session: SidebarRecentSession): TemplateResult {
|
|
return renderSessionTree({ host: this, session });
|
|
}
|
|
|
|
private renderSessions() {
|
|
const navigationState = this.getSessionNavigationState();
|
|
const visibleSessions = this.selectedAgentSessionRows(navigationState);
|
|
const expandedAgentId = this.expandedAgentId();
|
|
const liveRows = [
|
|
...(this.sessionData.sessionsResult?.sessions ?? []),
|
|
...Object.values(this.sessionData.sessionRowsByAgent).flat(),
|
|
];
|
|
const sidebarRowsByKey = new Map<string, SidebarRecentSession>();
|
|
for (const row of liveRows) {
|
|
if (!sidebarRowsByKey.has(row.key)) {
|
|
sidebarRowsByKey.set(row.key, navigationState.toSidebarSession(row));
|
|
}
|
|
}
|
|
const { sections } = this.zonedVisibleSections(visibleSessions);
|
|
if (
|
|
!this.catalogRenderer &&
|
|
(this.sessionData.sessionCatalogs.length > 0 ||
|
|
this.sessionData.sessionCatalogRefreshStatus.error !== null)
|
|
) {
|
|
void this.preloadCatalogRenderer().catch(() => undefined);
|
|
}
|
|
return renderSessionList({
|
|
host: this,
|
|
empty: visibleSessions.length === 0,
|
|
sections,
|
|
nativeSessionsHaveMore: this.sessionData.sessionsResult?.hasMore === true,
|
|
catalogRenderer: this.catalogRenderer,
|
|
showDraft:
|
|
Boolean(this.draftSessionAgentId) &&
|
|
normalizeAgentId(this.draftSessionAgentId) === expandedAgentId,
|
|
catalogs: {
|
|
catalogs: this.sessionData.sessionCatalogs,
|
|
refreshStatus: this.sessionData.sessionCatalogRefreshStatus,
|
|
basePath: this.basePath,
|
|
routeSessionKey: isSessionRouteId(this.activeRouteId) ? this.getRouteSessionKey() : "",
|
|
newSessionAgentId: expandedAgentId,
|
|
mainKey: this.sessionMainKey(),
|
|
loadingMoreCatalogIds: this.sessionData.loadingMoreSessionCatalogIds,
|
|
projectGrouping: this.catalogProjectGrouping,
|
|
liveRows,
|
|
sidebarRowsByKey,
|
|
creatorId: this.activeSessionCreatorId,
|
|
catalogOpenTarget: this.catalogOpenTarget,
|
|
terminalAvailable: this.terminalAvailable,
|
|
},
|
|
});
|
|
}
|
|
|
|
override render() {
|
|
const sidebarZone = this.reconciledSidebarZone();
|
|
return html`
|
|
<aside class="sidebar">
|
|
<div class="sidebar-shell" @mousedown=${beginNativeWindowDragFromTopInset}>
|
|
${renderAppSidebarBrand(this)}
|
|
<div
|
|
class="sidebar-shell__body sidebar-shell__body--scroll-${this.sessionData
|
|
.sessionsScrollState}"
|
|
@scroll=${(event: Event) =>
|
|
this.sessionData.updateSessionsScrollState(event.currentTarget as HTMLElement)}
|
|
>
|
|
<nav class="sidebar-nav" @contextmenu=${this.sidebarMenus.openCustomizeMenuFromContext}>
|
|
${renderAppSidebarPagesHead(this)}
|
|
<div
|
|
class="nav-section__items"
|
|
@dragover=${(event: DragEvent) =>
|
|
this.sessionOrganizer.handleSidebarZoneDragOver(event)}
|
|
@dragleave=${(event: DragEvent) =>
|
|
this.sessionOrganizer.handleSidebarZoneDragLeave(event)}
|
|
@drop=${(event: DragEvent) => this.sessionOrganizer.handleSidebarZoneDrop(event)}
|
|
>
|
|
${renderAppSidebarHomeRow(this)}
|
|
${sidebarZone.entries.map((entry) =>
|
|
renderAppSidebarZoneEntry(
|
|
this,
|
|
entry,
|
|
sidebarZone.sessionRows,
|
|
sidebarZone.workboardRows,
|
|
),
|
|
)}
|
|
${sidebarPluginTabs(this.context?.gateway.snapshot.hello?.controlUiTabs).map(
|
|
(tab) => renderAppSidebarPluginTabEntry(this, tab),
|
|
)}
|
|
</div>
|
|
</nav>
|
|
${this.renderSessions()}
|
|
</div>
|
|
<div class="sidebar-shell__footer">
|
|
${renderAppSidebarAttention(this)}
|
|
<openclaw-sidebar-update-card
|
|
.updateAvailable=${this.updateAvailable}
|
|
.updateRunning=${this.updateRunning}
|
|
.onUpdate=${this.onUpdate}
|
|
></openclaw-sidebar-update-card>
|
|
<openclaw-lobster-pet
|
|
.seed=${lobsterPetSeed(this.sessionKey)}
|
|
.mode=${resolveLobsterPetMode(
|
|
!this.offline,
|
|
this.sessionData.sessionsResult?.sessions,
|
|
)}
|
|
.runOutcome=${resolveLobsterRunOutcome(this.sessionData.sessionsResult?.sessions)}
|
|
.visitsEnabled=${this.lobsterPetVisits}
|
|
.soundsEnabled=${this.lobsterPetSounds}
|
|
.gatewayVersion=${this.gatewayVersion}
|
|
></openclaw-lobster-pet>
|
|
${this.devGitBranch
|
|
? html`<openclaw-tooltip .content=${this.devGitBranch}>
|
|
<div class="sidebar-footer-branch">
|
|
<span class="sidebar-footer-branch__icon" aria-hidden="true"
|
|
>${icons.gitBranch}</span
|
|
>
|
|
<span class="sidebar-footer-branch__name">${this.devGitBranch}</span>
|
|
</div>
|
|
</openclaw-tooltip>`
|
|
: nothing}
|
|
${renderAppSidebarFooterBar(this)}
|
|
</div>
|
|
</div>
|
|
${this.sidebarMenus.renderCustomizeMenu()} ${this.sidebarMenus.renderMoreMenu()}
|
|
${this.sidebarMenus.renderAgentMenu()} ${this.sidebarMenus.renderIdentityMenu()}
|
|
${this.sidebarMenus.renderSessionMenu()} ${this.sidebarMenus.catalogMenu.render()}
|
|
${this.sidebarMenus.renderSessionGroupMenu()} ${this.sidebarMenus.renderSessionSortMenu()}
|
|
${this.sidebarMenus.renderCatalogViewMenu()}
|
|
</aside>
|
|
`;
|
|
}
|
|
}
|
|
|
|
if (!customElements.get("openclaw-app-sidebar")) {
|
|
customElements.define("openclaw-app-sidebar", AppSidebar);
|
|
}
|