diff --git a/products/desktop/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/products/desktop/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index a2603abda259..06d333a662f0 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -29,6 +29,7 @@ import { } from "@posthog/ui/features/canvas/stores/channelPaneStore"; import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { NavResizeTooltip } from "@posthog/ui/features/sidebar/components/NavResizeTooltip"; import { ProjectSwitcher } from "@posthog/ui/features/sidebar/components/ProjectSwitcher"; import { SidebarMenu } from "@posthog/ui/features/sidebar/components/SidebarMenu"; @@ -158,8 +159,9 @@ function ChannelsSidebarImpl() { const channelsLayout = useChannelsLayout(); const peek = useSidebarPeekStore((s) => s.peek); + const revealOnHover = useSettingsStore((s) => s.revealSidebarOnHover); useSidebarEdgeHoverPeek({ - enabled: !open && !isResizing, + enabled: revealOnHover && !open && !isResizing, peeked: peek, side: "left", width, @@ -169,8 +171,8 @@ function ChannelsSidebarImpl() { onClose: () => endSidebarPeek(), }); useEffect(() => { - if (open) cancelSidebarPeek(); - }, [open]); + if (open || !revealOnHover) cancelSidebarPeek(); + }, [open, revealOnHover]); // The peek store is a module-level singleton — if this sidebar unmounts // while peeked (route without it), a stale peek would greet the remount. useEffect(() => () => cancelSidebarPeek(), []); diff --git a/products/desktop/packages/ui/src/features/settings/sections/GeneralSettings.tsx b/products/desktop/packages/ui/src/features/settings/sections/GeneralSettings.tsx index 96f547e0b89a..7f43a152c61c 100644 --- a/products/desktop/packages/ui/src/features/settings/sections/GeneralSettings.tsx +++ b/products/desktop/packages/ui/src/features/settings/sections/GeneralSettings.tsx @@ -138,6 +138,7 @@ export function GeneralSettings() { defaultCloudMessagingMode, defaultReasoningEffort, diffOpenMode, + revealSidebarOnHover, sendMessagesWith, setAutoConvertLongText, setDefaultInitialTaskMode, @@ -145,6 +146,7 @@ export function GeneralSettings() { setDefaultCloudMessagingMode, setDefaultReasoningEffort, setDiffOpenMode, + setRevealSidebarOnHover, setSendMessagesWith, } = useSettingsStore(); @@ -160,6 +162,18 @@ export function GeneralSettings() { [theme, setTheme], ); + const handleRevealSidebarOnHoverChange = useCallback( + (checked: boolean) => { + track(ANALYTICS_EVENTS.SETTING_CHANGED, { + setting_name: "reveal_sidebar_on_hover", + new_value: checked, + old_value: !checked, + }); + setRevealSidebarOnHover(checked); + }, + [setRevealSidebarOnHover], + ); + const handleAutoConvertLongTextChange = useCallback( (value: AutoConvertLongText) => { track(ANALYTICS_EVENTS.SETTING_CHANGED, { @@ -267,6 +281,18 @@ export function GeneralSettings() { )} + + + + + { ["slotMachineMode", false, true], ["dismissibleUpdateBanners", false, true], ["showSidebarWorktrees", false, true], + ["revealSidebarOnHover", true, false], ] as const)("rehydrates %s", async (field, initial, persisted) => { getItem.mockResolvedValue( JSON.stringify({ state: { [field]: persisted }, version: 0 }), diff --git a/products/desktop/packages/ui/src/features/settings/settingsStore.ts b/products/desktop/packages/ui/src/features/settings/settingsStore.ts index 9bf05ee08226..d70710c71e6b 100644 --- a/products/desktop/packages/ui/src/features/settings/settingsStore.ts +++ b/products/desktop/packages/ui/src/features/settings/settingsStore.ts @@ -314,6 +314,11 @@ export interface SettingsStore { // start a task in. Opt-in: off by default to keep the sidebar uncluttered. showSidebarWorktrees: boolean; setShowSidebarWorktrees: (enabled: boolean) => void; + // Slides the collapsed sidebar out when the pointer reaches the window edge + // or the title-bar toggle. On by default; off leaves the toggle and Cmd+B as + // the only ways to open it. + revealSidebarOnHover: boolean; + setRevealSidebarOnHover: (enabled: boolean) => void; // Experimental / misc hedgehogMode: boolean; @@ -588,6 +593,9 @@ export const useSettingsStore = create()( showSidebarWorktrees: false, setShowSidebarWorktrees: (enabled) => set({ showSidebarWorktrees: enabled }), + revealSidebarOnHover: true, + setRevealSidebarOnHover: (enabled) => + set({ revealSidebarOnHover: enabled }), // Experimental / misc hedgehogMode: false, @@ -733,6 +741,7 @@ export const useSettingsStore = create()( // Sidebar showSidebarWorktrees: state.showSidebarWorktrees, + revealSidebarOnHover: state.revealSidebarOnHover, // Experimental / misc hedgehogMode: state.hedgehogMode, diff --git a/products/desktop/packages/ui/src/primitives/hooks/useSidebarEdgeHoverPeek.test.ts b/products/desktop/packages/ui/src/primitives/hooks/useSidebarEdgeHoverPeek.test.ts index 45e61f10f1b5..f1ed234b9717 100644 --- a/products/desktop/packages/ui/src/primitives/hooks/useSidebarEdgeHoverPeek.test.ts +++ b/products/desktop/packages/ui/src/primitives/hooks/useSidebarEdgeHoverPeek.test.ts @@ -3,8 +3,10 @@ import { PEEK_REVEAL_THRESHOLD, shouldCloseOnExit, shouldRevealOnEdge, + useSidebarEdgeHoverPeek, } from "@posthog/ui/primitives/hooks/useSidebarEdgeHoverPeek"; -import { describe, expect, it } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; describe("shouldRevealOnEdge", () => { const threshold = PEEK_REVEAL_THRESHOLD; @@ -39,3 +41,31 @@ describe("shouldCloseOnExit", () => { expect(shouldCloseOnExit({ pointer, width, margin })).toBe(expected); }); }); + +describe("useSidebarEdgeHoverPeek", () => { + const moveTo = (clientX: number): void => { + document.dispatchEvent(new MouseEvent("mousemove", { clientX })); + }; + + it.each([ + ["reveals while enabled", true, 1], + ["stays closed while disabled", false, 0], + ])("%s", (_name, enabled, calls) => { + const onReveal = vi.fn(); + renderHook(() => + useSidebarEdgeHoverPeek({ + enabled, + peeked: false, + side: "left", + width: 240, + onReveal, + onClose: vi.fn(), + }), + ); + + moveTo(400); + moveTo(2); + + expect(onReveal).toHaveBeenCalledTimes(calls); + }); +}); diff --git a/products/desktop/packages/ui/src/router/routes/__root.tsx b/products/desktop/packages/ui/src/router/routes/__root.tsx index 9027ea01a256..93fff1ef990c 100644 --- a/products/desktop/packages/ui/src/router/routes/__root.tsx +++ b/products/desktop/packages/ui/src/router/routes/__root.tsx @@ -46,6 +46,7 @@ import { useInboxDeepLink } from "@posthog/ui/features/inbox/hooks/useInboxDeepL import { useIntegrations } from "@posthog/ui/features/integrations/useIntegrations"; import { useLoopDeepLink } from "@posthog/ui/features/loops/hooks/useLoopDeepLink"; import { useScoutDeepLink } from "@posthog/ui/features/scouts/hooks/useScoutDeepLink"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { useSetupDiscovery } from "@posthog/ui/features/setup/useSetupDiscovery"; import { NAV_RAIL_WIDTH } from "@posthog/ui/features/sidebar/constants"; import { @@ -218,6 +219,7 @@ function RootLayout() { const toggleSidebar = useSidebarStore((s) => s.toggle); const sidebarPeek = useSidebarPeekStore((s) => s.peek); + const revealSidebarOnHover = useSettingsStore((s) => s.revealSidebarOnHover); // Toggling makes any hover-peek redundant (opening replaces the overlay; // closing must not leave it lingering under the pointer). const handleToggleSidebar = (): void => { @@ -348,7 +350,7 @@ function RootLayout() { aria-label="Toggle sidebar" onClick={handleToggleSidebar} onMouseEnter={() => { - if (!sidebarOpen) beginSidebarPeek(); + if (revealSidebarOnHover && !sidebarOpen) beginSidebarPeek(); }} > {sidebarOpen ? (