mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-19 09:01:39 -06:00
feat(ui): consolidate activity filters into toolbar with people popover (#125917)
* test(ui): seed mock activity sessions across owners * feat(ui): consolidate activity filters into toolbar Also restores the shared .input styling contract for standalone form controls. * test(ui): update activity feed e2e for toolbar people popover
This commit is contained in:
committed by
GitHub
parent
97a4d324f5
commit
ab2bd4faa8
@@ -294,6 +294,44 @@ function buildSearchSessionListCases(
|
||||
return searchTerms.flatMap((search) => buildSessionListCases(sessions, { search }));
|
||||
}
|
||||
|
||||
function buildActivitySessionRows(baseTime: number) {
|
||||
const owners = {
|
||||
molty: { type: "agent", id: "profile-molty", label: "Molty" },
|
||||
riley: { type: "human", id: "presence-riley", label: "Riley" },
|
||||
colin: { type: "human", id: "presence-colin", label: "Colin" },
|
||||
patricia: {
|
||||
type: "human",
|
||||
id: "presence-patricia",
|
||||
label: "patricia.erichsen@example.com",
|
||||
},
|
||||
unresolved: { type: "human", id: "147591189530201337" },
|
||||
} as const;
|
||||
const hour = 60 * 60 * 1000;
|
||||
const day = 24 * hour;
|
||||
const fixtures = [
|
||||
["release-check", "Release readiness check", owners.riley, 5 * 60_000],
|
||||
["api-notes", "Gateway API notes", owners.molty, 20 * 60_000],
|
||||
["design-review", "Activity feed design review", owners.colin, hour],
|
||||
["archive-audit", "Archive retention audit", owners.unresolved, 3 * hour],
|
||||
["support-handoff", "Support handoff", owners.patricia, 6 * hour],
|
||||
["mobile-smoke", "Mobile layout smoke test", owners.riley, 18 * hour],
|
||||
["provider-matrix", "Provider matrix cleanup", owners.molty, 30 * hour],
|
||||
["docs-pass", "Operator docs pass", owners.colin, 2 * day],
|
||||
["queue-review", "Queue behavior review", owners.patricia, 2.5 * day],
|
||||
["identity-trace", "Identity trace", owners.unresolved, 3 * day],
|
||||
["channel-followup", "Channel delivery follow-up", owners.riley, 4 * day],
|
||||
["tooling-refresh", "Tooling refresh", owners.molty, 5 * day],
|
||||
["fixture-polish", "Mock fixture polish", owners.colin, 6 * day],
|
||||
["weekly-summary", "Weekly activity summary", owners.patricia, 6.5 * day],
|
||||
] as const;
|
||||
return fixtures.map(([key, label, owner, age]) =>
|
||||
sessionRow(`agent:activity:${key}`, label, baseTime - age, {
|
||||
createdActor: owner,
|
||||
owner: { actor: owner },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function usageCostTotals(totalTokens: number, totalCost = 0) {
|
||||
return {
|
||||
input: Math.round(totalTokens * 0.2),
|
||||
@@ -1418,7 +1456,9 @@ async function createChatPickerScenario(
|
||||
]
|
||||
: [];
|
||||
const workboardMocks = buildWorkboardMocks(baseTime);
|
||||
const activitySessions = buildActivitySessionRows(Date.now());
|
||||
const sessions = [
|
||||
...activitySessions,
|
||||
...(fixture === "workboard"
|
||||
? [
|
||||
sessionRow(workboardMocks.sessionKey, "Product operations dashboard", baseTime, {
|
||||
|
||||
@@ -136,7 +136,17 @@ suite.define(() => {
|
||||
app.runtime?.context.navigate("activity");
|
||||
});
|
||||
await waitForControlUiRoute(page, { pathname: "/activity", routeId: "activity" });
|
||||
await expect.poll(() => page.locator(".activity-feed__person").count()).toBe(3);
|
||||
await page.locator(".activity-feed__people-trigger").click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator(
|
||||
'.activity-feed__people-row[data-activity-person]:not([data-activity-person=""])',
|
||||
)
|
||||
.count(),
|
||||
)
|
||||
.toBe(3);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect
|
||||
.poll(() => page.locator(".activity-feed__sessions > .activity-feed__session").count())
|
||||
.toBe(4);
|
||||
|
||||
@@ -3363,6 +3363,11 @@ export const en: TranslationMap = {
|
||||
timeAll: "All time",
|
||||
people: "People",
|
||||
allPeople: "All",
|
||||
everyone: "Everyone",
|
||||
peopleButtonLabel: "Filter sessions by person",
|
||||
lastActive: "· {time}",
|
||||
unresolvedIdentities: "Unresolved identities",
|
||||
clearPersonFilter: "Clear person filter",
|
||||
sessions: "Sessions",
|
||||
showing: "Showing {shown} of {total}",
|
||||
today: "Today",
|
||||
|
||||
@@ -21,7 +21,7 @@ import { renderSettingsWorkspace } from "../../components/settings-workspace.ts"
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { isMissingOperatorReadScopeError } from "../../lib/gateway-errors.ts";
|
||||
import { canCallGatewayMethod, isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
|
||||
import type { PresenceViewer } from "../../lib/presence-users.ts";
|
||||
import { projectPresencePayload, type PresenceViewer } from "../../lib/presence-users.ts";
|
||||
import { resolveSessionKey } from "../../lib/sessions/index.ts";
|
||||
import { uiSessionEventMatches } from "../../lib/sessions/session-key.ts";
|
||||
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
|
||||
@@ -478,6 +478,7 @@ class ActivityPage extends OpenClawLightDomElement {
|
||||
this.routeData.mode === "sessions"
|
||||
? this.routeData.filters
|
||||
: ({ personId: null, query: "", time: "7d" } satisfies SessionActivityFilters);
|
||||
const presenceViewers = projectPresencePayload(this.presencePayload).users;
|
||||
const currentIdentity = filters.personId
|
||||
? resolveActivityIdentity(filters.personId, this.presencePayload, sessionRows)
|
||||
: null;
|
||||
@@ -517,6 +518,7 @@ class ActivityPage extends OpenClawLightDomElement {
|
||||
? renderSessionActivityView({
|
||||
context: this.context,
|
||||
filters,
|
||||
presenceViewers,
|
||||
retainedIdentity,
|
||||
rows: sessionRows,
|
||||
onFiltersChange: (next) =>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render } from "lit";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GatewaySessionRow } from "../../api/types.ts";
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import type { PresenceViewer } from "../../lib/presence-users.ts";
|
||||
import { renderSessionActivityView } from "./session-activity-view.ts";
|
||||
|
||||
function row(key: string, owner: { id: string; label?: string }, updatedAt: number) {
|
||||
const actor = { type: "human" as const, ...owner };
|
||||
return {
|
||||
key,
|
||||
kind: "direct",
|
||||
displayName: key,
|
||||
updatedAt,
|
||||
createdActor: actor,
|
||||
owner: { actor },
|
||||
} satisfies GatewaySessionRow;
|
||||
}
|
||||
|
||||
function props(overrides: Partial<Parameters<typeof renderSessionActivityView>[0]> = {}) {
|
||||
return {
|
||||
context: {
|
||||
basePath: "",
|
||||
navigate: vi.fn(),
|
||||
gateway: { snapshot: { hello: null } },
|
||||
agents: { state: { agentsList: { defaultId: "main", mainKey: "main" } } },
|
||||
agentSelection: { state: { selectedId: "main" } },
|
||||
sessions: { state: { result: { sessions: [] } } },
|
||||
} as unknown as ApplicationContext,
|
||||
filters: { personId: null, query: "", time: "7d" as const },
|
||||
presenceViewers: [] as PresenceViewer[],
|
||||
retainedIdentity: null,
|
||||
rows: [] as GatewaySessionRow[],
|
||||
onFiltersChange: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("session activity people filter", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("separates raw fallback identities and maps presence dots by exact viewer id", () => {
|
||||
const now = Date.now();
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
|
||||
render(
|
||||
renderSessionActivityView(
|
||||
props({
|
||||
rows: [
|
||||
row("Online session", { id: "online", label: "Online person" }, now),
|
||||
row("Offline session", { id: "offline", label: "Offline person" }, now - 1_000),
|
||||
row("Unknown session", { id: "147591189530201337" }, now - 2_000),
|
||||
row("Explicit label session", { id: "explicit-id", label: "explicit-id" }, now - 3_000),
|
||||
],
|
||||
presenceViewers: [
|
||||
{
|
||||
id: "online",
|
||||
name: "Online person",
|
||||
watchedSessions: [],
|
||||
entries: [{ instanceId: "online-device", user: { id: "online" }, ts: now }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-activity-person="online"] .activity-feed__presence-dot'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-activity-person="offline"] .activity-feed__presence-dot'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-activity-person="offline"] .activity-feed__last-active'),
|
||||
).not.toBeNull();
|
||||
const unresolved = container.querySelector("[data-activity-unresolved]");
|
||||
expect(unresolved?.textContent).toContain("14759118…");
|
||||
expect(unresolved?.textContent).not.toContain("147591189530201337");
|
||||
expect(unresolved?.querySelector('[data-activity-person="explicit-id"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("selecting Everyone clears the person while preserving the other filters", () => {
|
||||
const onFiltersChange = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
render(
|
||||
renderSessionActivityView(
|
||||
props({
|
||||
filters: { personId: "online", query: "release", time: "30d" },
|
||||
retainedIdentity: { id: "online", name: "Online person", watchedSessions: [] },
|
||||
rows: [row("Release session", { id: "online", label: "Online person" }, Date.now())],
|
||||
onFiltersChange,
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
container.querySelector<HTMLButtonElement>('[data-activity-person=""]')?.click();
|
||||
|
||||
expect(onFiltersChange).toHaveBeenCalledWith({
|
||||
personId: null,
|
||||
query: "release",
|
||||
time: "30d",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import { html, nothing } from "lit";
|
||||
import type { GatewaySessionRow } from "../../api/types.ts";
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import { icons } from "../../components/icons.ts";
|
||||
import "../../components/viewer-facepile.ts";
|
||||
import "../../components/web-awesome-popover.ts";
|
||||
import { renderSettingsStatus } from "../../components/settings-ui.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { formatRelativeTimestamp, formatTimeAgo } from "../../lib/format.ts";
|
||||
@@ -29,6 +31,7 @@ import {
|
||||
type SessionActivityViewProps = {
|
||||
context: ApplicationContext;
|
||||
filters: SessionActivityFilters;
|
||||
presenceViewers: readonly PresenceViewer[];
|
||||
retainedIdentity: PresenceViewer | null;
|
||||
rows: readonly GatewaySessionRow[];
|
||||
onFiltersChange: (filters: SessionActivityFilters) => void;
|
||||
@@ -41,6 +44,166 @@ const TIME_LABELS: Record<ActivityTimeFilter, string> = {
|
||||
all: "activityFeed.timeAll",
|
||||
};
|
||||
|
||||
type ActivityPerson = PresenceViewer & { count: number; lastActiveAt: number };
|
||||
|
||||
function isUnresolvedPerson(person: PresenceViewer): boolean {
|
||||
return !person.name && !person.email && presenceViewerLabel(person) === person.id;
|
||||
}
|
||||
|
||||
function compactPersonLabel(person: PresenceViewer): string {
|
||||
return isUnresolvedPerson(person) && person.id.length > 8
|
||||
? `${person.id.slice(0, 8)}…`
|
||||
: presenceViewerLabel(person);
|
||||
}
|
||||
|
||||
function renderPersonAvatar(person: PresenceViewer, showPresence = false) {
|
||||
if (isUnresolvedPerson(person)) {
|
||||
return html`<span
|
||||
class="viewer-avatar viewer-avatar--overflow activity-feed__unknown-avatar"
|
||||
aria-hidden="true"
|
||||
>${icons.users}</span
|
||||
>`;
|
||||
}
|
||||
return html`<span class="activity-feed__person-avatar">
|
||||
<openclaw-viewer-avatar
|
||||
.user=${person}
|
||||
.markAsViewer=${false}
|
||||
variant="footer"
|
||||
></openclaw-viewer-avatar>
|
||||
${showPresence && (person.entries?.length ?? 0) > 0
|
||||
? html`<span
|
||||
class="activity-feed__presence-dot"
|
||||
aria-label=${t("activityFeed.online")}
|
||||
></span>`
|
||||
: nothing}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
function selectPerson(event: Event, props: SessionActivityViewProps, personId: string | null) {
|
||||
if (event.currentTarget instanceof Element) {
|
||||
event.currentTarget.closest("wa-popover")?.removeAttribute("open");
|
||||
}
|
||||
props.onFiltersChange({ ...props.filters, personId });
|
||||
}
|
||||
|
||||
function setPeopleExpanded(event: Event, expanded: boolean) {
|
||||
if (event.currentTarget instanceof Element) {
|
||||
event.currentTarget.parentElement
|
||||
?.querySelector(".activity-feed__people-trigger")
|
||||
?.setAttribute("aria-expanded", String(expanded));
|
||||
}
|
||||
}
|
||||
|
||||
function renderPersonRow(person: ActivityPerson, props: SessionActivityViewProps) {
|
||||
const online = (person.entries?.length ?? 0) > 0;
|
||||
return html`<button
|
||||
type="button"
|
||||
class="session-menu__item activity-feed__people-row"
|
||||
data-activity-person=${person.id}
|
||||
aria-pressed=${String(props.filters.personId === person.id)}
|
||||
@click=${(event: Event) => selectPerson(event, props, person.id)}
|
||||
>
|
||||
${renderPersonAvatar(person, true)}
|
||||
<span class="activity-feed__people-copy">
|
||||
<span class="activity-feed__people-name">${compactPersonLabel(person)}</span>
|
||||
${online
|
||||
? nothing
|
||||
: html`<span class="activity-feed__last-active">
|
||||
${t("activityFeed.lastActive", {
|
||||
time: formatRelativeTimestamp(person.lastActiveAt, { fallback: "" }),
|
||||
})}
|
||||
</span>`}
|
||||
</span>
|
||||
<span class="activity-feed__people-count">${person.count}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function renderPeopleControl(
|
||||
props: SessionActivityViewProps,
|
||||
people: readonly ActivityPerson[],
|
||||
selectedPerson: PresenceViewer | null,
|
||||
totalSessions: number,
|
||||
) {
|
||||
const visible = people.slice(0, 3);
|
||||
const overflow = people.length - visible.length;
|
||||
const resolved = people.filter((person) => !isUnresolvedPerson(person));
|
||||
const unresolved = people.filter(isUnresolvedPerson);
|
||||
return html`<div class="activity-feed__people-control">
|
||||
<button
|
||||
id="activity-feed-people-trigger"
|
||||
type="button"
|
||||
class="btn btn--sm activity-feed__people-trigger"
|
||||
aria-label=${t("activityFeed.peopleButtonLabel")}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded="false"
|
||||
>
|
||||
${selectedPerson
|
||||
? html`${renderPersonAvatar(selectedPerson)}<span class="activity-feed__selected-person"
|
||||
>${compactPersonLabel(selectedPerson)}</span
|
||||
>`
|
||||
: html`<span class="activity-feed__facepile" aria-hidden="true">
|
||||
${visible.length > 0
|
||||
? visible.map((person) => renderPersonAvatar(person))
|
||||
: html`<span
|
||||
class="viewer-avatar viewer-avatar--overflow activity-feed__unknown-avatar"
|
||||
>${icons.users}</span
|
||||
>`}
|
||||
${overflow > 0
|
||||
? html`<span class="viewer-avatar viewer-avatar--overflow">+${overflow}</span>`
|
||||
: nothing}
|
||||
</span>`}
|
||||
</button>
|
||||
${selectedPerson
|
||||
? html`<button
|
||||
type="button"
|
||||
class="btn btn--sm activity-feed__people-clear"
|
||||
aria-label=${t("activityFeed.clearPersonFilter")}
|
||||
@click=${() => props.onFiltersChange({ ...props.filters, personId: null })}
|
||||
>
|
||||
×
|
||||
</button>`
|
||||
: nothing}
|
||||
<wa-popover
|
||||
class="activity-feed__people-popover"
|
||||
for="activity-feed-people-trigger"
|
||||
placement="bottom-end"
|
||||
without-arrow
|
||||
@wa-show=${(event: Event) => setPeopleExpanded(event, true)}
|
||||
@wa-hide=${(event: Event) => setPeopleExpanded(event, false)}
|
||||
>
|
||||
<div class="activity-feed__people-panel" aria-label=${t("activityFeed.peopleButtonLabel")}>
|
||||
<button
|
||||
type="button"
|
||||
class="session-menu__item activity-feed__people-row"
|
||||
data-activity-person=""
|
||||
aria-pressed=${String(props.filters.personId === null)}
|
||||
@click=${(event: Event) => selectPerson(event, props, null)}
|
||||
>
|
||||
<span
|
||||
class="viewer-avatar viewer-avatar--overflow activity-feed__unknown-avatar"
|
||||
aria-hidden="true"
|
||||
>${icons.users}</span
|
||||
>
|
||||
<span class="activity-feed__people-copy">
|
||||
<span class="activity-feed__people-name">${t("activityFeed.everyone")}</span>
|
||||
</span>
|
||||
<span class="activity-feed__people-count">${totalSessions}</span>
|
||||
</button>
|
||||
${resolved.map((person) => renderPersonRow(person, props))}
|
||||
${unresolved.length > 0
|
||||
? html`<div class="session-menu__separator" role="separator"></div>
|
||||
<div class="activity-feed__people-group-label">
|
||||
${t("activityFeed.unresolvedIdentities")}
|
||||
</div>
|
||||
<div data-activity-unresolved>
|
||||
${unresolved.map((person) => renderPersonRow(person, props))}
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
</wa-popover>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function navigateToSession(event: MouseEvent, context: ApplicationContext, row: GatewaySessionRow) {
|
||||
if (!shouldHandleNavigationClick(event)) {
|
||||
return;
|
||||
@@ -177,13 +340,22 @@ function renderIdentityHeader(
|
||||
export function renderSessionActivityView(props: SessionActivityViewProps) {
|
||||
const projection = projectSessionActivity(props.rows, props.filters);
|
||||
const identity = props.retainedIdentity;
|
||||
const onlineById = new Map(props.presenceViewers.map((person) => [person.id, person]));
|
||||
const people = projection.people.map((person) => {
|
||||
const online = onlineById.get(person.id);
|
||||
return online
|
||||
? { ...person, ...online, count: person.count, lastActiveAt: person.lastActiveAt }
|
||||
: person;
|
||||
});
|
||||
const selectedPerson = props.filters.personId
|
||||
? (people.find((person) => person.id === props.filters.personId) ?? identity)
|
||||
: null;
|
||||
return html`
|
||||
<div class="activity-feed">
|
||||
<aside class="activity-feed__facets" aria-label=${t("activityFeed.filters")}>
|
||||
<label class="activity-feed__search">
|
||||
<span>${t("activityFeed.search")}</span>
|
||||
<div class="activity-feed__toolbar">
|
||||
<label class="data-table-search activity-feed__search">
|
||||
${icons.search}
|
||||
<input
|
||||
class="input"
|
||||
type="search"
|
||||
.value=${props.filters.query}
|
||||
placeholder=${t("activityFeed.searchPlaceholder")}
|
||||
@@ -194,48 +366,26 @@ export function renderSessionActivityView(props: SessionActivityViewProps) {
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<section class="activity-feed__facet">
|
||||
<h2>${t("activityFeed.time")}</h2>
|
||||
<div
|
||||
class="settings-segmented activity-feed__time-filter"
|
||||
role="group"
|
||||
aria-label=${t("activityFeed.time")}
|
||||
>
|
||||
${ACTIVITY_TIME_FILTERS.map(
|
||||
(time) => html`<button
|
||||
type="button"
|
||||
class="activity-feed__facet-option"
|
||||
class="settings-segmented__btn ${props.filters.time === time
|
||||
? "settings-segmented__btn--active"
|
||||
: ""}"
|
||||
aria-pressed=${String(props.filters.time === time)}
|
||||
@click=${() => props.onFiltersChange({ ...props.filters, time })}
|
||||
>
|
||||
<span>${t(TIME_LABELS[time])}</span>
|
||||
${t(TIME_LABELS[time])}
|
||||
</button>`,
|
||||
)}
|
||||
</section>
|
||||
<section class="activity-feed__facet">
|
||||
<h2>${t("activityFeed.people")}</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="activity-feed__facet-option"
|
||||
aria-pressed=${String(props.filters.personId === null)}
|
||||
@click=${() => props.onFiltersChange({ ...props.filters, personId: null })}
|
||||
>
|
||||
<span>${t("activityFeed.allPeople")}</span>
|
||||
<span class="activity-feed__facet-count">${projection.timeCount}</span>
|
||||
</button>
|
||||
${projection.people.map(
|
||||
(person) => html`<button
|
||||
type="button"
|
||||
class="activity-feed__facet-option activity-feed__person"
|
||||
aria-pressed=${String(props.filters.personId === person.id)}
|
||||
@click=${() => props.onFiltersChange({ ...props.filters, personId: person.id })}
|
||||
>
|
||||
<openclaw-viewer-avatar
|
||||
.user=${person}
|
||||
.markAsViewer=${false}
|
||||
variant="footer"
|
||||
></openclaw-viewer-avatar>
|
||||
<span>${presenceViewerLabel(person)}</span>
|
||||
<span class="activity-feed__facet-count">${person.count}</span>
|
||||
</button>`,
|
||||
)}
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
${renderPeopleControl(props, people, selectedPerson, projection.timeCount)}
|
||||
</div>
|
||||
<main class="activity-feed__main">
|
||||
${props.filters.personId
|
||||
? identity
|
||||
|
||||
@@ -75,6 +75,34 @@ describe("session activity projection", () => {
|
||||
expect(filtered.timeCount).toBe(3);
|
||||
});
|
||||
|
||||
it("orders people by their latest activity instead of session count or online state", () => {
|
||||
const activity = projectSessionActivity(
|
||||
[
|
||||
session("agent:main:older-1", "Older one", now - 3_000, {
|
||||
id: "frequent",
|
||||
label: "Frequent",
|
||||
}),
|
||||
session("agent:main:older-2", "Older two", now - 2_000, {
|
||||
id: "frequent",
|
||||
label: "Frequent",
|
||||
}),
|
||||
session("agent:main:newest", "Newest", now - 1_000, {
|
||||
id: "recent",
|
||||
label: "Recent",
|
||||
}),
|
||||
],
|
||||
{ personId: null, query: "", time: "7d" },
|
||||
now,
|
||||
);
|
||||
|
||||
expect(
|
||||
activity.people.map(({ id, count, lastActiveAt }) => ({ id, count, lastActiveAt })),
|
||||
).toEqual([
|
||||
{ id: "recent", count: 1, lastActiveAt: now - 1_000 },
|
||||
{ id: "frequent", count: 2, lastActiveAt: now - 2_000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("round-trips linkable filters in a stable query order", () => {
|
||||
const search = sessionActivitySearch({
|
||||
personId: "profile/a",
|
||||
|
||||
@@ -15,7 +15,7 @@ export type SessionActivityFilters = {
|
||||
time: ActivityTimeFilter;
|
||||
};
|
||||
|
||||
type ActivityPerson = PresenceViewer & { count: number };
|
||||
type ActivityPerson = PresenceViewer & { count: number; lastActiveAt: number };
|
||||
|
||||
type SessionActivityDay = {
|
||||
key: string;
|
||||
@@ -129,10 +129,12 @@ function dayStart(timestamp: number): number {
|
||||
function projectPeople(rows: readonly GatewaySessionRow[]): ActivityPerson[] {
|
||||
const people = new Map<string, ActivityPerson>();
|
||||
for (const row of rows) {
|
||||
const lastActiveAt = sessionActivityTimestamp(row);
|
||||
for (const actor of sessionActors(row)) {
|
||||
const existing = people.get(actor.id);
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
existing.lastActiveAt = Math.max(existing.lastActiveAt, lastActiveAt);
|
||||
continue;
|
||||
}
|
||||
people.set(actor.id, {
|
||||
@@ -141,13 +143,14 @@ function projectPeople(rows: readonly GatewaySessionRow[]): ActivityPerson[] {
|
||||
avatarUrl: normalized(actor.avatarUrl),
|
||||
watchedSessions: [],
|
||||
count: 1,
|
||||
lastActiveAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...people.values()].toSorted((a, b) => {
|
||||
const countOrder = b.count - a.count;
|
||||
if (countOrder !== 0) {
|
||||
return countOrder;
|
||||
const activityOrder = b.lastActiveAt - a.lastActiveAt;
|
||||
if (activityOrder !== 0) {
|
||||
return activityOrder;
|
||||
}
|
||||
const labelA = presenceViewerLabel(a).toLowerCase();
|
||||
const labelB = presenceViewerLabel(b).toLowerCase();
|
||||
|
||||
+160
-61
@@ -55,8 +55,8 @@ openclaw-activity-page {
|
||||
}
|
||||
|
||||
.activity-feed {
|
||||
display: grid;
|
||||
grid-template-columns: 204px minmax(0, 1fr);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
@@ -66,95 +66,192 @@ openclaw-activity-page {
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.activity-feed__facets {
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-4) var(--space-3);
|
||||
border-right: 1px solid var(--border);
|
||||
.activity-feed__toolbar {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--bg-elevated) 58%, transparent);
|
||||
}
|
||||
|
||||
.activity-feed__search {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
color: var(--muted);
|
||||
font-size: var(--control-ui-text-xs);
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
position: relative;
|
||||
flex: 1 1 240px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.activity-feed__search svg {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 9px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
translate: 0 -50%;
|
||||
fill: none;
|
||||
stroke: var(--muted);
|
||||
stroke-width: 1.8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.activity-feed__search input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
height: var(--settings-control-height);
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
.activity-feed__facet {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
margin-top: var(--space-5);
|
||||
.activity-feed__time-filter {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.activity-feed__facet h2 {
|
||||
margin: 0 0 var(--space-2);
|
||||
padding-inline: var(--space-2);
|
||||
color: var(--muted);
|
||||
font-size: var(--control-ui-text-xs);
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.activity-feed__facet-option {
|
||||
.activity-feed__people-control {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 6px 8px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.activity-feed__people-trigger,
|
||||
.activity-feed__people-clear {
|
||||
height: var(--settings-control-height);
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: var(--control-ui-text-sm);
|
||||
text-align: left;
|
||||
cursor: var(--cursor-action);
|
||||
}
|
||||
|
||||
.activity-feed__facet-option:hover,
|
||||
.activity-feed__facet-option:focus-visible {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
.activity-feed__people-trigger {
|
||||
min-width: 64px;
|
||||
max-width: 220px;
|
||||
padding: 3px 10px;
|
||||
}
|
||||
|
||||
.activity-feed__facet-option[aria-pressed="true"] {
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--bg-hover));
|
||||
color: var(--text-strong);
|
||||
font-weight: 600;
|
||||
.activity-feed__people-trigger:has(+ .activity-feed__people-clear) {
|
||||
border-radius: var(--radius-md) 0 0 var(--radius-md);
|
||||
}
|
||||
|
||||
.activity-feed__facet-option > span:not(.viewer-avatar) {
|
||||
.activity-feed__people-clear {
|
||||
width: 28px;
|
||||
padding: 0;
|
||||
margin-left: -1px;
|
||||
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
||||
font-size: var(--control-ui-text-lg);
|
||||
}
|
||||
|
||||
.activity-feed__people-trigger:focus-visible,
|
||||
.activity-feed__people-clear:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--ring);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.activity-feed__selected-person {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.activity-feed__person .viewer-avatar {
|
||||
.activity-feed__facepile {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.activity-feed__facepile .activity-feed__person-avatar + .activity-feed__person-avatar,
|
||||
.activity-feed__facepile .activity-feed__person-avatar + .viewer-avatar--overflow {
|
||||
margin-left: -5px;
|
||||
}
|
||||
|
||||
.activity-feed__facepile .viewer-avatar {
|
||||
border-color: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.activity-feed__person-avatar {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.activity-feed__facet-count {
|
||||
margin-left: auto;
|
||||
.activity-feed__presence-dot {
|
||||
position: absolute;
|
||||
right: -1px;
|
||||
bottom: -1px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border: 1.5px solid var(--bg-elevated);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.activity-feed__unknown-avatar {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.activity-feed__unknown-avatar svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.activity-feed__people-popover {
|
||||
--max-width: min(360px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.activity-feed__people-popover::part(body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.activity-feed__people-panel {
|
||||
width: min(340px, calc(100vw - 32px));
|
||||
max-height: min(480px, 70vh);
|
||||
overflow-y: auto;
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.activity-feed__people-row {
|
||||
cursor: var(--cursor-action);
|
||||
}
|
||||
|
||||
.activity-feed__people-row[aria-pressed="true"] {
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--bg-hover));
|
||||
}
|
||||
|
||||
.activity-feed__people-copy {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.activity-feed__people-name,
|
||||
.activity-feed__last-active {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.activity-feed__people-name {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.activity-feed__last-active,
|
||||
.activity-feed__people-count,
|
||||
.activity-feed__people-group-label {
|
||||
color: var(--muted);
|
||||
font-size: var(--control-ui-text-xs);
|
||||
}
|
||||
|
||||
.activity-feed__people-count {
|
||||
margin-left: auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.activity-feed__people-group-label {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.activity-feed__main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-5);
|
||||
}
|
||||
@@ -521,15 +618,17 @@ openclaw-activity-page {
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.activity-feed {
|
||||
display: block;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.activity-feed__facets {
|
||||
overflow: visible;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
.activity-feed__toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.activity-feed__search {
|
||||
flex-basis: 100%;
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.activity-feed__main {
|
||||
|
||||
@@ -1120,7 +1120,8 @@ openclaw-session-progress-hovercard-provider {
|
||||
|
||||
.field input,
|
||||
.field textarea,
|
||||
.field select {
|
||||
.field select,
|
||||
.input {
|
||||
border: 1px solid var(--input);
|
||||
background: var(--card);
|
||||
border-radius: var(--radius-md);
|
||||
@@ -1133,21 +1134,25 @@ openclaw-session-progress-hovercard-provider {
|
||||
}
|
||||
|
||||
.field input:not([type="checkbox"]):not([type="radio"]),
|
||||
.field select {
|
||||
.field select,
|
||||
input.input:not([type="checkbox"]):not([type="radio"]),
|
||||
select.input {
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.field input,
|
||||
.field textarea,
|
||||
.field select {
|
||||
.field select,
|
||||
.input {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.field input:focus-visible,
|
||||
.field textarea:focus-visible,
|
||||
.field select:focus-visible {
|
||||
.field select:focus-visible,
|
||||
.input:focus-visible {
|
||||
border-color: var(--ring);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user