From 3ea90bdcdb30f2ad18a68203fee09fa7824170a1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 21 Aug 2026 13:21:43 -0700 Subject: [PATCH] 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. --- scripts/control-ui-mock-dev.ts | 9 ++- .../app-sidebar-session-list-render.ts | 49 ++++++++--- .../app-sidebar-session-menu-renderers.ts | 17 ++-- .../app-sidebar-session-navigation-logic.ts | 10 +-- .../app-sidebar-session-navigation.ts | 35 ++++---- ui/src/components/sidebar-menus-controller.ts | 2 + ui/src/components/sidebar-menus-render.ts | 2 +- ui/src/i18n/locales/en.ts | 1 + ui/src/lib/sessions/grouping.test.ts | 81 ++++++++++++++++++- ui/src/lib/sessions/grouping.ts | 57 ++++++++++++- ui/src/pages/sessions/sessions-page.ts | 7 +- ui/src/pages/sessions/view.test.ts | 52 ++++++++++++ ui/src/pages/sessions/view.ts | 15 +++- .../app-sidebar-cases/session-ownership.ts | 75 +++++++++++++++++ 14 files changed, 360 insertions(+), 52 deletions(-) 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} ${label} ${collapsed && totalRowCount > 0 ? html`${totalRowCount}` 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`