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:
Peter Steinberger
2026-08-18 11:41:06 -07:00
committed by GitHub
parent 97a4d324f5
commit ab2bd4faa8
10 changed files with 563 additions and 109 deletions
+40
View File
@@ -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);
+5
View File
@@ -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",
+3 -1
View File
@@ -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",
});
});
});
+188 -38
View File
@@ -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",
+7 -4
View File
@@ -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
View File
@@ -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 {
+9 -4
View File
@@ -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);
}