mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
fix(ui): restore person grouping for multiple session owners (#130326)
* fix(ui): restore person grouping for multiple session owners Use the live session-owner roster for Person grouping and People sorting instead of the connection-time identity policy. Preserve preferences while the roster is unknown and retain single-owner dormancy across sidebar and Sessions page. * docs(ui): match the owner sorting label * refactor(ui): reuse session owner and menu selection paths
This commit is contained in:
committed by
GitHub
parent
90f8598395
commit
d9b41332e4
@@ -46,7 +46,7 @@ The sidebar's session filter menu gains an **Owners** section when ownership is
|
||||
- A specific person or agent shows the sessions they currently own.
|
||||
- **Involving me** shows sessions you own plus sessions where you have prompted at least once. This filter is evaluated by the Gateway against the full participant history and matches only your authenticated profile identity — channel-native sender ids are display-only and never match, so a numeric channel id cannot collide with your profile.
|
||||
|
||||
**Involving me** requires a signed-in Gateway profile. The **People** sort mode groups sessions by current owner and orders the groups by name.
|
||||
**Involving me** requires a signed-in Gateway profile. When the loaded sessions have multiple owners, **Group by Person** creates a section for each current owner, and the **Owners** sort mode orders those owner groups by name.
|
||||
|
||||
## Reading the avatars
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -116,7 +116,8 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
|
||||
});
|
||||
|
||||
private sessionPeopleSortCapability(): boolean | undefined {
|
||||
return this.context?.gateway.snapshot.hello?.policy?.hasMultipleSessionSharingIdentities;
|
||||
const owners = this.selectedAgentSessionResult()?.owners;
|
||||
return owners ? owners.length >= 2 : undefined;
|
||||
}
|
||||
|
||||
sessionPeopleSortAvailable(): boolean {
|
||||
@@ -124,14 +125,14 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
|
||||
}
|
||||
|
||||
effectiveSessionSortMode(): SidebarSessionSortMode {
|
||||
// A reconnect can temporarily hide the capability. Render Created without
|
||||
// discarding People until an authoritative single-identity hello arrives.
|
||||
// A refresh can temporarily invalidate the owner facet. Render Created
|
||||
// without discarding People until an authoritative single-owner list arrives.
|
||||
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.
|
||||
// Refreshes can temporarily invalidate the owner facet; retain the Person
|
||||
// preference so it returns with the authoritative multi-owner list.
|
||||
const grouping = this.sessionsGrouping;
|
||||
return grouping === "person" && !this.sessionPeopleSortAvailable() ? "category" : grouping;
|
||||
}
|
||||
@@ -717,11 +718,7 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
|
||||
) {
|
||||
projected.unshift(navigationState.toSidebarSession(selectedFallback));
|
||||
}
|
||||
const ownerFacet =
|
||||
selected === loadedAgentId
|
||||
? this.sessionData.sessionsResult?.owners
|
||||
: this.sessionData.sessionResultsByAgent[selected]?.owners;
|
||||
return this.applySessionOwnerFilter(projected, ownerFacet);
|
||||
return this.applySessionOwnerFilter(projected, this.selectedAgentSessionResult()?.owners);
|
||||
}
|
||||
|
||||
private selectedAgentSessionResult(): SessionsListResult | null {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import type { Page } from "playwright";
|
||||
import type { Locator, Page } from "playwright";
|
||||
import { expect as expectBrowser } from "playwright/test";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
|
||||
@@ -22,6 +22,18 @@ const suite = createControlUiE2eSuite({
|
||||
});
|
||||
|
||||
let page: Page | undefined;
|
||||
|
||||
async function selectMenuValue(menu: Locator, value: string) {
|
||||
await menu.evaluate((element, selectedValue) => {
|
||||
element.dispatchEvent(
|
||||
new CustomEvent("wa-select", {
|
||||
bubbles: true,
|
||||
detail: { item: { value: selectedValue } },
|
||||
}),
|
||||
);
|
||||
}, value);
|
||||
}
|
||||
|
||||
function sessionsList(owners: [string, string], withAvatars = false) {
|
||||
const ownerFacet = [
|
||||
{
|
||||
@@ -261,7 +273,7 @@ suite.define(() => {
|
||||
}
|
||||
});
|
||||
|
||||
it("shows permanent owner chips and filters existing custom groups", async () => {
|
||||
it("derives People controls and owner filtering from current session owners", async () => {
|
||||
if (captureUiProofEnabled) {
|
||||
await mkdir(sessionOwnerProofArtifactDir, { recursive: true });
|
||||
}
|
||||
@@ -284,7 +296,7 @@ suite.define(() => {
|
||||
{ id: "profile-bob", background: "#985b42", label: "B" },
|
||||
]);
|
||||
const gateway = await installMockGateway(currentPage, {
|
||||
hasMultipleSessionSharingIdentities: true,
|
||||
hasMultipleSessionSharingIdentities: false,
|
||||
sessionKey: "agent:main:ada",
|
||||
presenceUsers: [
|
||||
{
|
||||
@@ -306,7 +318,9 @@ suite.define(() => {
|
||||
await expect.poll(() => currentPage.locator("openclaw-session-owner-chip").count()).toBe(3);
|
||||
|
||||
const ownerMenu = await openSidebarSortMenu(currentPage);
|
||||
await ownerMenu.locator('[value="sort:people"]').waitFor();
|
||||
await captureUiProof(currentPage, "00-people-controls-from-session-owners.png");
|
||||
await expectBrowser(ownerMenu.locator('[value="grouping:person"]')).toBeVisible();
|
||||
await expectBrowser(ownerMenu.locator('[value="sort:people"]')).toBeVisible();
|
||||
const ownerRows = ownerMenu.locator('wa-dropdown-item[value^="owner:"]:not([value="owner:"])');
|
||||
await expectBrowser(ownerRows).toHaveCount(3);
|
||||
await expectBrowser(ownerRows.first()).toHaveAttribute("value", "owner:profile-patrick");
|
||||
@@ -315,14 +329,23 @@ suite.define(() => {
|
||||
const firstOwnerCenterDelta = await avatarLabelCenterDelta(ownerRows.first());
|
||||
await captureUiProof(currentPage, "00-people-sort-available.png");
|
||||
expect(firstOwnerCenterDelta).toBeLessThanOrEqual(0.5);
|
||||
await ownerMenu.evaluate((element) =>
|
||||
element.dispatchEvent(
|
||||
new CustomEvent("wa-select", {
|
||||
bubbles: true,
|
||||
detail: { item: { value: "sort:people" } },
|
||||
}),
|
||||
),
|
||||
await selectMenuValue(ownerMenu, "grouping:person");
|
||||
await expectBrowser(
|
||||
currentPage.locator('[data-session-section="person:profile-ada"]'),
|
||||
).toContainText("Ada research");
|
||||
await expectBrowser(
|
||||
currentPage.locator('[data-session-section="person:profile-bob"]'),
|
||||
).toContainText("Bob operations");
|
||||
|
||||
const groupedMenu = await openSidebarSortMenu(currentPage);
|
||||
await expectBrowser(groupedMenu.locator('[value="grouping:person"]')).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
await selectMenuValue(groupedMenu, "grouping:category");
|
||||
|
||||
const sortableMenu = await openSidebarSortMenu(currentPage);
|
||||
await selectMenuValue(sortableMenu, "sort:people");
|
||||
const peopleMenu = await openSidebarSortMenu(currentPage);
|
||||
await expectBrowser(peopleMenu.locator('[value="sort:people"]')).toHaveAttribute(
|
||||
"aria-checked",
|
||||
@@ -330,14 +353,7 @@ suite.define(() => {
|
||||
);
|
||||
await captureUiProof(currentPage, "01-people-sort-selected.png");
|
||||
await peopleMenu.locator('[value="owner:profile-ada"]').waitFor();
|
||||
await peopleMenu.evaluate((element) =>
|
||||
element.dispatchEvent(
|
||||
new CustomEvent("wa-select", {
|
||||
bubbles: true,
|
||||
detail: { item: { value: "owner:profile-ada" } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
await selectMenuValue(peopleMenu, "owner:profile-ada");
|
||||
await currentPage.getByText("Ada research", { exact: true }).first().waitFor();
|
||||
await expect
|
||||
.poll(() => currentPage.locator('[data-session-key="agent:main:bob"]').count())
|
||||
@@ -427,14 +443,7 @@ suite.define(() => {
|
||||
sessions: allSessions.sessions.filter((session) => session.key === "agent:main:ada"),
|
||||
});
|
||||
const menu = await openSidebarSortMenu(currentPage);
|
||||
await menu.evaluate((element) =>
|
||||
element.dispatchEvent(
|
||||
new CustomEvent("wa-select", {
|
||||
bubbles: true,
|
||||
detail: { item: { value: "involving-me" } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
await selectMenuValue(menu, "involving-me");
|
||||
await expect
|
||||
.poll(() => currentPage.locator('[data-session-key="agent:main:bob"]').count())
|
||||
.toBe(0);
|
||||
|
||||
@@ -39,6 +39,57 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("sessions page managed roster", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "offers person grouping for multiple session owners despite a single-identity handshake",
|
||||
ownerCount: 2,
|
||||
handshakeIdentities: false,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: "hides person grouping for one session owner despite a multiple-identity handshake",
|
||||
ownerCount: 1,
|
||||
handshakeIdentities: true,
|
||||
available: false,
|
||||
},
|
||||
{
|
||||
name: "hides person grouping without session owners despite a multiple-identity handshake",
|
||||
ownerCount: 0,
|
||||
handshakeIdentities: true,
|
||||
available: false,
|
||||
},
|
||||
])("$name", async ({ ownerCount, handshakeIdentities, available }) => {
|
||||
const mutableGateway = createGateway({} as GatewayBrowserClient);
|
||||
mutableGateway.emit({
|
||||
hello: {
|
||||
...sessionMutationGatewayHello(),
|
||||
policy: { hasMultipleSessionSharingIdentities: handshakeIdentities },
|
||||
},
|
||||
});
|
||||
const managed = createManagedSessions();
|
||||
const context = createContext(mutableGateway.gateway, managed.sessions);
|
||||
const owners = [
|
||||
{ type: "human" as const, id: "profile-ada", label: "Ada Lovelace" },
|
||||
{ type: "human" as const, id: "profile-bob", label: "Bob Rivera" },
|
||||
].slice(0, ownerCount);
|
||||
const page = await createRenderedPage(context, {
|
||||
ts: 0,
|
||||
path: "(multiple)",
|
||||
count: owners.length,
|
||||
defaults: { modelProvider: null, model: null, contextTokens: null },
|
||||
owners,
|
||||
sessions: owners.map((owner, index) => ({
|
||||
key: `agent:main:${owner.id}`,
|
||||
kind: "direct",
|
||||
updatedAt: index,
|
||||
owner: { actor: owner },
|
||||
})),
|
||||
});
|
||||
|
||||
const personOption = page.querySelector('.session-groupby__select option[value="person"]');
|
||||
expect(personOption !== null).toBe(available);
|
||||
});
|
||||
|
||||
it("rejects route data from an earlier same-client connection epoch", async () => {
|
||||
const client = {} as GatewayBrowserClient;
|
||||
const mutableGateway = createGateway(client);
|
||||
|
||||
@@ -1511,8 +1511,7 @@ class SessionsPage extends OpenClawLightDomElement {
|
||||
|
||||
override render() {
|
||||
const context = this.context;
|
||||
const personGroupingAvailable =
|
||||
context?.gateway.snapshot.hello?.policy?.hasMultipleSessionSharingIdentities === true;
|
||||
const personGroupingAvailable = (this.result?.owners?.length ?? 0) > 1;
|
||||
if (!context) {
|
||||
return html``;
|
||||
}
|
||||
@@ -1563,7 +1562,7 @@ class SessionsPage extends OpenClawLightDomElement {
|
||||
sortColumn: this.sortColumn,
|
||||
sortDir: this.sortDir,
|
||||
// Same reconnect resilience as the sidebar: the stored Person
|
||||
// preference survives a temporarily hidden identity capability.
|
||||
// preference survives a temporarily unavailable owner roster.
|
||||
groupBy: personGroupingAvailable || this.groupBy !== "person" ? this.groupBy : "none",
|
||||
personGroupingAvailable,
|
||||
knownCategories: this.knownCategories(),
|
||||
|
||||
@@ -372,8 +372,9 @@ describe("AppSidebar session ownership", () => {
|
||||
expect(sidebar.querySelector("openclaw-session-owner-chip")).toBeNull();
|
||||
});
|
||||
|
||||
it("owns People availability and fallback at the server identity capability", async () => {
|
||||
it("owns People availability and fallback at the live session-owner roster", async () => {
|
||||
const gateway = createGatewayHarness({} as GatewayBrowserClient);
|
||||
gateway.publish({ hello: sessionSharingHello(true) });
|
||||
const keys = ["main", "b1", "a1", "b2", "a2"].map((id) => `agent:main:${id}`);
|
||||
const harness = createSessionsHarness("main", keys);
|
||||
const result = harness.sessions.state.result;
|
||||
@@ -409,31 +410,29 @@ describe("AppSidebar session ownership", () => {
|
||||
menu.dispatchEvent(new Event("wa-after-hide", { bubbles: true }));
|
||||
await sidebar.updateComplete;
|
||||
|
||||
gateway.publish({ hello: sessionSharingHello(true) });
|
||||
result.owners = [
|
||||
{ type: "human", id: "profile-ada", label: "Ada" },
|
||||
{ type: "human", id: "profile-bob", label: "Bob" },
|
||||
];
|
||||
gateway.publish({ hello: sessionSharingHello(false) });
|
||||
harness.publishList({ result, agentId: "main" });
|
||||
await sidebar.updateComplete;
|
||||
await expectSort(sidebar, "people", peopleOrder);
|
||||
|
||||
gateway.publish({ hello: null });
|
||||
await sidebar.updateComplete;
|
||||
menu = await openOwnerMenu(sidebar);
|
||||
expect(menu.querySelector('[value="sort:people"]')).toBeNull();
|
||||
expect(menu.querySelector('[value="sort:created"]')?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(menu.querySelector('[value="sort:people"]')?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(visibleSessionKeys(sidebar)).toEqual(peopleOrder);
|
||||
menu.dispatchEvent(new Event("wa-after-hide", { bubbles: true }));
|
||||
await sidebar.updateComplete;
|
||||
|
||||
gateway.publish({ hello: sessionSharingHello(true) });
|
||||
await sidebar.updateComplete;
|
||||
expect(visibleSessionKeys(sidebar)).toEqual(peopleOrder);
|
||||
|
||||
await expectSort(sidebar, "updated", updatedOrder);
|
||||
await expectSort(sidebar, "created", createdOrder);
|
||||
await expectSort(sidebar, "people", peopleOrder);
|
||||
result.owners = [
|
||||
{ type: "human", id: "profile-ada", label: "Ada" },
|
||||
{ type: "human", id: "profile-bob", label: "Bob" },
|
||||
];
|
||||
result.owners = [{ type: "human", id: "profile-bob", label: "Bob" }];
|
||||
gateway.publish({ hello: sessionSharingHello(true) });
|
||||
harness.publishList({ result, agentId: "main" });
|
||||
gateway.publish({ hello: sessionSharingHello(false) });
|
||||
await sidebar.updateComplete;
|
||||
await sidebar.updateComplete;
|
||||
|
||||
@@ -441,16 +440,24 @@ describe("AppSidebar session ownership", () => {
|
||||
expect(menu.querySelector('[value="sort:people"]')).toBeNull();
|
||||
expect(menu.querySelector('[value="sort:created"]')?.getAttribute("aria-checked")).toBe("true");
|
||||
menu.dispatchEvent(new Event("wa-after-hide", { bubbles: true }));
|
||||
gateway.publish({ hello: sessionSharingHello(true) });
|
||||
result.owners = [
|
||||
{ type: "human", id: "profile-ada", label: "Ada" },
|
||||
{ type: "human", id: "profile-bob", label: "Bob" },
|
||||
];
|
||||
gateway.publish({ hello: sessionSharingHello(false) });
|
||||
harness.publishList({ result, agentId: "main" });
|
||||
await sidebar.updateComplete;
|
||||
menu = await openOwnerMenu(sidebar);
|
||||
expect(menu.querySelector('[value="sort:people"]')).not.toBeNull();
|
||||
expect(menu.querySelector('[value="sort:created"]')?.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("groups sessions by owner only while the identity capability is available", async () => {
|
||||
it("groups sessions by owner based on the live session-owner roster", async () => {
|
||||
const gateway = createGatewayHarness({} as GatewayBrowserClient);
|
||||
gateway.publish({ selfUser: { id: "profile-zoe", name: "Zoe" } });
|
||||
gateway.publish({
|
||||
hello: sessionSharingHello(false),
|
||||
selfUser: { id: "profile-zoe", name: "Zoe" },
|
||||
});
|
||||
const harness = createSessionsHarness("main", [
|
||||
"agent:main:main",
|
||||
"agent:main:ada",
|
||||
@@ -479,12 +486,10 @@ describe("AppSidebar session ownership", () => {
|
||||
await sidebar.updateComplete;
|
||||
|
||||
let menu = await openOwnerMenu(sidebar);
|
||||
expect(menu.querySelector('[value="grouping:person"]')).toBeNull();
|
||||
expect(menu.querySelector('[value="grouping:person"]')).not.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 = () => [
|
||||
@@ -509,6 +514,18 @@ describe("AppSidebar session ownership", () => {
|
||||
|
||||
gateway.publish({ hello: null });
|
||||
await sidebar.updateComplete;
|
||||
expect(ownerSections()).toHaveLength(2);
|
||||
menu = await openOwnerMenu(sidebar);
|
||||
expect(menu.querySelector('[value="grouping:person"]')?.getAttribute("aria-checked")).toBe(
|
||||
"true",
|
||||
);
|
||||
menu.dispatchEvent(new Event("wa-after-hide", { bubbles: true }));
|
||||
await sidebar.updateComplete;
|
||||
|
||||
gateway.publish({ hello: sessionSharingHello(true) });
|
||||
result.owners = [{ type: "human", id: "profile-zoe", label: "Zoe" }];
|
||||
harness.publishList({ result, agentId: "main" });
|
||||
await sidebar.updateComplete;
|
||||
expect(ownerSections()).toHaveLength(0);
|
||||
menu = await openOwnerMenu(sidebar);
|
||||
expect(menu.querySelector('[value="grouping:person"]')).toBeNull();
|
||||
@@ -516,9 +533,13 @@ describe("AppSidebar session ownership", () => {
|
||||
"true",
|
||||
);
|
||||
menu.dispatchEvent(new Event("wa-after-hide", { bubbles: true }));
|
||||
await sidebar.updateComplete;
|
||||
|
||||
gateway.publish({ hello: sessionSharingHello(true) });
|
||||
result.owners = [
|
||||
{ type: "human", id: "profile-ada", label: "Ada" },
|
||||
{ type: "human", id: "profile-zoe", label: "Zoe" },
|
||||
];
|
||||
gateway.publish({ hello: sessionSharingHello(false) });
|
||||
harness.publishList({ result, agentId: "main" });
|
||||
await sidebar.updateComplete;
|
||||
expect(ownerSections()).toHaveLength(2);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user