Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions surfaces/gui/e2e/search-modal.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
4 changes: 2 additions & 2 deletions surfaces/gui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1752,6 +1752,7 @@ export function App() {
collapsed={navCollapsed}
onCollapse={toggleNav}
onPeekLeave={() => setNavPeek(false)}
onOpenSearch={() => setSearchOpen(true)}
/>
{surface === "scheduled" ? (
<ScheduledView
Expand Down Expand Up @@ -2250,8 +2251,7 @@ export function App() {
</div>
)}

{/* 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 && (
<SearchModal
sessions={sessions}
Expand Down
17 changes: 13 additions & 4 deletions surfaces/gui/src/components/SearchModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { baseName } from "../paths";
// Command-palette search (Codex-style): clicking Search opens this overlay over the whole app
// rather than filtering the sidebar in place (which made the grouped list collapse). It searches
// ALL sessions, split into Pinned + Recent, filters as you type, and supports ↑/↓ + Enter + ⌘1–9.
//
// Portaled to document.body so `position: fixed` is always viewport-relative. The collapsed
// sidebar uses `transform` for peek/slide (#282), which would otherwise become the containing
// block for any fixed descendant mounted inside `.sidebar`.

const byRecent = (a: SessionInfo, b: SessionInfo) =>
(b.updated_at || "").localeCompare(a.updated_at || "");
Expand Down Expand Up @@ -111,10 +115,14 @@ export function SearchModal({
);
};

return (
<div className="fixed inset-0 z-50" onKeyDown={onKey}>
// z-[70] sits above the collapsed sidebar peek (z-60) so the palette is never covered.
return createPortal(
<div className="fixed inset-0 z-[70]" data-testid="search-modal" onKeyDown={onKey}>
<div className="absolute inset-0 bg-black/30 backdrop-blur-[1px]" onClick={onClose} />
<div className="absolute left-1/2 top-[14vh] -translate-x-1/2 w-[640px] max-w-[92vw] rounded-xl2 border border-line bg-panel shadow-2xl overflow-hidden">
<div
data-testid="search-modal-panel"
className="absolute left-1/2 top-[14vh] -translate-x-1/2 w-[640px] max-w-[92vw] rounded-xl2 border border-line bg-panel shadow-2xl overflow-hidden"
>
<div className="px-4 pt-3.5 pb-2.5 border-b border-line flex items-center gap-2.5">
<Icon name="search" size={16} className="text-faint shrink-0" />
<input
Expand Down Expand Up @@ -153,6 +161,7 @@ export function SearchModal({
)}
</div>
</div>
</div>
</div>,
document.body,
);
}
17 changes: 5 additions & 12 deletions surfaces/gui/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down Expand Up @@ -1040,7 +1043,8 @@ export function Sidebar(props: Props) {
</div>

{/* 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. */}
<div className="px-2.5 mt-1">
<button
className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] text-left text-muted hover:bg-chromeHover hover:text-ink"
Expand Down Expand Up @@ -1297,17 +1301,6 @@ export function Sidebar(props: Props) {
</div>
</div>

{searchModalOpen && (
<SearchModal
sessions={props.sessions}
personas={personas ?? undefined}
onSelect={(id, ws, ag) => {
setSearchModalOpen(false);
props.onSelectSession(id, ws, ag);
}}
onClose={() => setSearchModalOpen(false)}
/>
)}
</div>
);
}