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