mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 10:21:34 +00:00
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:
committed by
GitHub
parent
bd48b7b26b
commit
fc92b9fd91
@@ -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),
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user