feat(ui): merge creator avatar into sidebar leading slot, move creator filter into Threads menu (#113150)

* feat(ui): merge creator avatar into sidebar leading slot and move creator filter into Threads menu

The per-row creator chip previously rendered next to the leading state
indicator, so rows with a known creator started their titles at a
different x-offset than rows without one. The avatar now occupies the
single fixed leading slot: unread renders as a corner badge on it,
running as a spinner ring, and open/merged PR state as a colored corner
badge, while attention and pinned icons keep the slot. Child rows keep
their status badges and no longer render owner chips.

The standalone 'Filter by creator' select above the session list is
gone; the Threads funnel menu gains a People radio section with owner
chips, and the funnel trigger shows an accent dot while a creator
filter is active. The empty Threads header stays visible when ownership
chrome is active so the filter can always be cleared. Mock dev fixtures
gain two creator identities so the ownership chrome is demonstrable.

* test(ui): guard parent row spread in child-avatar ownership case
This commit is contained in:
Peter Steinberger
2026-07-23 15:26:56 -07:00
committed by GitHub
parent bd48b7b26b
commit fc92b9fd91
15 changed files with 373 additions and 100 deletions

View File

@@ -30,12 +30,26 @@ type CliOptions = {
};
type SessionListOptions = {
creators?: readonly SessionCreatorFixture[];
hasMore: boolean;
nextOffset: number | null;
offset?: number;
totalCount: number;
};
type SessionCreatorFixture = { type: "human" | "agent"; id: string; label: string };
// Two creator identities so the sidebar's collaborative ownership chrome
// (owner avatars + People filter) renders in the mock harness.
const MOCK_SESSION_CREATORS: readonly SessionCreatorFixture[] = [
{ type: "human", id: "profile-peter", label: "Peter" },
{ type: "human", id: "profile-mira", label: "Mira" },
];
const [MOCK_CREATOR_PETER, MOCK_CREATOR_MIRA] = MOCK_SESSION_CREATORS as [
SessionCreatorFixture,
SessionCreatorFixture,
];
const SESSION_PAGE_SIZE = 50;
const TOTAL_MOCK_SESSIONS = 650;
const TOTAL_TELEGRAM_SESSIONS = 180;
@@ -175,6 +189,7 @@ function sessionsListResponse(sessions: unknown[], options: SessionListOptions)
hasMore: options.hasMore,
limitApplied: 50,
nextOffset: options.nextOffset,
...(options.creators ? { creators: options.creators } : {}),
offset: options.offset ?? 0,
path: "",
sessions,
@@ -183,11 +198,16 @@ function sessionsListResponse(sessions: unknown[], options: SessionListOptions)
};
}
function pagedSessionsListResponse(sessions: unknown[], offset: number) {
function pagedSessionsListResponse(
sessions: unknown[],
offset: number,
creators?: readonly SessionCreatorFixture[],
) {
const normalizedOffset = Math.max(0, Math.floor(offset));
const page = sessions.slice(normalizedOffset, normalizedOffset + SESSION_PAGE_SIZE);
const nextOffset = normalizedOffset + SESSION_PAGE_SIZE;
return sessionsListResponse(page, {
creators,
hasMore: nextOffset < sessions.length,
nextOffset: nextOffset < sessions.length ? nextOffset : null,
offset: normalizedOffset,
@@ -218,17 +238,18 @@ function buildSessionRows(params: {
function buildSessionListCases(
sessions: unknown[],
matchBase: Record<string, unknown> = {},
creators?: readonly SessionCreatorFixture[],
): Array<{ match: Record<string, unknown>; response: unknown }> {
const cases: Array<{ match: Record<string, unknown>; response: unknown }> = [];
for (let offset = SESSION_PAGE_SIZE; offset < sessions.length; offset += SESSION_PAGE_SIZE) {
cases.push({
match: { ...matchBase, offset },
response: pagedSessionsListResponse(sessions, offset),
response: pagedSessionsListResponse(sessions, offset, creators),
});
}
cases.push({
match: matchBase,
response: pagedSessionsListResponse(sessions, 0),
response: pagedSessionsListResponse(sessions, 0, creators),
});
return cases;
}
@@ -1080,6 +1101,7 @@ async function createChatPickerScenario(): Promise<ControlUiMockGatewayScenario>
status: "running",
}),
sessionRow(NARRATION_DEMO_SESSION_KEY, "Sidebar narration demo", baseTime - 15_000, {
createdActor: MOCK_CREATOR_MIRA,
hasActiveRun: true,
startedAt: baseTime - 45_000,
status: "running",
@@ -1092,6 +1114,7 @@ async function createChatPickerScenario(): Promise<ControlUiMockGatewayScenario>
icon: "name:spark",
}),
sessionRow("agent:main:production-export", "Production export", baseTime - 75_000, {
createdActor: MOCK_CREATOR_MIRA,
execCwd: "/Users/peter/Projects/clawdbot",
}),
sessionRow("agent:main:model-budget", "Model budget review", baseTime - 80_000, {
@@ -1100,6 +1123,7 @@ async function createChatPickerScenario(): Promise<ControlUiMockGatewayScenario>
lastRunError: "Model out of credits: openai/gpt-5.6",
}),
sessionRow("agent:main:work-openclaw", "OpenClaw work checkout", baseTime - 85_000, {
createdActor: MOCK_CREATOR_PETER,
execCwd: "/Users/peter/Work/openclaw",
lastReadAt: baseTime - 120_000,
observerDigest: {
@@ -1144,6 +1168,8 @@ async function createChatPickerScenario(): Promise<ControlUiMockGatewayScenario>
const archivedSessions = [
sessionRow("agent:main:archived-launch-notes", "Archived launch notes", baseTime - 86_400_000, {
archived: true,
archivedBy: MOCK_CREATOR_MIRA,
createdActor: MOCK_CREATOR_PETER,
totalTokens: 42_000,
}),
sessionRow(
@@ -1708,7 +1734,7 @@ async function createChatPickerScenario(): Promise<ControlUiMockGatewayScenario>
...searchPrefixes("claude-sonnet-4-6"),
...searchPrefixes("anthropic"),
]),
...buildSessionListCases([...sessions, ...archivedSessions]),
...buildSessionListCases([...sessions, ...archivedSessions], {}, MOCK_SESSION_CREATORS),
],
},
},

View File

@@ -168,7 +168,9 @@ function renderSessionSection(params: {
? html`
<button
type="button"
class="sidebar-session-group-actions sidebar-session-sort"
class="sidebar-session-group-actions sidebar-session-sort ${host.sessionCreatorFilterActive
? "sidebar-session-sort--filtered"
: ""}"
title=${t("chat.sidebar.sortSessions")}
aria-label=${t("chat.sidebar.sortSessions")}
aria-haspopup="menu"
@@ -356,12 +358,13 @@ function renderSessionListBody(params: {
trailing: params.codingTrailing ?? nothing,
});
}
// Threads hides its bare empty header; unfiltered custom categories stay
// visible because creation and drag flows depend on them as drop targets.
// Threads hides its bare empty header unless it owns the collaborative
// creator filter; custom categories stay visible as drop targets.
if (
section.id === "ungrouped" &&
section.totalRowCount === 0 &&
!showDraft &&
!host.sessionOwnershipVisible &&
host.sessionsStatusFilter === "active" &&
host.sessionOrganizer.draggingSessionKey === null
) {
@@ -384,7 +387,6 @@ export function renderSessionList(params: {
expandedRows: SidebarRecentSession[];
visibleRowCount: number;
showDraft: boolean;
creatorFilter: TemplateResult | typeof nothing;
catalogs: SessionCatalogRenderSnapshot;
}) {
const { host } = params;
@@ -419,7 +421,6 @@ export function renderSessionList(params: {
`
: nothing}
<div class="sidebar-recent-sessions" aria-label=${titleForRoute("sessions")}>
${params.creatorFilter}
${renderSessionListBody({
host,
sections: params.sections,

View File

@@ -11,6 +11,7 @@ import {
type SidebarSessionStatusFilter,
} from "./app-sidebar-session-types.ts";
import { icons } from "./icons.ts";
import { renderSessionOwnerChip, type SessionCreatorOption } from "./session-owner-chip.ts";
import {
consumeDropdownKeyboardDismissal,
syncDropdownItemRadio,
@@ -95,9 +96,12 @@ export function renderSidebarSessionSortMenu(params: {
sortMode: SidebarSessionSortMode;
statusFilter: SidebarSessionStatusFilter;
showCron: boolean;
creators: readonly SessionCreatorOption[];
creatorFilterId: string | null;
onGroupingChange: (grouping: SidebarSessionsGrouping) => void;
onSortModeChange: (mode: SidebarSessionSortMode) => void;
onStatusFilterChange: (statusFilter: SidebarSessionStatusFilter) => void;
onCreatorFilterChange: (creatorId: string | null) => void;
onShowCronChange: (show: boolean) => void;
onClose: (restoreFocus: boolean) => void;
}) {
@@ -130,6 +134,8 @@ export function renderSidebarSessionSortMenu(params: {
params.onStatusFilterChange(
value.slice("status:".length) as SidebarSessionStatusFilter,
);
} else if (value?.startsWith("creator:")) {
params.onCreatorFilterChange(value.slice("creator:".length) || null);
} else if (value === "show-cron") {
params.onShowCronChange(!params.showCron);
}
@@ -210,6 +216,45 @@ export function renderSidebarSessionSortMenu(params: {
</wa-dropdown-item>
`,
)}
${params.creators.length >= 2
? html`
<div class="session-menu__separator" role="separator"></div>
<div class="sidebar-session-sort-menu__title">${t("sessionsView.people")}</div>
<wa-dropdown-item
class="sidebar-session-sort-menu__item"
value="creator:"
role="menuitemradio"
aria-checked=${String(params.creatorFilterId === null)}
${ref((element) =>
syncDropdownItemRadio(element, params.creatorFilterId === null),
)}
>
<span slot="details" class="session-menu__check" aria-hidden="true"
>${params.creatorFilterId === null ? icons.check : nothing}</span
>
<span class="session-menu__text">${t("sessionsView.allCreators")}</span>
</wa-dropdown-item>
${params.creators.map(
(creator) => html`
<wa-dropdown-item
class="sidebar-session-sort-menu__item"
value=${`creator:${creator.id}`}
role="menuitemradio"
aria-checked=${String(params.creatorFilterId === creator.id)}
${ref((element) =>
syncDropdownItemRadio(element, params.creatorFilterId === creator.id),
)}
>
<span slot="details" class="session-menu__check" aria-hidden="true"
>${params.creatorFilterId === creator.id ? icons.check : nothing}</span
>
${renderSessionOwnerChip(creator, "row")}
<span class="session-menu__text">${creator.label ?? creator.id}</span>
</wa-dropdown-item>
`,
)}
`
: nothing}
<div class="session-menu__separator" role="separator"></div>
<wa-dropdown-item
class="sidebar-session-sort-menu__item"

View File

@@ -81,11 +81,11 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
);
};
@state() protected sessionCreatorFilterId: string | null = null;
@state() sessionCreatorFilterId: string | null = null;
protected sessionCreatorOptions: readonly SessionCreatorOption[] = [];
sessionCreatorOptions: readonly SessionCreatorOption[] = [];
protected activeSessionCreatorId: string | null = null;
protected sessionCreatorFilterActive = false;
sessionCreatorFilterActive = false;
sessionOwnershipVisible = false;
@state() selectedSessionKeys: ReadonlySet<string> = new Set();

View File

@@ -26,7 +26,6 @@ import type { SessionDataController } from "./session-data-controller.ts";
import { renderSessionLeadingState } from "./session-leading-indicator.ts";
import type { SessionPullRequestIndicatorState } from "./session-menu-work.ts";
import type { SessionOrganizerController } from "./session-organizer-controller.ts";
import { renderSessionOwnerChip } from "./session-owner-chip.ts";
import { renderSessionRowBadges } from "./session-row-badges.ts";
import {
renderSidebarSessionSubtitle,
@@ -72,6 +71,7 @@ export interface SessionListHost {
| "toggleSessionSortMenu"
>;
readonly sessionsStatusFilter: SidebarSessionStatusFilter;
readonly sessionCreatorFilterActive: boolean;
readonly sessionOwnershipVisible: boolean;
readonly onOpenNewSession?: (agentId: string, target?: NewSessionTarget) => void;
readonly onNavigate?: (
@@ -149,9 +149,17 @@ export function renderRecentSession(params: {
const pullRequestState = session.worktreeId
? host.sessionPullRequestIndicatorState(session.key, session.worktreeId)
: "none";
const ownerAttribution = host.sessionsStatusFilter === "archived" ? "archived" : "created";
const ownerActor = host.sessionOwnershipVisible
? host.sessionsStatusFilter === "archived"
? session.archivedBy
: session.createdActor
: undefined;
const { running, pinnedState, leadingIndicator } = renderSessionLeadingState(
session,
pullRequestState,
ownerActor,
ownerAttribution,
);
const meta = display?.meta ?? session.meta;
const rowMeta = session.pinned ? "" : meta;
@@ -228,15 +236,7 @@ export function renderRecentSession(params: {
aria-describedby=${metaId ?? nothing}
@click=${(event: MouseEvent) => host.handleSessionRowClick(event, session)}
>
<span class="sidebar-session-indicator">${leadingIndicator}</span>${renderSessionOwnerChip(
host.sessionOwnershipVisible
? host.sessionsStatusFilter === "archived"
? session.archivedBy
: session.createdActor
: undefined,
"row",
host.sessionsStatusFilter === "archived" ? "archived" : "created",
)}
<span class="sidebar-session-indicator">${leadingIndicator}</span>
<span class="sidebar-recent-session__text">
<span class="sidebar-recent-session__name hover-marquee"
>${session.archived

View File

@@ -48,7 +48,6 @@ import {
resolveLobsterRunOutcome,
} from "./lobster-pet-contract.ts";
import { SessionOrganizerController } from "./session-organizer-controller.ts";
import { renderSessionCreatorFilter } from "./session-owner-chip.ts";
import { SidebarMenusController } from "./sidebar-menus-controller.ts";
// The shared loader retries transient chunk failures online; a deploy-pruned
// chunk still stays off until reload when that retry fails, by design.
@@ -328,14 +327,6 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
showDraft:
Boolean(this.draftSessionAgentId) &&
normalizeAgentId(this.draftSessionAgentId) === expandedAgentId,
creatorFilter: renderSessionCreatorFilter({
creators: this.sessionOwnershipVisible ? this.sessionCreatorOptions : [],
selectedId: this.sessionCreatorFilterActive ? this.sessionCreatorFilterId : null,
onChange: (creatorId) => {
this.sessionCreatorFilterId = creatorId;
void this.context?.sessions.setCreatorFilter(creatorId);
},
}),
catalogs: {
catalogs: this.sessionData.sessionCatalogs,
basePath: this.basePath,

View File

@@ -8,10 +8,13 @@ import {
} from "./session-attention-presentation.ts";
import { resolveSessionIcon } from "./session-icon-registry.ts";
import type { SessionPullRequestIndicatorState } from "./session-menu-work.ts";
import { renderSessionOwnerChip, type SessionCreatedActor } from "./session-owner-chip.ts";
export function renderSessionLeadingState(
session: SidebarRecentSession,
pullRequestState: SessionPullRequestIndicatorState,
ownerActor: SessionCreatedActor | null | undefined,
attribution: "created" | "archived",
) {
const running = session.hasActiveRun || session.status === "running";
const sessionState = renderSessionState(session);
@@ -38,6 +41,44 @@ export function renderSessionLeadingState(
>`,
};
}
if (!session.isChild && ownerActor?.id?.trim()) {
const label =
pullRequestState === "open"
? t("sessionsView.openPullRequest")
: t("chat.pullRequests.merged");
return {
running,
pinnedState,
leadingIndicator: html`<span
class="sidebar-session-avatar ${running ? "sidebar-session-avatar--running" : ""}"
>
${renderSessionOwnerChip(ownerActor, "row", attribution)}
${running
? html`<span
class="sidebar-session-avatar__running-ring"
role="img"
aria-label=${t("sessionsView.activeRun")}
title=${t("sessionsView.activeRun")}
></span>`
: nothing}
${session.unread
? html`<span
class="sidebar-session-avatar__badge sidebar-session-avatar__badge--unread"
role="img"
aria-label=${t("sessionsView.unread")}
></span>`
: pullRequestState !== "none"
? html`<span
class="sidebar-session-avatar__badge sidebar-session-pr-indicator--${pullRequestState}"
data-session-pr-state=${pullRequestState}
role="img"
aria-label=${label}
title=${label}
></span>`
: nothing}
</span>`,
};
}
if (running) {
return { running, pinnedState, leadingIndicator: sessionState };
}

View File

@@ -46,30 +46,6 @@ export function renderSessionOwnerChip(
: nothing;
}
export function renderSessionCreatorFilter(params: {
creators: readonly SessionCreatorOption[];
selectedId: string | null;
onChange: (creatorId: string | null) => void;
}) {
if (params.creators.length < 2) {
return nothing;
}
return html`<label class="sidebar-session-creator-filter">
<span>${t("sessionsView.filterByCreator")}</span>
<select
aria-label=${t("sessionsView.filterByCreator")}
.value=${params.selectedId ?? ""}
@change=${(event: Event) =>
params.onChange((event.currentTarget as HTMLSelectElement).value || null)}
>
<option value="">${t("sessionsView.allCreators")}</option>
${params.creators.map(
(creator) => html`<option value=${creator.id}>${creator.label ?? creator.id}</option>`,
)}
</select>
</label>`;
}
function ownerInitials(createdActor: SessionCreatedActor): string {
const source = createdActor.label?.trim() || createdActor.id?.trim() || "";
if (!source) {

View File

@@ -26,6 +26,7 @@ import { fetchSessionMenuWork } from "./session-menu-work.ts";
import type { SessionMenuWork } from "./session-menu.ts";
import type { SessionOrganizerController } from "./session-organizer-controller.ts";
import type { SessionOrganizerControllerHost } from "./session-organizer-operations.runtime.ts";
import type { SessionCreatorOption } from "./session-owner-chip.ts";
type SidebarMenuAgent = {
id: string;
@@ -76,6 +77,10 @@ interface SidebarMenusControllerHost
Pick<SessionDataController, "approvalBadgeSnapshot" | "sessionsLoading">;
readonly sessionDataContext: ApplicationContext<RouteId> | undefined;
readonly sessionOrganizer: SessionOrganizerController;
readonly sessionCreatorFilterActive: boolean;
sessionCreatorFilterId: string | null;
readonly sessionCreatorOptions: readonly SessionCreatorOption[];
readonly sessionOwnershipVisible: boolean;
readonly sidebarEntries: readonly string[];
sessionSortMode: SidebarSessionSortMode;
readonly terminalAvailable: boolean;

View File

@@ -35,6 +35,7 @@ import type { SessionDataController } from "./session-data-controller.ts";
import type { SessionMenuAction, SessionMenuWork } from "./session-menu.ts";
import type { SessionOrganizerController } from "./session-organizer-controller.ts";
import type { SessionOrganizerControllerHost } from "./session-organizer-operations.runtime.ts";
import type { SessionCreatorOption } from "./session-owner-chip.ts";
type SidebarMenuAgent = {
id: string;
@@ -59,6 +60,10 @@ interface SidebarMenusRenderHost extends ReactiveControllerHost, SessionOrganize
Pick<SessionDataController, "approvalBadgeSnapshot" | "sessionsLoading">;
readonly sessionDataContext: ApplicationContext<RouteId> | undefined;
readonly sessionOrganizer: SessionOrganizerController;
readonly sessionCreatorFilterActive: boolean;
sessionCreatorFilterId: string | null;
readonly sessionCreatorOptions: readonly SessionCreatorOption[];
readonly sessionOwnershipVisible: boolean;
readonly sidebarEntries: readonly string[];
sessionSortMode: SidebarSessionSortMode;
readonly themeMode: ThemeMode;
@@ -370,6 +375,8 @@ export function renderSidebarSessionSortMenuForController(
sortMode: host.sessionSortMode,
statusFilter: host.sessionsStatusFilter,
showCron: host.sessionsShowCron,
creators: host.sessionOwnershipVisible ? host.sessionCreatorOptions : [],
creatorFilterId: host.sessionCreatorFilterActive ? host.sessionCreatorFilterId : null,
onGroupingChange: (grouping) => {
host.sessionOrganizer.setSessionsGrouping(grouping);
controller.closeSessionSortMenu({ restoreFocus: true });
@@ -382,6 +389,11 @@ export function renderSidebarSessionSortMenuForController(
host.sessionOrganizer.setSessionsStatusFilter(statusFilter);
controller.closeSessionSortMenu({ restoreFocus: true });
},
onCreatorFilterChange: (creatorId) => {
host.sessionCreatorFilterId = creatorId;
void host.sessionDataContext?.sessions.setCreatorFilter(creatorId);
controller.closeSessionSortMenu({ restoreFocus: true });
},
onShowCronChange: (show) => {
host.sessionOrganizer.setSessionsShowCron(show);
controller.closeSessionSortMenu({ restoreFocus: true });

View File

@@ -131,7 +131,17 @@ describeControlUiE2e("Control UI session ownership", () => {
await currentPage.getByText("Ready.", { exact: true }).waitFor();
await expect.poll(() => currentPage.locator("openclaw-session-owner-chip").count()).toBe(3);
await currentPage.getByLabel("Filter by creator").selectOption("profile-ada");
await currentPage.locator(".sidebar-session-sort").click();
const creatorMenu = currentPage.locator(".sidebar-session-sort-menu");
await creatorMenu.locator('[value="creator:profile-ada"]').waitFor();
await creatorMenu.evaluate((element) =>
element.dispatchEvent(
new CustomEvent("wa-select", {
bubbles: true,
detail: { item: { value: "creator:profile-ada" } },
}),
),
);
await currentPage.getByText("Ada research", { exact: true }).first().waitFor();
await expect
.poll(() => currentPage.locator('[data-session-key="agent:main:bob"]').count())
@@ -165,7 +175,13 @@ describeControlUiE2e("Control UI session ownership", () => {
await currentPage.getByText("Bob operations", { exact: true }).first().waitFor();
await currentPage.locator('[data-session-key="agent:main:ada"] a').click();
await currentPage.getByText("Ready.", { exact: true }).waitFor();
expect(await currentPage.getByLabel("Filter by creator").count()).toBe(0);
await currentPage.locator(".sidebar-session-sort").click();
const creatorMenu = currentPage.locator(".sidebar-session-sort-menu");
await creatorMenu.waitFor();
expect(
await creatorMenu.locator(".sidebar-session-sort-menu__title", { hasText: "People" }).count(),
).toBe(0);
expect(await creatorMenu.locator('[value^="creator:"]').count()).toBe(0);
expect(await currentPage.locator("openclaw-session-owner-chip").count()).toBe(0);
});

View File

@@ -591,7 +591,7 @@ export const en: TranslationMap = {
filters: "Filters",
createdBy: "Created by {name}",
archivedBy: "Archived by {name}",
filterByCreator: "Filter by creator",
people: "People",
allCreators: "All people",
filterControls: "Thread filters",
sourceFilters: "Thread source filters",

View File

@@ -45,24 +45,38 @@ openclaw-session-owner-chip {
font-size: 10px;
}
.sidebar-session-creator-filter {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 8px 6px;
color: var(--muted);
font-size: 10px;
.sidebar-session-avatar {
position: relative;
display: inline-flex;
width: 16px;
height: 16px;
flex: 0 0 16px;
}
.sidebar-session-creator-filter select {
min-width: 0;
flex: 1 1 auto;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text);
font: inherit;
padding: 3px 22px 3px 6px;
.sidebar-session-avatar__running-ring {
position: absolute;
inset: -2px;
box-sizing: border-box;
border: 1.5px solid color-mix(in srgb, var(--accent) 28%, transparent);
border-top-color: var(--accent);
border-radius: var(--radius-full);
pointer-events: none;
}
.sidebar-session-avatar__badge {
position: absolute;
z-index: 1;
top: -2px;
right: -2px;
width: 7px;
height: 7px;
border-radius: var(--radius-full);
background: currentColor;
box-shadow: 0 0 0 1.5px var(--bg);
}
.sidebar-session-avatar__badge--unread {
color: var(--accent);
}
.connect-splash__logo {
@@ -5681,6 +5695,10 @@ td.data-table-key-col {
border: 1.5px solid color-mix(in srgb, var(--accent) 28%, transparent);
border-top-color: var(--accent);
border-radius: var(--radius-full);
}
.session-run-spinner,
.sidebar-session-avatar__running-ring {
animation: session-run-spin 0.8s linear infinite;
}
@@ -5691,7 +5709,8 @@ td.data-table-key-col {
}
@media (prefers-reduced-motion: reduce) {
.session-run-spinner {
.session-run-spinner,
.sidebar-session-avatar__running-ring {
animation: none;
border-color: var(--accent);
}

View File

@@ -1409,6 +1409,7 @@ html.openclaw-native-macos
}
.sidebar-session-sort {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -1424,6 +1425,18 @@ html.openclaw-native-macos
color var(--duration-fast) ease;
}
.sidebar-session-sort--filtered::after {
position: absolute;
top: 2px;
right: 2px;
width: 5px;
height: 5px;
border-radius: var(--radius-full);
background: var(--accent);
box-shadow: 0 0 0 1.5px var(--bg);
content: "";
}
.sidebar-session-sort:hover,
.sidebar-session-sort[aria-expanded="true"],
.sidebar-session-sort[aria-pressed="true"] {

View File

@@ -1,8 +1,39 @@
import { describe, expect, it } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { createGateway, createSessionsHarness, mountSidebar } from "../app-sidebar.ts";
import {
createGateway,
createSessionsHarness,
mountSidebar,
type SidebarLifecycleState,
} from "../app-sidebar.ts";
import { waitForFast } from "../wait-for.ts";
import "../../components/app-sidebar.ts";
async function openCreatorMenu(sidebar: SidebarLifecycleState): Promise<HTMLElement> {
const trigger = sidebar.querySelector<HTMLButtonElement>(".sidebar-session-sort");
if (!trigger) {
throw new Error("expected session sort trigger");
}
trigger.click();
await sidebar.updateComplete;
const menu = sidebar.querySelector<HTMLElement>(".sidebar-session-sort-menu");
if (!menu) {
throw new Error("expected session sort menu");
}
return menu;
}
async function selectCreator(sidebar: SidebarLifecycleState, creatorId: string | null) {
const menu = await openCreatorMenu(sidebar);
menu.dispatchEvent(
new CustomEvent("wa-select", {
bubbles: true,
detail: { item: { value: `creator:${creatorId ?? ""}` } },
}),
);
await sidebar.updateComplete;
}
describe("AppSidebar session ownership", () => {
it("uses the complete facet and requests unloaded creators from the Gateway", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
@@ -28,11 +59,17 @@ describe("AppSidebar session ownership", () => {
expect(sidebar.sessionData.sessionsResult?.creators).toHaveLength(2);
expect(sidebar.querySelector('[data-session-key="agent:main:ada"]')).not.toBeNull();
expect(sidebar.querySelectorAll("openclaw-session-owner-chip")).toHaveLength(1);
const select = sidebar.querySelector<HTMLSelectElement>(
'.sidebar-session-creator-filter select[aria-label="Filter by creator"]',
const menu = await openCreatorMenu(sidebar);
expect(menu.textContent).toContain("People");
expect(menu.querySelector('[value="creator:"]')).not.toBeNull();
expect(menu.querySelector('[value="creator:profile-ada"]')).not.toBeNull();
expect(menu.querySelector('[value="creator:profile-bob"]')).not.toBeNull();
menu.dispatchEvent(
new CustomEvent("wa-select", {
bubbles: true,
detail: { item: { value: "creator:profile-bob" } },
}),
);
select!.value = "profile-bob";
select!.dispatchEvent(new Event("change", { bubbles: true }));
await sidebar.updateComplete;
expect(harness.setCreatorFilter).toHaveBeenCalledWith("profile-bob");
@@ -61,7 +98,13 @@ describe("AppSidebar session ownership", () => {
harness.publishList({ result, agentId: "main" });
await sidebar.updateComplete;
expect(sidebar.querySelector(".sidebar-session-creator-filter")).toBeNull();
const menu = await openCreatorMenu(sidebar);
expect(
[...menu.querySelectorAll(".sidebar-session-sort-menu__title")].some(
(title) => title.textContent?.trim() === "People",
),
).toBe(false);
expect(menu.querySelector('[value^="creator:"]')).toBeNull();
expect(sidebar.querySelector("openclaw-session-owner-chip")).toBeNull();
});
@@ -133,20 +176,15 @@ describe("AppSidebar session ownership", () => {
harness.publishList({ result, agentId: "main" });
await sidebar.updateComplete;
const select = sidebar.querySelector<HTMLSelectElement>(
'.sidebar-session-creator-filter select[aria-label="Filter by creator"]',
);
expect(select).not.toBeNull();
expect(sidebar.querySelectorAll("openclaw-session-owner-chip")).toHaveLength(2);
select!.value = "profile-ada";
select!.dispatchEvent(new Event("change", { bubbles: true }));
await sidebar.updateComplete;
await selectCreator(sidebar, "profile-ada");
expect(sidebar.querySelector('[data-session-key="agent:main:ada"]')).not.toBeNull();
expect(sidebar.querySelector('[data-session-key="agent:main:bob"]')).toBeNull();
expect(sidebar.querySelector('[data-session-section="category:Research"]')).not.toBeNull();
expect(sidebar.querySelector('[data-session-section="category:Operations"]')).toBeNull();
expect(sidebar.querySelector(".sidebar-session-sort--filtered")).not.toBeNull();
});
it("filters catalog rows by authoritative creator ownership", async () => {
@@ -215,12 +253,7 @@ describe("AppSidebar session ownership", () => {
expect(sidebar.querySelector(`[data-session-key="${backingSessionKey}"]`)).not.toBeNull();
expect(sidebar.textContent).toContain("External unowned session");
const select = sidebar.querySelector<HTMLSelectElement>(
'.sidebar-session-creator-filter select[aria-label="Filter by creator"]',
);
select!.value = "profile-ada";
select!.dispatchEvent(new Event("change", { bubbles: true }));
await sidebar.updateComplete;
await selectCreator(sidebar, "profile-ada");
expect(sidebar.querySelector(`[data-session-key="${backingSessionKey}"]`)).toBeNull();
expect(sidebar.textContent).not.toContain("External unowned session");
@@ -291,13 +324,108 @@ describe("AppSidebar session ownership", () => {
harness.publishList({ result, agentId: "main" });
await sidebar.updateComplete;
const select = sidebar.querySelector<HTMLSelectElement>(
'.sidebar-session-creator-filter select[aria-label="Filter by creator"]',
);
select!.value = "profile-ada";
select!.dispatchEvent(new Event("change", { bubbles: true }));
await sidebar.updateComplete;
await selectCreator(sidebar, "profile-ada");
expect(sidebar.querySelector(`[data-session-key="${unloadedSessionKey}"]`)).not.toBeNull();
});
it("renders unread state as a corner badge on an owner avatar", async () => {
const key = "agent:main:unread";
const harness = createSessionsHarness("main", ["agent:main:main", key, "agent:main:other"]);
const result = harness.sessions.state.result;
if (!result) {
throw new Error("expected session list");
}
const unread = result.sessions.find((row) => row.key === key);
const other = result.sessions.find((row) => row.key.endsWith(":other"));
if (!unread || !other) {
throw new Error("expected ownership rows");
}
unread.createdActor = { type: "human", id: "profile-ada", label: "Ada" };
unread.unread = true;
other.createdActor = { type: "human", id: "profile-bob", label: "Bob" };
result.creators = [
{ id: "profile-ada", label: "Ada" },
{ id: "profile-bob", label: "Bob" },
];
const { sidebar } = await mountSidebar(
createGateway({} as GatewayBrowserClient),
harness.sessions,
);
harness.publishList({ result, agentId: "main" });
await sidebar.updateComplete;
const row = sidebar.querySelector(`[data-session-key="${key}"]`);
expect(
row?.querySelector(".sidebar-session-avatar openclaw-session-owner-chip"),
).not.toBeNull();
expect(
row?.querySelector('.sidebar-session-avatar__badge[aria-label="Unread"]'),
).not.toBeNull();
expect(row?.querySelector(".sidebar-recent-session__unread")).toBeNull();
expect(row?.querySelector(".sidebar-session-indicator__dot")).toBeNull();
});
it("keeps owner avatars off child rows", async () => {
const parentKey = "agent:main:parent";
const childKey = "agent:main:child";
const harness = createSessionsHarness("main", [parentKey]);
const result = harness.sessions.state.result;
if (!result) {
throw new Error("expected session list");
}
const parentRow = result.sessions[0];
if (!parentRow) {
throw new Error("expected parent row");
}
result.sessions[0] = {
...parentRow,
key: parentKey,
createdActor: { type: "human", id: "profile-ada", label: "Ada" },
childSessions: [childKey],
};
result.creators = [
{ id: "profile-ada", label: "Ada" },
{ id: "profile-bob", label: "Bob" },
];
harness.list.mockResolvedValue({
ts: 2,
path: "",
count: 1,
defaults: { modelProvider: null, model: null, contextTokens: null },
sessions: [
{
key: childKey,
spawnedBy: parentKey,
kind: "direct",
label: "Child task",
updatedAt: 2,
status: "done",
createdActor: { type: "human", id: "profile-bob", label: "Bob" },
},
],
});
const { sidebar } = await mountSidebar(
createGateway({} as GatewayBrowserClient),
harness.sessions,
);
harness.publishList({ result, agentId: "main" });
await sidebar.updateComplete;
sidebar.querySelector<HTMLButtonElement>(`[data-child-session-toggle="${parentKey}"]`)?.click();
await waitForFast(() =>
expect(sidebar.querySelector(`[data-session-key="${childKey}"]`)).not.toBeNull(),
);
expect(
sidebar.querySelector(`[data-session-key="${parentKey}"] openclaw-session-owner-chip`),
).not.toBeNull();
expect(
sidebar.querySelector(`[data-session-key="${childKey}"] openclaw-session-owner-chip`),
).toBeNull();
expect(
sidebar.querySelector(`[data-session-key="${childKey}"] [aria-label="Done"]`),
).not.toBeNull();
});
});