mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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:
committed by
GitHub
parent
1ed682f883
commit
c77feb00f3
File diff suppressed because one or more lines are too long
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user