diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts
index 80dc4d593b02..1e796bf646cc 100644
--- a/scripts/control-ui-mock-dev.ts
+++ b/scripts/control-ui-mock-dev.ts
@@ -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,
diff --git a/ui/src/e2e/session-list-filter.e2e.test.ts b/ui/src/e2e/session-list-filter.e2e.test.ts
index f8c0d231ef24..4a68bb1c6efc 100644
--- a/ui/src/e2e/session-list-filter.e2e.test.ts
+++ b/ui/src/e2e/session-list-filter.e2e.test.ts
@@ -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 = [
diff --git a/ui/src/e2e/session-management.groups.e2e.test.ts b/ui/src/e2e/session-management.groups.e2e.test.ts
index f1688b26e989..aaa602081ebb 100644
--- a/ui/src/e2e/session-management.groups.e2e.test.ts
+++ b/ui/src/e2e/session-management.groups.e2e.test.ts
@@ -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");
diff --git a/ui/src/e2e/session-transcript-search.e2e.test.ts b/ui/src/e2e/session-transcript-search.e2e.test.ts
index 761dfb9f04b4..5c90d57fba15 100644
--- a/ui/src/e2e/session-transcript-search.e2e.test.ts
+++ b/ui/src/e2e/session-transcript-search.e2e.test.ts
@@ -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,
diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts
index 3f33ce2ba8f2..5e5982fcc05c 100644
--- a/ui/src/i18n/locales/en.ts
+++ b/ui/src/i18n/locales/en.ts
@@ -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",
diff --git a/ui/src/pages/sessions/view.browser.test.ts b/ui/src/pages/sessions/view.browser.test.ts
index 933c3e6fd762..2c0c4de7d345 100644
--- a/ui/src/pages/sessions/view.browser.test.ts
+++ b/ui/src/pages/sessions/view.browser.test.ts
@@ -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]) => `
-
-
-
-
-
- ${value}
- ${label}
-
-
+ ([value, label], index) => `
+ ${index > 0 ? '·' : ""}
+
+ ${value} ${label}
+
`,
)
.join("");
return `
-
-
+
+
+
@@ -208,7 +205,8 @@ function sessionsTableHtml() {
-
+
+
`;
}
@@ -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");
diff --git a/ui/src/pages/sessions/view.test.ts b/ui/src/pages/sessions/view.test.ts
index f70ceae99223..4fd66888704e 100644
--- a/ui/src/pages/sessions/view.test.ts
+++ b/ui/src/pages/sessions/view.test.ts
@@ -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(".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. */
diff --git a/ui/src/pages/sessions/view.ts b/ui/src/pages/sessions/view.ts
index 3fe4a8b60c76..affadc9a0b46 100644
--- a/ui/src/pages/sessions/view.ts
+++ b/ui/src/pages/sessions/view.ts
@@ -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 = [
+ [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`
-
- ${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`
-
- ${icon}
-
- ${value}
- ${label}
-
-
- `;
- })}
-
+
+ ${facts.map(
+ ([value, label, active], index) => html`
+ ${index > 0
+ ? html`·`
+ : nothing}
+
+ ${value} ${label}
+
+ `,
+ )}
+
`;
}
@@ -1052,6 +1025,7 @@ export function renderSessions(props: SessionsProps) {
`
: 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`${props.error}
` : 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[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`
+
+ setPreviousSiblingExpanded(event, true)}
+ @wa-hide=${(event: Event) => setPreviousSiblingExpanded(event, false)}
+ >
+
+
+ ${filterInputs.map(
+ ([key, suffix, label, tooltip, placeholder, disabled]) => html`
+
+
+
+ `,
+ )}
+
+
+ ${sourceFilters.map(([key, label, tooltip]) =>
+ renderFilterToggle({
+ name: key,
+ checked: props[key],
+ label,
+ title: tooltip,
+ onChange: (checked) => updateFilter(key, checked),
+ }),
+ )}
+
+
+ ${props.groupBy === "category"
+ ? html`
+
+ `
+ : nothing}
+
+
+ `;
+}
+
+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`
props.onSearchChange((e.target as HTMLInputElement).value)}
/>
-
- ${filterInputs.map(
- ([key, suffix, label, tooltip, placeholder, disabled]) => html`
-
-
-
- `,
- )}
-
-
- ${sourceFilters.map(([key, label, tooltip]) =>
- renderFilterToggle({
- name: key,
- checked: props[key],
- label,
- title: tooltip,
- onChange: (checked) => updateFilter(key, checked),
- }),
- )}
- ${renderSettingsSegmented({
- 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),
- })}
-
-
-
- ${props.groupBy === "category"
- ? html`
-
- `
- : nothing}
+ ${renderSettingsSegmented({
+ 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)}
${props.selectedKeys.size > 0
diff --git a/ui/src/styles/sessions.css b/ui/src/styles/sessions.css
index f938a7566119..884a06dc5eef 100644
--- a/ui/src/styles/sessions.css
+++ b/ui/src/styles/sessions.css
@@ -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;
+ }
+}
diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts
index 1104b1be20fb..972cdbe31ddf 100644
--- a/ui/src/test-helpers/control-ui-e2e.ts
+++ b/ui/src/test-helpers/control-ui-e2e.ts
@@ -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 = {