mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
feat(ui): person grouping and people filter in the sidebar catalog view menu (#113943)
This commit is contained in:
committed by
GitHub
parent
33876991e7
commit
360fe2317c
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user