feat(ui): person grouping and people filter in the sidebar catalog view menu (#113943)

This commit is contained in:
Peter Steinberger
2026-07-25 18:17:12 -07:00
committed by GitHub
parent 33876991e7
commit 360fe2317c
8 changed files with 161 additions and 4 deletions
@@ -16,6 +16,7 @@ import type {
} 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";
@@ -105,6 +106,7 @@ type SessionCatalogGroupsParams = {
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;
@@ -232,7 +234,9 @@ export function renderSessionCatalogGroups(params: SessionCatalogGroupsParams) {
</button>
<button
type="button"
class="sidebar-session-group-actions sidebar-session-sort sidebar-session-catalog-grouping"
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")}
@@ -293,7 +297,11 @@ function renderCatalogHostGroup(
) {
const errorHelp = host.error ? `[${host.error.code}] ${host.error.message}` : undefined;
const projectGroups =
params.projectGrouping === "project" ? groupCatalogSessionsByProject(host.sessions) : null;
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`
@@ -333,6 +333,7 @@ function renderSessionCatalogs(params: {
? (host.sidebarMenus.catalogViewMenuTrigger?.getAttribute("data-session-catalog-view-menu") ??
null)
: null,
creatorFilterActive: host.sessionCreatorFilterActive,
onOpenViewMenu: (trigger) => host.sidebarMenus.toggleCatalogViewMenu(trigger),
onLoadMore: (catalogId) => void host.sessionData.loadMoreSessionCatalog(catalogId),
onOpenNewSession: host.onOpenNewSession,
@@ -94,7 +94,10 @@ export function renderSidebarCatalogViewMenu(params: {
position: { x: number; y: number } | null;
trigger: HTMLElement | null;
grouping: CatalogProjectGrouping;
creators: readonly SessionCreatorOption[];
creatorFilterId: string | null;
onGroupingChange: (grouping: CatalogProjectGrouping) => void;
onCreatorFilterChange: (creatorId: string | null) => void;
onClose: (restoreFocus: boolean) => void;
}) {
const position = params.position;
@@ -103,6 +106,7 @@ export function renderSidebarCatalogViewMenu(params: {
}
const groupingOptions = [
{ grouping: "project", label: t("chat.sidebar.catalogGroupByProject") },
{ grouping: "person", label: t("chat.sidebar.catalogGroupByPerson") },
{ grouping: "none", label: t("sessionsView.groupByNone") },
] as const satisfies ReadonlyArray<{ grouping: CatalogProjectGrouping; label: string }>;
return keyed(
@@ -120,6 +124,8 @@ export function renderSidebarCatalogViewMenu(params: {
const value = event.detail.item.value;
if (value?.startsWith("grouping:")) {
params.onGroupingChange(value.slice("grouping:".length) as CatalogProjectGrouping);
} else if (value?.startsWith("creator:")) {
params.onCreatorFilterChange(value.slice("creator:".length) || null);
}
}}
@keydown=${(event: KeyboardEvent) =>
@@ -154,6 +160,45 @@ export function renderSidebarCatalogViewMenu(params: {
</wa-dropdown-item>
`,
)}
${params.creators.length >= 2
? html`
<div class="session-menu__separator" role="separator"></div>
<div class="sidebar-session-sort-menu__title">${t("sessionsView.people")}</div>
<wa-dropdown-item
class="sidebar-session-sort-menu__item"
value="creator:"
role="menuitemradio"
aria-checked=${String(params.creatorFilterId === null)}
${ref((element) =>
syncDropdownItemRadio(element, params.creatorFilterId === null),
)}
>
<span slot="details" class="session-menu__check" aria-hidden="true"
>${params.creatorFilterId === null ? icons.check : nothing}</span
>
<span class="session-menu__text">${t("sessionsView.allCreators")}</span>
</wa-dropdown-item>
${params.creators.map(
(creator) => html`
<wa-dropdown-item
class="sidebar-session-sort-menu__item"
value=${`creator:${creator.id}`}
role="menuitemradio"
aria-checked=${String(params.creatorFilterId === creator.id)}
${ref((element) =>
syncDropdownItemRadio(element, params.creatorFilterId === creator.id),
)}
>
<span slot="details" class="session-menu__check" aria-hidden="true"
>${params.creatorFilterId === creator.id ? icons.check : nothing}</span
>
${renderSessionOwnerChip(creator, "row")}
<span class="session-menu__text">${creator.label ?? creator.id}</span>
</wa-dropdown-item>
`,
)}
`
: nothing}
</wa-dropdown>
</openclaw-menu-surface>
`,
@@ -327,10 +327,17 @@ export function renderSidebarCatalogViewMenuForController(controller: SidebarMen
position,
trigger: controller.catalogViewMenuTrigger,
grouping: host.catalogProjectGrouping,
creators: host.sessionOwnershipVisible ? host.sessionCreatorOptions : [],
creatorFilterId: host.sessionCreatorFilterActive ? host.sessionCreatorFilterId : null,
onGroupingChange: (grouping) => {
host.setCatalogProjectGrouping(grouping);
controller.closeCatalogViewMenu({ restoreFocus: true });
},
onCreatorFilterChange: (creatorId) => {
host.sessionCreatorFilterId = creatorId;
void host.sessionDataContext?.sessions.setCreatorFilter(creatorId);
controller.closeCatalogViewMenu({ restoreFocus: true });
},
onClose: (restoreFocus) => {
if (controller.catalogViewMenuPosition !== position) {
return;
+26
View File
@@ -292,6 +292,7 @@ suite("Codex native session catalog", () => {
archived: false,
canContinue: true,
canArchive: true,
createdActor: { type: "human", id: "profile-ada", label: "Ada" },
},
{
threadId: "thread-worktree",
@@ -301,6 +302,7 @@ suite("Codex native session catalog", () => {
archived: false,
canContinue: true,
canArchive: true,
createdActor: { type: "human", id: "profile-zoe", label: "Zoe" },
},
{
threadId: "thread-other",
@@ -407,6 +409,30 @@ suite("Codex native session catalog", () => {
});
}
// Person mode groups adopted (attributed) sessions and leaves native
// threads without a creator in the flat tail.
await catalogHead.hover();
await viewMenuButton.click();
await page
.getByRole("menuitemradio", { name: "Person" })
.evaluate((element) => (element as HTMLElement).click());
await expect.poll(() => projectHeads.count()).toBe(2);
expect(
await section
.locator('[data-session-catalog-project="person:profile-ada"]')
.locator(".sidebar-session-catalog-project__label")
.textContent(),
).toBe("Ada");
expect(
await section
.locator('[data-session-catalog-project="person:profile-zoe"]')
.locator(".sidebar-session-catalog-project__label")
.textContent(),
).toBe("Zoe");
expect(
await page.evaluate((key) => localStorage.getItem(key), catalogGroupingStorageKey),
).toBe("person");
await catalogHead.hover();
await viewMenuButton.click();
await page
+1
View File
@@ -4034,6 +4034,7 @@ export const en: TranslationMap = {
coding: "Coding",
catalogViewOptions: "View options",
catalogGroupByProject: "Project",
catalogGroupByPerson: "Person",
openSessionMenu: "Open thread menu",
sortBy: "Sort by",
sortCreated: "Created",
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { SessionCatalogSession } from "../../../../packages/gateway-protocol/src/index.ts";
import {
groupCatalogSessionsByPerson,
groupCatalogSessionsByProject,
normalizeCatalogProjectGrouping,
} from "./catalog-project-grouping.ts";
@@ -8,6 +9,7 @@ import {
describe("normalizeCatalogProjectGrouping", () => {
it.each([
["project", "project"],
["person", "person"],
["none", "none"],
[undefined, "project"],
[null, "project"],
@@ -90,6 +92,42 @@ describe("groupCatalogSessionsByProject", () => {
});
});
describe("groupCatalogSessionsByPerson", () => {
it("groups attributed sessions by creator, sorted by label, and keeps session order", () => {
const result = groupCatalogSessionsByPerson([
{ ...session("z-1"), createdActor: { type: "human", id: "profile-zoe", label: "Zoe" } },
{ ...session("a-1"), createdActor: { type: "human", id: "profile-ada", label: "Ada" } },
{ ...session("z-2"), createdActor: { type: "human", id: "profile-zoe", label: "Zoe" } },
]);
expect(result.groups.map((group) => group.key)).toEqual([
"person:profile-ada",
"person:profile-zoe",
]);
expect(result.groups.map((group) => group.label)).toEqual(["Ada", "Zoe"]);
expect(result.groups[1]?.sessions.map((item) => item.threadId)).toEqual(["z-1", "z-2"]);
expect(result.groups[0]?.title).toBe("Created by Ada");
});
it("falls back to the actor id when the label is missing or blank", () => {
const result = groupCatalogSessionsByPerson([
{ ...session("one"), createdActor: { type: "human", id: "profile-ada", label: " " } },
]);
expect(result.groups[0]).toMatchObject({ key: "person:profile-ada", label: "profile-ada" });
});
it("leaves unattributed sessions in the flat ungrouped tail", () => {
const result = groupCatalogSessionsByPerson([
session("native"),
{ ...session("adopted"), createdActor: { type: "human", id: "profile-ada", label: "Ada" } },
]);
expect(result.groups).toHaveLength(1);
expect(result.ungrouped.map((item) => item.threadId)).toEqual(["native"]);
});
});
function session(threadId: string, cwd?: string): SessionCatalogSession {
return {
threadId,
@@ -1,9 +1,9 @@
import type { SessionCatalogSession } from "../../../../packages/gateway-protocol/src/index.ts";
export type CatalogProjectGrouping = "project" | "none";
export type CatalogProjectGrouping = "project" | "person" | "none";
export function normalizeCatalogProjectGrouping(raw: unknown): CatalogProjectGrouping {
return raw === "none" ? "none" : "project";
return raw === "none" || raw === "person" ? raw : "project";
}
type CatalogProjectGroup = {
@@ -74,3 +74,34 @@ export function groupCatalogSessionsByProject(sessions: readonly SessionCatalogS
return { groups: [...customGroups, ...projectGroups], ungrouped };
}
/** Groups adopted sessions by their creator identity. Native threads only carry
`createdActor` once adopted (the gateway strips provider-supplied actors), so
unattributed sessions intentionally fall to the flat ungrouped tail. */
export function groupCatalogSessionsByPerson(sessions: readonly SessionCatalogSession[]): {
groups: CatalogProjectGroup[];
ungrouped: SessionCatalogSession[];
} {
const groupsById = new Map<string, CatalogProjectGroup>();
const ungrouped: SessionCatalogSession[] = [];
for (const session of sessions) {
const actor = session.createdActor;
if (!actor?.id) {
ungrouped.push(session);
continue;
}
const key = `person:${actor.id}`;
let group = groupsById.get(key);
if (!group) {
const label = actor.label?.trim() || actor.id;
group = { key, label, title: `Created by ${label}`, sessions: [] };
groupsById.set(key, group);
}
group.sessions.push(session);
}
// Label order keeps the section stable regardless of roster sort.
const groups = [...groupsById.values()].toSorted((a, b) => a.label.localeCompare(b.label));
return { groups, ungrouped };
}