feat(ui): move session filter to a global sidebar toolbar (#125690)

* feat(ui): promote session controls to toolbar

* test(ui): add session toolbar proof capture script

* chore: register session toolbar proof script

* test(ui): target ungrouped session drop zone

* fix(ui): trim session toolbar startup cost

* docs(ui): point new-session guide at the Sessions toolbar

Also name the accepted tradeoff for the inert ungrouped-collapse preference at the partition site (ClawSweeper P1/P3 response).

* test(ui): deflake new-session workspace-memory e2e

Menu-geometry assertions now measure relative to the picker anchor in one atomic evaluate (immune to unrelated page settles, still catches focus-induced moves). The post-reload refill raced the composer's async draft restore, which appended the stored draft to the typed text; waiting for the restored draft asserts the documented persistence instead. Failed CI shard checks-ui-e2e 5/12 on runs 32122284238 attempts 1-2.

* fix(ui): use canonical session owner filter state

* chore(ui): keep session toolbar proof artifacts ignored
This commit is contained in:
Peter Steinberger
2026-08-18 08:09:57 -07:00
committed by GitHub
parent 1ed682f883
commit c77feb00f3
21 changed files with 676 additions and 260 deletions
File diff suppressed because one or more lines are too long
+1
View File
@@ -1990,6 +1990,7 @@
"ui:i18n:verify": "node --import tsx scripts/control-ui-i18n-verify.ts verify",
"ui:proof:composer-mic-hover": "node --import tsx scripts/capture-composer-mic-hover-proof.mts",
"ui:proof:model-picker": "node --import tsx scripts/capture-model-picker-proof.mts",
"ui:proof:session-toolbar": "node --import tsx scripts/capture-session-toolbar-proof.mts",
"ui:proof:workboard": "node --import tsx scripts/capture-workboard-ui-proof.mts",
"native:i18n:baseline": "node --import tsx scripts/native-app-i18n.ts baseline --write",
"native:i18n:check": "node --import tsx scripts/native-app-i18n.ts check",
+309
View File
@@ -0,0 +1,309 @@
#!/usr/bin/env node
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium, type Page } from "playwright";
import {
canRunPlaywrightChromium,
installMockGateway,
resolvePlaywrightChromiumExecutablePath,
startControlUiE2eServer,
} from "../ui/src/test-helpers/control-ui-e2e.ts";
type CaptureMode = "after" | "before";
function readOption(name: string): string | undefined {
const prefix = `--${name}=`;
const inline = process.argv.slice(2).find((arg) => arg.startsWith(prefix));
if (inline) {
return inline.slice(prefix.length);
}
const index = process.argv.indexOf(`--${name}`);
return index >= 0 ? process.argv[index + 1] : undefined;
}
function readMode(): CaptureMode {
const value = readOption("mode") ?? "after";
if (value !== "after" && value !== "before") {
throw new Error(`Expected --mode after|before, received ${value}`);
}
return value;
}
function sessionRow(
key: string,
label: string,
updatedAt: number,
extra: Record<string, unknown> = {},
) {
return {
contextTokens: 200_000,
displayName: label,
hasActiveRun: false,
key,
kind: "direct",
label,
model: "gpt-5.6-luna",
modelProvider: "openai",
status: "done",
totalTokens: 0,
updatedAt,
...extra,
};
}
function sessionsListResponse(sessions: unknown[]) {
return {
count: sessions.length,
defaults: {
contextTokens: 200_000,
model: "gpt-5.6-luna",
modelProvider: "openai",
},
hasMore: false,
limitApplied: 50,
nextOffset: null,
offset: 0,
path: "",
sessions,
totalCount: sessions.length,
ts: Date.parse("2026-08-17T20:00:00.000Z"),
};
}
const baseTime = Date.parse("2026-08-17T20:00:00.000Z");
const groupedSessions = [
sessionRow("agent:main:main", "Main", baseTime),
sessionRow("agent:main:jesse-roadmap", "Roadmap review", baseTime - 60_000, {
category: "Jesse",
}),
sessionRow("agent:main:jesse-launch", "Launch checklist", baseTime - 120_000, {
category: "Jesse",
}),
sessionRow("agent:main:josh-design", "Design handoff", baseTime - 180_000, {
category: "Josh",
}),
sessionRow("agent:main:josh-feedback", "Customer feedback", baseTime - 240_000, {
category: "Josh",
}),
sessionRow("agent:main:weekly-planning", "Weekly planning", baseTime - 300_000),
sessionRow("agent:main:travel-notes", "Travel notes", baseTime - 360_000),
sessionRow("agent:main:reading-list", "Reading list", baseTime - 420_000),
sessionRow("agent:main:toolbar-cleanup", "Toolbar cleanup", baseTime - 480_000, {
worktree: {
branch: "feat/session-toolbar",
id: "wt-session-toolbar",
repoRoot: "/Users/demo/Projects/openclaw",
},
}),
sessionRow("agent:main:filter-followup", "Filter menu follow-up", baseTime - 540_000, {
worktree: {
branch: "fix/filter-menu",
id: "wt-filter-menu",
repoRoot: "/Users/demo/Projects/openclaw",
},
}),
];
const ungroupedSessions = [
sessionRow("agent:main:main", "Main", baseTime),
sessionRow("agent:main:weekly-planning", "Weekly planning", baseTime - 60_000),
sessionRow("agent:main:travel-notes", "Travel notes", baseTime - 120_000),
sessionRow("agent:main:reading-list", "Reading list", baseTime - 180_000),
];
const mode = readMode();
const outputDir = path.resolve(
readOption("output-dir") ?? ".artifacts/control-ui-e2e/session-toolbar-proof",
);
const executablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
if (!canRunPlaywrightChromium(executablePath)) {
throw new Error(`Playwright Chromium is unavailable at ${executablePath}`);
}
await mkdir(outputDir, { recursive: true });
const server = await startControlUiE2eServer(undefined, { source: true });
const browser = await chromium.launch({ executablePath });
const captured: string[] = [];
async function settle(page: Page): Promise<void> {
await page.evaluate(async () => {
await document.fonts.ready;
await new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
});
}
async function capture(page: Page, name: string): Promise<void> {
await settle(page);
const target = path.join(outputDir, name);
await page.screenshot({
animations: "disabled",
clip: { x: 0, y: 0, width: 560, height: 900 },
path: target,
});
captured.push(target);
}
async function openScenario(sessions: unknown[], groups: string[] = []) {
const context = await browser.newContext({
colorScheme: "dark",
locale: "en-US",
reducedMotion: "reduce",
serviceWorkers: "block",
viewport: { width: 1280, height: 900 },
});
const page = await context.newPage();
page.setDefaultTimeout(30_000);
await installMockGateway(page, {
methodResponses: {
"sessions.list": sessionsListResponse(sessions),
},
sessionArchiveFiltering: true,
sessionGroups: groups,
sessionKey: "agent:main:main",
});
await page.goto(`${server.baseUrl}chat`);
await page.locator("openclaw-app-sidebar").waitFor({ state: "visible" });
await page.waitForFunction(() => document.documentElement.dataset.theme === "dark");
return { context, page };
}
async function expandCoding(page: Page): Promise<void> {
const coding = page.locator('[data-session-section="work"]');
await coding.waitFor({ state: "visible" });
const toggle = coding.getByRole("button", { name: "Coding", exact: true });
if ((await toggle.getAttribute("aria-expanded")) !== "true") {
await toggle.click();
}
await page.waitForFunction(
() =>
document.querySelectorAll('[data-session-section="work"] .sidebar-recent-session').length ===
2,
);
}
try {
if (mode === "after") {
const grouped = await openScenario(groupedSessions, ["Jesse", "Josh"]);
try {
await grouped.page.waitForFunction(() => {
const counts = ["Jesse", "Josh"].map(
(name) =>
document.querySelectorAll(
`[data-session-section="category:${name}"] .sidebar-recent-session`,
).length,
);
return counts.every((count) => count === 2);
});
await grouped.page.waitForFunction(
() =>
document.querySelectorAll('[data-session-section="ungrouped"] .sidebar-recent-session')
.length === 3,
);
await expandCoding(grouped.page);
const toolbar = grouped.page.locator(".sidebar-session-toolbar");
await toolbar.getByText("Sessions", { exact: true }).waitFor();
const filter = toolbar.getByRole("button", { name: "Filter & sort" });
const add = toolbar.getByRole("button", { name: "New session" });
await grouped.page.mouse.move(1_000, 850);
const toolbarOpacity = await Promise.all([
filter.evaluate((element) => Number.parseFloat(getComputedStyle(element).opacity)),
add.evaluate((element) => Number.parseFloat(getComputedStyle(element).opacity)),
]);
if (toolbarOpacity.some((opacity) => opacity <= 0)) {
throw new Error(
`Toolbar controls are not visible without hover: ${toolbarOpacity.join(", ")}`,
);
}
await grouped.page.getByText("Other", { exact: true }).waitFor();
await capture(grouped.page, "after-grouped.png");
await filter.click();
await grouped.page.locator(".sidebar-session-sort-menu").waitFor({ state: "visible" });
await capture(grouped.page, "after-toolbar-menu.png");
await grouped.page.getByRole("menuitemradio", { name: "All", exact: true }).click();
await grouped.page.waitForFunction(
() =>
document
.querySelector(".sidebar-session-toolbar .sidebar-session-sort")
?.classList.contains("sidebar-session-sort--filtered") === true,
);
await grouped.page.mouse.move(1_000, 850);
await capture(grouped.page, "after-filter-active.png");
} finally {
await grouped.context.close();
}
const ungrouped = await openScenario(ungroupedSessions);
try {
await ungrouped.page.waitForFunction(
() =>
document.querySelectorAll('[data-session-section="ungrouped"] .sidebar-recent-session')
.length === 3,
);
if (
(await ungrouped.page
.locator('[data-session-section="ungrouped"] > .sidebar-recent-sessions__head')
.count()) !== 0
) {
throw new Error("Ungrouped-only state unexpectedly rendered a section header");
}
await ungrouped.page.getByText("Sessions", { exact: true }).waitFor();
await capture(ungrouped.page, "after-ungrouped-only.png");
} finally {
await ungrouped.context.close();
}
} else {
const grouped = await openScenario(groupedSessions, ["Jesse", "Josh"]);
try {
await grouped.page.waitForFunction(
() =>
document.querySelectorAll('[data-session-section="ungrouped"] .sidebar-recent-session')
.length === 3,
);
await expandCoding(grouped.page);
const header = grouped.page.locator(
'[data-session-section="ungrouped"] > .sidebar-recent-sessions__head',
);
const filter = header.getByRole("button", { name: "Sort sessions" });
const add = header.getByRole("button", { name: "New session" });
await grouped.page.mouse.move(1_000, 850);
await settle(grouped.page);
const idleOpacity = await Promise.all([
filter.evaluate((element) => Number.parseFloat(getComputedStyle(element).opacity)),
add.evaluate((element) => Number.parseFloat(getComputedStyle(element).opacity)),
]);
if (idleOpacity.some((opacity) => opacity !== 0)) {
throw new Error(`Legacy controls are not hover-hidden: ${idleOpacity.join(", ")}`);
}
await capture(grouped.page, "before-grouped.png");
await header.hover();
await grouped.page.waitForFunction(() => {
const controls = [
...document.querySelectorAll(
'[data-session-section="ungrouped"] > .sidebar-recent-sessions__head .sidebar-session-group-actions',
),
];
return (
controls.length === 2 &&
controls.every((element) => getComputedStyle(element).opacity === "1")
);
});
await capture(grouped.page, "before-grouped-hover.png");
} finally {
await grouped.context.close();
}
}
} finally {
await browser.close();
await server.close();
}
console.log(
JSON.stringify({ captured, fixture: "custom installMockGateway scenario", mode }, null, 2),
);
@@ -28,6 +28,7 @@ type RenderableSessionSection = SidebarSessionSection<SidebarRecentSession> & {
visibleRowCount: number;
visibleLimit: number;
collapsedVisibleRowCount: number;
renderHeader: boolean;
};
type SidebarSessionListHost = SessionListHost & {
@@ -58,16 +59,16 @@ function renderSessionSection(params: {
const { host, section } = params;
const totalRowCount = section.totalRowCount;
const group = section.category;
// zonedVisibleSections removes pinned rows; AppSidebar renders them through
// renderPinnedSidebarSession, so every section here has a header.
const collapsed = host.collapsedSessionSections.has(section.id);
// Pinned rows render in the nav zone; renderHeader records whether this list
// section owns collapse UI or sits directly below the global toolbar.
const collapsed = section.renderHeader && host.collapsedSessionSections.has(section.id);
const label = section.groups
? t("chat.sidebar.groups")
: section.work
? t("chat.sidebar.coding")
: group
? group
: t("chat.sidebar.threads");
: t("chat.sidebar.otherSessions");
const zone = section.groups ? "groups" : section.work ? "coding" : group ? "category" : "threads";
// Collapsed Coding still signals live runs so background work stays visible.
const collapsedRunningDot =
@@ -112,128 +113,95 @@ function renderSessionSection(params: {
? (event: DragEvent) => host.sectionDrop(event, section.id, group)
: nothing}
>
${renderSidebarSessionSectionHeader({
sectionId: section.id,
disabledReason: groupWriteAccess.allowed ? undefined : groupWriteAccess.reason,
onStartDrag: (sectionId) => host.startSidebarSectionDrag(sectionId),
onFinishDrag: () => host.finishSidebarSectionDrag(),
onContextMenu: group
? (event: MouseEvent) => {
event.preventDefault();
host.sidebarMenus.openSessionGroupMenu(group, event.clientX, event.clientY, null);
}
: undefined,
content: html`
<button
type="button"
class="sidebar-session-group-toggle"
aria-expanded=${String(!collapsed)}
aria-label=${label}
@click=${() => host.toggleSection(section.id)}
>
<span class="sidebar-session-group-toggle__lead" aria-hidden="true">
<span class="sidebar-session-group-toggle__icon"
>${collapsed ? icons.chevronRight : icons.chevronDown}</span
${section.renderHeader
? renderSidebarSessionSectionHeader({
sectionId: section.id,
disabledReason: groupWriteAccess.allowed ? undefined : groupWriteAccess.reason,
onStartDrag: (sectionId) => host.startSidebarSectionDrag(sectionId),
onFinishDrag: () => host.finishSidebarSectionDrag(),
onContextMenu: group
? (event: MouseEvent) => {
event.preventDefault();
host.sidebarMenus.openSessionGroupMenu(group, event.clientX, event.clientY, null);
}
: undefined,
content: html`
<button
type="button"
class="sidebar-session-group-toggle"
aria-expanded=${String(!collapsed)}
aria-label=${label}
@click=${() => host.toggleSection(section.id)}
>
</span>
<span class="sidebar-recent-sessions__label-text">${label}</span>
${collapsed && totalRowCount > 0
? html`<span class="sidebar-session-group-count">${totalRowCount}</span>`
: nothing}
${collapsedRunningDot
? html`<span
class="session-run-spinner sidebar-session-group-running"
role="img"
aria-label=${t("sessionsView.activeRun")}
title=${t("sessionsView.activeRun")}
></span>`
: nothing}
${collapsedAttentionDot
? html`<span
class="sidebar-session-group-attention"
role="img"
aria-label=${t("sessionsView.attentionRequired")}
title=${t("sessionsView.attentionRequired")}
></span>`
: nothing}
</button>
${section.id === "ungrouped"
? html`
<button
type="button"
class="sidebar-session-group-actions sidebar-session-sort ${host.sessionOwnerFilterActive
? "sidebar-session-sort--filtered"
: ""}"
title=${t("chat.sidebar.sortSessions")}
aria-label=${t("chat.sidebar.sortSessions")}
aria-haspopup="menu"
aria-expanded=${String(host.sidebarMenus.sessionSortMenuPosition !== null)}
@click=${(event: MouseEvent) => {
event.stopPropagation();
host.sidebarMenus.toggleSessionSortMenu(event.currentTarget as HTMLElement);
}}
>
${icons.listFilter}
</button>
<button
type="button"
class="sidebar-session-group-actions sidebar-new-session"
title=${newSessionAccess.allowed
? t("chat.runControls.newSession")
: newSessionAccess.reason}
aria-label=${t("chat.runControls.newSession")}
?disabled=${!newSessionAccess.allowed}
@click=${(event: MouseEvent) => {
event.stopPropagation();
host.openNewSession();
}}
>
${icons.plus}
</button>
`
: nothing}
${group
? html`
<button
type="button"
class="sidebar-session-group-actions sidebar-new-session"
title=${newSessionAccess.allowed
? t("sessionsView.newSessionInGroup", { group })
: newSessionAccess.reason}
aria-label=${t("sessionsView.newSessionInGroup", { group })}
?disabled=${!newSessionAccess.allowed}
@click=${(event: MouseEvent) => {
event.stopPropagation();
host.openNewSession({ group });
}}
>
${icons.plus}
</button>
<button
type="button"
class="sidebar-session-group-actions"
title=${t("sessionsView.groupMenu", { group })}
aria-label=${t("sessionsView.groupMenu", { group })}
aria-haspopup="menu"
aria-expanded=${String(host.sidebarMenus.sessionGroupMenu?.group === group)}
@click=${(event: MouseEvent) => {
event.stopPropagation();
const trigger = event.currentTarget as HTMLElement;
const rect = trigger.getBoundingClientRect();
host.sidebarMenus.openSessionGroupMenu(
group,
rect.right,
rect.bottom + 4,
trigger,
);
}}
>
${icons.moreHorizontal}
</button>
`
: nothing}
`,
})}
<span class="sidebar-session-group-toggle__lead" aria-hidden="true">
<span class="sidebar-session-group-toggle__icon"
>${collapsed ? icons.chevronRight : icons.chevronDown}</span
>
</span>
<span class="sidebar-recent-sessions__label-text">${label}</span>
${collapsed && totalRowCount > 0
? html`<span class="sidebar-session-group-count">${totalRowCount}</span>`
: nothing}
${collapsedRunningDot
? html`<span
class="session-run-spinner sidebar-session-group-running"
role="img"
aria-label=${t("sessionsView.activeRun")}
title=${t("sessionsView.activeRun")}
></span>`
: nothing}
${collapsedAttentionDot
? html`<span
class="sidebar-session-group-attention"
role="img"
aria-label=${t("sessionsView.attentionRequired")}
title=${t("sessionsView.attentionRequired")}
></span>`
: nothing}
</button>
${group
? html`
<button
type="button"
class="sidebar-session-group-actions sidebar-new-session"
title=${newSessionAccess.allowed
? t("sessionsView.newSessionInGroup", { group })
: newSessionAccess.reason}
aria-label=${t("sessionsView.newSessionInGroup", { group })}
?disabled=${!newSessionAccess.allowed}
@click=${(event: MouseEvent) => {
event.stopPropagation();
host.openNewSession({ group });
}}
>
${icons.plus}
</button>
<button
type="button"
class="sidebar-session-group-actions"
title=${t("sessionsView.groupMenu", { group })}
aria-label=${t("sessionsView.groupMenu", { group })}
aria-haspopup="menu"
aria-expanded=${String(host.sidebarMenus.sessionGroupMenu?.group === group)}
@click=${(event: MouseEvent) => {
event.stopPropagation();
const trigger = event.currentTarget as HTMLElement;
const rect = trigger.getBoundingClientRect();
host.sidebarMenus.openSessionGroupMenu(
group,
rect.right,
rect.bottom + 4,
trigger,
);
}}
>
${icons.moreHorizontal}
</button>
`
: nothing}
`,
})
: nothing}
${collapsed
? nothing
: html`
@@ -389,11 +357,6 @@ function renderSessionListBody(params: {
className: "sidebar-session-error sidebar-session-catalog-error",
})
: nothing;
// Categorized threads still need the global sort and new-thread actions,
// which belong to Threads even when that section has no rows of its own.
const hasCategorizedThreads = params.sections.some(
(section) => Boolean(section.category) && section.totalRowCount > 0,
);
return html`
${params.sections.map((section, index) => {
if (section.id.startsWith("catalog:")) {
@@ -415,13 +378,11 @@ function renderSessionListBody(params: {
}
return renderSessionSection({ host, section });
}
// Hide an empty Threads header only when it does not own reachable
// actions for categorized threads, collaborators, or an active drag.
// Empty Other remains useful only as a collaborator or drag destination.
if (
section.id === "ungrouped" &&
section.totalRowCount === 0 &&
!params.nativeSessionsHaveMore &&
!hasCategorizedThreads &&
!host.sessionOwnershipVisible &&
host.sessionsStatusFilter === "active" &&
host.sessionOrganizer.draggingSessionKey === null
@@ -438,6 +399,42 @@ function renderSessionListBody(params: {
`;
}
function renderSessionListToolbar(host: SidebarSessionListHost) {
const newSessionAccess = host.readNewSessionAccess();
const filtered = host.sessionOwnerFilterActive || host.sessionsStatusFilter !== "active";
return html`
<div class="sidebar-session-toolbar">
<span class="sidebar-recent-sessions__label-text">${t("chat.sidebar.threads")}</span>
<button
type="button"
class="sidebar-session-toolbar__button sidebar-session-sort ${filtered
? "sidebar-session-sort--filtered"
: ""}"
title=${t("chat.sidebar.sortSessions")}
aria-label=${t("chat.sidebar.sortSessions")}
aria-haspopup="menu"
aria-expanded=${String(host.sidebarMenus.sessionSortMenuPosition !== null)}
@click=${(event: MouseEvent) =>
host.sidebarMenus.toggleSessionSortMenu(event.currentTarget as HTMLElement)}
>
${icons.listFilter}
</button>
<button
type="button"
class="sidebar-session-toolbar__button sidebar-new-session"
title=${newSessionAccess.allowed
? t("chat.runControls.newSession")
: newSessionAccess.reason}
aria-label=${t("chat.runControls.newSession")}
?disabled=${!newSessionAccess.allowed}
@click=${() => host.openNewSession()}
>
${icons.plus}
</button>
</div>
`;
}
export function renderSessionList(params: {
host: SidebarSessionListHost;
empty: boolean;
@@ -456,6 +453,7 @@ export function renderSessionList(params: {
@dragleave=${(event: DragEvent) => host.handleSessionListDragLeave(event)}
@drop=${(event: DragEvent) => host.handleSessionListDrop(event)}
>
${renderSessionListToolbar(host)}
${host.sessionData.sessionMutationError
? html`
<div
@@ -15,7 +15,6 @@ import {
import { isSessionRunActive } from "../lib/session-run-state.ts";
import {
groupSidebarSessionRows,
sidebarSectionHasHeader,
type SidebarSessionSection,
type SidebarSessionsGrouping,
} from "../lib/sessions/grouping.ts";
@@ -297,6 +296,7 @@ export type SidebarVisibleSections = {
visibleRowCount: number;
visibleLimit: number;
collapsedVisibleRowCount: number;
renderHeader: boolean;
})[];
expandedRows: SidebarRecentSession[];
visibleRows: SidebarRecentSession[];
@@ -312,8 +312,6 @@ export function partitionSidebarVisibleSections(input: {
hideEmptyOwnerFilteredGroup: (category: string | undefined, rowCount: number) => boolean;
visibleSessionLimits: ReadonlyMap<string, number>;
}): SidebarVisibleSections {
const isCollapsed = (sectionId: string) =>
sidebarSectionHasHeader(sectionId, input.grouping) && input.collapsedSections.has(sectionId);
const sections = groupSidebarSessionRows(input.rows, {
grouping: input.grouping,
knownGroups: input.knownGroups,
@@ -324,6 +322,15 @@ export function partitionSidebarVisibleSections(input: {
section.id !== "pinned" &&
!input.hideEmptyOwnerFilteredGroup(section.category, section.rows.length),
);
// A lone catch-all sits directly under the global Sessions toolbar. Empty
// Coding does not render, while empty custom/Groups sections remain targets.
const ungroupedHasPeerHeader = sections.some(
(section) => section.id !== "ungrouped" && (section.id !== "work" || section.rows.length > 0),
);
// Accepted tradeoff: headerless means no collapse control, so a stored
// ungrouped-collapsed preference is deliberately inert here — honoring it
// would blank the whole list with no affordance to undo. It re-applies
// unchanged once a peer section returns.
const expandedRows: SidebarRecentSession[] = [];
const visibleRows: SidebarRecentSession[] = [];
// totalRowCount is the pre-pagination size: headers and empty-zone
@@ -331,13 +338,15 @@ export function partitionSidebarVisibleSections(input: {
const limitedSections: SidebarVisibleSections["sections"] = [];
for (const section of sections) {
const totalRowCount = section.rows.length;
const renderHeader = section.id !== "ungrouped" || ungroupedHasPeerHeader;
const collapsed = renderHeader && input.collapsedSections.has(section.id);
const visibleLimit = input.visibleSessionLimits.get(section.id) ?? SIDEBAR_SESSION_PAGE_SIZE;
const collapsedVisibleRowCount = limitSidebarSessionRows(
section.rows,
SIDEBAR_SESSION_PAGE_SIZE,
).length;
let visibleRowCount = 0;
if (!isCollapsed(section.id)) {
if (!collapsed) {
expandedRows.push(...section.rows);
section.rows = limitSidebarSessionRows(section.rows, visibleLimit);
visibleRows.push(...section.rows);
@@ -349,6 +358,7 @@ export function partitionSidebarVisibleSections(input: {
visibleRowCount,
visibleLimit,
collapsedVisibleRowCount,
renderHeader,
}),
);
}
@@ -338,7 +338,7 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
catalogIds:
this.sessionsStatusFilter === "archived"
? []
: this.sessionData.sessionCatalogs.map((catalog) => catalog.id),
: this.visibleSessionCatalogs().map((catalog) => catalog.id),
collapsedSections: this.collapsedSessionSections,
hideEmptyOwnerFilteredGroup: (category, rowCount) =>
this.sessionOwnerFilterActive && Boolean(category) && rowCount === 0,
@@ -676,19 +676,16 @@ suite.define(() => {
.evaluate((label) => getComputedStyle(label).fontWeight);
expect(activeWeight).toBe(inactiveWeight);
const sortThreads = page.getByRole("button", { name: "Sort sessions" });
await sortThreads.locator("..").hover();
await sortThreads.click();
const filterAndSort = page.getByRole("button", { name: "Filter & sort" });
await filterAndSort.click();
await page.getByRole("menuitemradio", { name: "Last updated" }).click();
await expect.poll(() => sidebarSessionOrder(page)).toEqual(updatedOrder);
await sortThreads.locator("..").hover();
await sortThreads.click();
await filterAndSort.click();
await page.getByRole("menuitemradio", { name: "Created" }).click();
await expect.poll(() => sidebarSessionOrder(page)).toEqual(createdOrder);
await sortThreads.locator("..").hover();
await sortThreads.click();
await filterAndSort.click();
await page.getByRole("main").click();
await expect.poll(() => page.getByRole("menuitemradio", { name: "Created" }).count()).toBe(0);
} finally {
+4 -1
View File
@@ -237,7 +237,10 @@ describeControlUiE2e("Control UI chat message actions", () => {
await page.goto(`${server.baseUrl}chat`);
await setThemeMode(page, "dark");
const commandPaletteShortcut = process.platform === "darwin" ? "⌘K" : "Ctrl K";
await expectHoverTooltip(page.getByRole("button", { name: "New session" }), "New session");
await expectHoverTooltip(
page.locator(".sidebar-brand").getByRole("button", { name: "New session" }),
"New session",
);
await expectHoverTooltip(
page.getByRole("button", { name: "Open command palette" }),
`Open command palette (${commandPaletteShortcut})`,
@@ -270,12 +270,39 @@ suite.define(() => {
.toBe(true);
const secondShortcut = secondModel.locator('[data-chat-model-shortcut-number="2"]');
await expect.poll(() => secondShortcut.count()).toBe(1);
const menuBoxBeforeFocus = await page.locator(".chat-controls__model-menu").boundingBox();
const actionBoxBeforeFocus = await secondModel
.locator(".chat-controls__model-option-action")
.boundingBox();
expect(menuBoxBeforeFocus).not.toBeNull();
expect(actionBoxBeforeFocus).not.toBeNull();
// Async page loads can still shift the whole layout mid-test; measure the
// menu and action relative to the picker anchor in one synchronous pass so
// only a focus-induced menu move can change the snapshot.
const menuGeometry = () =>
page.evaluate(() => {
const anchor = document.querySelector('[data-chat-model-select="true"]');
const menu = document.querySelector(".chat-controls__model-menu");
const action = document.querySelector(
'[data-chat-model-option="anthropic/claude-sonnet-4-6"] .chat-controls__model-option-action',
);
if (!anchor || !menu || !action) {
return null;
}
const anchorBox = anchor.getBoundingClientRect();
const menuBox = menu.getBoundingClientRect();
const actionBox = action.getBoundingClientRect();
return {
menu: {
dx: menuBox.x - anchorBox.x,
dy: menuBox.y - anchorBox.y,
width: menuBox.width,
height: menuBox.height,
},
action: {
dx: actionBox.x - menuBox.x,
dy: actionBox.y - menuBox.y,
width: actionBox.width,
height: actionBox.height,
},
};
});
const geometryBeforeFocus = await menuGeometry();
expect(geometryBeforeFocus).not.toBeNull();
await expect
.poll(() => secondShortcut.evaluate((element) => getComputedStyle(element).opacity))
.toBe("1");
@@ -287,12 +314,7 @@ suite.define(() => {
await expect
.poll(() => secondShortcut.evaluate((element) => getComputedStyle(element).opacity))
.toBe("0");
expect(await page.locator(".chat-controls__model-menu").boundingBox()).toEqual(
menuBoxBeforeFocus,
);
expect(
await secondModel.locator(".chat-controls__model-option-action").boundingBox(),
).toEqual(actionBoxBeforeFocus);
expect(await menuGeometry()).toEqual(geometryBeforeFocus);
await search.press("1");
await expect.poll(() => search.inputValue()).toBe("1");
await expect.poll(() => picker.getAttribute("open")).toBe("");
@@ -786,6 +808,9 @@ suite.define(() => {
await waitForCommittedNewSessionDraft(page, "keep both remembered choices", 0);
await page.reload();
// The composer persists drafts across hard reloads; refilling here races
// the async restore, which can append the stored draft to the typed text.
// Waiting for the restored value asserts the documented persistence.
await expect
.poll(() => page.locator(".new-session-page__message").inputValue())
.toBe("keep both remembered choices");
@@ -636,22 +636,17 @@ suite.define(() => {
.toBe(2);
// Group by "None" flattens the category sections into the plain list. The
// confirm left the pointer over the dialog rather than the sidebar, and
// section actions only surface on hover, so reveal this one first.
const sortSessionsButton = page.locator(
"button.sidebar-session-sort:not(.sidebar-session-new)",
);
await page
.locator('[data-session-section="ungrouped"] .sidebar-recent-sessions__head')
.hover();
await sortSessionsButton.click();
// confirm left the pointer over the dialog rather than the sidebar; the
// global toolbar remains available without revealing a section action.
const filterAndSortButton = page.getByRole("button", { name: "Filter & sort" });
await filterAndSortButton.click();
const showAutomationSessions = page.getByRole("menuitemcheckbox", {
name: "Show automation sessions",
});
await activateSelfRemovingControl(showAutomationSessions);
await expect.poll(() => sortSessionsButton.getAttribute("aria-expanded")).toBe("false");
await expect.poll(() => filterAndSortButton.getAttribute("aria-expanded")).toBe("false");
await sortSessionsButton.click();
await filterAndSortButton.click();
await expect.poll(() => showAutomationSessions.getAttribute("aria-checked")).toBe("true");
await page.getByRole("menuitemradio", { name: "None" }).waitFor({ state: "visible" });
await captureUiProof(page, "sidebar-groupby-sort-menu.png");
@@ -677,12 +672,12 @@ suite.define(() => {
return Math.abs(automationRight - groupingRight);
})
.toBeLessThanOrEqual(1);
await sortSessionsButton.click();
await expect.poll(() => sortSessionsButton.getAttribute("aria-expanded")).toBe("false");
await filterAndSortButton.click();
await expect.poll(() => filterAndSortButton.getAttribute("aria-expanded")).toBe("false");
await expect.poll(() => page.getByRole("menuitemradio", { name: "None" }).count()).toBe(0);
await captureUiProof(page, "sidebar-groupby-sort-menu-closed.png");
await sortSessionsButton.click();
await filterAndSortButton.click();
await activateSelfRemovingControl(page.getByRole("menuitemradio", { name: "None" }));
await expect.poll(() => groups.count()).toBe(1);
await expect.poll(() => groups.first().locator(".sidebar-recent-session").count()).toBe(3);
@@ -911,9 +906,8 @@ suite.define(() => {
await expect.poll(() => page.locator(".sidebar-recent-session").count()).toBe(11);
const patchCountBeforeFlatDrag = (await gateway.getRequests("sessions.patch")).length;
const sortSessionsButton = page.getByRole("button", { name: "Sort sessions" });
await sortSessionsButton.locator("..").hover();
await sortSessionsButton.click();
const filterAndSortButton = page.getByRole("button", { name: "Filter & sort" });
await filterAndSortButton.click();
await activateSelfRemovingControl(page.getByRole("menuitemradio", { name: "None" }));
const flatSection = page.locator('[data-session-section="ungrouped"]');
await flatSection
@@ -693,11 +693,10 @@ suite.define(() => {
await page.mouse.move(sourceBox.x + sourceBox.width / 2 + 12, sourceBox.y + 12, {
steps: 4,
});
const sessionList = page.locator(".sidebar-sessions");
await sessionList.waitFor({ state: "visible" });
const targetBox = await sessionList.boundingBox();
await chatsGroup.waitFor({ state: "visible" });
const targetBox = await chatsGroup.boundingBox();
if (!targetBox) {
throw new Error("expected session list bounds");
throw new Error("expected ungrouped session bounds");
}
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, {
steps: 8,
+10 -10
View File
@@ -80,10 +80,9 @@ async function captureUiProof(targetPage: Page, fileName: string) {
}
async function openSidebarSortMenu(targetPage: Page) {
const sortThreads = targetPage.getByRole("button", { name: "Sort sessions" });
await expect.poll(() => sortThreads.count(), { timeout: 2_000 }).toBe(1);
await sortThreads.locator("..").hover();
await sortThreads.click();
const filterAndSort = targetPage.getByRole("button", { name: "Filter & sort" });
await expect.poll(() => filterAndSort.count(), { timeout: 2_000 }).toBe(1);
await filterAndSort.click();
const menu = targetPage.locator(".sidebar-session-sort-menu");
await menu.waitFor();
return menu;
@@ -196,7 +195,7 @@ suite.define(() => {
expect(await currentPage.locator("openclaw-session-owner-chip").count()).toBe(0);
});
it("keeps grouped single-owner thread actions accessible to keyboard users", async () => {
it("keeps global session actions accessible to keyboard users", async () => {
const context = await suite.browser.newContext({ viewport: { height: 800, width: 1200 } });
const currentPage = await context.newPage();
page = currentPage;
@@ -211,10 +210,8 @@ suite.define(() => {
await currentPage.getByText("Ada research", { exact: true }).first().waitFor();
await currentPage.getByText("Bob operations", { exact: true }).first().waitFor();
const threads = currentPage.locator('[data-session-section="ungrouped"]');
await expect.poll(() => threads.count(), { timeout: 2_000 }).toBe(1);
const sortThreads = threads.getByRole("button", { name: "Sort sessions" });
await sortThreads.focus();
const filterAndSort = currentPage.getByRole("button", { name: "Filter & sort" });
await filterAndSort.focus();
await currentPage.keyboard.press("Enter");
const menu = currentPage.locator(".sidebar-session-sort-menu");
@@ -225,9 +222,12 @@ suite.define(() => {
await expect
.poll(() => currentPage.locator('[data-session-section^="category:"]').count())
.toBe(0);
const threads = currentPage.locator('[data-session-section="ungrouped"]');
await expect.poll(() => threads.locator(".sidebar-recent-session").count()).toBe(2);
const newThread = threads.getByRole("button", { name: "New session" });
const newThread = currentPage
.locator(".sidebar-session-toolbar")
.getByRole("button", { name: "New session" });
await newThread.focus();
await currentPage.keyboard.press("Enter");
await expect.poll(() => new URL(currentPage.url()).pathname).toBe("/new");
+2 -1
View File
@@ -5318,6 +5318,7 @@ export const en: TranslationMap = {
serverUpdatedTitle: "Server updated",
serverUpdatedRefresh: "Refresh for full capabilities",
threads: "Sessions",
otherSessions: "Other",
groups: "Groups",
coding: "Coding",
noSessionsForAgent: "No sessions found for this agent",
@@ -5332,7 +5333,7 @@ export const en: TranslationMap = {
openSessionMenu: "Open session menu",
sortBy: "Sort by",
sortCreated: "Created",
sortSessions: "Sort sessions",
sortSessions: "Filter & sort",
sortUpdated: "Last updated",
sessionMenu: "Actions for {session}",
sessionMenuMany: "Actions for {count} sessions",
+1 -15
View File
@@ -97,20 +97,6 @@ export function moveSessionSection(
return moveSessionOrderEntry(order, source, target, position);
}
/**
* Sections that render a header (and therefore can collapse). Pinned rows
* render headerless like the nav entries above them; every other zone shows
* one Threads hosts the sort and new-session actions on its header.
* Shared by the renderer and keyboard-order walker so collapse behavior
* cannot drift between them.
*/
export function sidebarSectionHasHeader(
sectionId: string,
_grouping: SidebarSessionsGrouping,
): boolean {
return sectionId !== "pinned";
}
export function normalizeSessionsGroupBy(raw: unknown): SessionsGroupBy {
return SESSION_GROUP_MODES.includes(raw as SessionsGroupBy) ? (raw as SessionsGroupBy) : "none";
}
@@ -216,7 +202,7 @@ export function categoryClearReturnsToGroups(
/**
* Zone partition: pinned, named categories (persisted `knownGroups` order,
* new ones alphabetical), threads ("ungrouped" the agent's chat sessions),
* new ones alphabetical), other sessions ("ungrouped"),
* group conversations, then coding (worktree/exec-node/ACP). An explicit user
* category wins over the smart group/coding classification so manual curation
* sticks. `grouping: "none"` only disables categories; the kind-based Groups
+60 -35
View File
@@ -1400,6 +1400,11 @@ body.update-dialog-open .sidebar-update-card__status {
}
.sidebar-sessions {
--sidebar-inset: 8px;
--sidebar-lead: 20px;
--sidebar-row-gap: 8px;
--sidebar-text-offset: calc(var(--sidebar-inset) + var(--sidebar-lead) + var(--sidebar-row-gap));
display: flex;
flex-direction: column;
gap: 8px;
@@ -1416,6 +1421,61 @@ body.update-dialog-open .sidebar-update-card__status {
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--muted) 35%, transparent);
}
.sidebar-session-toolbar {
display: flex;
align-items: center;
gap: 2px;
min-height: 24px;
margin-inline: -8px 0;
padding: 0 10px 0 var(--sidebar-text-offset);
}
.sidebar-session-toolbar__button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
flex: 0 0 auto;
padding: 0;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--muted);
opacity: 0.55;
transition:
opacity var(--duration-fast) ease,
background var(--duration-fast) ease,
color var(--duration-fast) ease;
}
.sidebar-session-toolbar__button:first-of-type {
margin-left: auto;
}
.sidebar-session-toolbar__button:hover,
.sidebar-session-toolbar__button:focus-visible,
.sidebar-session-toolbar__button[aria-expanded="true"],
.sidebar-session-toolbar__button.sidebar-session-sort--filtered {
background: color-mix(in srgb, var(--bg-hover) 78%, transparent);
color: var(--text);
opacity: 1;
}
.sidebar-session-toolbar__button:disabled {
cursor: not-allowed;
}
.sidebar-session-toolbar__button svg {
width: 14px;
height: 14px;
stroke: currentColor;
fill: none;
stroke-width: 1.7px;
stroke-linecap: round;
stroke-linejoin: round;
}
.sidebar-session-error {
flex: 0 0 auto;
padding: 8px 10px;
@@ -1435,11 +1495,6 @@ body.update-dialog-open .sidebar-update-card__status {
}
.sidebar-recent-sessions {
--sidebar-inset: 8px;
--sidebar-lead: 20px;
--sidebar-row-gap: 8px;
--sidebar-text-offset: calc(var(--sidebar-inset) + var(--sidebar-lead) + var(--sidebar-row-gap));
display: flex;
flex-direction: column;
gap: var(--sidebar-group-gap);
@@ -1680,19 +1735,6 @@ body.update-dialog-open .sidebar-update-card__status {
.sidebar-session-sort {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
flex: 0 0 auto;
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--muted);
transition:
background var(--duration-fast) ease,
color var(--duration-fast) ease;
}
.sidebar-session-sort--filtered::after {
@@ -1707,23 +1749,6 @@ body.update-dialog-open .sidebar-update-card__status {
content: "";
}
.sidebar-session-sort:hover,
.sidebar-session-sort[aria-expanded="true"],
.sidebar-session-sort[aria-pressed="true"] {
background: color-mix(in srgb, var(--bg-hover) 78%, transparent);
color: var(--text);
}
.sidebar-session-sort svg {
width: 14px;
height: 14px;
stroke: currentColor;
fill: none;
stroke-width: 1.7px;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Group header kebab: hidden until hover/keyboard focus like row actions, but always
reachable on touch pointers where hover does not exist. */
.sidebar-session-group-actions {
@@ -318,13 +318,25 @@ describe("AppSidebar session attention", () => {
JSON.stringify(["ungrouped"]),
);
const sessionsHarness = createSessionsHarness("main", [sessionKey]);
setRows(sessionsHarness, [agentAttentionRow()]);
setRows(sessionsHarness, [
agentAttentionRow(),
{
key: "agent:main:peer",
kind: "direct",
updatedAt: 1,
worktree: { id: "peer", branch: "main", repoRoot: "/repo" },
},
]);
const { sidebar } = await mountSidebar(
createGateway({} as GatewayBrowserClient),
sessionsHarness.sessions,
);
const section = sidebar.querySelector('[data-session-section="ungrouped"]');
expect(sidebar.querySelector('[data-session-section="work"]')).not.toBeNull();
expect(
section?.querySelector(".sidebar-session-group-toggle")?.getAttribute("aria-expanded"),
).toBe("false");
expect(section?.querySelector(".sidebar-session-group-attention")).not.toBeNull();
expect(section?.querySelector(".sidebar-recent-session")).toBeNull();
});
@@ -340,6 +352,12 @@ describe("AppSidebar session attention", () => {
const sessionsHarness = createSessionsHarness("main", [parentKey]);
setRows(sessionsHarness, [
{ key: parentKey, kind: "direct", updatedAt: 1, childSessions: [childKey] },
{
key: "agent:main:peer",
kind: "direct",
updatedAt: 0,
worktree: { id: "peer", branch: "main", repoRoot: "/repo" },
},
]);
const approval = {
id: "approval-child",
@@ -375,6 +393,7 @@ describe("AppSidebar session attention", () => {
),
).not.toBeNull();
expect(sidebar.querySelector(`[data-session-key="${childKey}"]`)).toBeNull();
expect(sidebar.querySelector('[data-session-section="work"]')).not.toBeNull();
sidebar.querySelector<HTMLButtonElement>(".sidebar-session-group-toggle")?.click();
await sidebar.updateComplete;
expect(
@@ -364,10 +364,10 @@ describe("AppSidebar brand actions", () => {
brandButton?.click();
expect(onOpenNewSession).toHaveBeenCalledExactlyOnceWith("research");
const headerButton = sidebar.querySelector<HTMLButtonElement>(
'[data-session-section="ungrouped"] .sidebar-new-session',
const toolbarButton = sidebar.querySelector<HTMLButtonElement>(
".sidebar-session-toolbar .sidebar-new-session",
);
expect(headerButton?.getAttribute("aria-label")).toBe("New session");
expect(toolbarButton?.getAttribute("aria-label")).toBe("New session");
});
});
@@ -165,9 +165,9 @@ describe("AppSidebar section reordering", () => {
});
it("does not start a section drag from a header action button", async () => {
const { sidebar } = await mountWithGroups([]);
const { sidebar } = await mountWithGroups(["Alpha"]);
const dataTransfer = createDataTransferStub();
const newSessionButton = groupHeader(sidebar, "ungrouped").querySelector(
const newSessionButton = groupHeader(sidebar, "category:Alpha").querySelector(
".sidebar-new-session",
);
if (!newSessionButton) {
@@ -175,7 +175,7 @@ describe("AppSidebar section reordering", () => {
}
newSessionButton.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
dispatchDragEvent(groupHeader(sidebar, "ungrouped"), "dragstart", dataTransfer);
dispatchDragEvent(groupHeader(sidebar, "category:Alpha"), "dragstart", dataTransfer);
expect(dataTransfer.types).toEqual([]);
expect(sidebar.sessionOrganizer.draggingSidebarSection).toBeNull();
@@ -30,6 +30,9 @@ describe("AppSidebar session section visibility", () => {
expect(category?.querySelectorAll(".sidebar-recent-session")).toHaveLength(10);
expect(threads?.querySelectorAll(".sidebar-recent-session")).toHaveLength(10);
expect(threads?.querySelector(".sidebar-recent-sessions__label-text")?.textContent).toBe(
"Other",
);
expect(category?.querySelector('[aria-label="Show more"]')).not.toBeNull();
expect(threads?.querySelector('[aria-label="Show more"]')).not.toBeNull();
expect(sidebar.querySelectorAll(".sidebar-session-pagination")).toHaveLength(2);
@@ -43,7 +46,7 @@ describe("AppSidebar session section visibility", () => {
expect(threads?.querySelector('[aria-label="Show more"]')).toBeNull();
});
it("keeps global thread actions when every unpinned thread has a custom group", async () => {
it("keeps global session actions when every unpinned thread has a custom group", async () => {
const harness = createSessionsHarness("main", [
"agent:main:main",
"agent:main:research",
@@ -69,18 +72,62 @@ describe("AppSidebar session section visibility", () => {
expect(sidebar.querySelector('[data-session-section="category:Research"]')).not.toBeNull();
expect(sidebar.querySelector('[data-session-section="category:Operations"]')).not.toBeNull();
expect(threads).not.toBeNull();
expect(threads?.querySelectorAll(".sidebar-recent-session")).toHaveLength(0);
expect(threads).toBeNull();
const sort = threads?.querySelector<HTMLButtonElement>('[aria-label="Sort sessions"]');
expect(sort).not.toBeNull();
expect(threads?.querySelector('[aria-label="New session"]')).not.toBeNull();
sort?.click();
const toolbar = sidebar.querySelector(".sidebar-session-toolbar");
expect(toolbar?.querySelector(".sidebar-recent-sessions__label-text")?.textContent).toBe(
"Sessions",
);
const filter = toolbar?.querySelector<HTMLButtonElement>(".sidebar-session-sort");
expect(filter).not.toBeNull();
expect(filter?.getAttribute("aria-label")).toBe("Filter & sort");
expect(toolbar?.querySelector('[aria-label="New session"]')).not.toBeNull();
filter?.click();
await sidebar.updateComplete;
expect(sidebar.querySelector(".sidebar-session-sort-menu")).not.toBeNull();
});
it("hides empty Threads at rest but keeps empty categories and the drag drop target", async () => {
it("renders a lone ungrouped list without a header despite stale collapsed state", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(
gateway,
createSessions("main", ["agent:main:main", "agent:main:other"]),
);
sidebar.sessionOrganizer.saveCollapsedSessionSections(new Set(["ungrouped"]));
await sidebar.updateComplete;
const ungrouped = sidebar.querySelector('[data-session-section="ungrouped"]');
expect(ungrouped?.querySelector(".sidebar-recent-sessions__head")).toBeNull();
expect(ungrouped?.querySelector('[data-session-key="agent:main:other"]')).not.toBeNull();
});
it("marks the toolbar filter when the status is not active", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(
gateway,
createSessions("main", ["agent:main:main", "agent:main:other"]),
);
const filter = sidebar.querySelector<HTMLButtonElement>(
".sidebar-session-toolbar .sidebar-session-sort",
);
expect(filter?.getAttribute("aria-label")).toBe("Filter & sort");
expect(filter?.classList.contains("sidebar-session-sort--filtered")).toBe(false);
filter?.click();
await sidebar.updateComplete;
sidebar.querySelector(".sidebar-session-sort-menu")?.dispatchEvent(
new CustomEvent("wa-select", {
bubbles: true,
detail: { item: { value: "status:all" } },
}),
);
await sidebar.updateComplete;
expect(filter?.classList.contains("sidebar-session-sort--filtered")).toBe(true);
});
it("hides empty Other at rest but keeps empty categories and the drag drop target", async () => {
const harness = createSessionsHarness("main", ["agent:main:main", "agent:main:alpha"]);
const result = harness.sessions.state.result;
const alpha = result?.sessions.find((row) => row.key === "agent:main:alpha");
@@ -94,7 +141,7 @@ describe("AppSidebar session section visibility", () => {
const { sidebar } = await mountSidebar(gateway, harness.sessions);
// Empty user-created groups stay visible (creation and drag targets);
// only the bare Threads header disappears while nothing lives in it.
// only the bare Other header disappears while nothing lives in it.
expect(sidebar.querySelector('[data-session-section="category:Empty"]')).not.toBeNull();
expect(sidebar.querySelector('[data-session-section="ungrouped"]')).toBeNull();
@@ -147,7 +147,9 @@ describe("AppSidebar session ownership filtering", () => {
expect(sidebar.querySelector('[data-session-key="agent:main:bob"]')).toBeNull();
expect(sidebar.querySelector('[data-session-section="category:Research"]')).not.toBeNull();
expect(sidebar.querySelector('[data-session-section="category:Operations"]')).toBeNull();
expect(sidebar.querySelector(".sidebar-session-sort--filtered")).not.toBeNull();
expect(
sidebar.querySelector(".sidebar-session-toolbar .sidebar-session-sort--filtered"),
).not.toBeNull();
});
it("filters adopted catalog rows by authoritative live ownership", async () => {
@@ -388,7 +388,7 @@ describe("AppSidebar session accessibility", () => {
const row = sidebar.querySelector(`[data-session-key="${key}"]`);
const tree = row?.closest(".sidebar-session-tree");
const link = row?.querySelector<HTMLAnchorElement>(".sidebar-recent-session__link");
expect(list?.getAttribute("aria-label")).toBe("Sessions");
expect(list?.getAttribute("aria-label")).toBe("Other");
expect(tree?.parentElement).toBe(list);
expect(tree?.getAttribute("role")).toBe("listitem");
expect(row?.hasAttribute("role")).toBe(false);