diff --git a/packages/gateway-protocol/src/schema/sessions.ts b/packages/gateway-protocol/src/schema/sessions.ts index d65943ea0218..0bf7be4b9bab 100644 --- a/packages/gateway-protocol/src/schema/sessions.ts +++ b/packages/gateway-protocol/src/schema/sessions.ts @@ -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 })), }); diff --git a/src/gateway/session-groups.test.ts b/src/gateway/session-groups.test.ts index b60490884264..24ad780cf228 100644 --- a/src/gateway/session-groups.test.ts +++ b/src/gateway/session-groups.test.ts @@ -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", diff --git a/src/gateway/session-groups.ts b/src/gateway/session-groups.ts index 8ea424f9c3e2..ee599ddac420 100644 --- a/src/gateway/session-groups.ts +++ b/src/gateway/session-groups.ts @@ -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; diff --git a/ui/src/components/app-sidebar-session-catalog-render.ts b/ui/src/components/app-sidebar-session-catalog-render.ts new file mode 100644 index 000000000000..fa559613ff67 --- /dev/null +++ b/ui/src/components/app-sidebar-session-catalog-render.ts @@ -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; + loadingMoreCatalogIds: ReadonlySet; + 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``; +} + +function renderCatalogHeaderStatus(hasActiveRun: boolean, hasUnread: boolean) { + if (hasActiveRun) { + return renderSessionRunSpinner(); + } + return hasUnread + ? html`` + : nothing; +} + +function catalogErrorMessages(catalog: SessionCatalog): string[] { + const messages = new Set(); + 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(); + 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` +
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` + + + ${canCreateSession + ? html`` + : nothing} + `, + })} + ${collapsed + ? nothing + : html` + ${hasMore + ? html`` + : nothing}`} +
+ `; + }); +} + +export type SessionCatalogGroupsRenderer = typeof renderSessionCatalogGroups; + +function renderCatalogHostGroup( + catalog: SessionCatalog, + host: SessionCatalogHost, + liveRowsByKey: ReadonlyMap, + 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` + + `; +} + +function renderCatalogSessionRow( + catalog: SessionCatalog, + host: SessionCatalogHost, + session: SessionCatalogSession, + liveRowsByKey: ReadonlyMap, + 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` + + `; +} diff --git a/ui/src/components/app-sidebar-session-catalogs.ts b/ui/src/components/app-sidebar-session-catalogs.ts index 4a8b4aabe6c8..249bba23207c 100644 --- a/ui/src/components/app-sidebar-session-catalogs.ts +++ b/ui/src/components/app-sidebar-session-catalogs.ts @@ -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; - loadingMoreCatalogIds: ReadonlySet; - 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``; -} - -function renderCatalogHeaderStatus(hasActiveRun: boolean, hasUnread: boolean) { - if (hasActiveRun) { - return renderSessionRunSpinner(); - } - return hasUnread - ? html`` - : nothing; -} - -function catalogErrorMessages(catalog: SessionCatalog): string[] { - const messages = new Set(); - 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(); - 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` - - `; - }); -} - -function renderCatalogHostGroup( - catalog: SessionCatalog, - host: SessionCatalogHost, - liveRowsByKey: ReadonlyMap, - 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` - - `; -} - -function renderCatalogSessionRow( - catalog: SessionCatalog, - host: SessionCatalogHost, - session: SessionCatalogSession, - liveRowsByKey: ReadonlyMap, - 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` - - `; -} diff --git a/ui/src/components/app-sidebar-session-list-render.ts b/ui/src/components/app-sidebar-session-list-render.ts index 80c5adda6f67..ee9308e2d8b3 100644 --- a/ui/src/components/app-sidebar-session-list-render.ts +++ b/ui/src/components/app-sidebar-session-list-render.ts @@ -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` - - `} + `, + })} ${collapsed ? nothing : html` @@ -243,7 +211,7 @@ function renderSessionSection(params: { ${section.rows.map((session) => renderSessionTree({ host, session }))} ` : nothing} - ${renderSessionPagination({ host, section })} ${trailing} + ${renderSessionPagination({ host, section })} `} `; @@ -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`; 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" && diff --git a/ui/src/components/app-sidebar-session-navigation.ts b/ui/src/components/app-sidebar-session-navigation.ts index 92232828eb95..66f7cf075b1e 100644 --- a/ui/src/components/app-sidebar-session-navigation.ts +++ b/ui/src/components/app-sidebar-session-navigation.ts @@ -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({ diff --git a/ui/src/components/app-sidebar-session-section-header.ts b/ui/src/components/app-sidebar-session-section-header.ts new file mode 100644 index 000000000000..efea171cf9cf --- /dev/null +++ b/ui/src/components/app-sidebar-session-section-header.ts @@ -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` + + `; +} diff --git a/ui/src/components/app-sidebar.test.ts b/ui/src/components/app-sidebar.test.ts index 1633da86a656..5cf0a6d28456 100644 --- a/ui/src/components/app-sidebar.test.ts +++ b/ui/src/components/app-sidebar.test.ts @@ -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"; diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts index 8715be64821b..755e74bb8843 100644 --- a/ui/src/components/app-sidebar.ts +++ b/ui/src/components/app-sidebar.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, diff --git a/ui/src/components/session-organizer-operations.runtime.ts b/ui/src/components/session-organizer-operations.runtime.ts index 3394f3abd771..8d27880f3b40 100644 --- a/ui/src/components/session-organizer-operations.runtime.ts +++ b/ui/src/components/session-organizer-operations.runtime.ts @@ -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, diff --git a/ui/src/lib/sessions/custom-groups.test.ts b/ui/src/lib/sessions/custom-groups.test.ts index d647a64fa828..ccf97672d6d4 100644 --- a/ui/src/lib/sessions/custom-groups.test.ts +++ b/ui/src/lib/sessions/custom-groups.test.ts @@ -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([]); }); }); diff --git a/ui/src/lib/sessions/custom-groups.ts b/ui/src/lib/sessions/custom-groups.ts index 704f5db3d808..3ead973386e4 100644 --- a/ui/src/lib/sessions/custom-groups.ts +++ b/ui/src/lib/sessions/custom-groups.ts @@ -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; diff --git a/ui/src/lib/sessions/grouping.test.ts b/ui/src/lib/sessions/grouping.test.ts index 1cb0bf658f38..fdfdd583c48e 100644 --- a/ui/src/lib/sessions/grouping.test.ts +++ b/ui/src/lib/sessions/grouping.test.ts @@ -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( diff --git a/ui/src/lib/sessions/grouping.ts b/ui/src/lib/sessions/grouping.ts index c4cc8f04d37c..0756be6ecc06 100644 --- a/ui/src/lib/sessions/grouping.ts +++ b/ui/src/lib/sessions/grouping.ts @@ -26,7 +26,7 @@ export type SessionRowGroup = { }; export type SidebarSessionSection = { - 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( rows: readonly Row[], @@ -206,6 +218,7 @@ export function groupSidebarSessionRows( knownGroups?: readonly string[]; grouping?: SidebarSessionsGrouping; sectionOrder?: readonly string[]; + catalogIds?: readonly string[]; } = {}, ): SidebarSessionSection[] { const grouping = options.grouping ?? "category"; @@ -271,9 +284,21 @@ export function groupSidebarSessionRows( 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 => ({ 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["id"]); if (section) { sections.push(section); diff --git a/ui/src/lib/sessions/section-order.test.ts b/ui/src/lib/sessions/section-order.test.ts index 814aded8dbb9..8b9587d6d838 100644 --- a/ui/src/lib/sessions/section-order.test.ts +++ b/ui/src/lib/sessions/section-order.test.ts @@ -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 }); diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index e5aee79c07e4..a905aa56c3dc 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -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); diff --git a/ui/src/test-helpers/app-sidebar-cases/interactions.ts b/ui/src/test-helpers/app-sidebar-cases/interactions.ts index ca9d68e91820..6ea97abcc0eb 100644 --- a/ui/src/test-helpers/app-sidebar-cases/interactions.ts +++ b/ui/src/test-helpers/app-sidebar-cases/interactions.ts @@ -19,29 +19,6 @@ import { import { waitForFast } from "../wait-for.ts"; import "../../components/app-sidebar.ts"; -function createDataTransferStub() { - const data = new Map(); - 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, -) { - 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>, diff --git a/ui/src/test-helpers/app-sidebar-cases/section-reordering.ts b/ui/src/test-helpers/app-sidebar-cases/section-reordering.ts new file mode 100644 index 000000000000..a8512983171e --- /dev/null +++ b/ui/src/test-helpers/app-sidebar-cases/section-reordering.ts @@ -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(); + 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, +) { + 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"], + ), + ); + }); +}); diff --git a/ui/src/test-helpers/app-sidebar.ts b/ui/src/test-helpers/app-sidebar.ts index 61ed579beb80..8fb254bbcb35 100644 --- a/ui/src/test-helpers/app-sidebar.ts +++ b/ui/src/test-helpers/app-sidebar.ts @@ -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 }; - } - ).sidebarMenus.preloadMenuRenderer(); + const sidebarWithPreloads = sidebar as unknown as { + preloadCatalogRenderer: () => Promise; + sidebarMenus: { preloadMenuRenderer: () => Promise }; + }; + await Promise.all([ + sidebarWithPreloads.preloadCatalogRenderer(), + sidebarWithPreloads.sidebarMenus.preloadMenuRenderer(), + ]); await sidebar.updateComplete; return { provider, sidebar, context }; }