diff --git a/ui/src/styles/base.css b/ui/src/styles/base.css index abe25a10299b..85b45b827fd7 100644 --- a/ui/src/styles/base.css +++ b/ui/src/styles/base.css @@ -120,6 +120,9 @@ --safe-area-bottom: env(safe-area-inset-bottom, 0px); --safe-area-left: env(safe-area-inset-left, 0px); + /* Layering */ + --z-dropdown: 100; + color-scheme: dark; } diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 5f139b0df975..c8e61f87ba32 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -460,9 +460,11 @@ .shell-nav { grid-area: nav; + position: relative; + z-index: 10; display: flex; min-height: 100%; - overflow: hidden; + overflow: visible; border-right: 1px solid color-mix(in srgb, var(--border) 74%, transparent); transition: width var(--shell-focus-duration) var(--shell-focus-ease); } diff --git a/ui/src/ui/chat/sidebar-session-picker.browser.test.ts b/ui/src/ui/chat/sidebar-session-picker.browser.test.ts new file mode 100644 index 000000000000..545bd06b3097 --- /dev/null +++ b/ui/src/ui/chat/sidebar-session-picker.browser.test.ts @@ -0,0 +1,292 @@ +// Control UI tests cover sidebar session picker layering and interaction. +import { existsSync } from "node:fs"; +import { chromium, type Browser, type Page } from "playwright"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { readStyleSheet } from "../../../../test/helpers/ui-style-fixtures.js"; + +const describeBrowserLayout = existsSync(chromium.executablePath()) ? describe : describe.skip; + +let browser: Browser; + +function readUiCss(): string { + const files = [ + "ui/src/styles/base.css", + "ui/src/styles/layout.css", + "ui/src/styles/layout.mobile.css", + "ui/src/styles/components.css", + "ui/src/styles/chat/layout.css", + "ui/src/styles/chat/text.css", + "ui/src/styles/chat/grouped.css", + "ui/src/styles/chat/tool-cards.css", + "ui/src/styles/chat/sidebar.css", + ]; + return files.map((file) => readStyleSheet(file)).join("\n"); +} + +function iconSvg() { + return ``; +} + +function sidebarSessionPickerHtml(opts: { workspaceRail?: boolean } = {}) { + const optionButtons = Array.from({ length: 18 }, (_, index) => { + const sessionKey = `dashboard-session-${index + 1}`; + const selected = index === 0; + return ` + + `; + }).join(""); + const workspaceRail = opts.workspaceRail + ? ` + + ` + : ""; + return ` +
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+

Keep the sidebar session picker interactive even when the desktop chat workbench is visible.

+
+
+
+
+
+ + + + +
+
+
+
+
+ ${workspaceRail} +
+
+
+
+ + `; +} + +async function openSidebarSessionPickerFixture( + width: number, + height: number, + opts: { workspaceRail?: boolean } = {}, +): Promise { + const page = await browser.newPage({ viewport: { width, height } }); + await page.setContent( + `${sidebarSessionPickerHtml(opts)}`, + ); + return page; +} + +async function expectNoHorizontalOverflow(page: Page) { + const metrics = await page.evaluate(() => ({ + body: document.body.scrollWidth, + html: document.documentElement.scrollWidth, + viewport: window.innerWidth, + })); + expect(metrics.html).toBeLessThanOrEqual(metrics.viewport + 1); + expect(metrics.body).toBeLessThanOrEqual(metrics.viewport + 1); +} + +beforeAll(async () => { + browser = await chromium.launch({ headless: true }); +}); + +afterAll(async () => { + await browser.close(); +}); + +describeBrowserLayout("sidebar session picker browser layout", () => { + it("keeps the collapsed sidebar session picker interactive above the desktop workbench when the workspace rail is visible", async () => { + const page = await openSidebarSessionPickerFixture(1366, 900, { workspaceRail: true }); + try { + await expectNoHorizontalOverflow(page); + const input = page.locator('[data-chat-session-picker-search="true"]'); + const list = page.locator(".chat-session-picker__list"); + const targetOption = page.locator( + '[data-chat-session-picker-option="true"][data-session-key="dashboard-session-12"]', + ); + + const inputHit = await input.evaluate((node) => { + const rect = (node as HTMLElement).getBoundingClientRect(); + const hit = document.elementFromPoint( + rect.left + rect.width / 2, + rect.top + rect.height / 2, + ); + return hit === node || node.contains(hit); + }); + expect(inputHit).toBe(true); + + await input.click(); + await input.fill("telegram"); + await expect + .poll(() => page.evaluate(() => document.body.dataset.searchValue ?? "")) + .toBe("telegram"); + + const listBox = await list.boundingBox(); + if (!listBox) { + throw new Error("Expected session picker list bounding box"); + } + const listScrollBefore = await list.evaluate((node) => (node as HTMLElement).scrollTop); + await page.mouse.move(listBox.x + listBox.width / 2, listBox.y + listBox.height / 2); + await page.mouse.wheel(0, 420); + await expect + .poll(() => list.evaluate((node) => (node as HTMLElement).scrollTop)) + .toBeGreaterThan(listScrollBefore); + + const optionHit = await targetOption.evaluate((node) => { + const rect = (node as HTMLElement).getBoundingClientRect(); + const hit = document.elementFromPoint( + rect.left + rect.width / 2, + rect.top + Math.min(rect.height / 2, rect.height - 4), + ); + return hit === node || node.contains(hit); + }); + expect(optionHit).toBe(true); + + await targetOption.click(); + await expect + .poll(() => page.evaluate(() => document.body.dataset.clickedSession ?? "")) + .toBe("dashboard-session-12"); + } finally { + await page.close(); + } + }); +});