diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 0144eb6d52b..c4fd8dc5623 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -212,6 +212,11 @@ export function Layout() { // effective `peeking` to desktop + collapsed + hover-capable pointers, so // these handlers are inert otherwise. const peekTimer = useRef(null); + // Whether the pointer is currently over the peek panel. Used to keep the peek + // open across focus changes (e.g. navigation steals focus to
) as long as + // the user is still hovering — it should only close when they actually mouse off + // (PAP-10676). + const pointerInsidePanel = useRef(false); const clearPeekTimer = useCallback(() => { if (peekTimer.current !== null) { window.clearTimeout(peekTimer.current); @@ -230,15 +235,29 @@ export function Layout() { clearPeekTimer(); peekTimer.current = window.setTimeout(() => setPeeking(false), 120); }, [clearPeekTimer, setPeeking]); + const handlePanelPointerEnter = useCallback(() => { + pointerInsidePanel.current = true; + openPeek(); + }, [openPeek]); + const handlePanelPointerLeave = useCallback(() => { + pointerInsidePanel.current = false; + closePeek(); + }, [closePeek]); + // Close on focus leaving the panel only when the pointer isn't hovering it. + // Clicking a rail/peek nav item moves focus to
on navigation; if the + // mouse is still over the flyout we keep it open until the pointer leaves. + const handlePanelBlur = useCallback(() => { + if (pointerInsidePanel.current) return; + closePeek(); + }, [closePeek]); // Tidy up any pending peek timer on unmount. useEffect(() => clearPeekTimer, [clearPeekTimer]); - // Close peek on navigation so it never lingers over a freshly routed page. - useEffect(() => { - clearPeekTimer(); - setPeeking(false); - }, [location.pathname, clearPeekTimer, setPeeking]); + // Intentionally do NOT close the peek on navigation: clicking a nav item means + // the pointer is still over the flyout, so it should stay open until the user + // actually mouses off (handled by onPanelMouseLeave) or blurs out / hits Escape + // (PAP-10676). Auto-closing here made the sidebar collapse on every page change. // Escape closes an open peek without trapping the pointer. useEffect(() => { @@ -435,10 +454,10 @@ export function Layout() { collapsed={collapsed} peeking={peeking} resizable - onPanelMouseEnter={collapsed ? openPeek : undefined} - onPanelMouseLeave={collapsed ? closePeek : undefined} + onPanelMouseEnter={collapsed ? handlePanelPointerEnter : undefined} + onPanelMouseLeave={collapsed ? handlePanelPointerLeave : undefined} onPanelFocusCapture={collapsed ? openPeekImmediate : undefined} - onPanelBlurCapture={collapsed ? closePeek : undefined} + onPanelBlurCapture={collapsed ? handlePanelBlur : undefined} >
{isCompanySettingsRoute ? ( diff --git a/ui/src/components/Sidebar.test.tsx b/ui/src/components/Sidebar.test.tsx index acd8f5c5303..960adfcc31d 100644 --- a/ui/src/components/Sidebar.test.tsx +++ b/ui/src/components/Sidebar.test.tsx @@ -315,19 +315,20 @@ describe("Sidebar", () => { }); }); - it("header toggle expands a collapsed rail", async () => { + it("keeps the collapsed rail top bar to just the company logo (no clipped search/toggle)", async () => { + // In the narrow rail the search/toggle controls don't fit beside the logo and + // would overflow/clip, shoving the logo out of the icon column (PAP-10676), so + // they are dropped in the rail. Expansion stays reachable via hover-peek + Pin + // and Cmd/Ctrl+B. The full controls return as soon as the panel is expanded or + // peeking (covered by the other top-bar tests). mockSidebar.collapsed = true; mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); const root = await renderSidebar(); - const toggle = container.querySelector('button[aria-label="Expand sidebar"]'); - expect(toggle).not.toBeNull(); - expect(toggle?.getAttribute("aria-expanded")).toBe("false"); - - act(() => { - toggle?.click(); - }); - expect(mockSidebar.toggleCollapsed).toHaveBeenCalledTimes(1); + expect(container.querySelector('button[aria-label="Expand sidebar"]')).toBeNull(); + expect(container.querySelector('a[aria-label="Open search"]')).toBeNull(); + // The company menu (workspace switcher / logo) is still present in the rail. + expect(container.textContent).toContain("Company menu"); flushSync(() => { root.unmount(); diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 04778f264f2..73d08e59837 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -72,47 +72,56 @@ export function Sidebar() { {/* Top bar: Company name (bold) + Search — aligned with top sections (no visible border) */}
- - {/* Desktop-only collapse/expand affordance. While peeking (hover flyout - over the collapsed rail) it becomes a Pin that promotes the peek to a - pinned-expanded sidebar; otherwise it toggles the pinned rail. Mobile - uses the off-canvas drawer, so this control is hidden there. */} - {!isMobile ? ( - peeking ? ( + {/* In the collapsed rail the search/toggle controls don't fit beside the + logo — keeping them would overflow the 64px rail and squeeze the logo + out of alignment with the icon column below it (PAP-10676). They return + as soon as the panel is expanded (pinned) or peeking. Expansion in the + rail is still reachable via hover-peek + Pin and Cmd/Ctrl+B. */} + {!rail ? ( + <> - ) : ( - - ) + {/* Desktop-only collapse/expand affordance. While peeking (hover flyout + over the collapsed rail) it becomes a Pin that promotes the peek to a + pinned-expanded sidebar; otherwise it toggles the pinned rail. Mobile + uses the off-canvas drawer, so this control is hidden there. */} + {!isMobile ? ( + peeking ? ( + + ) : ( + + ) + ) : null} + ) : null}
diff --git a/ui/src/components/SidebarCompanyMenu.tsx b/ui/src/components/SidebarCompanyMenu.tsx index 7b93c5a2693..c1d77f84534 100644 --- a/ui/src/components/SidebarCompanyMenu.tsx +++ b/ui/src/components/SidebarCompanyMenu.tsx @@ -225,7 +225,12 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb