diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md
index aa3ca03f1a5d..e8ed76147469 100644
--- a/docs/web/control-ui.md
+++ b/docs/web/control-ui.md
@@ -230,7 +230,7 @@ and [Linux](/platforms/linux) desktop apps, the
## Sidebar navigation
-The sidebar organizes everything around the agent. The identity row at the top is the active agent; below it, the **Pages** section starts with **Home** — the agent's rolling main session, badged with its unread or running state — followed by the pinned destinations (**Automations** and **Plugins** by default). The customize control on the Pages header opens a menu with every other destination, including **Usage** and plugin-provided tabs, plus **Edit pinned items**; right-clicking the navigation area opens the pin editor directly. The session list below splits into zones: **Threads** for the agent's chat sessions (the main session stays behind Home; sessions it spawned appear here as top-level threads, and named threads show without a type prefix), **Groups** for group and room conversations, and **Coding** for sessions bound to a managed worktree or exec node (rows show a `repo ⎇ branch` line plus the node host), ACP-backed harness sessions, and the Codex/Claude CLI catalogs. Coding starts collapsed on first run and remembers your choice; its collapsed header keeps the true count and shows a running indicator while contained sessions work. Custom groups (the session `category`) and **Pinned** rows sit above Threads, and assigning a session to a custom group always wins over the automatic zone classification. The Threads header holds the sort control (Created or Last updated, Group by, and a persisted **Status** filter for Active, Archived, or All) and the **+** that opens the New session page. Archived rows stay inline, dimmed with an archive glyph; they do not contribute unread or attention state and stay outside lineage promotion. Opening a session moves the selection highlight without reordering rows. Parent sessions with recent child runs show a disclosure and child count; expand it to inspect nested child sessions, live or terminal status, and runtime without leaving the sidebar. Selecting a child opens its chat and automatically reveals its ancestor path. Child rows stay outside root grouping, pinning, dragging, multi-select, and pagination; collapsed zones do not consume the visible page budget. Sessions with new activity since they were last read show an unread dot, and opening one marks it read. An agent can also publish a short expiring status line and optionally request attention with a curated amber icon; that declaration clears when you open the session, send the next message, clear it explicitly, or its TTL expires. Cloud-worker lifecycle states use a globe badge; local and reclaimed sessions omit a placement badge because local execution is the default. Each root session row has a context menu (kebab button or right-click) with Pin/Unpin, Mark as unread/read, Rename, Fork, Move to group (including New group and Remove from group), Archive or Unarchive, and Delete; touch layouts keep the direct pin and menu controls visible. Cmd/Ctrl-click toggles root rows into a multi-select and Shift-click extends it across the visible order; opening the menu on a selected row then offers batch actions (Mark N as unread/read, Move N to group, Archive N, Delete N) that apply to every selected session, with a single confirmation for batch delete. Drag a root session onto **Pinned** to pin it, or onto a custom group to move it. Custom group headers can be collapsed, expanded, or dragged to reorder them; group names and their order live in the gateway (`sessions.groups.*`), so they follow you across browsers, while collapsed state stays in the browser profile. Group headers also have a menu (kebab button or right-click) with Rename group, New group, and Delete group; renaming or deleting a group updates every member session server-side, including archived ones, and deleting a group keeps its sessions and moves them back to Threads.
+The sidebar organizes everything around the agent. The identity row at the top is the active agent; below it, the **Pages** section starts with **Home** — the agent's rolling main session, badged with its unread or running state — followed by the pinned destinations (**Automations** and **Plugins** by default). The customize control on the Pages header opens a menu with every other destination, including **Usage** and plugin-provided tabs, plus **Edit pinned items**; right-clicking the navigation area opens the pin editor directly. The session list below splits into zones: **Threads** for the agent's chat sessions (the main session stays behind Home; sessions it spawned appear here as top-level threads, and named threads show without a type prefix), **Groups** for group and room conversations, and **Coding** for sessions bound to a managed worktree or exec node (rows show a `repo ⎇ branch` line plus the node host), ACP-backed harness sessions, and the Codex/Claude CLI catalogs. Coding starts collapsed on first run and remembers your choice; its collapsed header keeps the true count and shows a running indicator while contained sessions work. Custom groups (the session `category`) and **Pinned** rows sit above Threads, and assigning a session to a custom group always wins over the automatic zone classification. The Threads header holds the sort control (Created, Last updated, or People when the Gateway reports multiple server identities), Group by, a persisted **Status** filter for Active, Archived, or All, and the **+** that opens the New session page. People orders creator groups by name and keeps Created order within each group. Archived rows stay inline, dimmed with an archive glyph; they do not contribute unread or attention state and stay outside lineage promotion. Opening a session moves the selection highlight without reordering rows. Parent sessions with recent child runs show a disclosure and child count; expand it to inspect nested child sessions, live or terminal status, and runtime without leaving the sidebar. Selecting a child opens its chat and automatically reveals its ancestor path. Child rows stay outside root grouping, pinning, dragging, multi-select, and pagination; collapsed zones do not consume the visible page budget. Sessions with new activity since they were last read show an unread dot, and opening one marks it read. An agent can also publish a short expiring status line and optionally request attention with a curated amber icon; that declaration clears when you open the session, send the next message, clear it explicitly, or its TTL expires. Cloud-worker lifecycle states use a globe badge; local and reclaimed sessions omit a placement badge because local execution is the default. Each root session row has a context menu (kebab button or right-click) with Pin/Unpin, Mark as unread/read, Rename, Fork, Move to group (including New group and Remove from group), Archive or Unarchive, and Delete; touch layouts keep the direct pin and menu controls visible. Cmd/Ctrl-click toggles root rows into a multi-select and Shift-click extends it across the visible order; opening the menu on a selected row then offers batch actions (Mark N as unread/read, Move N to group, Archive N, Delete N) that apply to every selected session, with a single confirmation for batch delete. Drag a root session onto **Pinned** to pin it, or onto a custom group to move it. Custom group headers can be collapsed, expanded, or dragged to reorder them; group names and their order live in the gateway (`sessions.groups.*`), so they follow you across browsers, while collapsed state stays in the browser profile. Group headers also have a menu (kebab button or right-click) with Rename group, New group, and Delete group; renaming or deleting a group updates every member session server-side, including archived ones, and deleting a group keeps its sessions and moves them back to Threads.
## New session page
diff --git a/ui/src/components/app-sidebar-session-menu-renderers.ts b/ui/src/components/app-sidebar-session-menu-renderers.ts
index ba1e9b5ef9d8..575d94de7da0 100644
--- a/ui/src/components/app-sidebar-session-menu-renderers.ts
+++ b/ui/src/components/app-sidebar-session-menu-renderers.ts
@@ -229,6 +229,7 @@ export function renderSidebarSessionSortMenu(params: {
trigger: HTMLElement | null;
grouping: SidebarSessionsGrouping;
sortMode: SidebarSessionSortMode;
+ peopleSortAvailable: boolean;
statusFilter: SidebarSessionStatusFilter;
showCron: boolean;
creators: readonly SessionCreatorOption[];
@@ -291,7 +292,9 @@ export function renderSidebarSessionSortMenu(params: {
)}
- ${SIDEBAR_SESSION_SORT_OPTIONS.map((option) =>
+ ${SIDEBAR_SESSION_SORT_OPTIONS.filter(
+ (option) => option.mode !== "people" || params.peopleSortAvailable,
+ ).map((option) =>
renderSidebarMenuRadioItem({
value: `sort:${option.mode}`,
checked: params.sortMode === option.mode,
diff --git a/ui/src/components/app-sidebar-session-navigation-logic.ts b/ui/src/components/app-sidebar-session-navigation-logic.ts
index 56eeabbb5e41..9350b0c0c3d8 100644
--- a/ui/src/components/app-sidebar-session-navigation-logic.ts
+++ b/ui/src/components/app-sidebar-session-navigation-logic.ts
@@ -3,6 +3,7 @@ import { SIDEBAR_NAV_ROUTES } from "../app-navigation.ts";
import type { NavigationRouteId } from "../app-navigation.ts";
import type { RouteId } from "../app-route-paths.ts";
import type { ApplicationContext } from "../app/context.ts";
+import { t } from "../i18n/index.ts";
import {
resolveChannelSessionInfo,
resolveSessionDisplayName,
@@ -41,6 +42,7 @@ import {
SIDEBAR_SESSION_NO_ATTENTION,
SIDEBAR_SESSION_PAGE_SIZE,
type SidebarRecentSession,
+ type SidebarSessionSortMode,
type SidebarSessionStatusFilter,
} from "./app-sidebar-session-types.ts";
import type { SidebarWorkboardBoard } from "./app-sidebar-workboard.ts";
@@ -53,6 +55,39 @@ import {
type SessionRow = SessionsListResult["sessions"][number];
+export function compareSidebarSessionRowsByMode(input: {
+ a: SessionRow;
+ b: SessionRow;
+ sortMode: SidebarSessionSortMode;
+ creators: SessionsListResult["creators"];
+ createdOrder: ReadonlyMap;
+}): number {
+ const { a, b } = input;
+ if (input.sortMode !== "people") {
+ return input.sortMode === "updated"
+ ? compareSessionRowsByUpdatedAt(a, b)
+ : (input.createdOrder.get(a.key) ?? Number.MAX_SAFE_INTEGER) -
+ (input.createdOrder.get(b.key) ?? Number.MAX_SAFE_INTEGER);
+ }
+ const creators = input.creators ?? [];
+ const idA = a.createdActor?.id?.trim() ?? "";
+ const idB = b.createdActor?.id?.trim() ?? "";
+ if (idA !== idB) {
+ const creatorA = creators.find((candidate) => candidate.id === idA);
+ const creatorB = creators.find((candidate) => candidate.id === idB);
+ const labelA = creatorA?.label?.trim() || a.createdActor?.label?.trim() || idA;
+ const labelB = creatorB?.label?.trim() || b.createdActor?.label?.trim() || idB;
+ const byCreator = labelA.localeCompare(labelB) || idA.localeCompare(idB);
+ if (byCreator !== 0) {
+ return byCreator;
+ }
+ }
+ const byCreated =
+ (input.createdOrder.get(a.key) ?? Number.MAX_SAFE_INTEGER) -
+ (input.createdOrder.get(b.key) ?? Number.MAX_SAFE_INTEGER);
+ return byCreated || a.key.localeCompare(b.key);
+}
+
function isSidebarDraftOwnedBySelf(
row: Pick,
selfUserId: string | undefined,
@@ -350,6 +385,37 @@ export function latestVisibleAgentSessionRow(input: {
return visible.toSorted(compareSessionRowsByUpdatedAt)[0] ?? null;
}
+export function resolveSidebarAgentResumeKey(
+ latest: SessionRow | null,
+ agentId: string,
+ mainKey: string,
+): string {
+ return latest?.key ?? buildAgentMainSessionKey({ agentId, mainKey });
+}
+
+export function resolveSidebarAgentChipSubtitle(latest: SessionRow | null): string {
+ if (latest?.hasActiveRun) {
+ return t("agentChip.working");
+ }
+ return latest ? resolveSessionDisplayName(latest.key, latest) : t("agentChip.ready");
+}
+
+export function collectKnownSidebarSessionCatalogIds(input: {
+ loadedCatalogIds: readonly string[];
+ hasLoaded: boolean;
+ sectionOrder: readonly string[];
+}): string[] {
+ if (input.hasLoaded) {
+ return [...input.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 = input.sectionOrder.flatMap((sectionId) =>
+ sectionId.startsWith("catalog:") ? [sectionId.slice("catalog:".length)] : [],
+ );
+ return [...new Set([...input.loadedCatalogIds, ...storedCatalogIds])];
+}
+
export function resolveSidebarMainSessionKey(input: {
agentId: string;
agentsList: ApplicationContext["agents"]["state"]["agentsList"] | undefined;
diff --git a/ui/src/components/app-sidebar-session-navigation.ts b/ui/src/components/app-sidebar-session-navigation.ts
index 0194fc0c4dc0..87d16cae1021 100644
--- a/ui/src/components/app-sidebar-session-navigation.ts
+++ b/ui/src/components/app-sidebar-session-navigation.ts
@@ -6,13 +6,9 @@ import { isSessionRouteId } from "../app-route-paths.ts";
import { t } from "../i18n/index.ts";
import { listSelectableAgents } from "../lib/agents/display.ts";
import { shouldHandleNavigationClick } from "../lib/navigation-click.ts";
-import { isCronSessionKey, resolveSessionDisplayName } from "../lib/session-display.ts";
+import { isCronSessionKey } from "../lib/session-display.ts";
import type { SidebarSessionsGrouping } from "../lib/sessions/grouping.ts";
-import {
- compareSessionRowsByUpdatedAt,
- filterVisibleSessionRows,
- sessionMatchesArchivedFilter,
-} from "../lib/sessions/index.ts";
+import { filterVisibleSessionRows, sessionMatchesArchivedFilter } from "../lib/sessions/index.ts";
import {
composerDraftSearch,
resolveSessionPreferredFace,
@@ -20,7 +16,6 @@ import {
} from "../lib/sessions/route-navigation.ts";
import {
areUiSessionKeysEquivalent,
- buildAgentMainSessionKey,
normalizeAgentId,
parseAgentSessionKey,
resolveUiConfiguredMainKey,
@@ -34,6 +29,8 @@ import {
applySidebarSessionCreatorFilter,
buildReconciledSidebarZone,
buildSidebarSessionNavigationState,
+ compareSidebarSessionRowsByMode,
+ collectKnownSidebarSessionCatalogIds,
collectKnownSidebarSessionGroups,
extendSidebarSessionSelection,
findSidebarMainSessionRow,
@@ -41,6 +38,8 @@ import {
latestVisibleAgentSessionRow,
partitionSidebarVisibleSections,
promoteSidebarSessionCreatedOrder,
+ resolveSidebarAgentChipSubtitle,
+ resolveSidebarAgentResumeKey,
resolveSidebarMainSessionKey,
toggleSidebarSessionSelection,
type SidebarSessionNavigationState,
@@ -77,15 +76,35 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
protected readonly compareSidebarSessionRows = (
a: SessionsListResult["sessions"][number],
b: SessionsListResult["sessions"][number],
- ) => {
- if (this.sessionSortMode === "updated") {
- return compareSessionRowsByUpdatedAt(a, b);
- }
- return (
- (this.sessionData.sessionCreatedOrder.get(a.key) ?? Number.MAX_SAFE_INTEGER) -
- (this.sessionData.sessionCreatedOrder.get(b.key) ?? Number.MAX_SAFE_INTEGER)
- );
- };
+ ) =>
+ compareSidebarSessionRowsByMode({
+ a,
+ b,
+ sortMode: this.effectiveSessionSortMode(),
+ creators: this.sessionData.sessionsResult?.creators,
+ createdOrder: this.sessionData.sessionCreatedOrder,
+ });
+
+ private sessionPeopleSortCapability(): boolean | undefined {
+ return this.context?.gateway.snapshot.hello?.policy?.hasMultipleSessionSharingIdentities;
+ }
+
+ sessionPeopleSortAvailable(): boolean {
+ return this.sessionPeopleSortCapability() === true;
+ }
+
+ effectiveSessionSortMode(): SidebarSessionSortMode {
+ // A reconnect can temporarily hide the capability. Render Created without
+ // discarding People until an authoritative single-identity hello arrives.
+ return this.sessionSortMode === "people" && !this.sessionPeopleSortAvailable()
+ ? "created"
+ : this.sessionSortMode;
+ }
+
+ setSessionSortMode(mode: SidebarSessionSortMode) {
+ this.sessionSortMode =
+ mode === "people" && this.sessionPeopleSortCapability() === false ? "created" : mode;
+ }
@state() sessionCreatorFilterId: string | null = null;
@@ -165,6 +184,9 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
super.updated(changedProperties);
const selectedId = this.sessionCreatorFilterId;
const creators = this.sessionData.sessionsResult?.creators;
+ if (this.sessionSortMode === "people" && this.sessionPeopleSortCapability() === false) {
+ this.setSessionSortMode("created");
+ }
if (
selectedId &&
creators &&
@@ -494,14 +516,11 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
}
private agentResumeKey(agentId: string): string {
- const latest = this.latestAgentSessionRow(agentId);
- if (latest) {
- return latest.key;
- }
- return buildAgentMainSessionKey({
+ return resolveSidebarAgentResumeKey(
+ this.latestAgentSessionRow(agentId),
agentId,
- mainKey: this.sessionMainKey(),
- });
+ this.sessionMainKey(),
+ );
}
protected sessionMainKey(): string {
@@ -521,14 +540,7 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
}
agentChipSubtitle(agentId: string): string {
- const latest = this.latestAgentSessionRow(agentId);
- if (latest?.hasActiveRun) {
- return t("agentChip.working");
- }
- if (latest) {
- return resolveSessionDisplayName(latest.key, latest);
- }
- return t("agentChip.ready");
+ return resolveSidebarAgentChipSubtitle(this.latestAgentSessionRow(agentId));
}
switchChipAgent(agentId: string) {
@@ -565,21 +577,14 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
);
}
- knownSectionOrder(): string[] {
- return [...(this.context?.sessions.state.sectionOrder ?? [])];
- }
+ readonly knownSectionOrder = () => [...(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])];
+ return collectKnownSidebarSessionCatalogIds({
+ loadedCatalogIds: this.sessionData.sessionCatalogs.map((catalog) => catalog.id),
+ hasLoaded: this.sessionData.sessionCatalogRefreshStatus.hasLoaded,
+ sectionOrder: this.knownSectionOrder(),
+ });
}
findSidebarSessionByKey(sessionKey: string): SidebarRecentSession | undefined {
diff --git a/ui/src/components/app-sidebar-session-types.ts b/ui/src/components/app-sidebar-session-types.ts
index 8b40ac1019c9..14a163d604b1 100644
--- a/ui/src/components/app-sidebar-session-types.ts
+++ b/ui/src/components/app-sidebar-session-types.ts
@@ -146,7 +146,7 @@ export type SidebarSessionGroupMenuState = {
y: number;
};
-export type SidebarSessionSortMode = "created" | "updated";
+export type SidebarSessionSortMode = "created" | "updated" | "people";
export type SidebarSessionStatusFilter = "active" | "archived" | "all";
export type SidebarSessionsScrollState = "none" | "top" | "middle" | "bottom";
@@ -331,9 +331,10 @@ export function setStoredSessionCatalogHidden(catalogId: string, hidden: boolean
export const SIDEBAR_SESSION_SORT_OPTIONS = [
{ mode: "created", labelKey: "chat.sidebar.sortCreated" },
{ mode: "updated", labelKey: "chat.sidebar.sortUpdated" },
+ { mode: "people", labelKey: "sessionsView.people" },
] as const satisfies ReadonlyArray<{
mode: SidebarSessionSortMode;
- labelKey: "chat.sidebar.sortCreated" | "chat.sidebar.sortUpdated";
+ labelKey: "chat.sidebar.sortCreated" | "chat.sidebar.sortUpdated" | "sessionsView.people";
}>;
export const SIDEBAR_SESSION_STATUS_OPTIONS = [
diff --git a/ui/src/components/sidebar-menus-controller.ts b/ui/src/components/sidebar-menus-controller.ts
index d575637a50b3..2543bd07b201 100644
--- a/ui/src/components/sidebar-menus-controller.ts
+++ b/ui/src/components/sidebar-menus-controller.ts
@@ -104,6 +104,9 @@ export interface SidebarMenusControllerHost
setCatalogProjectGrouping(grouping: CatalogProjectGrouping): void;
hideSessionCatalog(catalogId: string): void;
sessionSortMode: SidebarSessionSortMode;
+ effectiveSessionSortMode(): SidebarSessionSortMode;
+ sessionPeopleSortAvailable(): boolean;
+ setSessionSortMode(mode: SidebarSessionSortMode): void;
readonly terminalAvailable: boolean;
readonly themeMode: ThemeMode;
readonly workboardBoards: readonly SidebarWorkboardBoard[];
diff --git a/ui/src/components/sidebar-menus-render.ts b/ui/src/components/sidebar-menus-render.ts
index c210a8420794..f5779031f67f 100644
--- a/ui/src/components/sidebar-menus-render.ts
+++ b/ui/src/components/sidebar-menus-render.ts
@@ -312,7 +312,8 @@ export function renderSidebarSessionSortMenuForController(controller: SidebarMen
position,
trigger: controller.sessionSortMenuTrigger,
grouping: host.sessionsGrouping,
- sortMode: host.sessionSortMode,
+ sortMode: host.effectiveSessionSortMode(),
+ peopleSortAvailable: host.sessionPeopleSortAvailable(),
statusFilter: host.sessionsStatusFilter,
showCron: host.sessionsShowCron,
creators: host.sessionOwnershipVisible ? host.sessionCreatorOptions : [],
@@ -322,7 +323,7 @@ export function renderSidebarSessionSortMenuForController(controller: SidebarMen
controller.closeSessionSortMenu({ restoreFocus: true });
},
onSortModeChange: (mode) => {
- host.sessionSortMode = mode;
+ host.setSessionSortMode(mode);
controller.closeSessionSortMenu({ restoreFocus: true });
},
onStatusFilterChange: (statusFilter) => {
diff --git a/ui/src/e2e/session-ownership.e2e.test.ts b/ui/src/e2e/session-ownership.e2e.test.ts
index 2d116baef5ae..ed5bcdb13ab4 100644
--- a/ui/src/e2e/session-ownership.e2e.test.ts
+++ b/ui/src/e2e/session-ownership.e2e.test.ts
@@ -107,6 +107,7 @@ suite.define(() => {
const currentPage = await context.newPage();
page = currentPage;
const gateway = await installMockGateway(currentPage, {
+ hasMultipleSessionSharingIdentities: true,
sessionKey: "agent:main:ada",
historyMessages: [{ role: "assistant", content: [{ type: "text", text: "Ready." }] }],
methodResponses: { "sessions.list": sessionsList(["profile-ada", "profile-bob"]) },
@@ -120,8 +121,24 @@ suite.define(() => {
await expect.poll(() => currentPage.locator("openclaw-session-owner-chip").count()).toBe(3);
const creatorMenu = await openSidebarSortMenu(currentPage);
- await creatorMenu.locator('[value="creator:profile-ada"]').waitFor();
+ await creatorMenu.locator('[value="sort:people"]').waitFor();
+ await captureUiProof(currentPage, "00-people-sort-available.png");
await creatorMenu.evaluate((element) =>
+ element.dispatchEvent(
+ new CustomEvent("wa-select", {
+ bubbles: true,
+ detail: { item: { value: "sort:people" } },
+ }),
+ ),
+ );
+ const peopleMenu = await openSidebarSortMenu(currentPage);
+ await expectBrowser(peopleMenu.locator('[value="sort:people"]')).toHaveAttribute(
+ "aria-checked",
+ "true",
+ );
+ await captureUiProof(currentPage, "01-people-sort-selected.png");
+ await peopleMenu.locator('[value="creator:profile-ada"]').waitFor();
+ await peopleMenu.evaluate((element) =>
element.dispatchEvent(
new CustomEvent("wa-select", {
bubbles: true,
@@ -163,6 +180,7 @@ suite.define(() => {
await currentPage.locator('[data-session-key="agent:main:ada"] a').click();
await currentPage.getByText("Ready.", { exact: true }).waitFor();
const creatorMenu = await openSidebarSortMenu(currentPage);
+ await captureUiProof(currentPage, "00-people-sort-hidden.png");
expect(
await creatorMenu.locator(".sidebar-session-sort-menu__title", { hasText: "People" }).count(),
).toBe(0);
diff --git a/ui/src/test-helpers/app-sidebar-cases/session-ownership.ts b/ui/src/test-helpers/app-sidebar-cases/session-ownership.ts
index fdabd5635b61..eec8cd5e4e00 100644
--- a/ui/src/test-helpers/app-sidebar-cases/session-ownership.ts
+++ b/ui/src/test-helpers/app-sidebar-cases/session-ownership.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
+import type { ApplicationGatewaySnapshot } from "../../app/context.ts";
import {
createGateway,
createGatewayHarness,
@@ -24,17 +25,43 @@ async function openCreatorMenu(sidebar: SidebarLifecycleState): Promise(".sidebar-recent-session[data-session-key]")]
+ .filter((row) => !row.classList.contains("sidebar-recent-session--child"))
+ .map((row) => row.dataset.sessionKey ?? "");
+}
+
describe("AppSidebar session ownership", () => {
it("renders durable actor avatars identically regardless of live presence", async () => {
const gateway = createGatewayHarness({} as GatewayBrowserClient);
@@ -242,6 +269,79 @@ describe("AppSidebar session ownership", () => {
expect(sidebar.querySelector("openclaw-session-owner-chip")).toBeNull();
});
+ it("owns People availability and fallback at the server identity capability", async () => {
+ const gateway = createGatewayHarness({} as GatewayBrowserClient);
+ const keys = ["main", "b1", "a1", "b2", "a2"].map((id) => `agent:main:${id}`);
+ const harness = createSessionsHarness("main", keys);
+ const result = harness.sessions.state.result;
+ if (!result) {
+ throw new Error("expected session list");
+ }
+ for (const [index, id, label, updatedAt] of [
+ [1, "profile-bob", "Bob", 40],
+ [2, "profile-ada", "Ada", 40],
+ [3, "profile-bob", "Bob", 30],
+ [4, "profile-ada", "Ada", 20],
+ ] as const) {
+ Object.assign(result.sessions[index]!, {
+ createdActor: { type: "human", id, label },
+ updatedAt,
+ });
+ }
+ result.creators = [{ id: "profile-bob", label: "Bob" }];
+ const createdOrder = keys.slice(1);
+ const updatedOrder = [keys[1]!, keys[2]!, keys[3]!, keys[4]!];
+ const peopleOrder = [keys[2]!, keys[4]!, keys[1]!, keys[3]!];
+
+ const { sidebar } = await mountSidebar(gateway.gateway, harness.sessions);
+ harness.publishList({ result, agentId: "main" });
+ await sidebar.updateComplete;
+
+ let menu = await openCreatorMenu(sidebar);
+ 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 }));
+ await sidebar.updateComplete;
+
+ gateway.publish({ hello: sessionSharingHello(true) });
+ await sidebar.updateComplete;
+ await expectSort(sidebar, "people", peopleOrder);
+
+ gateway.publish({ hello: null });
+ await sidebar.updateComplete;
+ menu = await openCreatorMenu(sidebar);
+ 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 }));
+ 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.creators = [
+ { id: "profile-ada", label: "Ada" },
+ { id: "profile-bob", label: "Bob" },
+ ];
+ harness.publishList({ result, agentId: "main" });
+ gateway.publish({ hello: sessionSharingHello(false) });
+ await sidebar.updateComplete;
+ await sidebar.updateComplete;
+
+ menu = await openCreatorMenu(sidebar);
+ 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) });
+ await sidebar.updateComplete;
+ menu = await openCreatorMenu(sidebar);
+ expect(menu.querySelector('[value="sort:people"]')).not.toBeNull();
+ expect(menu.querySelector('[value="sort:created"]')?.getAttribute("aria-checked")).toBe("true");
+ });
+
it("shows archive attribution only in collaborative archived-session lists", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const harness = createSessionsHarness("main", [