diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts
index 2b151fc65302..844d3a2f1c93 100644
--- a/scripts/control-ui-mock-dev.ts
+++ b/scripts/control-ui-mock-dev.ts
@@ -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.
diff --git a/ui/src/components/app-sidebar-session-list-render.ts b/ui/src/components/app-sidebar-session-list-render.ts
index 9289fce9a4c0..94791308b716 100644
--- a/ui/src/components/app-sidebar-session-list-render.ts
+++ b/ui/src/components/app-sidebar-session-list-render.ts
@@ -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: {
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}
+ ${personOwner
+ ? html``
+ : nothing}
${collapsed && totalRowCount > 0
? html``
diff --git a/ui/src/components/app-sidebar-session-menu-renderers.ts b/ui/src/components/app-sidebar-session-menu-renderers.ts
index 3015c9b9cea5..7d5cd4690423 100644
--- a/ui/src/components/app-sidebar-session-menu-renderers.ts
+++ b/ui/src/components/app-sidebar-session-menu-renderers.ts
@@ -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"))}
- ${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,
+ }),
+ )}
${SIDEBAR_SESSION_SORT_OPTIONS.filter(
diff --git a/ui/src/components/app-sidebar-session-navigation-logic.ts b/ui/src/components/app-sidebar-session-navigation-logic.ts
index b22d8beda1ea..8afe28bb7222 100644
--- a/ui/src/components/app-sidebar-session-navigation-logic.ts
+++ b/ui/src/components/app-sidebar-session-navigation-logic.ts
@@ -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;
hideEmptyOwnerFilteredGroup: (category: string | undefined, rowCount: number) => boolean;
visibleSessionLimits: ReadonlyMap;
}): 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),
diff --git a/ui/src/components/app-sidebar-session-navigation.ts b/ui/src/components/app-sidebar-session-navigation.ts
index 9dfc0733d639..e7e0f7b25c43 100644
--- a/ui/src/components/app-sidebar-session-navigation.ts
+++ b/ui/src/components/app-sidebar-session-navigation.ts
@@ -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. */
diff --git a/ui/src/components/sidebar-menus-controller.ts b/ui/src/components/sidebar-menus-controller.ts
index f90cf074180d..2bc0308974dc 100644
--- a/ui/src/components/sidebar-menus-controller.ts
+++ b/ui/src/components/sidebar-menus-controller.ts
@@ -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;
diff --git a/ui/src/components/sidebar-menus-render.ts b/ui/src/components/sidebar-menus-render.ts
index 82d1f7d606ed..23bb0b62d150 100644
--- a/ui/src/components/sidebar-menus-render.ts
+++ b/ui/src/components/sidebar-menus-render.ts
@@ -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,
diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts
index c33b6123ad13..019940ecdc28 100644
--- a/ui/src/i18n/locales/en.ts
+++ b/ui/src/i18n/locales/en.ts
@@ -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",
diff --git a/ui/src/lib/sessions/grouping.test.ts b/ui/src/lib/sessions/grouping.test.ts
index fdfdd583c48e..551b9b1a73fa 100644
--- a/ui/src/lib/sessions/grouping.test.ts
+++ b/ui/src/lib/sessions/grouping.test.ts
@@ -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" }),
diff --git a/ui/src/lib/sessions/grouping.ts b/ui/src/lib/sessions/grouping.ts
index ec7c647b1334..501cdb4dc594 100644
--- a/ui/src/lib/sessions/grouping.ts
+++ b/ui/src/lib/sessions/grouping.ts
@@ -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 = {
- 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(
options: {
knownGroups?: readonly string[];
grouping?: SidebarSessionsGrouping;
+ selfOwnerId?: string | null;
sectionOrder?: readonly string[];
catalogIds?: readonly string[];
} = {},
@@ -226,6 +239,7 @@ export function groupSidebarSessionRows(
const groups: Row[] = [];
const coding: Row[] = [];
const categories = new Map();
+ const people = new Map>();
if (grouping === "category") {
for (const name of options.knownGroups ?? []) {
const trimmed = name.trim();
@@ -239,6 +253,26 @@ export function groupSidebarSessionRows(
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(
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)),
];
diff --git a/ui/src/pages/sessions/sessions-page.ts b/ui/src/pages/sessions/sessions-page.ts
index c75a5152922d..6d900556ad74 100644
--- a/ui/src/pages/sessions/sessions-page.ts
+++ b/ui/src/pages/sessions/sessions-page.ts
@@ -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,
diff --git a/ui/src/pages/sessions/view.test.ts b/ui/src/pages/sessions/view.test.ts
index 26285eef43a8..f70ceae99223 100644
--- a/ui/src/pages/sessions/view.test.ts
+++ b/ui/src/pages/sessions/view.test.ts
@@ -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('.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(".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(
diff --git a/ui/src/pages/sessions/view.ts b/ui/src/pages/sessions/view.ts
index a4cd25169c9b..3fe4a8b60c76 100644
--- a/ui/src/pages/sessions/view.ts
+++ b/ui/src/pages/sessions/view.ts
@@ -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 = {
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`