From 251dd0f700481de449f7de95a40b439b6d0cf3cf Mon Sep 17 00:00:00 2001 From: aleexzxzxzx Date: Tue, 21 Jul 2026 18:14:15 -0700 Subject: [PATCH 1/4] fix(sidebar): keep peeked sidebar open while the task Filter menu is open With hover-peek active, opening the tasks-header Filter dropdown and then moving the pointer off the panel collapsed the sidebar while the menu (rendered in a portal anchored to its trigger) stayed open, leaving it floating over the content, chasing its vanished anchor. Apply the same hold used by the ProjectSwitcher (#3484): TaskFilterMenu holds the sidebar peek on open and releases it on close (and on unmount), so the peek stays until the menu closes. Hover behaviour is unchanged when no menu is open. Generated-By: PostHog Code Task-Id: 1252280f-2bb1-49bc-9f6a-fe86f670957e --- .../sidebar/components/TasksHeader.tsx | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/sidebar/components/TasksHeader.tsx b/packages/ui/src/features/sidebar/components/TasksHeader.tsx index ebe4066a59..6aeab2c283 100644 --- a/packages/ui/src/features/sidebar/components/TasksHeader.tsx +++ b/packages/ui/src/features/sidebar/components/TasksHeader.tsx @@ -26,12 +26,16 @@ import { import type { WorkspaceMode } from "@posthog/shared"; import { useMeQuery } from "@posthog/ui/features/auth/useMeQuery"; import { useFolders } from "@posthog/ui/features/folders/useFolders"; +import { + holdSidebarPeek, + releaseSidebarPeek, +} from "@posthog/ui/features/sidebar/sidebarPeekStore"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; import { Tooltip } from "@posthog/ui/primitives/Tooltip"; import { toast } from "@posthog/ui/primitives/toast"; import { useCommandMenuStore } from "@posthog/ui/shell/commandMenuStore"; import { logger } from "@posthog/ui/shell/logger"; -import { useState } from "react"; +import { useEffect, useState } from "react"; const log = logger.scope("tasks-header"); @@ -105,8 +109,20 @@ function TaskFilterMenu() { const { data: currentUser } = useMeQuery(); const isStaff = currentUser?.is_staff === true; + // Hold the sidebar's hover-peek open while this dropdown is open: it lives in + // a portal anchored to the trigger, so if the peek collapsed underneath it + // (pointer leaving the panel, e.g. toward the Environment flyout) the menu + // would be left floating over the content, chasing its vanished anchor. + const handleOpenChange = (next: boolean): void => { + if (next) holdSidebarPeek(); + else releaseSidebarPeek(); + }; + // Release if we unmount while the menu is open (e.g. a route change) so the + // hold can't outlive it. + useEffect(() => () => releaseSidebarPeek(), []); + return ( - + From b8a827932af4a95a51581f7dcfea22342fa6d936 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 21 Jul 2026 20:23:16 -0700 Subject: [PATCH 2/4] remove redundant comments --- packages/ui/src/features/sidebar/components/TasksHeader.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/ui/src/features/sidebar/components/TasksHeader.tsx b/packages/ui/src/features/sidebar/components/TasksHeader.tsx index 6aeab2c283..d2a2321b76 100644 --- a/packages/ui/src/features/sidebar/components/TasksHeader.tsx +++ b/packages/ui/src/features/sidebar/components/TasksHeader.tsx @@ -109,16 +109,10 @@ function TaskFilterMenu() { const { data: currentUser } = useMeQuery(); const isStaff = currentUser?.is_staff === true; - // Hold the sidebar's hover-peek open while this dropdown is open: it lives in - // a portal anchored to the trigger, so if the peek collapsed underneath it - // (pointer leaving the panel, e.g. toward the Environment flyout) the menu - // would be left floating over the content, chasing its vanished anchor. const handleOpenChange = (next: boolean): void => { if (next) holdSidebarPeek(); else releaseSidebarPeek(); }; - // Release if we unmount while the menu is open (e.g. a route change) so the - // hold can't outlive it. useEffect(() => () => releaseSidebarPeek(), []); return ( From 60ed183ff4a6213a5389a7064a79dc7861978bf0 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 21 Jul 2026 20:34:26 -0700 Subject: [PATCH 3/4] make sidebar peek hold a counter --- .../features/sidebar/sidebarPeekStore.test.ts | 28 ++++++++++++++++++- .../src/features/sidebar/sidebarPeekStore.ts | 14 +++++----- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/features/sidebar/sidebarPeekStore.test.ts b/packages/ui/src/features/sidebar/sidebarPeekStore.test.ts index 8c2188b149..640a3f73f0 100644 --- a/packages/ui/src/features/sidebar/sidebarPeekStore.test.ts +++ b/packages/ui/src/features/sidebar/sidebarPeekStore.test.ts @@ -13,7 +13,7 @@ const isPeeked = (): boolean => useSidebarPeekStore.getState().peek; describe("sidebarPeekStore", () => { beforeEach(() => { vi.useFakeTimers(); - // Reset shared module-level state (hold flag + hide timer + peek) so each + // Reset shared module-level state (hold count + hide timer + peek) so each // case starts clean. cancelSidebarPeek(); }); @@ -48,6 +48,32 @@ describe("sidebarPeekStore", () => { expect(isPeeked()).toBe(false); }); + it("keeps the peek held until every holder has released", () => { + beginSidebarPeek(); + holdSidebarPeek(); + holdSidebarPeek(); + + releaseSidebarPeek(); + endSidebarPeek(0); + vi.runAllTimers(); + expect(isPeeked()).toBe(true); + + releaseSidebarPeek(); + endSidebarPeek(0); + vi.runAllTimers(); + expect(isPeeked()).toBe(false); + }); + + it("releaseSidebarPeek without a hold does not break a later hold", () => { + releaseSidebarPeek(); + + beginSidebarPeek(); + holdSidebarPeek(); + endSidebarPeek(0); + vi.runAllTimers(); + expect(isPeeked()).toBe(true); + }); + it("holdSidebarPeek cancels a hide that is already pending", () => { beginSidebarPeek(); endSidebarPeek(200); diff --git a/packages/ui/src/features/sidebar/sidebarPeekStore.ts b/packages/ui/src/features/sidebar/sidebarPeekStore.ts index 7040b48b5d..faec675002 100644 --- a/packages/ui/src/features/sidebar/sidebarPeekStore.ts +++ b/packages/ui/src/features/sidebar/sidebarPeekStore.ts @@ -22,9 +22,9 @@ let hideTimer: ReturnType | null = null; // While a sidebar-spawned menu is open the peek is "held": endSidebarPeek is a // no-op so a pointer that leaves the panel (e.g. toward a submenu flyout) can't -// collapse it and strand the open menu's portal anchor. Module-level to match -// hideTimer. -let held = false; +// collapse it and strand the open menu's portal anchor. Counted, not boolean, +// so one menu's release can't drop a hold another menu still needs. +let holdCount = 0; const clearHideTimer = (): void => { if (hideTimer) { @@ -43,16 +43,16 @@ export function beginSidebarPeek(): void { // releasing hands control back to the hover logic, which collapses the peek on // the next pointer move outside the panel. export function holdSidebarPeek(): void { - held = true; + holdCount += 1; clearHideTimer(); } export function releaseSidebarPeek(): void { - held = false; + holdCount = Math.max(0, holdCount - 1); } export function endSidebarPeek(delayMs = 0): void { - if (held) return; + if (holdCount > 0) return; clearHideTimer(); hideTimer = setTimeout(() => { hideTimer = null; @@ -61,7 +61,7 @@ export function endSidebarPeek(delayMs = 0): void { } export function cancelSidebarPeek(): void { - held = false; + holdCount = 0; clearHideTimer(); useSidebarPeekStore.getState().setPeek(false); } From c83549cde1b9c8e2e9412364aa33d197ecc61b6c Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 21 Jul 2026 20:34:50 -0700 Subject: [PATCH 4/4] share sidebar peek hold via hook --- .../sidebar/components/ProjectSwitcher.tsx | 18 ++--- .../sidebar/components/TasksHeader.tsx | 13 +--- .../sidebar/useHoldSidebarPeek.test.tsx | 71 +++++++++++++++++++ .../features/sidebar/useHoldSidebarPeek.ts | 25 +++++++ 4 files changed, 103 insertions(+), 24 deletions(-) create mode 100644 packages/ui/src/features/sidebar/useHoldSidebarPeek.test.tsx create mode 100644 packages/ui/src/features/sidebar/useHoldSidebarPeek.ts diff --git a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx index 64c6988495..08f4520ec3 100644 --- a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx +++ b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx @@ -49,34 +49,24 @@ import { import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { useProjects } from "@posthog/ui/features/projects/useProjects"; import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; -import { - holdSidebarPeek, - releaseSidebarPeek, -} from "@posthog/ui/features/sidebar/sidebarPeekStore"; +import { useHoldSidebarPeek } from "@posthog/ui/features/sidebar/useHoldSidebarPeek"; import { useWhatsNewStore } from "@posthog/ui/features/updates/whatsNewStore"; import { openExternalUrl } from "@posthog/ui/shell/openExternal"; import { isMac } from "@posthog/ui/utils/platform"; import { getPostHogUrl } from "@posthog/ui/utils/urls"; import { Avatar, Box } from "@radix-ui/themes"; import { ChevronRightIcon } from "lucide-react"; -import { type ReactNode, useEffect, useMemo, useState } from "react"; +import { type ReactNode, useMemo, useState } from "react"; // The two-line user/project card used at the bottom of the sidebar. export function ProjectSwitcher() { const [popoverOpen, setPopoverOpen] = useState(false); - // Hold the sidebar's hover-peek open while this dropdown is open: it lives in - // a portal anchored to the trigger, so if the peek collapsed underneath it - // (pointer leaving the panel, e.g. toward a submenu flyout) the menu would be - // left floating over the content, chasing its vanished anchor. + const holdPeek = useHoldSidebarPeek(); const handleOpenChange = (next: boolean): void => { setPopoverOpen(next); - if (next) holdSidebarPeek(); - else releaseSidebarPeek(); + holdPeek(next); }; - // Release if we unmount while the menu is open (e.g. a route change) so the - // hold can't outlive it. - useEffect(() => () => releaseSidebarPeek(), []); const currentOrgId = useAuthStateValue((state) => state.currentOrgId); const client = useOptionalAuthenticatedClient(); diff --git a/packages/ui/src/features/sidebar/components/TasksHeader.tsx b/packages/ui/src/features/sidebar/components/TasksHeader.tsx index d2a2321b76..edb49e4e8e 100644 --- a/packages/ui/src/features/sidebar/components/TasksHeader.tsx +++ b/packages/ui/src/features/sidebar/components/TasksHeader.tsx @@ -26,16 +26,13 @@ import { import type { WorkspaceMode } from "@posthog/shared"; import { useMeQuery } from "@posthog/ui/features/auth/useMeQuery"; import { useFolders } from "@posthog/ui/features/folders/useFolders"; -import { - holdSidebarPeek, - releaseSidebarPeek, -} from "@posthog/ui/features/sidebar/sidebarPeekStore"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; +import { useHoldSidebarPeek } from "@posthog/ui/features/sidebar/useHoldSidebarPeek"; import { Tooltip } from "@posthog/ui/primitives/Tooltip"; import { toast } from "@posthog/ui/primitives/toast"; import { useCommandMenuStore } from "@posthog/ui/shell/commandMenuStore"; import { logger } from "@posthog/ui/shell/logger"; -import { useEffect, useState } from "react"; +import { useState } from "react"; const log = logger.scope("tasks-header"); @@ -109,11 +106,7 @@ function TaskFilterMenu() { const { data: currentUser } = useMeQuery(); const isStaff = currentUser?.is_staff === true; - const handleOpenChange = (next: boolean): void => { - if (next) holdSidebarPeek(); - else releaseSidebarPeek(); - }; - useEffect(() => () => releaseSidebarPeek(), []); + const handleOpenChange = useHoldSidebarPeek(); return ( diff --git a/packages/ui/src/features/sidebar/useHoldSidebarPeek.test.tsx b/packages/ui/src/features/sidebar/useHoldSidebarPeek.test.tsx new file mode 100644 index 0000000000..35ab7e3e66 --- /dev/null +++ b/packages/ui/src/features/sidebar/useHoldSidebarPeek.test.tsx @@ -0,0 +1,71 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + beginSidebarPeek, + cancelSidebarPeek, + endSidebarPeek, + holdSidebarPeek, + useSidebarPeekStore, +} from "./sidebarPeekStore"; +import { useHoldSidebarPeek } from "./useHoldSidebarPeek"; + +const isPeeked = (): boolean => useSidebarPeekStore.getState().peek; + +const expectPeekAfterEnd = (expected: boolean): void => { + endSidebarPeek(0); + act(() => { + vi.runAllTimers(); + }); + expect(isPeeked()).toBe(expected); +}; + +describe("useHoldSidebarPeek", () => { + beforeEach(() => { + vi.useFakeTimers(); + cancelSidebarPeek(); + }); + + afterEach(() => { + cancelSidebarPeek(); + vi.useRealTimers(); + }); + + it("holds while open and releases on close", () => { + beginSidebarPeek(); + const { result } = renderHook(() => useHoldSidebarPeek()); + + act(() => result.current(true)); + expectPeekAfterEnd(true); + + act(() => result.current(false)); + expectPeekAfterEnd(false); + }); + + it("releases on unmount while open", () => { + beginSidebarPeek(); + const { result, unmount } = renderHook(() => useHoldSidebarPeek()); + + act(() => result.current(true)); + unmount(); + expectPeekAfterEnd(false); + }); + + it("unmounting without opening leaves another holder's hold intact", () => { + beginSidebarPeek(); + holdSidebarPeek(); + const { unmount } = renderHook(() => useHoldSidebarPeek()); + + unmount(); + expectPeekAfterEnd(true); + }); + + it("repeated open events acquire a single hold", () => { + beginSidebarPeek(); + const { result } = renderHook(() => useHoldSidebarPeek()); + + act(() => result.current(true)); + act(() => result.current(true)); + act(() => result.current(false)); + expectPeekAfterEnd(false); + }); +}); diff --git a/packages/ui/src/features/sidebar/useHoldSidebarPeek.ts b/packages/ui/src/features/sidebar/useHoldSidebarPeek.ts new file mode 100644 index 0000000000..3b9ff67976 --- /dev/null +++ b/packages/ui/src/features/sidebar/useHoldSidebarPeek.ts @@ -0,0 +1,25 @@ +import { + holdSidebarPeek, + releaseSidebarPeek, +} from "@posthog/ui/features/sidebar/sidebarPeekStore"; +import { useCallback, useEffect, useRef } from "react"; + +// Sidebar menus render in portals; holding the peek while one is open stops the +// sidebar collapsing underneath it and stranding the menu's anchor. +export function useHoldSidebarPeek(): (open: boolean) => void { + const holdingRef = useRef(false); + + useEffect( + () => () => { + if (holdingRef.current) releaseSidebarPeek(); + }, + [], + ); + + return useCallback((open: boolean) => { + if (open === holdingRef.current) return; + holdingRef.current = open; + if (open) holdSidebarPeek(); + else releaseSidebarPeek(); + }, []); +}