From 4a70af553c2efbd14e4455f2dfdb8fa22f6f573f Mon Sep 17 00:00:00 2001 From: ClawSweeper Date: Sat, 22 Aug 2026 21:06:51 -0700 Subject: [PATCH] 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> --- scripts/control-ui-mock-dev.ts | 2 + ui/src/e2e/session-list-filter.e2e.test.ts | 7 + .../e2e/session-management.groups.e2e.test.ts | 2 + .../e2e/session-transcript-search.e2e.test.ts | 1 + ui/src/i18n/locales/en.ts | 1 + ui/src/pages/sessions/view.browser.test.ts | 43 +-- ui/src/pages/sessions/view.test.ts | 91 ++---- ui/src/pages/sessions/view.ts | 293 +++++++++--------- ui/src/styles/sessions.css | 227 ++++++++------ ui/src/test-helpers/control-ui-e2e.ts | 3 + 10 files changed, 348 insertions(+), 322 deletions(-) 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 `
-
-
${overviewTiles}
-
-
+
+
+

+ Sessions 3 + ${headingFacts} +

+
+
@@ -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` -
- - - ${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`` : 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 = {