fix(ui,gateway): make catalog sidebar sections drag-reorderable (#114074)

* fix(ui,gateway): make catalog sidebar sections drag-reorderable

* test(ui): split sidebar section-reordering cases under max-lines

* perf(ui): keep catalog section rendering out of startup bundle
This commit is contained in:
Peter Steinberger
2026-07-26 05:31:33 -04:00
committed by GitHub
parent fe847911c0
commit cd5a5ecb4c
21 changed files with 942 additions and 640 deletions

View File

@@ -577,13 +577,13 @@ const SidebarSectionIdString = Type.String({ minLength: 1, maxLength: 512 });
/** Custom session group catalog in display order. */
export const SessionsGroupsListResultSchema = closedObject({
groups: Type.Array(SessionGroupSchema),
sectionOrder: Type.Optional(Type.Array(SidebarSectionIdString, { maxItems: 203 })),
sectionOrder: Type.Optional(Type.Array(SidebarSectionIdString, { maxItems: 232 })),
});
/** Replaces the ordered group catalog; creates listed names, keeps member categories untouched. */
export const SessionsGroupsPutParamsSchema = closedObject({
names: Type.Array(SessionLabelString, { maxItems: 200 }),
sectionOrder: Type.Optional(Type.Array(SidebarSectionIdString, { maxItems: 203 })),
sectionOrder: Type.Optional(Type.Array(SidebarSectionIdString, { maxItems: 232 })),
});
/** Renames a group and repoints every member session's category. */
@@ -599,7 +599,7 @@ export const SessionsGroupsDeleteParamsSchema = closedObject({ name: SessionLabe
export const SessionsGroupsMutationResultSchema = closedObject({
ok: Type.Literal(true),
groups: Type.Array(SessionGroupSchema),
sectionOrder: Type.Optional(Type.Array(SidebarSectionIdString, { maxItems: 203 })),
sectionOrder: Type.Optional(Type.Array(SidebarSectionIdString, { maxItems: 232 })),
updatedSessions: Type.Optional(Type.Integer({ minimum: 0 })),
});

View File

@@ -58,16 +58,19 @@ describe("session groups catalog", () => {
]);
});
it("roundtrips normalized sidebar order and prunes unknown category ids", () => {
it("roundtrips normalized sidebar order, including catalog section ids", () => {
putSessionGroups(
["Alpha", " Beta ", "Alpha"],
[
" work ",
" catalog: codex ",
"category:Beta",
"category:Missing",
"category: Alpha ",
"groups",
"groups",
"catalog:",
"catalog:codex",
"pinned",
"",
],
@@ -76,6 +79,7 @@ describe("session groups catalog", () => {
expect(listSessionGroups(env).map((group) => group.name)).toEqual(["Alpha", "Beta"]);
expect(listSidebarSectionOrder(env)).toEqual([
"work",
"catalog:codex",
"category:Beta",
"category:Alpha",
"groups",
@@ -84,6 +88,7 @@ describe("session groups catalog", () => {
putSessionGroups(["Beta", "Alpha"], undefined, env);
expect(listSidebarSectionOrder(env)).toEqual([
"work",
"catalog:codex",
"category:Beta",
"category:Alpha",
"groups",

View File

@@ -88,6 +88,11 @@ function normalizeSidebarSectionOrder(
if (name && groups.has(name)) {
canonical = `category:${name}`;
}
} else if (sectionId.startsWith("catalog:")) {
const catalogId = normalizeOptionalString(sectionId.slice("catalog:".length));
if (catalogId) {
canonical = `catalog:${catalogId}`;
}
}
if (!canonical || seen.has(canonical)) {
continue;

View File

@@ -0,0 +1,442 @@
import { html, nothing } from "lit";
import type {
SessionCatalog,
SessionCatalogHost,
SessionCatalogSession,
} from "../../../packages/gateway-protocol/src/index.ts";
import type { GatewaySessionRow } from "../api/types.ts";
import type { NavigationRouteId } from "../app-navigation.ts";
import { pathForRoute } from "../app-route-paths.ts";
import type { ApplicationNavigationOptions } from "../app/context.ts";
import { t } from "../i18n/index.ts";
import type { CatalogSessionKey } from "../lib/sessions/catalog-key.ts";
import { buildCatalogSessionKey } from "../lib/sessions/catalog-key.ts";
import {
groupCatalogSessionsByPerson,
groupCatalogSessionsByProject,
type CatalogProjectGrouping,
} from "../lib/sessions/catalog-project-grouping.ts";
import { searchForSession } from "../lib/sessions/index.ts";
import type { NewSessionTarget } from "../pages/new-session/location.ts";
import { shouldHandleNavigationClick } from "./app-sidebar-nav-menus.ts";
import {
formatSidebarTimestamp,
type CatalogBackingSessionDisplay,
type CatalogSessionMenuRequest,
} from "./app-sidebar-session-catalogs.ts";
import { renderSidebarSessionSectionHeader } from "./app-sidebar-session-section-header.ts";
import { icons } from "./icons.ts";
import { renderSessionRowBadges } from "./session-row-badges.ts";
type SessionCatalogGroupsParams = {
catalogs: readonly SessionCatalog[];
connected: boolean;
basePath: string;
routeSessionKey: string;
newSessionAgentId: string;
collapsedSections: ReadonlySet<string>;
loadingMoreCatalogIds: ReadonlySet<string>;
projectGrouping: CatalogProjectGrouping;
liveRows: readonly GatewaySessionRow[];
creatorId?: string | null;
renderLiveRow: (row: GatewaySessionRow, display: CatalogBackingSessionDisplay) => unknown;
onToggleSection: (sectionId: string) => void;
draggingSectionId: string | null;
sectionDropTarget: { sectionId: string; position: "before" | "after" } | null;
onSectionDragOver: (event: DragEvent, sectionId: string) => void;
onSectionDragLeave: (event: DragEvent, sectionId: string) => void;
onSectionDrop: (event: DragEvent, sectionId: string) => void;
onStartSectionDrag: (sectionId: string) => void;
onFinishSectionDrag: () => void;
viewMenuOpenCatalogId: string | null;
creatorFilterActive: boolean;
onOpenViewMenu: (trigger: HTMLElement) => void;
onLoadMore: (catalogId: string) => void;
onOpenNewSession?: (agentId: string, target?: NewSessionTarget) => void;
onNavigate?: (routeId: NavigationRouteId, options?: ApplicationNavigationOptions) => void;
catalogOpenTarget: "viewer" | "terminal";
terminalAvailable: boolean;
onOpenTerminal: (key: CatalogSessionKey) => void;
onOpenMenu: (
request: CatalogSessionMenuRequest,
x: number,
y: number,
trigger?: HTMLElement,
) => void;
};
function renderSessionRunSpinner() {
return html`<span
class="session-run-spinner"
role="img"
aria-label=${t("sessionsView.activeRun")}
title=${t("sessionsView.activeRun")}
></span>`;
}
function renderCatalogHeaderStatus(hasActiveRun: boolean, hasUnread: boolean) {
if (hasActiveRun) {
return renderSessionRunSpinner();
}
return hasUnread
? html`<span
class="session-unread-dot"
role="img"
aria-label=${t("sessionsView.unread")}
></span>`
: nothing;
}
function catalogErrorMessages(catalog: SessionCatalog): string[] {
const messages = new Set<string>();
const add = (error: SessionCatalog["error"]) => {
if (error) {
messages.add(`[${error.code}] ${error.message}`);
}
};
add(catalog.error);
for (const host of catalog.hosts) {
// A disconnected empty host is normal fleet state, not a provider failure.
// Cached rows still expose the host-level offline badge when the host is visible.
if (host.error?.code !== "NODE_OFFLINE") {
add(host.error);
}
}
return [...messages];
}
export function renderSessionCatalogGroups(params: SessionCatalogGroupsParams) {
// Adopted rows reuse the live session row so activity, unread state, and
// the session menu behave exactly like the regular list.
const liveRowsByKey = new Map<string, GatewaySessionRow>();
for (const row of params.liveRows) {
if (!liveRowsByKey.has(row.key)) {
liveRowsByKey.set(row.key, row);
}
}
return params.catalogs.map((catalog) => {
const sectionId = `catalog:${catalog.id}`;
const collapsed = params.collapsedSections.has(sectionId);
const hosts = catalog.hosts;
const visibleHosts: SessionCatalogHost[] = [];
for (const host of hosts) {
const sessions = host.sessions.filter(
(session) => !params.creatorId || session.createdActor?.id === params.creatorId,
);
if (sessions.length > 0) {
visibleHosts.push(sessions.length === host.sessions.length ? host : { ...host, sessions });
}
}
const rows = visibleHosts.flatMap((host) =>
host.sessions.map((session) => ({ host, session })),
);
const liveRows = rows.flatMap(({ session }) => {
const row = session.sessionKey ? liveRowsByKey.get(session.sessionKey) : undefined;
return row ? [row] : [];
});
const hasActiveRun = liveRows.some((row) => row.hasActiveRun === true);
const hasUnread = liveRows.some((row) => row.unread === true);
const loadingMore = params.loadingMoreCatalogIds.has(catalog.id);
const hasMore = hosts.some((host) => Boolean(host.nextCursor));
const canCreateSession = catalog.capabilities.createSession !== undefined;
const errorMessages = catalogErrorMessages(catalog);
const hasError = errorMessages.length > 0;
// Keep provider failures distinguishable from successful empty results.
// Hiding both states would silently mask unavailable session sources.
if (rows.length === 0 && !hasMore && !hasError && !catalog.capabilities.createSession) {
return nothing;
}
const errorMessage = errorMessages.join("; ");
const errorHelp = `${errorMessage}. Configure native thread discovery in Settings > Automation > Plugins.`;
const sectionClass = [
"sidebar-recent-sessions__group",
"sidebar-recent-sessions__group--zone-coding",
collapsed ? "sidebar-recent-sessions__group--collapsed" : "",
params.draggingSectionId === sectionId ? "sidebar-recent-sessions__group--dragging" : "",
params.sectionDropTarget?.sectionId === sectionId
? `sidebar-recent-sessions__group--section-drop-${params.sectionDropTarget.position}`
: "",
]
.filter(Boolean)
.join(" ");
return html`
<div
class=${sectionClass}
data-session-section=${sectionId}
@dragover=${(event: DragEvent) => params.onSectionDragOver(event, sectionId)}
@dragleave=${(event: DragEvent) => params.onSectionDragLeave(event, sectionId)}
@drop=${(event: DragEvent) => params.onSectionDrop(event, sectionId)}
>
${renderSidebarSessionSectionHeader({
sectionId,
onStartDrag: params.onStartSectionDrag,
onFinishDrag: params.onFinishSectionDrag,
content: html`
<button
type="button"
class="sidebar-session-group-toggle"
aria-expanded=${String(!collapsed)}
aria-label=${hasError ? `${catalog.label}: ${errorHelp}` : catalog.label}
title=${hasError ? errorHelp : nothing}
@click=${() => params.onToggleSection(sectionId)}
>
<span class="sidebar-recent-sessions__label-text">${catalog.label}</span>
<span class="sidebar-session-group-toggle__icon" aria-hidden="true"
>${collapsed ? icons.chevronRight : icons.chevronDown}</span
>
${renderCatalogHeaderStatus(hasActiveRun, hasUnread)}
${hasError || (collapsed && rows.length > 0)
? html`<span
class="sidebar-session-group-count ${hasError
? "sidebar-session-group-count--error"
: ""}"
data-session-catalog-error=${hasError ? catalog.id : nothing}
aria-hidden="true"
>${hasError ? icons.alertTriangle : rows.length}</span
>`
: nothing}
</button>
<button
type="button"
class="sidebar-session-group-actions sidebar-session-sort sidebar-session-catalog-grouping ${params.creatorFilterActive
? "sidebar-session-sort--filtered"
: ""}"
data-session-catalog-view-menu=${catalog.id}
title=${t("chat.sidebar.catalogViewOptions")}
aria-label=${t("chat.sidebar.catalogViewOptions")}
aria-haspopup="menu"
aria-expanded=${String(params.viewMenuOpenCatalogId === catalog.id)}
@click=${(event: MouseEvent) => {
event.stopPropagation();
params.onOpenViewMenu(event.currentTarget as HTMLElement);
}}
>
${icons.listFilter}
</button>
${canCreateSession
? html`<button
type="button"
class="sidebar-session-group-actions sidebar-session-sort sidebar-session-new sidebar-session-catalog-new"
title=${`${t("chat.runControls.newSession")}${catalog.label}`}
aria-label=${`${t("chat.runControls.newSession")}${catalog.label}`}
?disabled=${!params.connected}
@click=${() =>
params.onOpenNewSession?.(params.newSessionAgentId, {
catalogId: catalog.id,
})}
>
${icons.plus}
</button>`
: nothing}
`,
})}
${collapsed
? nothing
: html`<div class="sidebar-recent-sessions__list">
${visibleHosts.map((host) =>
renderCatalogHostGroup(catalog, host, liveRowsByKey, params),
)}
</div>
${hasMore
? html`<button
type="button"
class="sidebar-session-catalog-load-more"
data-session-catalog-load-more=${catalog.id}
?disabled=${loadingMore}
aria-busy=${String(loadingMore)}
@click=${() => params.onLoadMore(catalog.id)}
>
${t("chat.selectors.loadMoreSessions")}
</button>`
: nothing}`}
</div>
`;
});
}
export type SessionCatalogGroupsRenderer = typeof renderSessionCatalogGroups;
function renderCatalogHostGroup(
catalog: SessionCatalog,
host: SessionCatalogHost,
liveRowsByKey: ReadonlyMap<string, GatewaySessionRow>,
params: SessionCatalogGroupsParams,
) {
const errorHelp = host.error ? `[${host.error.code}] ${host.error.message}` : undefined;
const projectGroups =
params.projectGrouping === "project"
? groupCatalogSessionsByProject(host.sessions)
: params.projectGrouping === "person"
? groupCatalogSessionsByPerson(host.sessions)
: null;
// Gateway errors stay on the catalog header; node headings remain so remote rows keep their owner.
const showHostHeading = host.kind !== "gateway";
return html`
<section class="sidebar-session-catalog-host" data-session-catalog-host=${host.hostId}>
${showHostHeading
? html`<div
class="sidebar-session-catalog-host__head"
aria-label=${errorHelp ? `${host.label}: ${errorHelp}` : host.label}
title=${errorHelp ?? host.label}
>
<span class="sidebar-session-catalog-host__label">${host.label}</span>
<span
class="sidebar-session-catalog-host__count ${host.error
? "sidebar-session-catalog-host__count--error"
: ""}"
aria-hidden="true"
>${host.error ? icons.alertTriangle : host.sessions.length}</span
>
</div>`
: nothing}
<div class="sidebar-session-catalog-host__sessions" role="list" aria-label=${host.label}>
${projectGroups
? html`${projectGroups.groups.map((group) => {
const sectionId = `catalog-project:${catalog.id}:${host.hostId}:${group.key}`;
const collapsed = params.collapsedSections.has(sectionId);
return html`
<button
type="button"
class="sidebar-session-catalog-project__head"
data-session-catalog-project=${group.key}
aria-expanded=${String(!collapsed)}
title=${group.title}
@click=${() => params.onToggleSection(sectionId)}
>
<span class="sidebar-session-catalog-project__icon" aria-hidden="true"
>${collapsed ? icons.chevronRight : icons.chevronDown}</span
>
<span class="sidebar-session-catalog-project__label">${group.label}</span>
<span class="sidebar-session-catalog-project__count" aria-hidden="true"
>${group.sessions.length}</span
>
</button>
${collapsed
? nothing
: group.sessions.map((session) =>
renderCatalogSessionRow(catalog, host, session, liveRowsByKey, params, true),
)}
`;
})}
${projectGroups.ungrouped.map((session) =>
renderCatalogSessionRow(catalog, host, session, liveRowsByKey, params),
)}`
: host.sessions.map((session) =>
renderCatalogSessionRow(catalog, host, session, liveRowsByKey, params),
)}
</div>
</section>
`;
}
function renderCatalogSessionRow(
catalog: SessionCatalog,
host: SessionCatalogHost,
session: SessionCatalogSession,
liveRowsByKey: ReadonlyMap<string, GatewaySessionRow>,
params: SessionCatalogGroupsParams,
projectChild = false,
) {
const rawTimestamp = session.recencyAt ?? session.updatedAt ?? session.createdAt;
const timestamp =
typeof rawTimestamp === "number" && rawTimestamp < 1_000_000_000_000
? rawTimestamp * 1000
: rawTimestamp;
const adoptedRow = session.sessionKey ? liveRowsByKey.get(session.sessionKey) : undefined;
if (adoptedRow) {
const label = session.name || session.threadId;
return params.renderLiveRow(adoptedRow, {
label,
meta: formatSidebarTimestamp(timestamp),
title: `${label} · ${host.label}`,
...(session.pullRequest ? { pullRequest: session.pullRequest } : {}),
});
}
const catalogKey = {
catalogId: catalog.id,
hostId: host.hostId,
threadId: session.threadId,
} satisfies CatalogSessionKey;
const key = session.sessionKey ?? buildCatalogSessionKey(catalogKey);
const label = session.name || session.threadId;
const meta = formatSidebarTimestamp(timestamp);
const search = searchForSession(key);
const href = `${pathForRoute("chat", params.basePath)}${search}`;
const active = params.routeSessionKey !== "" && key === params.routeSessionKey;
const running = session.status === "active" || session.status === "running";
const canOpenTerminal = session.canOpenTerminal === true && params.terminalAvailable;
const openTerminal = () => params.onOpenTerminal(catalogKey);
const openMenu = (x: number, y: number, trigger?: HTMLElement) =>
params.onOpenMenu(
{ key: catalogKey, search, canOpenTerminal: session.canOpenTerminal === true, meta },
x,
y,
trigger,
);
return html`
<div
class="sidebar-recent-session session-row-host ${active
? "sidebar-recent-session--active"
: ""} ${projectChild ? "sidebar-recent-session--catalog-project-child" : ""} ${running
? "session-row-host--running"
: ""}"
data-session-key=${key}
role="listitem"
@contextmenu=${(event: MouseEvent) => {
event.preventDefault();
openMenu(event.clientX, event.clientY);
}}
>
<a
href=${href}
class="sidebar-recent-session__link"
title=${`${label} · ${host.label}`}
aria-current=${active ? "page" : nothing}
@click=${(event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
return;
}
event.preventDefault();
if (params.catalogOpenTarget === "terminal" && canOpenTerminal) {
openTerminal();
} else {
params.onNavigate?.("chat", { search });
}
}}
>
<span class="sidebar-session-indicator"
>${running
? renderSessionRunSpinner()
: html`<span class="sidebar-session-indicator__dot" aria-hidden="true"></span>`}</span
>
<span class="sidebar-recent-session__text">
<span class="sidebar-recent-session__name hover-marquee">${label}</span>
</span>
${renderSessionRowBadges({
hasAutomation: false,
pullRequest: session.pullRequest,
})}
</a>
<span class="sidebar-recent-session__aside session-row-aside">
<span class="session-row-actions">
<button
class="session-action"
data-catalog-session-menu="true"
type="button"
title=${t("chat.sidebar.openSessionMenu")}
aria-label=${t("chat.sidebar.openSessionMenu")}
aria-haspopup="menu"
@click=${(event: MouseEvent) => {
event.stopPropagation();
const trigger = event.currentTarget as HTMLElement;
const rect = trigger.getBoundingClientRect();
openMenu(rect.right, rect.bottom + 4, trigger);
}}
>
${icons.moreHorizontal}
</button>
</span>
</span>
</div>
`;
}

View File

@@ -1,30 +1,12 @@
import { html, nothing } from "lit";
import type {
SessionCatalog,
SessionCatalogHost,
SessionCatalogSession,
} from "../../../packages/gateway-protocol/src/index.ts";
import type { GatewaySessionRow } from "../api/types.ts";
import type { NavigationRouteId } from "../app-navigation.ts";
import { pathForRoute } from "../app-route-paths.ts";
import type { ApplicationNavigationOptions } from "../app/context.ts";
import { t } from "../i18n/index.ts";
import { formatRelativeTimestamp } from "../lib/format.ts";
import type {
CatalogSessionContinuedDetail,
CatalogSessionKey,
} from "../lib/sessions/catalog-key.ts";
import { buildCatalogSessionKey } from "../lib/sessions/catalog-key.ts";
import {
groupCatalogSessionsByPerson,
groupCatalogSessionsByProject,
type CatalogProjectGrouping,
} from "../lib/sessions/catalog-project-grouping.ts";
import { searchForSession } from "../lib/sessions/index.ts";
import type { NewSessionTarget } from "../pages/new-session/location.ts";
import { shouldHandleNavigationClick } from "./app-sidebar-nav-menus.ts";
import { icons } from "./icons.ts";
import { renderSessionRowBadges } from "./session-row-badges.ts";
export function formatSidebarTimestamp(timestampMs: number | null | undefined): string {
const value = formatRelativeTimestamp(timestampMs, { fallback: "" });
@@ -91,385 +73,3 @@ export function bindAdoptedCatalogSession(
: catalog,
);
}
type SessionCatalogGroupsParams = {
catalogs: readonly SessionCatalog[];
connected: boolean;
basePath: string;
routeSessionKey: string;
newSessionAgentId: string;
collapsedSections: ReadonlySet<string>;
loadingMoreCatalogIds: ReadonlySet<string>;
projectGrouping: CatalogProjectGrouping;
liveRows: readonly GatewaySessionRow[];
creatorId?: string | null;
renderLiveRow: (row: GatewaySessionRow, display: CatalogBackingSessionDisplay) => unknown;
onToggleSection: (sectionId: string) => void;
viewMenuOpenCatalogId: string | null;
creatorFilterActive: boolean;
onOpenViewMenu: (trigger: HTMLElement) => void;
onLoadMore: (catalogId: string) => void;
onOpenNewSession?: (agentId: string, target?: NewSessionTarget) => void;
onNavigate?: (routeId: NavigationRouteId, options?: ApplicationNavigationOptions) => void;
catalogOpenTarget: "viewer" | "terminal";
terminalAvailable: boolean;
onOpenTerminal: (key: CatalogSessionKey) => void;
onOpenMenu: (
request: CatalogSessionMenuRequest,
x: number,
y: number,
trigger?: HTMLElement,
) => void;
};
function renderSessionRunSpinner() {
return html`<span
class="session-run-spinner"
role="img"
aria-label=${t("sessionsView.activeRun")}
title=${t("sessionsView.activeRun")}
></span>`;
}
function renderCatalogHeaderStatus(hasActiveRun: boolean, hasUnread: boolean) {
if (hasActiveRun) {
return renderSessionRunSpinner();
}
return hasUnread
? html`<span
class="session-unread-dot"
role="img"
aria-label=${t("sessionsView.unread")}
></span>`
: nothing;
}
function catalogErrorMessages(catalog: SessionCatalog): string[] {
const messages = new Set<string>();
const add = (error: SessionCatalog["error"]) => {
if (error) {
messages.add(`[${error.code}] ${error.message}`);
}
};
add(catalog.error);
for (const host of catalog.hosts) {
// A disconnected empty host is normal fleet state, not a provider failure.
// Cached rows still expose the host-level offline badge when the host is visible.
if (host.error?.code !== "NODE_OFFLINE") {
add(host.error);
}
}
return [...messages];
}
export function renderSessionCatalogGroups(params: SessionCatalogGroupsParams) {
// Adopted rows reuse the live session row so activity, unread state, and
// the session menu behave exactly like the regular list.
const liveRowsByKey = new Map<string, GatewaySessionRow>();
for (const row of params.liveRows) {
if (!liveRowsByKey.has(row.key)) {
liveRowsByKey.set(row.key, row);
}
}
return params.catalogs.map((catalog) => {
const sectionId = `catalog:${catalog.id}`;
const collapsed = params.collapsedSections.has(sectionId);
const hosts = catalog.hosts;
const visibleHosts: SessionCatalogHost[] = [];
for (const host of hosts) {
const sessions = host.sessions.filter(
(session) => !params.creatorId || session.createdActor?.id === params.creatorId,
);
if (sessions.length > 0) {
visibleHosts.push(sessions.length === host.sessions.length ? host : { ...host, sessions });
}
}
const rows = visibleHosts.flatMap((host) =>
host.sessions.map((session) => ({ host, session })),
);
const liveRows = rows.flatMap(({ session }) => {
const row = session.sessionKey ? liveRowsByKey.get(session.sessionKey) : undefined;
return row ? [row] : [];
});
const hasActiveRun = liveRows.some((row) => row.hasActiveRun === true);
const hasUnread = liveRows.some((row) => row.unread === true);
const loadingMore = params.loadingMoreCatalogIds.has(catalog.id);
const hasMore = hosts.some((host) => Boolean(host.nextCursor));
const canCreateSession = catalog.capabilities.createSession !== undefined;
const errorMessages = catalogErrorMessages(catalog);
const hasError = errorMessages.length > 0;
// Keep provider failures distinguishable from successful empty results.
// Hiding both states would silently mask unavailable session sources.
if (rows.length === 0 && !hasMore && !hasError && !catalog.capabilities.createSession) {
return nothing;
}
const errorMessage = errorMessages.join("; ");
const errorHelp = `${errorMessage}. Configure native thread discovery in Settings > Automation > Plugins.`;
return html`
<div class="sidebar-recent-sessions__group" data-session-section=${sectionId}>
<div class="sidebar-recent-sessions__head">
<button
type="button"
class="sidebar-session-group-toggle"
aria-expanded=${String(!collapsed)}
aria-label=${hasError ? `${catalog.label}: ${errorHelp}` : catalog.label}
title=${hasError ? errorHelp : nothing}
@click=${() => params.onToggleSection(sectionId)}
>
<span class="sidebar-recent-sessions__label-text">${catalog.label}</span>
<span class="sidebar-session-group-toggle__icon" aria-hidden="true"
>${collapsed ? icons.chevronRight : icons.chevronDown}</span
>
${renderCatalogHeaderStatus(hasActiveRun, hasUnread)}
${hasError || (collapsed && rows.length > 0)
? html`<span
class="sidebar-session-group-count ${hasError
? "sidebar-session-group-count--error"
: ""}"
data-session-catalog-error=${hasError ? catalog.id : nothing}
aria-hidden="true"
>${hasError ? icons.alertTriangle : rows.length}</span
>`
: nothing}
</button>
<button
type="button"
class="sidebar-session-group-actions sidebar-session-sort sidebar-session-catalog-grouping ${params.creatorFilterActive
? "sidebar-session-sort--filtered"
: ""}"
data-session-catalog-view-menu=${catalog.id}
title=${t("chat.sidebar.catalogViewOptions")}
aria-label=${t("chat.sidebar.catalogViewOptions")}
aria-haspopup="menu"
aria-expanded=${String(params.viewMenuOpenCatalogId === catalog.id)}
@click=${(event: MouseEvent) => {
event.stopPropagation();
params.onOpenViewMenu(event.currentTarget as HTMLElement);
}}
>
${icons.listFilter}
</button>
${canCreateSession
? html`<button
type="button"
class="sidebar-session-group-actions sidebar-session-sort sidebar-session-new sidebar-session-catalog-new"
title=${`${t("chat.runControls.newSession")}${catalog.label}`}
aria-label=${`${t("chat.runControls.newSession")}${catalog.label}`}
?disabled=${!params.connected}
@click=${() =>
params.onOpenNewSession?.(params.newSessionAgentId, {
catalogId: catalog.id,
})}
>
${icons.plus}
</button>`
: nothing}
</div>
${collapsed
? nothing
: html`<div class="sidebar-recent-sessions__list">
${visibleHosts.map((host) =>
renderCatalogHostGroup(catalog, host, liveRowsByKey, params),
)}
</div>
${hasMore
? html`<button
type="button"
class="sidebar-session-catalog-load-more"
data-session-catalog-load-more=${catalog.id}
?disabled=${loadingMore}
aria-busy=${String(loadingMore)}
@click=${() => params.onLoadMore(catalog.id)}
>
${t("chat.selectors.loadMoreSessions")}
</button>`
: nothing}`}
</div>
`;
});
}
function renderCatalogHostGroup(
catalog: SessionCatalog,
host: SessionCatalogHost,
liveRowsByKey: ReadonlyMap<string, GatewaySessionRow>,
params: SessionCatalogGroupsParams,
) {
const errorHelp = host.error ? `[${host.error.code}] ${host.error.message}` : undefined;
const projectGroups =
params.projectGrouping === "project"
? groupCatalogSessionsByProject(host.sessions)
: params.projectGrouping === "person"
? groupCatalogSessionsByPerson(host.sessions)
: null;
// Gateway errors stay on the catalog header; node headings remain so remote rows keep their owner.
const showHostHeading = host.kind !== "gateway";
return html`
<section class="sidebar-session-catalog-host" data-session-catalog-host=${host.hostId}>
${showHostHeading
? html`<div
class="sidebar-session-catalog-host__head"
aria-label=${errorHelp ? `${host.label}: ${errorHelp}` : host.label}
title=${errorHelp ?? host.label}
>
<span class="sidebar-session-catalog-host__label">${host.label}</span>
<span
class="sidebar-session-catalog-host__count ${host.error
? "sidebar-session-catalog-host__count--error"
: ""}"
aria-hidden="true"
>${host.error ? icons.alertTriangle : host.sessions.length}</span
>
</div>`
: nothing}
<div class="sidebar-session-catalog-host__sessions" role="list" aria-label=${host.label}>
${projectGroups
? html`${projectGroups.groups.map((group) => {
const sectionId = `catalog-project:${catalog.id}:${host.hostId}:${group.key}`;
const collapsed = params.collapsedSections.has(sectionId);
return html`
<button
type="button"
class="sidebar-session-catalog-project__head"
data-session-catalog-project=${group.key}
aria-expanded=${String(!collapsed)}
title=${group.title}
@click=${() => params.onToggleSection(sectionId)}
>
<span class="sidebar-session-catalog-project__icon" aria-hidden="true"
>${collapsed ? icons.chevronRight : icons.chevronDown}</span
>
<span class="sidebar-session-catalog-project__label">${group.label}</span>
<span class="sidebar-session-catalog-project__count" aria-hidden="true"
>${group.sessions.length}</span
>
</button>
${collapsed
? nothing
: group.sessions.map((session) =>
renderCatalogSessionRow(catalog, host, session, liveRowsByKey, params, true),
)}
`;
})}
${projectGroups.ungrouped.map((session) =>
renderCatalogSessionRow(catalog, host, session, liveRowsByKey, params),
)}`
: host.sessions.map((session) =>
renderCatalogSessionRow(catalog, host, session, liveRowsByKey, params),
)}
</div>
</section>
`;
}
function renderCatalogSessionRow(
catalog: SessionCatalog,
host: SessionCatalogHost,
session: SessionCatalogSession,
liveRowsByKey: ReadonlyMap<string, GatewaySessionRow>,
params: SessionCatalogGroupsParams,
projectChild = false,
) {
const rawTimestamp = session.recencyAt ?? session.updatedAt ?? session.createdAt;
const timestamp =
typeof rawTimestamp === "number" && rawTimestamp < 1_000_000_000_000
? rawTimestamp * 1000
: rawTimestamp;
const adoptedRow = session.sessionKey ? liveRowsByKey.get(session.sessionKey) : undefined;
if (adoptedRow) {
const label = session.name || session.threadId;
return params.renderLiveRow(adoptedRow, {
label,
meta: formatSidebarTimestamp(timestamp),
title: `${label} · ${host.label}`,
...(session.pullRequest ? { pullRequest: session.pullRequest } : {}),
});
}
const catalogKey = {
catalogId: catalog.id,
hostId: host.hostId,
threadId: session.threadId,
} satisfies CatalogSessionKey;
const key = session.sessionKey ?? buildCatalogSessionKey(catalogKey);
const label = session.name || session.threadId;
const meta = formatSidebarTimestamp(timestamp);
const search = searchForSession(key);
const href = `${pathForRoute("chat", params.basePath)}${search}`;
const active = params.routeSessionKey !== "" && key === params.routeSessionKey;
const running = session.status === "active" || session.status === "running";
const canOpenTerminal = session.canOpenTerminal === true && params.terminalAvailable;
const openTerminal = () => params.onOpenTerminal(catalogKey);
const openMenu = (x: number, y: number, trigger?: HTMLElement) =>
params.onOpenMenu(
{ key: catalogKey, search, canOpenTerminal: session.canOpenTerminal === true, meta },
x,
y,
trigger,
);
return html`
<div
class="sidebar-recent-session session-row-host ${active
? "sidebar-recent-session--active"
: ""} ${projectChild ? "sidebar-recent-session--catalog-project-child" : ""} ${running
? "session-row-host--running"
: ""}"
data-session-key=${key}
role="listitem"
@contextmenu=${(event: MouseEvent) => {
event.preventDefault();
openMenu(event.clientX, event.clientY);
}}
>
<a
href=${href}
class="sidebar-recent-session__link"
title=${`${label} · ${host.label}`}
aria-current=${active ? "page" : nothing}
@click=${(event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
return;
}
event.preventDefault();
if (params.catalogOpenTarget === "terminal" && canOpenTerminal) {
openTerminal();
} else {
params.onNavigate?.("chat", { search });
}
}}
>
<span class="sidebar-session-indicator"
>${running
? renderSessionRunSpinner()
: html`<span class="sidebar-session-indicator__dot" aria-hidden="true"></span>`}</span
>
<span class="sidebar-recent-session__text">
<span class="sidebar-recent-session__name hover-marquee">${label}</span>
</span>
${renderSessionRowBadges({
hasAutomation: false,
pullRequest: session.pullRequest,
})}
</a>
<span class="sidebar-recent-session__aside session-row-aside">
<span class="session-row-actions">
<button
class="session-action"
data-catalog-session-menu="true"
type="button"
title=${t("chat.sidebar.openSessionMenu")}
aria-label=${t("chat.sidebar.openSessionMenu")}
aria-haspopup="menu"
@click=${(event: MouseEvent) => {
event.stopPropagation();
const trigger = event.currentTarget as HTMLElement;
const rect = trigger.getBoundingClientRect();
openMenu(rect.right, rect.bottom + 4, trigger);
}}
>
${icons.moreHorizontal}
</button>
</span>
</span>
</div>
`;
}

View File

@@ -1,4 +1,4 @@
import { html, nothing, type TemplateResult } from "lit";
import { html, nothing } from "lit";
import type { SessionCatalog } from "../../../packages/gateway-protocol/src/index.ts";
import type { GatewaySessionRow } from "../api/types.ts";
import { titleForRoute } from "../app-navigation.ts";
@@ -6,14 +6,14 @@ import type { CatalogOpenTarget } from "../app/settings.ts";
import { t } from "../i18n/index.ts";
import type { CatalogProjectGrouping } from "../lib/sessions/catalog-project-grouping.ts";
import { openCatalogSessionInTerminal } from "../lib/sessions/catalog-terminal.ts";
import { writeSidebarSectionDragData } from "../lib/sessions/drag.ts";
import type { SidebarSessionSection } from "../lib/sessions/grouping.ts";
import { renderSessionCatalogGroups } from "./app-sidebar-session-catalogs.ts";
import type { SessionCatalogGroupsRenderer } from "./app-sidebar-session-catalog-render.ts";
import {
renderRecentSession,
renderSessionTree,
type SessionListHost,
} from "./app-sidebar-session-row-render.ts";
import { renderSidebarSessionSectionHeader } from "./app-sidebar-session-section-header.ts";
import {
rowDemandsVisibility,
RowVisibilityReason,
@@ -49,11 +49,9 @@ type SessionCatalogRenderSnapshot = {
function renderSessionSection(params: {
host: SessionListHost;
section: RenderableSessionSection;
trailing?: TemplateResult | typeof nothing;
showDraft?: boolean;
}) {
const { host, section } = params;
const trailing = params.trailing ?? nothing;
const showDraft = params.showDraft ?? false;
const totalRowCount = section.totalRowCount;
const group = section.category;
@@ -100,47 +98,17 @@ function renderSessionSection(params: {
@dragleave=${(event: DragEvent) => host.sectionDragLeave(event, section.id, group)}
@drop=${(event: DragEvent) => host.sectionDrop(event, section.id, group)}
>
${html`
<div
class="sidebar-recent-sessions__head sidebar-recent-sessions__head--draggable"
draggable="true"
@mousedown=${(event: MouseEvent) => {
const header = event.currentTarget as HTMLElement;
header.toggleAttribute(
"data-section-drag-blocked",
Boolean((event.target as HTMLElement).closest("button")),
);
}}
@mouseup=${(event: MouseEvent) => {
(event.currentTarget as HTMLElement).removeAttribute("data-section-drag-blocked");
}}
@dragstart=${(event: DragEvent) => {
const header = event.currentTarget as HTMLElement;
const startedFromButton =
Boolean((event.target as HTMLElement).closest("button")) ||
header.hasAttribute("data-section-drag-blocked");
header.removeAttribute("data-section-drag-blocked");
if (startedFromButton) {
${renderSidebarSessionSectionHeader({
sectionId: section.id,
onStartDrag: (sectionId) => host.startSidebarSectionDrag(sectionId),
onFinishDrag: () => host.finishSidebarSectionDrag(),
onContextMenu: group
? (event: MouseEvent) => {
event.preventDefault();
return;
host.sidebarMenus.openSessionGroupMenu(group, event.clientX, event.clientY, null);
}
if (event.dataTransfer) {
writeSidebarSectionDragData(event.dataTransfer, section.id);
host.startSidebarSectionDrag(section.id);
}
}}
@dragend=${(event: DragEvent) => {
(event.currentTarget as HTMLElement).removeAttribute("data-section-drag-blocked");
host.finishSidebarSectionDrag();
}}
@contextmenu=${group
? (event: MouseEvent) => {
event.preventDefault();
host.sidebarMenus.openSessionGroupMenu(group, event.clientX, event.clientY, null);
}
: nothing}
>
<span class="sidebar-session-group-drag-handle" aria-hidden="true"></span>
: undefined,
content: html`
<button
type="button"
class="sidebar-session-group-toggle"
@@ -232,8 +200,8 @@ function renderSessionSection(params: {
</button>
`
: nothing}
</div>
`}
`,
})}
${collapsed
? nothing
: html`
@@ -243,7 +211,7 @@ function renderSessionSection(params: {
${section.rows.map((session) => renderSessionTree({ host, session }))}
</div>`
: nothing}
${renderSessionPagination({ host, section })} ${trailing}
${renderSessionPagination({ host, section })}
`}
</div>
`;
@@ -310,19 +278,16 @@ function renderSessionPagination(params: {
`;
}
function renderSessionCatalogs(params: {
function renderSessionCatalog(params: {
host: SessionListHost;
snapshot: SessionCatalogRenderSnapshot;
catalog: SessionCatalog;
renderer: SessionCatalogGroupsRenderer;
}) {
const { host, snapshot } = params;
const { host, snapshot, catalog, renderer } = params;
return html`
${renderPanelRefreshStatus({
status: snapshot.refreshStatus,
onRetry: () => void host.sessionData.refreshSessionCatalogs(),
className: "sidebar-session-error sidebar-session-catalog-error",
})}
${renderSessionCatalogGroups({
catalogs: snapshot.catalogs,
${renderer({
catalogs: [catalog],
connected: host.connected,
basePath: snapshot.basePath,
routeSessionKey: snapshot.routeSessionKey,
@@ -339,6 +304,13 @@ function renderSessionCatalogs(params: {
display,
}),
onToggleSection: (sectionId) => host.toggleSection(sectionId),
draggingSectionId: host.sessionOrganizer.draggingSidebarSection,
sectionDropTarget: host.sessionOrganizer.sidebarSectionDropTarget,
onSectionDragOver: (event, sectionId) => host.sectionDragOver(event, sectionId),
onSectionDragLeave: (event, sectionId) => host.sectionDragLeave(event, sectionId),
onSectionDrop: (event, sectionId) => host.sectionDrop(event, sectionId),
onStartSectionDrag: (sectionId) => host.startSidebarSectionDrag(sectionId),
onFinishSectionDrag: () => host.finishSidebarSectionDrag(),
// aria-expanded must land on the one header whose menu is open, so the
// catalog id rides on the trigger's data attribute instead of a global flag.
viewMenuOpenCatalogId: host.sidebarMenus.catalogViewMenuPosition
@@ -363,29 +335,58 @@ function renderSessionListBody(params: {
host: SessionListHost;
sections: RenderableSessionSection[];
showDraft: boolean;
codingTrailing?: TemplateResult | typeof nothing;
codingTrailingPresent?: boolean;
catalogs: SessionCatalogRenderSnapshot;
catalogRenderer: SessionCatalogGroupsRenderer | null;
}) {
const { host } = params;
const catalogsVisible = host.sessionsStatusFilter !== "archived";
const catalogsBySectionId = new Map(
params.catalogs.catalogs.map((catalog) => [`catalog:${catalog.id}`, catalog]),
);
const firstCatalogSectionIndex = catalogsVisible
? params.sections.findIndex((section) => section.id.startsWith("catalog:"))
: -1;
const catalogStatus = catalogsVisible
? renderPanelRefreshStatus({
status: params.catalogs.refreshStatus,
onRetry: () => void host.sessionData.refreshSessionCatalogs(),
className: "sidebar-session-error sidebar-session-catalog-error",
})
: nothing;
// Categorized threads still need the global sort and new-thread actions,
// which belong to Threads even when that section has no rows of its own.
const hasCategorizedThreads = params.sections.some(
(section) => Boolean(section.category) && section.totalRowCount > 0,
);
return html`
${params.sections.map((section) => {
${params.sections.map((section, index) => {
const showDraft = section.id === "ungrouped" && params.showDraft;
if (section.id.startsWith("catalog:")) {
const catalog = catalogsBySectionId.get(section.id);
return html`${index === firstCatalogSectionIndex ? catalogStatus : nothing}${catalog
? params.catalogRenderer
? renderSessionCatalog({
host,
snapshot: params.catalogs,
catalog,
renderer: params.catalogRenderer,
})
: nothing
: nothing}`;
}
if (section.id === "work") {
// Coding hosts live work/ACP rows plus the CLI catalogs; hide the
// whole zone when both are empty.
if (section.totalRowCount === 0 && params.codingTrailingPresent !== true) {
// Keep the Coding header visible beside catalog sections just as it
// was when those sections were nested inside it.
if (
section.totalRowCount === 0 &&
!(
catalogsVisible &&
(params.catalogs.catalogs.length > 0 || params.catalogs.refreshStatus.error !== null)
)
) {
return nothing;
}
return renderSessionSection({
host,
section,
trailing: params.codingTrailing ?? nothing,
});
return renderSessionSection({ host, section });
}
// Hide an empty Threads header only when it does not own reachable
// actions for categorized threads, collaborators, or an active drag.
@@ -402,6 +403,7 @@ function renderSessionListBody(params: {
}
return renderSessionSection({ host, section, showDraft });
})}
${firstCatalogSectionIndex < 0 ? catalogStatus : nothing}
`;
}
@@ -411,6 +413,7 @@ export function renderSessionList(params: {
sections: RenderableSessionSection[];
showDraft: boolean;
catalogs: SessionCatalogRenderSnapshot;
catalogRenderer: SessionCatalogGroupsRenderer | null;
}) {
const { host } = params;
return html`
@@ -448,13 +451,8 @@ export function renderSessionList(params: {
host,
sections: params.sections,
showDraft: params.showDraft,
codingTrailing:
host.sessionsStatusFilter === "archived"
? nothing
: html`${renderSessionCatalogs({ host, snapshot: params.catalogs })}`,
codingTrailingPresent:
host.sessionsStatusFilter !== "archived" &&
(params.catalogs.catalogs.length > 0 || params.catalogs.refreshStatus.error !== null),
catalogs: params.catalogs,
catalogRenderer: params.catalogRenderer,
})}
${host.sessionsStatusFilter === "archived" && params.empty
? html`<span class="sidebar-session-empty-hint"

View File

@@ -190,6 +190,7 @@ export function partitionSidebarVisibleSections(input: {
rows: SidebarRecentSession[];
grouping: SidebarSessionsGrouping;
knownGroups: string[] | undefined;
catalogIds?: readonly string[];
sectionOrder?: readonly string[];
collapsedSections: ReadonlySet<string>;
hideEmptyCreatorFilteredGroup: (category: string | undefined, rowCount: number) => boolean;
@@ -201,6 +202,7 @@ export function partitionSidebarVisibleSections(input: {
grouping: input.grouping,
knownGroups: input.knownGroups,
sectionOrder: input.sectionOrder,
catalogIds: input.catalogIds,
}).filter(
(section) =>
section.id !== "pinned" &&

View File

@@ -296,6 +296,10 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
// Raw gateway-owned order: grouping normalizes it against the full
// discovered category set without dropping catalog-lagging categories.
sectionOrder: this.knownSectionOrder(),
catalogIds:
this.sessionsStatusFilter === "archived"
? []
: this.sessionData.sessionCatalogs.map((catalog) => catalog.id),
collapsedSections: this.collapsedSessionSections,
hideEmptyCreatorFilteredGroup: (category, rowCount) =>
this.hideEmptyCreatorFilteredGroup(category, rowCount),
@@ -525,6 +529,19 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
return [...(this.context?.sessions.state.sectionOrder ?? [])];
}
knownSessionCatalogIds(): string[] {
const loadedCatalogIds = this.sessionData.sessionCatalogs.map((catalog) => catalog.id);
if (this.sessionData.sessionCatalogRefreshStatus.hasLoaded) {
return loadedCatalogIds;
}
// Until the first authoritative list completes, progressive rows are only
// a partial view. Preserve stored slots so an unrelated drag cannot erase them.
const storedCatalogIds = this.knownSectionOrder().flatMap((sectionId) =>
sectionId.startsWith("catalog:") ? [sectionId.slice("catalog:".length)] : [],
);
return [...new Set([...loadedCatalogIds, ...storedCatalogIds])];
}
findSidebarSessionByKey(sessionKey: string): SidebarRecentSession | undefined {
const navigationState = this.getSessionNavigationState();
return findProjectedSidebarSession({

View File

@@ -0,0 +1,50 @@
import { html, nothing, type TemplateResult } from "lit";
import { writeSidebarSectionDragData } from "../lib/sessions/drag.ts";
export function renderSidebarSessionSectionHeader(params: {
sectionId: string;
content: TemplateResult;
onStartDrag: (sectionId: string) => void;
onFinishDrag: () => void;
onContextMenu?: (event: MouseEvent) => void;
}) {
return html`
<div
class="sidebar-recent-sessions__head sidebar-recent-sessions__head--draggable"
draggable="true"
@mousedown=${(event: MouseEvent) => {
const header = event.currentTarget as HTMLElement;
header.toggleAttribute(
"data-section-drag-blocked",
Boolean((event.target as HTMLElement).closest("button")),
);
}}
@mouseup=${(event: MouseEvent) => {
(event.currentTarget as HTMLElement).removeAttribute("data-section-drag-blocked");
}}
@dragstart=${(event: DragEvent) => {
const header = event.currentTarget as HTMLElement;
const startedFromButton =
Boolean((event.target as HTMLElement).closest("button")) ||
header.hasAttribute("data-section-drag-blocked");
header.removeAttribute("data-section-drag-blocked");
if (startedFromButton) {
event.preventDefault();
return;
}
if (event.dataTransfer) {
writeSidebarSectionDragData(event.dataTransfer, params.sectionId);
params.onStartDrag(params.sectionId);
}
}}
@dragend=${(event: DragEvent) => {
(event.currentTarget as HTMLElement).removeAttribute("data-section-drag-blocked");
params.onFinishDrag();
}}
@contextmenu=${params.onContextMenu ?? nothing}
>
<span class="sidebar-session-group-drag-handle" aria-hidden="true"></span>
${params.content}
</div>
`;
}

View File

@@ -19,6 +19,7 @@ import "../test-helpers/app-sidebar-cases/interactions.ts";
import "../test-helpers/app-sidebar-cases/narration.ts";
import "../test-helpers/app-sidebar-cases/outbox-badges.ts";
import "../test-helpers/app-sidebar-cases/pull-request-state.ts";
import "../test-helpers/app-sidebar-cases/section-reordering.ts";
import "../test-helpers/app-sidebar-cases/sidebar-scroll.ts";
import "../test-helpers/app-sidebar-cases/sessions.ts";
import "../test-helpers/app-sidebar-cases/session-ownership.ts";

View File

@@ -25,6 +25,7 @@ import {
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 {
@@ -75,6 +76,19 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
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();
}
},
);
@state() catalogProjectGrouping = loadStoredSidebarCatalogGrouping();
constructor() {
@@ -125,6 +139,7 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
override disconnectedCallback() {
window.removeEventListener("openclaw:native-gateways-changed", this.nativeGatewaysChanged);
this.narration?.disconnect();
this.catalogRendererImport.dispose();
super.disconnectedCallback();
}
@@ -209,6 +224,7 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
// 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() {
@@ -288,6 +304,10 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
this.sessionData.dismissSessionMutationError();
}
preloadCatalogRenderer() {
return this.catalogRendererImport.load();
}
setCatalogProjectGrouping(next: CatalogProjectGrouping): void {
storeSidebarCatalogGrouping(next);
this.catalogProjectGrouping = next;
@@ -321,10 +341,18 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
}
}
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,
catalogRenderer: this.catalogRenderer,
showDraft:
Boolean(this.draftSessionAgentId) &&
normalizeAgentId(this.draftSessionAgentId) === expandedAgentId,

View File

@@ -38,6 +38,7 @@ export interface SessionOrganizerControllerHost extends ReactiveControllerHost {
clearSessionSelection(): void;
findSidebarSessionByKey(sessionKey: string): SidebarRecentSession | undefined;
knownSessionGroups(): string[];
knownSessionCatalogIds(): string[];
knownSectionOrder(): string[];
pruneSidebarSessionEntry(key: string): void;
reconciledSidebarZone(): { sidebarEntries: readonly string[] };
@@ -443,8 +444,9 @@ export async function reorderSidebarSection(
// knownSessionGroups() is the full discovered set (gateway catalog plus
// row-discovered categories), so normalize only prunes deleted groups.
const knownGroups = host.knownSessionGroups();
const knownCatalogIds = host.knownSessionCatalogIds();
const next = moveSessionSection(
normalizeSessionSectionOrder(host.knownSectionOrder(), knownGroups),
normalizeSessionSectionOrder(host.knownSectionOrder(), knownGroups, knownCatalogIds),
sourceSectionId,
targetSectionId,
position,

View File

@@ -15,9 +15,17 @@ describe("session group catalog readers", () => {
it("reads normalized section order", () => {
expect(
readSidebarSectionOrder({
sectionOrder: [" work ", "", 42, "work", "category: Alpha "],
sectionOrder: [
" work ",
"",
42,
"work",
"category: Alpha ",
" catalog: codex ",
"catalog:",
],
}),
).toEqual(["work", "category:Alpha"]);
).toEqual(["work", "category:Alpha", "catalog:codex"]);
expect(readSidebarSectionOrder({})).toEqual([]);
});
});

View File

@@ -33,6 +33,18 @@ export function normalizeSessionSectionOrderTokens(value: unknown): string[] | n
continue;
}
const trimmed = entry.trim();
// Catalog-backed sections (session catalog providers) order alongside the
// built-ins and custom categories, so their ids must survive normalization.
const catalogName = trimmed.startsWith("catalog:")
? trimmed.slice("catalog:".length).trim()
: "";
if (catalogName) {
const catalogSectionId = `catalog:${catalogName}`;
if (!normalized.includes(catalogSectionId)) {
normalized.push(catalogSectionId);
}
continue;
}
let token: string | null = null;
if (BUILT_IN_SESSION_SECTION_IDS.has(trimmed)) {
token = trimmed;

View File

@@ -138,6 +138,22 @@ describe("groupSidebarSessionRows", () => {
]);
});
it("emits catalog sections after coding by default and honors their stored positions", () => {
const rows = [row({ key: "thread" }), row({ key: "work", workSession: true })];
expect(
groupSidebarSessionRows(rows, { catalogIds: ["claude", "codex"] }).map(
(section) => section.id,
),
).toEqual(["ungrouped", "work", "catalog:claude", "catalog:codex"]);
expect(
groupSidebarSessionRows(rows, {
catalogIds: ["claude", "codex"],
sectionOrder: ["catalog:codex", "ungrouped", "work", "catalog:claude"],
}).map((section) => section.id),
).toEqual(["catalog:codex", "ungrouped", "work", "catalog:claude"]);
});
it("appends sections missing from stored order in default relative order", () => {
const sections = groupSidebarSessionRows(
[row({ key: "a", category: "Alpha" }), row({ key: "thread" })],
@@ -229,6 +245,16 @@ describe("normalizeSessionSectionOrder", () => {
).toEqual(["category:Alpha", "ungrouped", "groups", "work"]);
});
it("appends unseen catalogs after coding and drops disappeared catalogs", () => {
expect(
normalizeSessionSectionOrder(
["catalog:codex", "ungrouped", "groups", "work", "catalog:removed"],
[],
["claude", "codex"],
),
).toEqual(["catalog:codex", "ungrouped", "groups", "work", "catalog:claude"]);
});
it("drops invalid and duplicate tokens", () => {
expect(
normalizeSessionSectionOrder(

View File

@@ -26,7 +26,7 @@ export type SessionRowGroup = {
};
export type SidebarSessionSection<Row> = {
id: "pinned" | "ungrouped" | "groups" | "work" | `category:${string}`;
id: "pinned" | "ungrouped" | "groups" | "work" | `category:${string}` | `catalog:${string}`;
category?: string;
/** Built-in smart group-conversation section (kind "group" rows). */
groups?: boolean;
@@ -40,14 +40,22 @@ const DEFAULT_SESSION_SECTION_ORDER = ["ungrouped", "groups", "work"] as const;
export function normalizeSessionSectionOrder(
stored: readonly string[],
knownGroups: readonly string[],
knownCatalogIds: readonly string[] = [],
): string[] {
const groups = [...new Set(knownGroups.map((name) => name.trim()).filter(Boolean))];
const knownGroupSet = new Set(groups);
const catalogIds = [
...new Set(knownCatalogIds.map((catalogId) => catalogId.trim()).filter(Boolean)),
];
const knownCatalogIdSet = new Set(catalogIds);
const order = (normalizeSessionSectionOrderTokens(stored) ?? []).filter((token) => {
if (!token.startsWith("category:")) {
return true;
if (token.startsWith("category:")) {
return knownGroupSet.has(token.slice("category:".length));
}
return knownGroupSet.has(token.slice("category:".length));
if (token.startsWith("catalog:")) {
return knownCatalogIdSet.has(token.slice("catalog:".length));
}
return true;
});
for (const group of groups) {
@@ -74,6 +82,10 @@ export function normalizeSessionSectionOrder(
const previousId = DEFAULT_SESSION_SECTION_ORDER[index - 1]!;
order.splice(order.indexOf(previousId) + 1, 0, sectionId);
}
const unseenCatalogTokens = catalogIds
.map((catalogId) => `catalog:${catalogId}`)
.filter((token) => !order.includes(token));
order.splice(order.indexOf("work") + 1, 0, ...unseenCatalogTokens);
return order;
}
@@ -197,8 +209,8 @@ type SidebarGroupableRow = {
* category wins over the smart group/coding classification so manual curation
* sticks. `grouping: "none"` only disables categories; the kind-based Groups
* and Coding zones always split so chat threads stay readable. The coding
* section is always emitted (even empty) because the renderer appends CLI
* catalog sessions into it.
* section is always emitted (even empty) so its ordered position remains a
* stable sibling of any catalog sections.
*/
export function groupSidebarSessionRows<Row extends SidebarGroupableRow>(
rows: readonly Row[],
@@ -206,6 +218,7 @@ export function groupSidebarSessionRows<Row extends SidebarGroupableRow>(
knownGroups?: readonly string[];
grouping?: SidebarSessionsGrouping;
sectionOrder?: readonly string[];
catalogIds?: readonly string[];
} = {},
): SidebarSessionSection<Row>[] {
const grouping = options.grouping ?? "category";
@@ -271,9 +284,21 @@ export function groupSidebarSessionRows<Row extends SidebarGroupableRow>(
orderedSections.push({ id: "groups", groups: true, rows: groups });
}
orderedSections.push({ id: "work", work: true, rows: coding });
const catalogIds = [
...new Set((options.catalogIds ?? []).map((catalogId) => catalogId.trim()).filter(Boolean)),
];
orderedSections.push(
...catalogIds.map(
(catalogId): SidebarSessionSection<Row> => ({ id: `catalog:${catalogId}`, rows: [] }),
),
);
if (options.sectionOrder) {
const sectionsById = new Map(orderedSections.map((section) => [section.id, section]));
for (const sectionId of normalizeSessionSectionOrder(options.sectionOrder, orderedCategories)) {
for (const sectionId of normalizeSessionSectionOrder(
options.sectionOrder,
orderedCategories,
catalogIds,
)) {
const section = sectionsById.get(sectionId as SidebarSessionSection<Row>["id"]);
if (section) {
sections.push(section);

View File

@@ -5,7 +5,7 @@ import { createSessionCapability } from "./index.ts";
describe("gateway-owned sidebar section order", () => {
it("sends and publishes the order", async () => {
const sectionOrder = ["work", "category:Beta", "ungrouped", "category:Alpha"];
const sectionOrder = ["catalog:codex", "work", "category:Beta", "ungrouped", "category:Alpha"];
const request = vi.fn(async (method: string, params: unknown) => {
expect(method).toBe("sessions.groups.put");
expect(params).toEqual({ names: ["Beta", "Alpha"], sectionOrder });

View File

@@ -1250,12 +1250,6 @@ html.openclaw-native-macos
box-shadow var(--duration-fast) ease;
}
/* Catalog sections are nested inside Coding, so add to its 2px item gap to
match the 10px rhythm between top-level session groups. */
.sidebar-recent-sessions__group--zone-coding > .sidebar-recent-sessions__group {
margin-top: 8px;
}
.sidebar-recent-sessions__group--session-drop {
background: color-mix(in srgb, var(--accent) 10%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 55%, transparent);

View File

@@ -19,29 +19,6 @@ import {
import { waitForFast } from "../wait-for.ts";
import "../../components/app-sidebar.ts";
function createDataTransferStub() {
const data = new Map<string, string>();
return {
get types() {
return [...data.keys()];
},
setData: (type: string, value: string) => void data.set(type, value),
getData: (type: string) => data.get(type) ?? "",
effectAllowed: "none",
dropEffect: "none",
};
}
function dispatchDragEvent(
target: Element,
type: "dragstart" | "dragover" | "drop",
dataTransfer: ReturnType<typeof createDataTransferStub>,
) {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperty(event, "dataTransfer", { value: dataTransfer });
target.dispatchEvent(event);
}
describe("AppSidebar multi-select", () => {
const KEYS = ["agent:main:main", "agent:main:a", "agent:main:b", "agent:main:c"];
@@ -343,123 +320,6 @@ describe("AppSidebar transient menus", () => {
});
});
describe("AppSidebar section reordering", () => {
async function mountWithGroups(groups: string[], sectionOrder: string[] = []) {
const client = {} as GatewayBrowserClient;
const gateway = createGateway(client);
const harness = createSessionsHarness("main", [
"agent:main:main",
"agent:main:plain",
"agent:main:thread",
"agent:main:builtin-group",
...groups.map((_, index) => `agent:main:group-${index}`),
]);
const result = harness.sessions.state.result;
if (!result) {
throw new Error("expected grouped session fixtures");
}
const codingRow = result.sessions.find((entry) => entry.key === "agent:main:thread");
if (!codingRow) {
throw new Error("expected coding session fixture");
}
codingRow.worktree = { id: "worktree-1", branch: "feature", repoRoot: "/repo" };
const builtinGroupRow = result.sessions.find(
(entry) => entry.key === "agent:main:builtin-group",
);
if (!builtinGroupRow) {
throw new Error("expected built-in group session fixture");
}
builtinGroupRow.kind = "group";
for (const [index, group] of groups.entries()) {
const row = result.sessions.find((entry) => entry.key === `agent:main:group-${index}`);
if (!row) {
throw new Error(`expected session fixture for ${group}`);
}
row.category = group;
}
const { sidebar } = await mountSidebar(gateway, harness.sessions);
sidebar.connected = true;
harness.publish({ groups, sectionOrder });
await sidebar.updateComplete;
return { sidebar, harness };
}
function groupHeader(sidebar: SidebarLifecycleState, sectionId: string) {
const header = sidebar.querySelector(
`[data-session-section="${sectionId}"] .sidebar-recent-sessions__head`,
);
if (!header) {
throw new Error(`expected header for section ${sectionId}`);
}
return header;
}
it("marks every section header draggable and renders a grip", async () => {
const { sidebar } = await mountWithGroups(["Alpha", "Beta"]);
expect(groupHeader(sidebar, "category:Alpha").getAttribute("draggable")).toBe("true");
expect(groupHeader(sidebar, "ungrouped").getAttribute("draggable")).toBe("true");
expect(groupHeader(sidebar, "groups").getAttribute("draggable")).toBe("true");
expect(groupHeader(sidebar, "work").getAttribute("draggable")).toBe("true");
for (const sectionId of ["category:Alpha", "category:Beta", "ungrouped", "groups", "work"]) {
expect(
groupHeader(sidebar, sectionId).querySelector(".sidebar-session-group-drag-handle"),
).not.toBeNull();
}
});
it("does not start a section drag from a header action button", async () => {
const { sidebar } = await mountWithGroups([]);
const dataTransfer = createDataTransferStub();
const newSessionButton = groupHeader(sidebar, "ungrouped").querySelector(
".sidebar-new-session",
);
if (!newSessionButton) {
throw new Error("expected new-session header action");
}
newSessionButton.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
dispatchDragEvent(groupHeader(sidebar, "ungrouped"), "dragstart", dataTransfer);
expect(dataTransfer.types).toEqual([]);
expect(sidebar.sessionOrganizer.draggingSidebarSection).toBeNull();
});
it("persists the new catalog order when a group header drops onto another group", async () => {
const { sidebar, harness } = await mountWithGroups(["Alpha", "Beta", "Gamma"]);
const dataTransfer = createDataTransferStub();
dispatchDragEvent(groupHeader(sidebar, "category:Gamma"), "dragstart", dataTransfer);
const alphaSection = sidebar.querySelector('[data-session-section="category:Alpha"]');
if (!alphaSection) {
throw new Error("expected Alpha section");
}
dispatchDragEvent(alphaSection, "drop", dataTransfer);
await waitForFast(() =>
expect(harness.groupsPut).toHaveBeenCalledWith(
["Gamma", "Alpha", "Beta"],
["category:Gamma", "category:Alpha", "category:Beta", "ungrouped", "groups", "work"],
),
);
});
it("persists a built-in section move with the full section order", async () => {
const { sidebar, harness } = await mountWithGroups([]);
const dataTransfer = createDataTransferStub();
dispatchDragEvent(groupHeader(sidebar, "work"), "dragstart", dataTransfer);
const threadsSection = sidebar.querySelector('[data-session-section="ungrouped"]');
if (!threadsSection) {
throw new Error("expected Threads section");
}
dispatchDragEvent(threadsSection, "drop", dataTransfer);
await waitForFast(() =>
expect(harness.groupsPut).toHaveBeenCalledWith([], ["work", "ungrouped", "groups"]),
);
});
});
describe("AppSidebar catalog session rows", () => {
const catalogList = (
sessions: Array<Record<string, unknown>>,

View File

@@ -0,0 +1,224 @@
import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { ApplicationGatewaySnapshot } from "../../app/context.ts";
import {
catalogPage,
createGatewayHarness,
createSessionsHarness,
mountSidebar,
type SidebarLifecycleState,
} from "../app-sidebar.ts";
import { waitForFast } from "../wait-for.ts";
import "../../components/app-sidebar.ts";
function createDataTransferStub() {
const data = new Map<string, string>();
return {
get types() {
return [...data.keys()];
},
setData: (type: string, value: string) => void data.set(type, value),
getData: (type: string) => data.get(type) ?? "",
effectAllowed: "none",
dropEffect: "none",
};
}
function dispatchDragEvent(
target: Element,
type: "dragstart" | "dragover" | "drop",
dataTransfer: ReturnType<typeof createDataTransferStub>,
) {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperty(event, "dataTransfer", { value: dataTransfer });
target.dispatchEvent(event);
}
describe("AppSidebar section reordering", () => {
async function mountWithGroups(
groups: string[],
sectionOrder: string[] = [],
options: { withCatalog?: boolean } = {},
) {
const request = vi
.fn()
.mockResolvedValue(catalogPage([{ threadId: "thread-codex", name: "Codex thread" }]));
const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient);
if (options.withCatalog) {
gateway.publish({
hello: {
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"],
});
}
const harness = createSessionsHarness("main", [
"agent:main:main",
"agent:main:plain",
"agent:main:thread",
"agent:main:builtin-group",
...groups.map((_, index) => `agent:main:group-${index}`),
]);
const result = harness.sessions.state.result;
if (!result) {
throw new Error("expected grouped session fixtures");
}
const codingRow = result.sessions.find((entry) => entry.key === "agent:main:thread");
if (!codingRow) {
throw new Error("expected coding session fixture");
}
codingRow.worktree = { id: "worktree-1", branch: "feature", repoRoot: "/repo" };
const builtinGroupRow = result.sessions.find(
(entry) => entry.key === "agent:main:builtin-group",
);
if (!builtinGroupRow) {
throw new Error("expected built-in group session fixture");
}
builtinGroupRow.kind = "group";
for (const [index, group] of groups.entries()) {
const row = result.sessions.find((entry) => entry.key === `agent:main:group-${index}`);
if (!row) {
throw new Error(`expected session fixture for ${group}`);
}
row.category = group;
}
const { sidebar } = await mountSidebar(gateway.gateway, harness.sessions);
sidebar.connected = true;
harness.publish({ groups, sectionOrder });
await sidebar.updateComplete;
if (options.withCatalog) {
await waitForFast(() =>
expect(sidebar.querySelector('[data-session-section="catalog:codex"]')).not.toBeNull(),
);
}
return { sidebar, harness };
}
function groupHeader(sidebar: SidebarLifecycleState, sectionId: string) {
const header = sidebar.querySelector(
`[data-session-section="${sectionId}"] .sidebar-recent-sessions__head`,
);
if (!header) {
throw new Error(`expected header for section ${sectionId}`);
}
return header;
}
it("marks every section header draggable and renders a grip", async () => {
const { sidebar } = await mountWithGroups(["Alpha", "Beta"], [], { withCatalog: true });
expect(groupHeader(sidebar, "category:Alpha").getAttribute("draggable")).toBe("true");
expect(groupHeader(sidebar, "ungrouped").getAttribute("draggable")).toBe("true");
expect(groupHeader(sidebar, "groups").getAttribute("draggable")).toBe("true");
expect(groupHeader(sidebar, "work").getAttribute("draggable")).toBe("true");
expect(groupHeader(sidebar, "catalog:codex").getAttribute("draggable")).toBe("true");
const codingSection = sidebar.querySelector('[data-session-section="work"]');
const catalogSection = sidebar.querySelector('[data-session-section="catalog:codex"]');
expect(catalogSection?.parentElement).toBe(codingSection?.parentElement);
expect(catalogSection?.previousElementSibling).toBe(codingSection);
for (const sectionId of [
"category:Alpha",
"category:Beta",
"ungrouped",
"groups",
"work",
"catalog:codex",
]) {
expect(
groupHeader(sidebar, sectionId).querySelector(".sidebar-session-group-drag-handle"),
).not.toBeNull();
}
});
it("does not start a section drag from a header action button", async () => {
const { sidebar } = await mountWithGroups([]);
const dataTransfer = createDataTransferStub();
const newSessionButton = groupHeader(sidebar, "ungrouped").querySelector(
".sidebar-new-session",
);
if (!newSessionButton) {
throw new Error("expected new-session header action");
}
newSessionButton.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
dispatchDragEvent(groupHeader(sidebar, "ungrouped"), "dragstart", dataTransfer);
expect(dataTransfer.types).toEqual([]);
expect(sidebar.sessionOrganizer.draggingSidebarSection).toBeNull();
});
it("persists the new catalog order when a group header drops onto another group", async () => {
const { sidebar, harness } = await mountWithGroups(["Alpha", "Beta", "Gamma"]);
const dataTransfer = createDataTransferStub();
dispatchDragEvent(groupHeader(sidebar, "category:Gamma"), "dragstart", dataTransfer);
const alphaSection = sidebar.querySelector('[data-session-section="category:Alpha"]');
if (!alphaSection) {
throw new Error("expected Alpha section");
}
dispatchDragEvent(alphaSection, "drop", dataTransfer);
await waitForFast(() =>
expect(harness.groupsPut).toHaveBeenCalledWith(
["Gamma", "Alpha", "Beta"],
["category:Gamma", "category:Alpha", "category:Beta", "ungrouped", "groups", "work"],
),
);
});
it("persists a built-in section move with the full section order", async () => {
const { sidebar, harness } = await mountWithGroups([]);
const dataTransfer = createDataTransferStub();
dispatchDragEvent(groupHeader(sidebar, "work"), "dragstart", dataTransfer);
const threadsSection = sidebar.querySelector('[data-session-section="ungrouped"]');
if (!threadsSection) {
throw new Error("expected Threads section");
}
dispatchDragEvent(threadsSection, "drop", dataTransfer);
await waitForFast(() =>
expect(harness.groupsPut).toHaveBeenCalledWith([], ["work", "ungrouped", "groups"]),
);
});
it("preserves saved catalog slots while the first catalog load is pending", async () => {
const { sidebar, harness } = await mountWithGroups(
[],
["ungrouped", "groups", "work", "catalog:codex"],
);
const dataTransfer = createDataTransferStub();
dispatchDragEvent(groupHeader(sidebar, "work"), "dragstart", dataTransfer);
const threadsSection = sidebar.querySelector('[data-session-section="ungrouped"]');
if (!threadsSection) {
throw new Error("expected Threads section");
}
dispatchDragEvent(threadsSection, "drop", dataTransfer);
await waitForFast(() =>
expect(harness.groupsPut).toHaveBeenCalledWith(
[],
["work", "ungrouped", "groups", "catalog:codex"],
),
);
});
it("persists a catalog section move relative to Coding", async () => {
const { sidebar, harness } = await mountWithGroups([], [], { withCatalog: true });
const dataTransfer = createDataTransferStub();
dispatchDragEvent(groupHeader(sidebar, "catalog:codex"), "dragstart", dataTransfer);
const codingSection = sidebar.querySelector('[data-session-section="work"]');
if (!codingSection) {
throw new Error("expected Coding section");
}
dispatchDragEvent(codingSection, "drop", dataTransfer);
await waitForFast(() =>
expect(harness.groupsPut).toHaveBeenCalledWith(
[],
["ungrouped", "groups", "catalog:codex", "work"],
),
);
});
});

View File

@@ -344,11 +344,14 @@ export async function mountSidebar(
provider.append(sidebar);
document.body.append(provider);
await sidebar.updateComplete;
await (
sidebar as unknown as {
sidebarMenus: { preloadMenuRenderer: () => Promise<unknown> };
}
).sidebarMenus.preloadMenuRenderer();
const sidebarWithPreloads = sidebar as unknown as {
preloadCatalogRenderer: () => Promise<unknown>;
sidebarMenus: { preloadMenuRenderer: () => Promise<unknown> };
};
await Promise.all([
sidebarWithPreloads.preloadCatalogRenderer(),
sidebarWithPreloads.sidebarMenus.preloadMenuRenderer(),
]);
await sidebar.updateComplete;
return { provider, sidebar, context };
}