mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
improve(ui): simplify Sessions visual hierarchy (#127793)
* style(ui): simplify Sessions visual hierarchy * test(ui): cover compact Sessions facts responsively * style(ui): tighten Sessions vertical rhythm * style(ui): simplify Sessions filters * style(ui): align Sessions filter trigger * test(ui): advertise transcript search in current mocks Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --------- Co-authored-by: RoboClaw <309084314+roboclaw-bot@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -1750,6 +1750,7 @@ async function createChatPickerScenario(
|
||||
"sessions.groups.rename",
|
||||
"sessions.patch",
|
||||
"sessions.patchMany",
|
||||
"sessions.search",
|
||||
"sessions.catalog.list",
|
||||
"sessions.catalog.read",
|
||||
"sessions.create",
|
||||
@@ -2652,6 +2653,7 @@ async function createChatPickerScenario(
|
||||
...buildSessionListCases([...sessions, ...archivedSessions], {}, MOCK_SESSION_OWNERS),
|
||||
],
|
||||
},
|
||||
"sessions.search": { results: [] },
|
||||
...(fixture === "workboard" ? workboardMocks.methodResponses : {}),
|
||||
},
|
||||
models: modelProviders.models,
|
||||
|
||||
@@ -8,6 +8,11 @@ const suite = createControlUiE2eSuite({
|
||||
name: "Control UI session-list event scope",
|
||||
});
|
||||
|
||||
async function openSessionFilters(page: Page) {
|
||||
await page.getByRole("button", { name: "Filters" }).click();
|
||||
await page.locator("wa-popover.sessions-filter-popover[open]").waitFor();
|
||||
}
|
||||
|
||||
// Browser contexts preserve test isolation; keep one process warm for this file.
|
||||
let page: Page | undefined;
|
||||
suite.define(() => {
|
||||
@@ -245,6 +250,7 @@ suite.define(() => {
|
||||
expect(initialPageParams).toMatchObject({ limit: 50 });
|
||||
expect(initialPageParams).not.toHaveProperty("activeMinutes");
|
||||
|
||||
await openSessionFilters(currentPage);
|
||||
const activeMinutes = sessionsPage.getByLabel("Updated within");
|
||||
const limit = sessionsPage.getByLabel("Limit");
|
||||
await expect.poll(() => activeMinutes.inputValue()).toBe("");
|
||||
@@ -295,6 +301,7 @@ suite.define(() => {
|
||||
|
||||
await currentPage.goto(`${suite.server?.baseUrl ?? ""}sessions`);
|
||||
await gateway.waitForRequest("sessions.list");
|
||||
await openSessionFilters(currentPage);
|
||||
const activeMinutes = currentPage.getByLabel("Updated within");
|
||||
const limit = currentPage.getByLabel("Limit");
|
||||
const cases = [
|
||||
|
||||
@@ -480,6 +480,8 @@ suite.define(() => {
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}sessions`);
|
||||
await page.getByRole("button", { name: "Filters" }).click();
|
||||
await page.locator("wa-popover.sessions-filter-popover[open]").waitFor();
|
||||
await page.locator(".session-groupby__select").selectOption("category");
|
||||
await page.getByRole("button", { name: "New group…" }).click();
|
||||
const field = page.locator("openclaw-modal-dialog input");
|
||||
|
||||
@@ -436,6 +436,7 @@ describeControlUiE2e("Control UI session transcript search", () => {
|
||||
});
|
||||
page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
featureMethods: ["chat.metadata", "chat.startup"],
|
||||
methodResponses: {
|
||||
"sessions.list": {
|
||||
count: 0,
|
||||
|
||||
@@ -969,6 +969,7 @@ export const en: TranslationMap = {
|
||||
assignToMe: "Assign to me",
|
||||
assignTo: "Assign to…",
|
||||
filterControls: "Session filters",
|
||||
filters: "Filters",
|
||||
sourceFilters: "Session source filters",
|
||||
global: "Global",
|
||||
unknown: "Unknown",
|
||||
|
||||
@@ -38,32 +38,29 @@ function readUiCss(): string {
|
||||
|
||||
function sessionsTableHtml() {
|
||||
const headers = ["", "Key", "Kind", "Status", "Updated", "Tokens", "Actions"];
|
||||
const overviewTiles = [
|
||||
["3", "Sessions"],
|
||||
const headingFacts = [
|
||||
["1", "Live"],
|
||||
["1", "Unread"],
|
||||
["123k", "Tokens"],
|
||||
]
|
||||
.map(
|
||||
([value, label]) => `
|
||||
<div class="sessions-overview__tile">
|
||||
<span class="sessions-overview__icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" /></svg>
|
||||
</span>
|
||||
<span class="sessions-overview__meta">
|
||||
<span class="sessions-overview__value">${value}</span>
|
||||
<span class="sessions-overview__label">${label}</span>
|
||||
</span>
|
||||
</div>
|
||||
([value, label], index) => `
|
||||
${index > 0 ? '<span class="sessions-heading-fact__separator" aria-hidden="true">·</span>' : ""}
|
||||
<span class="sessions-heading-fact">
|
||||
<strong>${value}</strong> ${label}
|
||||
</span>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
return `
|
||||
<div class="settings-page settings-page--wide">
|
||||
<div class="settings-group">
|
||||
<div class="sessions-overview">${overviewTiles}</div>
|
||||
</div>
|
||||
<div class="settings-group">
|
||||
<section class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
<h2 class="settings-section__heading">
|
||||
Sessions <span class="settings-count">3</span>
|
||||
<span class="sessions-heading-facts">${headingFacts}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="settings-group">
|
||||
<div class="data-table-container">
|
||||
<table class="data-table sessions-table">
|
||||
<thead>
|
||||
@@ -208,7 +205,8 @@ function sessionsTableHtml() {
|
||||
<button>Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -267,22 +265,27 @@ describeBrowserLayout("sessions responsive browser layout", () => {
|
||||
const kind = document.querySelector(".session-kind");
|
||||
const key = document.querySelector(".session-key-cell .session-link");
|
||||
const details = document.querySelector(".session-details-panel");
|
||||
const facts = document.querySelector(".sessions-heading-facts");
|
||||
if (
|
||||
!(container instanceof HTMLElement) ||
|
||||
!(actions instanceof HTMLElement) ||
|
||||
!(trigger instanceof HTMLElement) ||
|
||||
!(status instanceof HTMLElement) ||
|
||||
!(kind instanceof HTMLElement) ||
|
||||
!(key instanceof HTMLElement)
|
||||
!(key instanceof HTMLElement) ||
|
||||
!(facts instanceof HTMLElement)
|
||||
) {
|
||||
throw new Error("Missing sessions table fixture elements");
|
||||
}
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const actionsRect = actions.getBoundingClientRect();
|
||||
const statusRect = status.getBoundingClientRect();
|
||||
const factsRect = facts.getBoundingClientRect();
|
||||
const statusStyle = getComputedStyle(status);
|
||||
return {
|
||||
bodyOverflow: document.documentElement.scrollWidth - window.innerWidth,
|
||||
factsText: facts.textContent?.replace(/\s+/gu, " ").trim(),
|
||||
factsVisible: factsRect.left >= 0 && factsRect.right <= window.innerWidth,
|
||||
checkpointCount: trigger.querySelector(".session-compaction-count")?.textContent?.trim(),
|
||||
statusText: status.textContent?.trim(),
|
||||
keyWhiteSpace: getComputedStyle(key).whiteSpace,
|
||||
@@ -299,6 +302,8 @@ describeBrowserLayout("sessions responsive browser layout", () => {
|
||||
});
|
||||
|
||||
expect(metrics.bodyOverflow).toBeLessThanOrEqual(1);
|
||||
expect(metrics.factsText).toBe("1 Live · 1 Unread");
|
||||
expect(metrics.factsVisible).toBe(true);
|
||||
expect(metrics.checkpointCount).toBe("1");
|
||||
expect(metrics.statusText).toBe("Live");
|
||||
expect(metrics.keyWhiteSpace).toBe("nowrap");
|
||||
|
||||
@@ -789,7 +789,7 @@ describe("sessions view", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps active and limit together and renders streamlined source toggles", async () => {
|
||||
it("keeps session state visible and moves advanced controls into the filter popover", async () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderSessions({
|
||||
@@ -802,15 +802,21 @@ describe("sessions view", () => {
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
const primaryRow = container.querySelector(".session-filter-primary-row");
|
||||
expect(primaryRow?.querySelector(".session-filter-input--minutes")?.closest("label")).toBe(
|
||||
primaryRow?.firstElementChild?.querySelector("label"),
|
||||
);
|
||||
expect(primaryRow?.querySelector(".session-filter-input--limit")?.closest("label")).toBe(
|
||||
primaryRow?.lastElementChild?.querySelector("label"),
|
||||
);
|
||||
const toolbar = container.querySelector(".sessions-filter-bar");
|
||||
const trigger = toolbar?.querySelector<HTMLButtonElement>(".sessions-filter-popover__trigger");
|
||||
const popover = toolbar?.querySelector("wa-popover");
|
||||
const panel = popover?.querySelector(".sessions-filter-popover__panel");
|
||||
expect(toolbar?.querySelector(".sessions-view-segment")).not.toBeNull();
|
||||
expect(trigger?.textContent?.trim()).toBe("");
|
||||
expect(trigger?.getAttribute("aria-label")).toBe("Filters");
|
||||
expect(trigger?.getAttribute("title")).toBe("Filters");
|
||||
expect(trigger?.getAttribute("aria-haspopup")).toBe("dialog");
|
||||
expect(trigger?.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(panel?.querySelector(".session-filter-input--minutes")?.closest("label")).not.toBeNull();
|
||||
expect(panel?.querySelector(".session-filter-input--limit")?.closest("label")).not.toBeNull();
|
||||
expect(panel?.querySelector(".session-groupby__select")).not.toBeNull();
|
||||
|
||||
const toggleGroup = container.querySelector(".session-filter-toggle-group");
|
||||
const toggleGroup = panel?.querySelector(".session-filter-toggle-group");
|
||||
expect(toggleGroup?.getAttribute("role")).toBe("group");
|
||||
expect(toggleGroup?.getAttribute("aria-label")).toBe("Session source filters");
|
||||
expect(toggleGroup?.querySelectorAll(".session-filter-check")).toHaveLength(2);
|
||||
@@ -827,6 +833,11 @@ describe("sessions view", () => {
|
||||
["includeUnknown", ["session-filter-check", "session-filter-toggle"]],
|
||||
]);
|
||||
expect(toggleGroup?.querySelector(".session-filter-check__box")).toBeNull();
|
||||
|
||||
popover?.dispatchEvent(new Event("wa-show"));
|
||||
expect(trigger?.getAttribute("aria-expanded")).toBe("true");
|
||||
popover?.dispatchEvent(new Event("wa-hide"));
|
||||
expect(trigger?.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("renders and patches provider-owned thinking ids", async () => {
|
||||
@@ -1709,7 +1720,7 @@ describe("sessions view", () => {
|
||||
expect(emptyState?.querySelector("button") !== null).toBe(testCase.filtered);
|
||||
});
|
||||
|
||||
it("summarizes loaded sessions in the overview tiles", async () => {
|
||||
it("summarizes loaded sessions beside the roster heading", async () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderSessions(
|
||||
@@ -1737,19 +1748,18 @@ describe("sessions view", () => {
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
const tiles = Array.from(container.querySelectorAll(".sessions-overview__tile"));
|
||||
const readTile = (tile: Element) => [
|
||||
tile.querySelector(".sessions-overview__label")?.textContent?.trim(),
|
||||
tile.querySelector(".sessions-overview__value")?.textContent?.trim(),
|
||||
];
|
||||
expect(tiles.map(readTile)).toEqual([
|
||||
["Sessions", "2"],
|
||||
["Live", "1"],
|
||||
["Unread", "1"],
|
||||
["Tokens", "1.5k"],
|
||||
const facts = Array.from(container.querySelectorAll(".sessions-heading-fact"));
|
||||
const readFact = (fact: Element) => {
|
||||
const value = fact.querySelector("strong")?.textContent?.trim() ?? "";
|
||||
return [value, fact.textContent?.replace(value, "").trim()];
|
||||
};
|
||||
expect(container.querySelector(".settings-count")?.textContent?.trim()).toBe("2");
|
||||
expect(facts.map(readFact)).toEqual([
|
||||
["1", "Live"],
|
||||
["1", "Unread"],
|
||||
]);
|
||||
expect(tiles[1]?.classList.contains("sessions-overview__tile--active")).toBe(true);
|
||||
expect(tiles[2]?.classList.contains("sessions-overview__tile--active")).toBe(true);
|
||||
expect(facts[0]?.classList.contains("sessions-heading-fact--active")).toBe(true);
|
||||
expect(facts[1]?.classList.contains("sessions-heading-fact--active")).toBe(true);
|
||||
});
|
||||
|
||||
it("renders a context meter with usage tone on the tokens cell", async () => {
|
||||
@@ -1811,41 +1821,6 @@ describe("sessions view", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reports the overview token sum as unavailable or partial when snapshots are missing", async () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderSessions(
|
||||
buildProps(
|
||||
buildMultiResult([
|
||||
{ key: "agent:main:a", kind: "direct", updatedAt: 2, totalTokens: 1200 },
|
||||
{ key: "agent:main:b", kind: "direct", updatedAt: 1 },
|
||||
]),
|
||||
),
|
||||
),
|
||||
container,
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
const tokensTile = container.querySelector(".sessions-overview__tile--tokens");
|
||||
expect(tokensTile?.querySelector(".sessions-overview__value")?.textContent?.trim()).toBe(
|
||||
"~1.2k",
|
||||
);
|
||||
|
||||
render(
|
||||
renderSessions(
|
||||
buildProps(buildMultiResult([{ key: "agent:main:b", kind: "direct", updatedAt: 1 }])),
|
||||
),
|
||||
container,
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(
|
||||
container
|
||||
.querySelector(".sessions-overview__tile--tokens .sessions-overview__value")
|
||||
?.textContent?.trim(),
|
||||
).toBe("n/a");
|
||||
});
|
||||
|
||||
it("omits the context meter when a session reports no context window", async () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
@@ -1908,7 +1883,7 @@ describe("sessions view", () => {
|
||||
|
||||
expect(container.querySelectorAll(".session-skeleton-row").length).toBeGreaterThan(0);
|
||||
expect(container.querySelector(".data-table-empty-cell")).toBeNull();
|
||||
expect(container.querySelector(".sessions-overview")).toBeNull();
|
||||
expect(container.querySelector(".sessions-heading-facts")).toBeNull();
|
||||
});
|
||||
});
|
||||
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|
||||
|
||||
+151
-142
@@ -18,6 +18,8 @@ import type {
|
||||
} from "../../api/types.ts";
|
||||
import { icons } from "../../components/icons.ts";
|
||||
import "../../components/tooltip.ts";
|
||||
import "../../components/web-awesome.ts";
|
||||
import "../../components/web-awesome-popover.ts";
|
||||
import {
|
||||
renderSettingsPage,
|
||||
renderSettingsSegmented,
|
||||
@@ -58,6 +60,7 @@ import {
|
||||
sessionNavigationTarget,
|
||||
} from "../../lib/sessions/route-navigation.ts";
|
||||
import { parseSessionKeyParts } from "../../lib/sessions/session-key.ts";
|
||||
import { DEFAULT_SESSION_LIST_QUERY } from "../../lib/sessions/session-requests.ts";
|
||||
|
||||
export type TranscriptSearchState =
|
||||
| { status: "idle" }
|
||||
@@ -298,10 +301,6 @@ function renderSessionAvatar(row: GatewaySessionRow) {
|
||||
const CONTEXT_METER_WARN_PERCENT = 65;
|
||||
const CONTEXT_METER_DANGER_PERCENT = 85;
|
||||
|
||||
function hasKnownTokenTotal(row: GatewaySessionRow): boolean {
|
||||
return typeof row.totalTokens === "number" && Number.isFinite(row.totalTokens);
|
||||
}
|
||||
|
||||
function renderTokensCell(row: GatewaySessionRow) {
|
||||
const total = row.totalTokens;
|
||||
if (typeof total !== "number" || !Number.isFinite(total)) {
|
||||
@@ -348,63 +347,37 @@ function renderTokensCell(row: GatewaySessionRow) {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSessionsOverview(
|
||||
function renderSessionsHeadingFacts(
|
||||
rows: GatewaySessionRow[],
|
||||
liveCount: number,
|
||||
statusFilter: SessionArchivedFilter,
|
||||
) {
|
||||
const unreadCount = rows.filter((row) => row.unread === true && row.archived !== true).length;
|
||||
const archivedCount = rows.filter((row) => row.archived === true).length;
|
||||
// Sum only known token totals; "~" marks the sum as partial/approximate when
|
||||
// rows lack a snapshot or carry a stale one, and no snapshot at all is n/a
|
||||
// rather than a fabricated 0.
|
||||
const rowsWithTokens = rows.filter(hasKnownTokenTotal);
|
||||
const totalTokens = rowsWithTokens.reduce((sum, row) => sum + (row.totalTokens ?? 0), 0);
|
||||
const tokensApproximate =
|
||||
rowsWithTokens.length < rows.length ||
|
||||
rowsWithTokens.some((row) => row.totalTokensFresh === false);
|
||||
const tokensValue =
|
||||
rowsWithTokens.length === 0
|
||||
? t("common.na")
|
||||
: `${tokensApproximate ? "~" : ""}${formatCompactTokenCount(totalTokens)}`;
|
||||
const tiles: Array<
|
||||
readonly [string, (typeof icons)[keyof typeof icons], string, string, boolean]
|
||||
> = [
|
||||
["sessions", icons.messageSquare, t("sessionsView.title"), String(rows.length), false],
|
||||
["live", icons.zap, t("sessionsView.statusLive"), String(liveCount), liveCount > 0],
|
||||
["unread", icons.eye, t("sessionsView.unread"), String(unreadCount), unreadCount > 0],
|
||||
["tokens", icons.barChart, t("sessionsView.tokens"), tokensValue, false],
|
||||
const facts: Array<readonly [string, string, boolean]> = [
|
||||
[String(liveCount), t("sessionsView.statusLive"), liveCount > 0],
|
||||
[String(unreadCount), t("sessionsView.unread"), unreadCount > 0],
|
||||
];
|
||||
if (statusFilter !== "active") {
|
||||
tiles.push([
|
||||
"archived",
|
||||
icons.archive,
|
||||
t("sessionsView.archived"),
|
||||
String(archivedCount),
|
||||
false,
|
||||
]);
|
||||
facts.push([String(archivedCount), t("sessionsView.archived"), false]);
|
||||
}
|
||||
return html`
|
||||
<div class="sessions-overview">
|
||||
${tiles.map(([id, icon, label, value, active]) => {
|
||||
const tileClass = [
|
||||
"sessions-overview__tile",
|
||||
`sessions-overview__tile--${id}`,
|
||||
active ? "sessions-overview__tile--active" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return html`
|
||||
<div class=${tileClass}>
|
||||
<span class="sessions-overview__icon" aria-hidden="true">${icon}</span>
|
||||
<span class="sessions-overview__meta">
|
||||
<span class="sessions-overview__value">${value}</span>
|
||||
<span class="sessions-overview__label">${label}</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
<span class="sessions-heading-facts">
|
||||
${facts.map(
|
||||
([value, label, active], index) => html`
|
||||
${index > 0
|
||||
? html`<span class="sessions-heading-fact__separator" aria-hidden="true">·</span>`
|
||||
: nothing}
|
||||
<span
|
||||
class=${active
|
||||
? "sessions-heading-fact sessions-heading-fact--active"
|
||||
: "sessions-heading-fact"}
|
||||
>
|
||||
<strong>${value}</strong> ${label}
|
||||
</span>
|
||||
`,
|
||||
)}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1052,6 +1025,7 @@ export function renderSessions(props: SessionsProps) {
|
||||
</openclaw-tooltip>
|
||||
`
|
||||
: nothing}
|
||||
${props.result ? renderSessionsHeadingFacts(rawRows, liveCount, props.statusFilter) : nothing}
|
||||
`;
|
||||
const refreshAction = html`
|
||||
${props.statusFilter === "archived"
|
||||
@@ -1074,22 +1048,17 @@ export function renderSessions(props: SessionsProps) {
|
||||
`;
|
||||
const children = [
|
||||
props.error ? html`<div class="sessions-error" role="alert">${props.error}</div>` : nothing,
|
||||
props.result
|
||||
? renderSettingsSection({}, renderSessionsOverview(rawRows, liveCount, props.statusFilter))
|
||||
: nothing,
|
||||
// When the gateway lacks sessions.search the section still renders: the
|
||||
// form disables itself and shows the unavailable notice (shipped behavior).
|
||||
renderSettingsSection(
|
||||
{
|
||||
title: t("sessionsView.transcriptSearchTitle"),
|
||||
description: t("sessionsView.transcriptSearchDescription"),
|
||||
},
|
||||
renderTranscriptSearch(props, rawRows),
|
||||
),
|
||||
renderSettingsSection(
|
||||
{
|
||||
title: sessionsTitle,
|
||||
description: t("sessionsView.subtitle"),
|
||||
actions: refreshAction,
|
||||
},
|
||||
renderSessionsTable(props, {
|
||||
@@ -1122,13 +1091,13 @@ type SessionsTableContext = {
|
||||
) => unknown;
|
||||
};
|
||||
|
||||
function renderSessionsTable(props: SessionsProps, ctx: SessionsTableContext) {
|
||||
const { paginated, groups, emptyBecauseFiltered, emptyMessage, totalRows, totalPages, page } =
|
||||
ctx;
|
||||
const sortHeader = ctx.sortHeader;
|
||||
const emptyStateMessage = emptyBecauseFiltered
|
||||
? t("sessionsView.noSessionsMatchFilters")
|
||||
: emptyMessage;
|
||||
function setPreviousSiblingExpanded(event: Event, expanded: boolean) {
|
||||
if (event.currentTarget instanceof Element) {
|
||||
event.currentTarget.previousElementSibling?.setAttribute("aria-expanded", String(expanded));
|
||||
}
|
||||
}
|
||||
|
||||
function renderSessionsAdvancedFilters(props: SessionsProps) {
|
||||
// Archived timestamps are intentionally stale, so recency only applies to the active view.
|
||||
const filterInputs = [
|
||||
[
|
||||
@@ -1150,6 +1119,109 @@ function renderSessionsTable(props: SessionsProps, ctx: SessionsTableContext) {
|
||||
key: keyof Parameters<SessionsProps["onFiltersChange"]>[0],
|
||||
value: string | boolean,
|
||||
) => props.onFiltersChange({ activeMinutes, limit, includeGlobal, includeUnknown, [key]: value });
|
||||
const active =
|
||||
activeMinutes.trim() !== "" ||
|
||||
limit.trim() !== String(DEFAULT_SESSION_LIST_QUERY.limit) ||
|
||||
!includeGlobal ||
|
||||
includeUnknown ||
|
||||
props.groupBy !== "none";
|
||||
return html`
|
||||
<button
|
||||
id="sessions-filter-popover-trigger"
|
||||
type="button"
|
||||
class="btn btn--sm sessions-filter-popover__trigger ${active ? "active" : ""}"
|
||||
title=${t("sessionsView.filters")}
|
||||
aria-label=${t("sessionsView.filters")}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded="false"
|
||||
>
|
||||
${icons.listFilter}
|
||||
</button>
|
||||
<wa-popover
|
||||
class="sessions-filter-popover"
|
||||
for="sessions-filter-popover-trigger"
|
||||
placement="bottom-end"
|
||||
without-arrow
|
||||
@wa-show=${(event: Event) => setPreviousSiblingExpanded(event, true)}
|
||||
@wa-hide=${(event: Event) => setPreviousSiblingExpanded(event, false)}
|
||||
>
|
||||
<div class="sessions-filter-popover__panel">
|
||||
<div class="sessions-filter-popover__fields">
|
||||
${filterInputs.map(
|
||||
([key, suffix, label, tooltip, placeholder, disabled]) => html`
|
||||
<openclaw-tooltip .content=${tooltip}>
|
||||
<label class="session-filter-field">
|
||||
<span class="session-filter-label">${label}</span>
|
||||
<input
|
||||
class="session-filter-input session-filter-input--${suffix}"
|
||||
placeholder=${placeholder}
|
||||
.value=${props[key]}
|
||||
?disabled=${disabled}
|
||||
@input=${(event: Event) =>
|
||||
updateFilter(key, (event.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
</openclaw-tooltip>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
class="session-filter-toggle-group"
|
||||
role="group"
|
||||
aria-label=${t("sessionsView.sourceFilters")}
|
||||
>
|
||||
${sourceFilters.map(([key, label, tooltip]) =>
|
||||
renderFilterToggle({
|
||||
name: key,
|
||||
checked: props[key],
|
||||
label,
|
||||
title: tooltip,
|
||||
onChange: (checked) => updateFilter(key, checked),
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
<label class="session-groupby">
|
||||
<span class="session-groupby__label">${t("sessionsView.groupBy")}</span>
|
||||
<select
|
||||
class="session-groupby__select"
|
||||
@change=${(event: Event) =>
|
||||
props.onGroupByChange((event.target as HTMLSelectElement).value as SessionsGroupBy)}
|
||||
>
|
||||
${SESSION_GROUP_MODES.filter(
|
||||
(mode) => mode !== "person" || props.personGroupingAvailable,
|
||||
).map(
|
||||
(mode) => html`
|
||||
<option value=${mode} ?selected=${props.groupBy === mode}>
|
||||
${groupModeLabel(mode)}
|
||||
</option>
|
||||
`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
${props.groupBy === "category"
|
||||
? html`
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
?disabled=${Boolean(props.groupWriteDisabledReason)}
|
||||
title=${props.groupWriteDisabledReason ?? nothing}
|
||||
@click=${() => props.onRequestNewCategory()}
|
||||
>
|
||||
${icons.plus} ${t("sessionsView.newGroup")}
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
</wa-popover>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSessionsTable(props: SessionsProps, ctx: SessionsTableContext) {
|
||||
const { paginated, groups, emptyBecauseFiltered, emptyMessage, totalRows, totalPages, page } =
|
||||
ctx;
|
||||
const sortHeader = ctx.sortHeader;
|
||||
const emptyStateMessage = emptyBecauseFiltered
|
||||
? t("sessionsView.noSessionsMatchFilters")
|
||||
: emptyMessage;
|
||||
const paginatedKeys = groups ? new Set(paginated.map((row) => row.key)) : null;
|
||||
return html`
|
||||
<div
|
||||
@@ -1165,85 +1237,22 @@ function renderSessionsTable(props: SessionsProps, ctx: SessionsTableContext) {
|
||||
@input=${(e: Event) => props.onSearchChange((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="session-filter-primary-row">
|
||||
${filterInputs.map(
|
||||
([key, suffix, label, tooltip, placeholder, disabled]) => html`
|
||||
<openclaw-tooltip .content=${tooltip}>
|
||||
<label class="session-filter-field">
|
||||
<span class="session-filter-label">${label}</span>
|
||||
<input
|
||||
class="session-filter-input session-filter-input--${suffix}"
|
||||
placeholder=${placeholder}
|
||||
.value=${props[key]}
|
||||
?disabled=${disabled}
|
||||
@input=${(event: Event) =>
|
||||
updateFilter(key, (event.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
</openclaw-tooltip>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
class="session-filter-toggle-group"
|
||||
role="group"
|
||||
aria-label=${t("sessionsView.sourceFilters")}
|
||||
>
|
||||
${sourceFilters.map(([key, label, tooltip]) =>
|
||||
renderFilterToggle({
|
||||
name: key,
|
||||
checked: props[key],
|
||||
label,
|
||||
title: tooltip,
|
||||
onChange: (checked) => updateFilter(key, checked),
|
||||
}),
|
||||
)}
|
||||
${renderSettingsSegmented<SessionArchivedFilter>({
|
||||
value: props.statusFilter,
|
||||
ariaLabel: t("sessionsView.sessionState"),
|
||||
className: "sessions-view-segment",
|
||||
options: [
|
||||
{ value: "active", label: t("common.active") },
|
||||
{
|
||||
value: "archived",
|
||||
label: t("sessionsView.archived"),
|
||||
title: t("sessionsView.archivedOnlyTooltip"),
|
||||
},
|
||||
{ value: "all", label: t("sessionsView.all") },
|
||||
],
|
||||
onChange: (value) => props.onStatusFilterChange(value),
|
||||
})}
|
||||
</div>
|
||||
<span class="sessions-toolbar__divider" aria-hidden="true"></span>
|
||||
<label class="session-groupby">
|
||||
<span class="session-groupby__label">${t("sessionsView.groupBy")}</span>
|
||||
<select
|
||||
class="session-groupby__select"
|
||||
@change=${(e: Event) =>
|
||||
props.onGroupByChange((e.target as HTMLSelectElement).value as SessionsGroupBy)}
|
||||
>
|
||||
${SESSION_GROUP_MODES.filter(
|
||||
(mode) => mode !== "person" || props.personGroupingAvailable,
|
||||
).map(
|
||||
(mode) =>
|
||||
html`<option value=${mode} ?selected=${props.groupBy === mode}>
|
||||
${groupModeLabel(mode)}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
${props.groupBy === "category"
|
||||
? html`
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
?disabled=${Boolean(props.groupWriteDisabledReason)}
|
||||
title=${props.groupWriteDisabledReason ?? nothing}
|
||||
@click=${() => props.onRequestNewCategory()}
|
||||
>
|
||||
${icons.plus} ${t("sessionsView.newGroup")}
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
${renderSettingsSegmented<SessionArchivedFilter>({
|
||||
value: props.statusFilter,
|
||||
ariaLabel: t("sessionsView.sessionState"),
|
||||
className: "sessions-view-segment",
|
||||
options: [
|
||||
{ value: "active", label: t("common.active") },
|
||||
{
|
||||
value: "archived",
|
||||
label: t("sessionsView.archived"),
|
||||
title: t("sessionsView.archivedOnlyTooltip"),
|
||||
},
|
||||
{ value: "all", label: t("sessionsView.all") },
|
||||
],
|
||||
onChange: (value) => props.onStatusFilterChange(value),
|
||||
})}
|
||||
${renderSessionsAdvancedFilters(props)}
|
||||
</div>
|
||||
|
||||
${props.selectedKeys.size > 0
|
||||
|
||||
+124
-103
@@ -1,7 +1,7 @@
|
||||
/* ===========================================
|
||||
Sessions page
|
||||
Structure comes from the settings design language (settings.css). This file
|
||||
keeps the sessions-specific escape hatches: overview grid, roster toolbar,
|
||||
keeps the sessions-specific escape hatches: overview facts, roster toolbar,
|
||||
table cells, and the row drawer's flat interior.
|
||||
=========================================== */
|
||||
|
||||
@@ -12,93 +12,55 @@
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* --- Overview grid (escape hatch inside one group) --- */
|
||||
.sessions-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
/* Sessions is a scan-heavy workspace, so keep its section rhythm tighter than
|
||||
form-heavy settings pages while preserving the shared control sizes. */
|
||||
.settings-page:has(.sessions-transcript-search) {
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.sessions-overview__tile {
|
||||
.settings-page:has(.sessions-transcript-search) > .settings-section {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* --- Compact overview facts stay attached to the Sessions heading. --- */
|
||||
.settings-section__heading:has(.sessions-heading-facts) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
min-width: 0;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.sessions-overview__icon {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex: 0 0 auto;
|
||||
.sessions-heading-facts {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
color: var(--muted);
|
||||
background: var(--bg-hover);
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.sessions-overview__icon svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 2px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.sessions-overview__meta {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sessions-overview__value {
|
||||
overflow: hidden;
|
||||
.sessions-heading-fact strong {
|
||||
color: var(--text-strong);
|
||||
font-family: var(--mono);
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sessions-overview__label {
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: var(--control-ui-text-xs);
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.05em;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sessions-overview__tile--live.sessions-overview__tile--active .sessions-overview__icon,
|
||||
.sessions-overview__tile--live.sessions-overview__tile--active .sessions-overview__value {
|
||||
.sessions-heading-fact--active,
|
||||
.sessions-heading-fact--active strong {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.sessions-overview__tile--live.sessions-overview__tile--active .sessions-overview__icon {
|
||||
background: var(--ok-subtle);
|
||||
}
|
||||
|
||||
.sessions-overview__tile--unread.sessions-overview__tile--active .sessions-overview__icon,
|
||||
.sessions-overview__tile--unread.sessions-overview__tile--active .sessions-overview__value {
|
||||
color: var(--accent-2);
|
||||
}
|
||||
|
||||
.sessions-overview__tile--unread.sessions-overview__tile--active .sessions-overview__icon {
|
||||
background: var(--accent-2-subtle);
|
||||
.sessions-heading-fact__separator {
|
||||
color: var(--border-strong);
|
||||
}
|
||||
|
||||
/* --- Transcript search: server-backed message content, separate from roster filters --- */
|
||||
.sessions-transcript-search {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3) var(--space-3);
|
||||
}
|
||||
|
||||
.sessions-transcript-search__form {
|
||||
@@ -215,8 +177,8 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
|
||||
}
|
||||
|
||||
@@ -243,15 +205,6 @@
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
.sessions-toolbar__divider {
|
||||
align-self: center;
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
flex: 0 0 auto;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.session-filter-primary-row,
|
||||
.session-filter-toggle-group {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -265,16 +218,16 @@
|
||||
gap: var(--space-1);
|
||||
height: 30px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 4px 0 10px;
|
||||
background: color-mix(in srgb, var(--bg) 80%, var(--bg-elevated) 20%);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
|
||||
padding: 0 4px 0 8px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: border-color var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.session-filter-field:focus-within {
|
||||
border-color: var(--border-strong);
|
||||
box-shadow: var(--focus-ring);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.session-filter-label,
|
||||
@@ -318,9 +271,9 @@
|
||||
gap: var(--space-1);
|
||||
height: 30px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 11px;
|
||||
background: color-mix(in srgb, var(--bg) 80%, var(--bg-elevated) 20%);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
|
||||
padding: 0 8px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
user-select: none;
|
||||
transition:
|
||||
@@ -335,7 +288,7 @@
|
||||
}
|
||||
|
||||
.session-filter-check--active {
|
||||
border-color: color-mix(in srgb, var(--accent) 44%, var(--border));
|
||||
border-color: color-mix(in srgb, var(--accent) 28%, transparent);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
@@ -382,6 +335,73 @@
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sessions-filter-popover {
|
||||
--max-width: min(280px, calc(100vw - 48px));
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sessions-filter-popover::part(body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sessions-filter-popover__trigger {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: 0 0 auto;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sessions-filter-popover__trigger svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.sessions-filter-popover__panel {
|
||||
display: grid;
|
||||
width: min(280px, calc(100vw - 48px));
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--overlay-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--overlay-shadow);
|
||||
}
|
||||
|
||||
.sessions-filter-popover__fields {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.sessions-filter-popover__panel .session-filter-field {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
background: var(--bg);
|
||||
border-color: color-mix(in srgb, var(--border) 70%, transparent);
|
||||
}
|
||||
|
||||
.sessions-filter-popover__panel .session-filter-toggle-group,
|
||||
.sessions-filter-popover__panel .session-groupby {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sessions-filter-popover__panel .session-filter-check {
|
||||
flex: 1 1 0;
|
||||
justify-content: center;
|
||||
border-color: color-mix(in srgb, var(--border) 70%, transparent);
|
||||
}
|
||||
|
||||
.sessions-filter-popover__panel .session-groupby {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sessions-filter-popover__panel .session-groupby__select {
|
||||
flex: 1 1 auto;
|
||||
background: var(--bg);
|
||||
border-color: color-mix(in srgb, var(--border) 70%, transparent);
|
||||
}
|
||||
|
||||
.session-groupby {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
@@ -399,8 +419,8 @@
|
||||
height: 30px;
|
||||
padding: 0 8px;
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--bg) 80%, var(--bg-elevated) 20%);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--control-ui-text-sm);
|
||||
}
|
||||
@@ -410,6 +430,11 @@
|
||||
min-width: 760px;
|
||||
}
|
||||
|
||||
.sessions-table th,
|
||||
.sessions-table td {
|
||||
padding-block: var(--space-2);
|
||||
}
|
||||
|
||||
.sessions-table tbody tr.session-data-row > td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -883,8 +908,8 @@
|
||||
|
||||
.session-details-panel {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@@ -929,8 +954,8 @@
|
||||
|
||||
.session-details-section {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding-top: var(--space-3);
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
|
||||
}
|
||||
|
||||
@@ -969,8 +994,8 @@
|
||||
.session-details-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
|
||||
gap: var(--space-3);
|
||||
padding-top: var(--space-3);
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
|
||||
}
|
||||
|
||||
@@ -1050,11 +1075,6 @@
|
||||
|
||||
/* --- Narrow layouts --- */
|
||||
@media (max-width: 768px) {
|
||||
.sessions-overview {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.sessions-toolbar {
|
||||
align-items: stretch;
|
||||
}
|
||||
@@ -1075,11 +1095,6 @@
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.sessions-toolbar__divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.session-filter-primary-row,
|
||||
.session-filter-toggle-group,
|
||||
.session-groupby {
|
||||
width: 100%;
|
||||
@@ -1099,3 +1114,9 @@
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.sessions-filter-popover__trigger {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +231,7 @@ export const defaultControlUiFeatureMethods = [
|
||||
"sessions.reclaim",
|
||||
"sessions.reset",
|
||||
"sessions.rewind",
|
||||
"sessions.search",
|
||||
"tools.github.status",
|
||||
"tools.github.configure",
|
||||
"tools.github.authorize.start",
|
||||
@@ -1859,6 +1860,8 @@ function installControlUiMockGateway(
|
||||
},
|
||||
params,
|
||||
);
|
||||
case "sessions.search":
|
||||
return { results: [] };
|
||||
case "sessions.patchMany": {
|
||||
const targets = isRecord(params) && Array.isArray(params.targets) ? params.targets : [];
|
||||
const result = {
|
||||
|
||||
Reference in New Issue
Block a user