feat(ui): add Person grouping mode for sessions sidebar and sessions page (#127346)

* feat(ui): add Person grouping mode for sessions sidebar and sessions page

Sessions can now be grouped by their durable owner identity: the sidebar
Group-by menu gains a capability-gated Person mode (self first, humans by
label, agent identities after; ownerless rows keep their smart zones), and
the sessions page gains the matching person mode. Person section headers
render the owner avatar and profile label; the mock dev server now
advertises the multi-identity policy and carries explicit row owners the
way the gateway projects createdActor fallbacks.

* feat(ui): gate sessions-page Person grouping on the identity capability

Mirrors the sidebar: the Person option hides without
hasMultipleSessionSharingIdentities and a stored Person preference
renders as None until the capability returns.
This commit is contained in:
Peter Steinberger
2026-08-21 13:21:43 -07:00
committed by GitHub
parent 7909ea6983
commit 3ea90bdcdb
14 changed files with 360 additions and 52 deletions
+8 -1
View File
@@ -65,7 +65,7 @@ const MOCK_ACTOR_MIRA: SessionActorFixture = {
id: "profile-mira",
label: "Mira",
};
// These actors are also the effective owners because the mock rows are unassigned.
// Rows carry explicit owners the way the gateway projects createdActor fallbacks.
const MOCK_SESSION_OWNERS: readonly SessionActorFixture[] = [MOCK_ACTOR_PETER, MOCK_ACTOR_MIRA];
const SESSION_PAGE_SIZE = 50;
@@ -1531,6 +1531,7 @@ async function createChatPickerScenario(
sessionRow(NARRATION_DEMO_SESSION_KEY, "Sidebar narration demo", baseTime - 15_000, {
createdActor: MOCK_ACTOR_MIRA,
hasActiveRun: true,
owner: { actor: MOCK_ACTOR_MIRA },
startedAt: baseTime - 45_000,
status: "running",
}),
@@ -1544,10 +1545,12 @@ async function createChatPickerScenario(
category: "Research",
createdActor: MOCK_ACTOR_MIRA,
execCwd: "/Users/peter/Projects/clawdbot",
owner: { actor: MOCK_ACTOR_MIRA },
}),
sessionRow("agent:main:model-budget", "Model budget review", baseTime - 80_000, {
category: "Research",
execCwd: "/Users/peter/Projects/openclaw",
owner: { actor: { type: "human", id: "presence-riley", label: "Riley" } },
status: "failed",
lastRunError: "Model out of credits: openai/gpt-5.6",
}),
@@ -1555,6 +1558,7 @@ async function createChatPickerScenario(
createdActor: MOCK_ACTOR_PETER,
execCwd: "/Users/peter/Work/openclaw",
lastReadAt: baseTime - 120_000,
owner: { actor: MOCK_ACTOR_PETER },
observerDigest: {
headline: "Done: fixed the flaky retry-window test",
health: "done",
@@ -1787,6 +1791,9 @@ async function createChatPickerScenario(
// Terminal has a second gate beyond the advertised method (see
// ui/src/lib/terminal-availability.ts).
terminalEnabled: true,
// The mock rows span several owners; advertise the multi-identity policy
// so people-aware UI (People sort, Person grouping) is exercisable here.
hasMultipleSessionSharingIdentities: true,
historyMessages,
// Lights up the footer facepile and who's-online roster; the email-only
// entry keeps the roster's no-display-name row exercised.
@@ -59,17 +59,28 @@ function renderSessionSection(params: {
const { host, section } = params;
const totalRowCount = section.totalRowCount;
const group = section.category;
const personOwner = section.personOwner;
// Pinned rows render in the nav zone; renderHeader records whether this list
// section owns collapse UI or sits directly below the global toolbar.
const collapsed = section.renderHeader && host.collapsedSessionSections.has(section.id);
const label = section.groups
? t("chat.sidebar.groups")
: section.work
? t("chat.sidebar.coding")
: group
? group
: t("chat.sidebar.otherSessions");
const zone = section.groups ? "groups" : section.work ? "coding" : group ? "category" : "threads";
const label = personOwner
? personOwner.label || personOwner.id
: section.groups
? t("chat.sidebar.groups")
: section.work
? t("chat.sidebar.coding")
: group
? group
: t("chat.sidebar.otherSessions");
const zone = personOwner
? "person"
: section.groups
? "groups"
: section.work
? "coding"
: group
? "category"
: "threads";
// Collapsed Coding still signals live runs so background work stays visible.
const collapsedRunningDot =
collapsed &&
@@ -83,6 +94,7 @@ function renderSessionSection(params: {
method: "sessions.groups.put",
requiredScope: "operator.write",
});
const sectionDropEnabled = groupWriteAccess.allowed && !personOwner;
const sectionClass = [
"sidebar-recent-sessions__group",
`sidebar-recent-sessions__group--zone-${zone}`,
@@ -103,19 +115,21 @@ function renderSessionSection(params: {
<div
class=${sectionClass}
data-session-section=${section.id}
@dragover=${groupWriteAccess.allowed
data-zone=${zone}
@dragover=${sectionDropEnabled
? (event: DragEvent) => host.sectionDragOver(event, section.id, group)
: nothing}
@dragleave=${groupWriteAccess.allowed
@dragleave=${sectionDropEnabled
? (event: DragEvent) => host.sectionDragLeave(event, section.id, group)
: nothing}
@drop=${groupWriteAccess.allowed
@drop=${sectionDropEnabled
? (event: DragEvent) => host.sectionDrop(event, section.id, group)
: nothing}
>
${section.renderHeader
? renderSidebarSessionSectionHeader({
sectionId: section.id,
draggable: !personOwner,
disabledReason: groupWriteAccess.allowed ? undefined : groupWriteAccess.reason,
onStartDrag: (sectionId) => host.startSidebarSectionDrag(sectionId),
onFinishDrag: () => host.finishSidebarSectionDrag(),
@@ -138,6 +152,19 @@ function renderSessionSection(params: {
>${collapsed ? icons.chevronRight : icons.chevronDown}</span
>
</span>
${personOwner
? html`<openclaw-viewer-avatar
.user=${{
id: personOwner.id,
name: personOwner.label,
avatarUrl: personOwner.avatarUrl,
watchedSessions: [],
}}
.markAsViewer=${false}
variant="session"
aria-hidden="true"
></openclaw-viewer-avatar>`
: nothing}
<span class="sidebar-recent-sessions__label-text">${label}</span>
${collapsed && totalRowCount > 0
? html`<span class="sidebar-session-group-count">${totalRowCount}</span>`
@@ -294,6 +294,7 @@ export function renderSidebarSessionSortMenu(params: {
}
const groupingOptions = [
{ grouping: "category", label: t("sessionsView.groupByCategory") },
{ grouping: "person", label: t("sessionsView.groupByPerson") },
{ grouping: "none", label: t("sessionsView.groupByNone") },
] as const satisfies ReadonlyArray<{ grouping: SidebarSessionsGrouping; label: string }>;
return keyed(
@@ -336,13 +337,15 @@ export function renderSidebarSessionSortMenu(params: {
>
${renderSidebarMenuTrigger(position, t("chat.sidebar.sortSessions"))}
<div class="sidebar-session-sort-menu__title">${t("sessionsView.groupBy")}</div>
${groupingOptions.map((option) =>
renderSidebarMenuRadioItem({
value: `grouping:${option.grouping}`,
checked: params.grouping === option.grouping,
label: option.label,
}),
)}
${groupingOptions
.filter((option) => option.grouping !== "person" || params.peopleSortAvailable)
.map((option) =>
renderSidebarMenuRadioItem({
value: `grouping:${option.grouping}`,
checked: params.grouping === option.grouping,
label: option.label,
}),
)}
<div class="session-menu__separator" role="separator"></div>
<div class="sidebar-session-sort-menu__title">${t("chat.sidebar.sortBy")}</div>
${SIDEBAR_SESSION_SORT_OPTIONS.filter(
@@ -308,18 +308,16 @@ export function partitionSidebarVisibleSections(input: {
rows: SidebarRecentSession[];
grouping: SidebarSessionsGrouping;
knownGroups: string[] | undefined;
selfOwnerId?: string | null;
catalogIds?: readonly string[];
sectionOrder?: readonly string[];
collapsedSections: ReadonlySet<string>;
hideEmptyOwnerFilteredGroup: (category: string | undefined, rowCount: number) => boolean;
visibleSessionLimits: ReadonlyMap<string, number>;
}): SidebarVisibleSections {
const sections = groupSidebarSessionRows(input.rows, {
grouping: input.grouping,
knownGroups: input.knownGroups,
sectionOrder: input.sectionOrder,
catalogIds: input.catalogIds,
}).filter(
const { grouping, knownGroups, selfOwnerId, sectionOrder, catalogIds } = input;
const sectionOptions = { grouping, knownGroups, selfOwnerId, sectionOrder, catalogIds };
const sections = groupSidebarSessionRows(input.rows, sectionOptions).filter(
(section) =>
section.id !== "pinned" &&
!input.hideEmptyOwnerFilteredGroup(section.category, section.rows.length),
@@ -120,6 +120,13 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
return resolveSidebarSessionSortMode(this.sessionSortMode, this.sessionPeopleSortAvailable());
}
effectiveSessionsGrouping(): SidebarSessionsGrouping {
// Reconnects temporarily hide the capability; retain the stored Person
// preference so it returns when the authoritative identity policy does.
const grouping = this.sessionsGrouping;
return grouping === "person" && !this.sessionPeopleSortAvailable() ? "category" : grouping;
}
setSessionSortMode(mode: SidebarSessionSortMode) {
this.sessionSortMode = storeSidebarSessionSortMode(mode, this.sessionPeopleSortCapability());
}
@@ -331,10 +338,12 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
/** Collapsed zones keep full rows for true header counts and status dots. */
protected zonedVisibleSections(rows: SidebarRecentSession[]): SidebarVisibleSections {
const grouping = this.effectiveSessionsGrouping();
return partitionSidebarVisibleSections({
rows,
grouping: this.sessionsGrouping,
knownGroups: this.sessionsGrouping === "category" ? this.knownSessionGroups() : [],
grouping,
knownGroups: grouping === "category" ? this.knownSessionGroups() : [],
selfOwnerId: this.context?.gateway.snapshot.selfUser?.id ?? null,
// Normalize gateway order without dropping catalog-lagging categories.
sectionOrder: this.knownSectionOrder(),
catalogIds:
@@ -381,13 +390,10 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
const rows = this.selectedAgentSessionRows(navigationState);
const { visibleRows } = this.zonedVisibleSections(rows);
const pinnedByKey = new Map(rows.filter((row) => row.pinned).map((row) => [row.key, row]));
const pinnedRows = this.reconciledSidebarZone().entries.flatMap((entry) =>
entry.type === "session"
? pinnedByKey.get(entry.key)
? [pinnedByKey.get(entry.key)!]
: []
: [],
);
const pinnedRows = this.reconciledSidebarZone().entries.flatMap((entry) => {
const row = entry.type === "session" ? pinnedByKey.get(entry.key) : undefined;
return row ? [row] : [];
});
return [...pinnedRows, ...visibleRows];
}
@@ -486,9 +492,7 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
expandedAgentId(): string {
const selected = normalizeOptionalString(this.context?.agentSelection.state.selectedId);
return selected
? normalizeAgentId(selected)
: normalizeAgentId(this.getSessionNavigationState().selectedAgentId);
return normalizeAgentId(selected || this.getSessionNavigationState().selectedAgentId);
}
activeChipAgent() {
@@ -509,11 +513,8 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
}
private agentResumeKey(agentId: string): string {
return resolveSidebarAgentResumeKey(
this.latestAgentSessionRow(agentId),
agentId,
this.sessionMainKey(),
);
const latest = this.latestAgentSessionRow(agentId);
return resolveSidebarAgentResumeKey(latest, agentId, this.sessionMainKey());
}
/** Offline routes to Settings instead of a dead chat load. */
@@ -17,6 +17,7 @@ import {
sessionPullRequestsForGateway,
} from "../lib/session-pull-requests.ts";
import type { CatalogProjectGrouping } from "../lib/sessions/catalog-project-grouping.ts";
import type { SidebarSessionsGrouping } from "../lib/sessions/grouping.ts";
import { sessionNavigationTarget } from "../lib/sessions/route-navigation.ts";
import { parseAgentSessionKey } from "../lib/sessions/session-key.ts";
import { SidebarCatalogMenuController } from "./app-sidebar-catalog-menu.ts";
@@ -117,6 +118,7 @@ interface SidebarMenusControllerHost
hideSessionCatalog(catalogId: string): void;
sessionSortMode: SidebarSessionSortMode;
effectiveSessionSortMode(): SidebarSessionSortMode;
effectiveSessionsGrouping(): SidebarSessionsGrouping;
sessionPeopleSortAvailable(): boolean;
setSessionSortMode(mode: SidebarSessionSortMode): void;
readonly terminalAvailable: boolean;
+1 -1
View File
@@ -380,7 +380,7 @@ export function renderSidebarSessionSortMenuForController(controller: SidebarMen
return renderSidebarSessionSortMenu({
position,
trigger: controller.sessionSortMenuTrigger,
grouping: host.sessionsGrouping,
grouping: host.effectiveSessionsGrouping(),
sortMode: host.effectiveSessionSortMode(),
peopleSortAvailable: host.sessionPeopleSortAvailable(),
statusFilter: host.sessionsStatusFilter,
+1
View File
@@ -1164,6 +1164,7 @@ export const en: TranslationMap = {
groupBy: "Group by",
groupByNone: "None",
groupByCategory: "Custom groups",
groupByPerson: "Person",
showSessionPreview: "Show message preview",
showCronSessions: "Show automation sessions",
showSystemSessions: "Show system sessions",
+80 -1
View File
@@ -82,6 +82,68 @@ describe("groupSidebarSessionRows", () => {
expect(sections[1]?.rows.map((item) => item.key)).toEqual(["tg"]);
});
it("orders owner sections before stored zones and leaves ownerless rows in their smart zones", () => {
const sections = groupSidebarSessionRows(
[
row({ key: "agent", owner: { actor: { type: "agent", id: "agent-z", label: "Zed" } } }),
row({
key: "owned-group",
kind: "group",
category: "Ignored",
owner: { actor: { type: "human", id: "profile-b", label: "Bea" } },
}),
row({ key: "thread" }),
row({ key: "group", kind: "group" }),
row({ key: "work", workSession: true }),
row({ key: "human-a", owner: { actor: { type: "human", id: "profile-a", label: "Ada" } } }),
row({
key: "self",
owner: {
actor: {
type: "human",
id: "profile-self",
label: "Zoe",
avatarUrl: "/avatars/self",
},
},
}),
row({ key: "agent-a", owner: { actor: { type: "agent", id: "agent-a", label: "Alpha" } } }),
row({ key: "blank-owner", owner: { actor: { type: "human", id: " " } } }),
row({ key: "pinned", pinned: true, owner: { actor: { type: "human", id: "profile-a" } } }),
],
{
grouping: "person",
selfOwnerId: "profile-self",
knownGroups: ["Ignored"],
catalogIds: ["catalog"],
sectionOrder: ["work", "person:profile-b", "groups", "ungrouped", "catalog:catalog"],
},
);
expect(sections.map((section) => section.id)).toEqual([
"pinned",
"person:profile-self",
"person:profile-a",
"person:profile-b",
"person:agent-a",
"person:agent-z",
"work",
"groups",
"ungrouped",
"catalog:catalog",
]);
expect(sections[1]?.personOwner).toEqual({
type: "human",
id: "profile-self",
label: "Zoe",
avatarUrl: "/avatars/self",
});
expect(sections[3]?.rows.map((item) => item.key)).toEqual(["owned-group"]);
expect(sections[6]?.rows.map((item) => item.key)).toEqual(["work"]);
expect(sections[7]?.rows.map((item) => item.key)).toEqual(["group"]);
expect(sections[8]?.rows.map((item) => item.key)).toEqual(["thread", "blank-owner"]);
});
it("always emits threads and coding so the renderer can host fallbacks and catalogs", () => {
expect(groupSidebarSessionRows([row({ key: "a" })]).map((section) => section.id)).toEqual([
"ungrouped",
@@ -302,8 +364,9 @@ describe("moveSessionSection", () => {
});
describe("normalizeSidebarSessionsGrouping", () => {
it("accepts none and falls back to category grouping", () => {
it("accepts supported modes and falls back to category grouping", () => {
expect(normalizeSidebarSessionsGrouping("none")).toBe("none");
expect(normalizeSidebarSessionsGrouping("person")).toBe("person");
expect(normalizeSidebarSessionsGrouping("category")).toBe("category");
expect(normalizeSidebarSessionsGrouping(null)).toBe("category");
expect(normalizeSidebarSessionsGrouping("bogus")).toBe("category");
@@ -329,6 +392,7 @@ function row(
describe("normalizeSessionsGroupBy", () => {
it("accepts known modes and falls back to none", () => {
expect(normalizeSessionsGroupBy("category")).toBe("category");
expect(normalizeSessionsGroupBy("person")).toBe("person");
expect(normalizeSessionsGroupBy("date")).toBe("date");
expect(normalizeSessionsGroupBy("bogus")).toBe("none");
expect(normalizeSessionsGroupBy(null)).toBe("none");
@@ -362,6 +426,21 @@ describe("groupSessionRows", () => {
expect(groups.map((group) => group.id)).toEqual(["discord", "telegram", UNGROUPED_ID]);
});
it("groups sessions by their durable owner identity and leaves ownerless sessions last", () => {
const groups = groupSessionRows({
rows: [
row({ key: "bob", owner: { actor: { type: "human", id: "profile-b", label: "Bob" } } }),
row({ key: "ownerless" }),
row({ key: "ada", owner: { actor: { type: "human", id: " profile-a ", label: "Ada" } } }),
row({ key: "blank", owner: { actor: { type: "human", id: " " } } }),
],
mode: "person",
});
expect(groups.map((group) => group.id)).toEqual(["profile-a", "profile-b", UNGROUPED_ID]);
expect(groups[2]?.rows.map((item) => item.key)).toEqual(["ownerless", "blank"]);
});
it("preserves row order within a group", () => {
const rows = [
row({ key: "agent:main:discord:channel:1" }),
+53 -4
View File
@@ -6,6 +6,7 @@ import { parseAgentSessionKey, parseSessionKeyParts } from "./session-key.ts";
export const SESSION_GROUP_MODES = [
"none",
"category",
"person",
"channel",
"kind",
"agent",
@@ -25,8 +26,16 @@ export type SessionRowGroup = {
};
export type SidebarSessionSection<Row> = {
id: "pinned" | "ungrouped" | "groups" | "work" | `category:${string}` | `catalog:${string}`;
id:
| "pinned"
| "ungrouped"
| "groups"
| "work"
| `category:${string}`
| `person:${string}`
| `catalog:${string}`;
category?: string;
personOwner?: { type: string; id: string; label?: string; avatarUrl?: string };
/** Built-in smart group-conversation section (kind "group" rows). */
groups?: boolean;
/** Built-in smart coding section (worktree/exec-node/ACP sessions). */
@@ -128,6 +137,8 @@ function resolveSessionGroupId(row: GatewaySessionRow, mode: SessionsGroupBy, no
switch (mode) {
case "category":
return row.category?.trim() ?? UNGROUPED_ID;
case "person":
return row.owner?.actor.id?.trim() || UNGROUPED_ID;
case "channel":
return sessionRowChannel(row);
case "kind":
@@ -169,16 +180,17 @@ export function groupSessionRows(params: {
return ids.map((id) => ({ id, rows: byId.get(id) ?? [] }));
}
/** How the sidebar buckets non-pinned rows: category sections or one flat list. */
export type SidebarSessionsGrouping = "category" | "none";
/** How the sidebar buckets non-pinned rows before its built-in smart zones. */
export type SidebarSessionsGrouping = "category" | "person" | "none";
export function normalizeSidebarSessionsGrouping(raw: unknown): SidebarSessionsGrouping {
return raw === "none" ? "none" : "category";
return raw === "none" || raw === "person" ? raw : "category";
}
type SidebarGroupableRow = {
pinned?: boolean;
category?: string | null;
owner?: { actor: { type: string; id?: string; label?: string; avatarUrl?: string } };
/** Session kind from the gateway row; "group" rows form the Groups zone. */
kind?: string;
/** Session bound to a managed worktree or exec node (Coding zone). */
@@ -216,6 +228,7 @@ export function groupSidebarSessionRows<Row extends SidebarGroupableRow>(
options: {
knownGroups?: readonly string[];
grouping?: SidebarSessionsGrouping;
selfOwnerId?: string | null;
sectionOrder?: readonly string[];
catalogIds?: readonly string[];
} = {},
@@ -226,6 +239,7 @@ export function groupSidebarSessionRows<Row extends SidebarGroupableRow>(
const groups: Row[] = [];
const coding: Row[] = [];
const categories = new Map<string, Row[]>();
const people = new Map<string, SidebarSessionSection<Row>>();
if (grouping === "category") {
for (const name of options.knownGroups ?? []) {
const trimmed = name.trim();
@@ -239,6 +253,26 @@ export function groupSidebarSessionRows<Row extends SidebarGroupableRow>(
pinned.push(row);
continue;
}
const owner = grouping === "person" ? row.owner?.actor : undefined;
const ownerId = owner?.id?.trim();
if (owner && ownerId) {
const personSection = people.get(ownerId);
if (personSection) {
personSection.rows.push(row);
} else {
people.set(ownerId, {
id: `person:${ownerId}`,
personOwner: {
type: owner.type,
id: ownerId,
...(owner.label ? { label: owner.label } : {}),
...(owner.avatarUrl ? { avatarUrl: owner.avatarUrl } : {}),
},
rows: [row],
});
}
continue;
}
const category = grouping === "category" ? row.category?.trim() : undefined;
if (category) {
const categoryRows = categories.get(category);
@@ -264,6 +298,21 @@ export function groupSidebarSessionRows<Row extends SidebarGroupableRow>(
if (pinned.length > 0) {
sections.push({ id: "pinned", rows: pinned });
}
sections.push(
...[...people.values()].toSorted((left, right) => {
const leftOwner = left.personOwner!;
const rightOwner = right.personOwner!;
const leftRank =
leftOwner.id === options.selfOwnerId ? 0 : leftOwner.type === "agent" ? 2 : 1;
const rightRank =
rightOwner.id === options.selfOwnerId ? 0 : rightOwner.type === "agent" ? 2 : 1;
return (
leftRank - rightRank ||
(leftOwner.label || leftOwner.id).localeCompare(rightOwner.label || rightOwner.id) ||
leftOwner.id.localeCompare(rightOwner.id)
);
}),
);
const knownGroups = [
...new Set((options.knownGroups ?? []).map((name) => name.trim()).filter(Boolean)),
];
+6 -1
View File
@@ -1510,6 +1510,8 @@ class SessionsPage extends OpenClawLightDomElement {
override render() {
const context = this.context;
const personGroupingAvailable =
context?.gateway.snapshot.hello?.policy?.hasMultipleSessionSharingIdentities === true;
if (!context) {
return html``;
}
@@ -1559,7 +1561,10 @@ class SessionsPage extends OpenClawLightDomElement {
),
sortColumn: this.sortColumn,
sortDir: this.sortDir,
groupBy: this.groupBy,
// Same reconnect resilience as the sidebar: the stored Person
// preference survives a temporarily hidden identity capability.
groupBy: personGroupingAvailable || this.groupBy !== "person" ? this.groupBy : "none",
personGroupingAvailable,
knownCategories: this.knownCategories(),
page: this.page,
pageSize: this.pageSize,
+52
View File
@@ -49,6 +49,7 @@ function buildProps(result: SessionsListResult): SessionsProps {
sortColumn: "updated",
sortDir: "desc",
groupBy: "none",
personGroupingAvailable: true,
knownCategories: [],
page: 0,
pageSize: 10,
@@ -482,6 +483,57 @@ describe("sessions view", () => {
expect(container.querySelectorAll(".session-data-row")).toHaveLength(1);
});
it("offers person grouping and labels owner sections from their durable profile", async () => {
const container = document.createElement("div");
render(
renderSessions({
...buildProps(
buildMultiResult([
{
key: "agent:main:ada",
kind: "direct",
updatedAt: 2,
owner: { actor: { type: "human", id: "profile-ada", label: "Ada Lovelace" } },
},
{ key: "agent:main:ownerless", kind: "direct", updatedAt: 1 },
]),
),
groupBy: "person",
}),
container,
);
await Promise.resolve();
expect(
container
.querySelector<HTMLOptionElement>('.session-groupby__select option[value="person"]')
?.textContent?.trim(),
).toBe("Person");
expect(
[...container.querySelectorAll(".session-group-row__label")].map((label) =>
label.textContent?.trim(),
),
).toEqual(["Ada Lovelace", "Ungrouped"]);
});
it("hides the person grouping option without the identity capability", async () => {
const container = document.createElement("div");
render(
renderSessions({
...buildProps(buildResult({ key: "agent:main:a", kind: "direct", updatedAt: 1 })),
personGroupingAvailable: false,
}),
container,
);
await Promise.resolve();
const modes = [
...container.querySelectorAll<HTMLOptionElement>(".session-groupby__select option"),
].map((option) => option.value);
expect(modes).not.toContain("person");
expect(modes).toContain("category");
});
it("selects and names the current page size on first render", async () => {
const container = document.createElement("div");
render(
+12 -3
View File
@@ -90,6 +90,8 @@ export type SessionsProps = {
sortColumn: "key" | "kind" | "updated" | "tokens";
sortDir: "asc" | "desc";
groupBy: SessionsGroupBy;
/** Multi-identity gateways only; hides the Person mode elsewhere. */
personGroupingAvailable: boolean;
knownCategories: string[];
page: number;
pageSize: number;
@@ -780,6 +782,7 @@ function sessionsTableColumnCount(props: SessionsProps): number {
const SESSION_GROUP_MODE_LABELS = {
none: "sessionsView.groupByNone",
category: "sessionsView.groupByCategory",
person: "sessionsView.groupByPerson",
channel: "sessionsView.groupByChannel",
kind: "sessionsView.groupByKind",
agent: "sessionsView.groupByAgent",
@@ -790,7 +793,8 @@ function groupModeLabel(mode: SessionsGroupBy): string {
return t(SESSION_GROUP_MODE_LABELS[mode] ?? SESSION_GROUP_MODE_LABELS.none);
}
function sessionGroupLabel(id: string, props: SessionsProps): string {
function sessionGroupLabel(group: SessionRowGroup, props: SessionsProps): string {
const { id } = group;
if (props.groupBy === "date") {
const labels: Record<string, string> = {
today: "sessionsView.dateToday",
@@ -811,6 +815,9 @@ function sessionGroupLabel(id: string, props: SessionsProps): string {
return emoji ? `${emoji} ${name}` : name;
}
}
if (props.groupBy === "person") {
return group.rows[0]?.owner?.actor.label?.trim() || id;
}
return id;
}
@@ -856,7 +863,7 @@ function categoryDropHandlers(props: SessionsProps, category: string | null) {
}
function renderGroupHeaderRow(group: SessionRowGroup, props: SessionsProps) {
const label = sessionGroupLabel(group.id, props);
const label = sessionGroupLabel(group, props);
const count =
group.rows.length === 1
? t("sessionsView.groupRowCountOne", { count: "1" })
@@ -1215,7 +1222,9 @@ function renderSessionsTable(props: SessionsProps, ctx: SessionsTableContext) {
@change=${(e: Event) =>
props.onGroupByChange((e.target as HTMLSelectElement).value as SessionsGroupBy)}
>
${SESSION_GROUP_MODES.map(
${SESSION_GROUP_MODES.filter(
(mode) => mode !== "person" || props.personGroupingAvailable,
).map(
(mode) =>
html`<option value=${mode} ?selected=${props.groupBy === mode}>
${groupModeLabel(mode)}
@@ -432,6 +432,81 @@ describe("AppSidebar session ownership", () => {
expect(menu.querySelector('[value="sort:created"]')?.getAttribute("aria-checked")).toBe("true");
});
it("groups sessions by owner only while the identity capability is available", async () => {
const gateway = createGatewayHarness({} as GatewayBrowserClient);
gateway.publish({ selfUser: { id: "profile-zoe", name: "Zoe" } });
const harness = createSessionsHarness("main", [
"agent:main:main",
"agent:main:ada",
"agent:main:zoe",
]);
const result = harness.sessions.state.result;
const ada = result?.sessions.find((row) => row.key.endsWith(":ada"));
const zoe = result?.sessions.find((row) => row.key.endsWith(":zoe"));
if (!result || !ada || !zoe) {
throw new Error("expected owner rows");
}
setEffectiveOwner(ada, { type: "human", id: "profile-ada", label: "Ada" });
setEffectiveOwner(zoe, {
type: "human",
id: "profile-zoe",
label: "Zoe",
avatarUrl: "/avatars/zoe",
});
result.owners = [
{ type: "human", id: "profile-ada", label: "Ada" },
{ type: "human", id: "profile-zoe", label: "Zoe" },
];
const { sidebar } = await mountSidebar(gateway.gateway, harness.sessions);
harness.publishList({ result, agentId: "main" });
await sidebar.updateComplete;
let menu = await openOwnerMenu(sidebar);
expect(menu.querySelector('[value="grouping:person"]')).toBeNull();
menu.dispatchEvent(new Event("wa-after-hide", { bubbles: true }));
await sidebar.updateComplete;
gateway.publish({ hello: sessionSharingHello(true) });
await sidebar.updateComplete;
await selectSessionMenuValue(sidebar, "grouping:person");
const ownerSections = () => [
...sidebar.querySelectorAll<HTMLElement>('[data-session-section^="person:"]'),
];
expect(ownerSections().map((section) => section.dataset.sessionSection)).toEqual([
"person:profile-zoe",
"person:profile-ada",
]);
expect(
ownerSections()[0]?.querySelector(".sidebar-recent-sessions__label-text")?.textContent,
).toBe("Zoe");
expect(
ownerSections()[0]?.querySelector("openclaw-viewer-avatar")?.getAttribute("aria-hidden"),
).toBe("true");
expect(
ownerSections()[0]
?.querySelector(".sidebar-recent-sessions__head")
?.getAttribute("draggable"),
).toBe("false");
expect(ownerSections()[0]?.querySelector(".sidebar-session-group-actions")).toBeNull();
gateway.publish({ hello: null });
await sidebar.updateComplete;
expect(ownerSections()).toHaveLength(0);
menu = await openOwnerMenu(sidebar);
expect(menu.querySelector('[value="grouping:person"]')).toBeNull();
expect(menu.querySelector('[value="grouping:category"]')?.getAttribute("aria-checked")).toBe(
"true",
);
menu.dispatchEvent(new Event("wa-after-hide", { bubbles: true }));
await sidebar.updateComplete;
gateway.publish({ hello: sessionSharingHello(true) });
await sidebar.updateComplete;
expect(ownerSections()).toHaveLength(2);
});
it("shows archive attribution only in collaborative archived-session lists", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const harness = createSessionsHarness("main", [