diff --git a/ui/src/e2e/session-management.e2e.test.ts b/ui/src/e2e/session-management.e2e.test.ts index 6a9f875e8c28..6374343398cc 100644 --- a/ui/src/e2e/session-management.e2e.test.ts +++ b/ui/src/e2e/session-management.e2e.test.ts @@ -1084,6 +1084,80 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => { } }); + it("scrolls long session lists in short windows instead of squeezing sections", async () => { + const baseTime = Date.parse("2026-07-01T16:00:00.000Z"); + const rows = [ + ...Array.from({ length: 8 }, (_, index) => + sessionRow(`agent:main:work-${index}`, `Work session ${index}`, baseTime - index * 60_000, { + worktree: { branch: `openclaw/wt-${index}`, repoRoot: "/Users/dev/Projects/clawdbot" }, + }), + ), + ...Array.from({ length: 30 }, (_, index) => + sessionRow(`agent:main:chat-${index}`, `Chat ${index}`, baseTime - (index + 10) * 60_000), + ), + ]; + const context = await browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 620, width: 1280 }, + }); + const page = await context.newPage(); + await installMockGateway(page, { + methodResponses: { + "sessions.list": sessionsListResponse(rows), + }, + sessionKey: "agent:main:main", + }); + try { + await page.goto(`${server.baseUrl}chat`); + await expect + .poll(() => page.locator(".sidebar-recent-session").count(), { timeout: 15_000 }) + .toBeGreaterThanOrEqual(rows.length); + await captureUiProof(page, "short-window-session-sections.png"); + + // Sections must stack below each other, not paint over the rows above. + const overlaps = await page.evaluate(() => { + const rects = [ + ...document.querySelectorAll(".sidebar-recent-session, .sidebar-recent-sessions__head"), + ] + .map((element) => { + const rect = element.getBoundingClientRect(); + return { top: rect.top, bottom: rect.bottom }; + }) + .filter((rect) => rect.bottom > rect.top) + .toSorted((a, b) => a.top - b.top); + let bad = 0; + for (let index = 1; index < rects.length; index += 1) { + if (rects[index].top < rects[index - 1].bottom - 2) { + bad += 1; + } + } + return bad; + }); + expect(overlaps).toBe(0); + + // The squeeze regression compressed sections into the viewport with no + // overflow; a healthy list is taller than its container and scrolls. + const scroll = await page.evaluate(() => { + const list = document.querySelector(".sidebar-recent-sessions"); + if (!list) { + return null; + } + list.scrollTop = list.scrollHeight; + return { + clientHeight: list.clientHeight, + scrollHeight: list.scrollHeight, + scrollTop: list.scrollTop, + }; + }); + expect(scroll).not.toBeNull(); + expect(scroll?.scrollHeight ?? 0).toBeGreaterThan(scroll?.clientHeight ?? 0); + expect(scroll?.scrollTop ?? 0).toBeGreaterThan(0); + } finally { + await context.close(); + } + }); + it("keeps sidebar session controls reachable on touch pointers", async () => { const context = await browser.newContext({ hasTouch: true, diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 30c4698cebb4..46bc403e1edb 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -695,6 +695,9 @@ html.openclaw-native-nav .shell-nav-expand:focus-visible { } .sidebar { + /* Sticky headers inside the sidebar reuse --sidebar-bg so scrolled rows + never shine through with a mismatched tone. */ + --sidebar-bg: color-mix(in srgb, var(--bg) 96%, var(--bg-elevated) 4%); position: relative; display: flex; flex-direction: column; @@ -702,11 +705,11 @@ html.openclaw-native-nav .shell-nav-expand:focus-visible { min-height: 0; min-width: 0; overflow: hidden; - background: color-mix(in srgb, var(--bg) 96%, var(--bg-elevated) 4%); + background: var(--sidebar-bg); } :root[data-theme-mode="light"] .sidebar { - background: color-mix(in srgb, var(--panel) 98%, white 2%); + --sidebar-bg: color-mix(in srgb, var(--panel) 98%, white 2%); } .sidebar-shell { @@ -990,6 +993,13 @@ html.openclaw-native-nav .shell-nav-expand:focus-visible { cursor: default; } +/* The scroller's children must not flex-shrink: shrink squeezes each section + to its min-height while the fixed-height rows inside overflow and paint + over the following section. Fixed sizing makes the list scroll instead. */ +.sidebar-recent-sessions > * { + flex: 0 0 auto; +} + .sidebar-recent-sessions__group { display: flex; flex-direction: column; @@ -1039,7 +1049,7 @@ html.openclaw-native-nav .shell-nav-expand:focus-visible { position: sticky; top: 0; z-index: 2; - background: var(--bg); + background: var(--sidebar-bg); } .sidebar-recent-sessions__head .sidebar-recent-sessions__label-text {