mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 11:31:34 +00:00
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:
committed by
GitHub
parent
fe847911c0
commit
cd5a5ecb4c
@@ -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 })),
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
442
ui/src/components/app-sidebar-session-catalog-render.ts
Normal file
442
ui/src/components/app-sidebar-session-catalog-render.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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({
|
||||
|
||||
50
ui/src/components/app-sidebar-session-section-header.ts
Normal file
50
ui/src/components/app-sidebar-session-section-header.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
224
ui/src/test-helpers/app-sidebar-cases/section-reordering.ts
Normal file
224
ui/src/test-helpers/app-sidebar-cases/section-reordering.ts
Normal 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"],
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user