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:
ClawSweeper
2026-08-22 21:06:51 -07:00
committed by GitHub
parent 91fb03223a
commit 4a70af553c
10 changed files with 348 additions and 322 deletions
+2
View File
@@ -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,
+1
View File
@@ -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",
+24 -19
View File
@@ -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");
+33 -58
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
+3
View File
@@ -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 = {