diff --git a/surfaces/gui/e2e/search-modal.spec.ts b/surfaces/gui/e2e/search-modal.spec.ts new file mode 100644 index 000000000..84f737e07 --- /dev/null +++ b/surfaces/gui/e2e/search-modal.spec.ts @@ -0,0 +1,76 @@ +// Search command palette (#282): must stay viewport-centered even when opened from the +// collapsed/peeked sidebar, whose CSS transform would otherwise become the containing block +// for a nested `position: fixed` overlay. +import { expect } from "@playwright/test"; +import { test } from "./fixtures"; + +async function ready(page: import("@playwright/test").Page) { + await page.goto("/"); + await expect(page.locator(".app")).not.toHaveClass(/boot-splash/); + await expect(page.locator(".sidebar")).toBeVisible(); +} + +test("search from expanded sidebar is centered in the viewport", async ({ page }) => { + await ready(page); + await page.locator(".sidebar").getByRole("button", { name: "Search", exact: true }).click(); + + const panel = page.getByTestId("search-modal-panel"); + await expect(panel).toBeVisible(); + await expect(page.getByPlaceholder("Search chats")).toBeVisible(); + + const box = await panel.boundingBox(); + const viewport = page.viewportSize(); + expect(box).toBeTruthy(); + expect(viewport).toBeTruthy(); + const panelCenter = box!.x + box!.width / 2; + const viewportCenter = viewport!.width / 2; + expect(Math.abs(panelCenter - viewportCenter)).toBeLessThan(8); +}); + +test("search from peeked collapsed sidebar stays viewport-centered", async ({ page }) => { + await ready(page); + const app = page.locator(".app"); + + await page.getByRole("button", { name: "Collapse sidebar" }).click(); + await expect(app).toHaveClass(/nav-collapsed/); + + // Hover the left-edge zone to peek the floating sidebar, then open Search from it. + await page.locator(".nav-hover-zone").hover(); + await expect(app).toHaveClass(/nav-peek/); + await page.locator(".sidebar").getByRole("button", { name: "Search", exact: true }).click(); + + const panel = page.getByTestId("search-modal-panel"); + await expect(panel).toBeVisible(); + + // Peek should dismiss so the floating sidebar does not cover the palette. + await expect(app).not.toHaveClass(/nav-peek/); + + const box = await panel.boundingBox(); + const viewport = page.viewportSize(); + expect(box).toBeTruthy(); + expect(viewport).toBeTruthy(); + const panelCenter = box!.x + box!.width / 2; + const viewportCenter = viewport!.width / 2; + expect(Math.abs(panelCenter - viewportCenter)).toBeLessThan(8); +}); + +test("collapsed topbar search is also viewport-centered", async ({ page }) => { + await ready(page); + await page.getByRole("button", { name: "Collapse sidebar" }).click(); + await expect(page.locator(".app")).toHaveClass(/nav-collapsed/); + + const cluster = page.getByTestId("topbar-cluster"); + await expect(cluster).toBeVisible(); + await cluster.getByRole("button", { name: "Search" }).click(); + + const panel = page.getByTestId("search-modal-panel"); + await expect(panel).toBeVisible(); + + const box = await panel.boundingBox(); + const viewport = page.viewportSize(); + expect(box).toBeTruthy(); + expect(viewport).toBeTruthy(); + const panelCenter = box!.x + box!.width / 2; + const viewportCenter = viewport!.width / 2; + expect(Math.abs(panelCenter - viewportCenter)).toBeLessThan(8); +}); diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index 67cff675c..da6cba6f4 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -1752,6 +1752,7 @@ export function App() { collapsed={navCollapsed} onCollapse={toggleNav} onPeekLeave={() => setNavPeek(false)} + onOpenSearch={() => setSearchOpen(true)} /> {surface === "scheduled" ? ( )} - {/* Search from the collapsed-sidebar topbar cluster (the sidebar's own instance is - unreachable while it's collapsed). */} + {/* Single SearchModal for sidebar Search and the collapsed topbar cluster. */} {searchOpen && ( (b.updated_at || "").localeCompare(a.updated_at || ""); @@ -111,10 +115,14 @@ export function SearchModal({ ); }; - return ( -
+ // z-[70] sits above the collapsed sidebar peek (z-60) so the palette is never covered. + return createPortal( +
-
+
-
+
, + document.body, ); } diff --git a/surfaces/gui/src/components/Sidebar.tsx b/surfaces/gui/src/components/Sidebar.tsx index a5e11fb8a..5da56ef64 100644 --- a/surfaces/gui/src/components/Sidebar.tsx +++ b/surfaces/gui/src/components/Sidebar.tsx @@ -150,6 +150,9 @@ interface Props { collapsed?: boolean; onCollapse?: () => void; onPeekLeave?: () => void; + // Opens the app-level SearchModal. Kept out of this tree so the collapsed sidebar's + // `transform` cannot become the containing block for `position: fixed` (#282). + onOpenSearch?: () => void; } // Compact age for project session rows: "now" / "5m" / "6h" / "3d" / "2w" / "4mo" / "2y". @@ -1040,7 +1043,8 @@ export function Sidebar(props: Props) {
{/* Search: a borderless nav-style entry (not a boxed input) that opens the command-palette - SearchModal over the whole app. Matches the bottom-nav rows to reduce the boxy look. */} + SearchModal over the whole app. Matches the bottom-nav rows to reduce the boxy look. + Opens via App so the palette is never mounted under the transformed collapsed sidebar. */}
- {searchModalOpen && ( - { - setSearchModalOpen(false); - props.onSelectSession(id, ws, ag); - }} - onClose={() => setSearchModalOpen(false)} - /> - )}
); }