From 14d9ed73e7b1b30aa142a4d43694500b01c7b5db Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Thu, 30 Jul 2026 14:57:41 -0400 Subject: [PATCH 1/3] fix(canvas): stop the Activity hover card resurfacing after the bell navigates Clicking the Activity bell navigates to the Activity page, but quill's popover trigger runs its own open handler after ours and still inside the same click, so `isActivity` is false and the card stores itself as open. The Activity page only hid that state instead of clearing it, so the card reappeared over the next page opened from the feed. Swallow the open belonging to the navigating click, the same way the sidebar's Activity row already does. Generated-By: PostHog Code Task-Id: f89ba875-c0c8-4a08-b088-67b2a900e66d --- .../canvas/components/ChannelNav.test.tsx | 17 +++++++++++ .../features/canvas/components/ChannelNav.tsx | 29 +++++++++++++++---- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelNav.test.tsx b/packages/ui/src/features/canvas/components/ChannelNav.test.tsx index fe2da703cb..b48c5237ab 100644 --- a/packages/ui/src/features/canvas/components/ChannelNav.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelNav.test.tsx @@ -79,4 +79,21 @@ describe("ChannelNav", () => { await new Promise((resolve) => setTimeout(resolve, 400)); expect(screen.queryByText("Recent activity card")).not.toBeInTheDocument(); }); + + it("does not resurface the hover card after the bell navigated to Activity", async () => { + const user = userEvent.setup(); + const { rerender } = render(); + + await user.click(screen.getByLabelText("Activity")); + mocks.view = { type: "activity" }; + rerender(); + expect(screen.queryByText("Recent activity card")).not.toBeInTheDocument(); + + // Opening a notification from the Activity page navigates to its task. + mocks.view = { type: "task-detail" }; + rerender(); + + await new Promise((resolve) => setTimeout(resolve, 400)); + expect(screen.queryByText("Recent activity card")).not.toBeInTheDocument(); + }); }); diff --git a/packages/ui/src/features/canvas/components/ChannelNav.tsx b/packages/ui/src/features/canvas/components/ChannelNav.tsx index fe76afeb19..7bfad6f521 100644 --- a/packages/ui/src/features/canvas/components/ChannelNav.tsx +++ b/packages/ui/src/features/canvas/components/ChannelNav.tsx @@ -39,7 +39,12 @@ import { } from "@posthog/ui/router/navigationBridge"; import { useAppView } from "@posthog/ui/router/useAppView"; import { track } from "@posthog/ui/shell/analytics"; -import { type ComponentPropsWithRef, type ReactNode, useState } from "react"; +import { + type ComponentPropsWithRef, + type ReactNode, + useRef, + useState, +} from "react"; import { ActivityHoverCard } from "./ActivityHoverCard"; const INBOX_REFETCH_INTERVAL_MS = 60_000; @@ -136,7 +141,6 @@ function NavButton({ export function ChannelNav() { const view = useAppView(); const loopsEnabled = useFeatureFlag(LOOPS_FLAG, import.meta.env.DEV); - const [activityOpen, setActivityOpen] = useState(false); const { counts } = useInboxAllReports({ ignoreFilters: true, @@ -158,6 +162,15 @@ export function ChannelNav() { const isActivity = view.type === "activity"; const isCommandCenter = view.type === "command-center"; + // Clicking the bell navigates to Activity, but quill's trigger runs its own + // open after our handler and still inside the same click, so `isActivity` is + // false and the card records itself as open. The Activity page then only hides + // it, and it resurfaces over the next page you open. Swallowing that one open + // is what the ref is for — the sidebar's Activity row does the same. + const [activityOpen, setActivityOpen] = useState(false); + const suppressClickOpenRef = useRef(false); + const activityCardOpen = activityOpen && !isActivity; + return ( // One provider for the row: once any tooltip is up, moving to its // neighbour reveals that one immediately instead of serving the warm-up @@ -179,8 +192,13 @@ export function ChannelNav() { } /> setActivityOpen(!isActivity && open)} + open={activityCardOpen} + onOpenChange={(open) => { + const suppressed = open && suppressClickOpenRef.current; + suppressClickOpenRef.current = false; + if (suppressed) return; + setActivityOpen(!isActivity && open); + }} > { + suppressClickOpenRef.current = true; setActivityOpen(false); withTrack("activity", navigateToActivity)(); }} @@ -209,7 +228,7 @@ export function ChannelNav() { /> } /> - {!isActivity && activityOpen && ( + {activityCardOpen && ( setActivityOpen(false)} From 87d00c3e0d6159181140e7b4ac14bb94d424dc4a Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Thu, 30 Jul 2026 15:11:42 -0400 Subject: [PATCH 2/3] fix(canvas): never refuse a hover-card open on the Activity bell The first pass swallowed the open that quill's trigger applies on click, which stopped the stray card but left the underlying problem: refusing an open desyncs the trigger. Base UI applies the open internally before we see it, so handing back `false` leaves the bell stuck with `data-popup-open`/`data-pressed` and its hover-open dead until the trigger remounts. The old `!isActivity` gate refused opens the same way, so this was reachable on the Activity page too. Remove the refusals instead: the trigger's own click-open is prevented outright with `preventBaseUIHandler`, and the Activity page renders the bell with no popover at all. Owning the open state in a component that only mounts off the Activity page means it is born closed on every visit, so there is nothing to mask and nothing left over to resurface. Generated-By: PostHog Code Task-Id: f89ba875-c0c8-4a08-b088-67b2a900e66d --- .../canvas/components/ChannelNav.test.tsx | 33 +++++- .../features/canvas/components/ChannelNav.tsx | 108 +++++++++--------- 2 files changed, 83 insertions(+), 58 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelNav.test.tsx b/packages/ui/src/features/canvas/components/ChannelNav.test.tsx index b48c5237ab..91af5d993e 100644 --- a/packages/ui/src/features/canvas/components/ChannelNav.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelNav.test.tsx @@ -74,26 +74,51 @@ describe("ChannelNav", () => { const activity = screen.getByLabelText("Activity"); expect(activity).toBeEnabled(); + expect(activity).not.toHaveAttribute("aria-haspopup"); await user.hover(activity); await new Promise((resolve) => setTimeout(resolve, 400)); expect(screen.queryByText("Recent activity card")).not.toBeInTheDocument(); }); - it("does not resurface the hover card after the bell navigated to Activity", async () => { + it("leaves no popover state on the bell after it navigates to Activity", async () => { const user = userEvent.setup(); const { rerender } = render(); + const bell = () => screen.getByLabelText("Activity"); - await user.click(screen.getByLabelText("Activity")); + await user.hover(bell()); + await screen.findByText("Recent activity card", {}, { timeout: 1_000 }); + await user.click(bell()); mocks.view = { type: "activity" }; rerender(); + expect(screen.queryByText("Recent activity card")).not.toBeInTheDocument(); + expect(bell()).not.toHaveAttribute("data-popup-open"); + expect(bell()).not.toHaveAttribute("data-pressed"); + }); - // Opening a notification from the Activity page navigates to its task. - mocks.view = { type: "task-detail" }; + it("neither resurfaces nor wedges the hover card once the bell has navigated", async () => { + const user = userEvent.setup(); + const { rerender } = render(); + const bell = () => screen.getByLabelText("Activity"); + + await user.hover(bell()); + await user.click(bell()); + mocks.view = { type: "activity" }; rerender(); + await user.unhover(bell()); + // Opening a notification from the Activity page navigates to its task: the + // card must not come along for the ride. + mocks.view = { type: "task-detail" }; + rerender(); await new Promise((resolve) => setTimeout(resolve, 400)); expect(screen.queryByText("Recent activity card")).not.toBeInTheDocument(); + + // ...and hover must still work there. + await user.hover(bell()); + expect( + await screen.findByText("Recent activity card", {}, { timeout: 1_000 }), + ).toBeInTheDocument(); }); }); diff --git a/packages/ui/src/features/canvas/components/ChannelNav.tsx b/packages/ui/src/features/canvas/components/ChannelNav.tsx index 7bfad6f521..67f3a8fbde 100644 --- a/packages/ui/src/features/canvas/components/ChannelNav.tsx +++ b/packages/ui/src/features/canvas/components/ChannelNav.tsx @@ -41,8 +41,8 @@ import { useAppView } from "@posthog/ui/router/useAppView"; import { track } from "@posthog/ui/shell/analytics"; import { type ComponentPropsWithRef, + type ReactElement, type ReactNode, - useRef, useState, } from "react"; import { ActivityHoverCard } from "./ActivityHoverCard"; @@ -138,6 +138,54 @@ function NavButton({ ); } +// Only mounted off the Activity page, so the card's open state is born fresh on +// every visit. That matters because refusing an open is not free: quill's +// trigger applies it internally before we see it, so a `false` we hand back +// leaves the trigger stuck in its pressed state with hover-open dead until it +// remounts. Nothing here refuses one — the click prevents the trigger's own +// open, and the Activity page renders the bell without a popover at all. +function ActivityHoverPopover({ trigger }: { trigger: ReactElement }) { + const [open, setOpen] = useState(false); + + return ( + + event.preventBaseUIHandler()} + render={trigger} + /> + {open && ( + setOpen(false)} /> + )} + + ); +} + +function ActivityNavItem({ + isActive, + unreadCount, + onNavigate, +}: { + isActive: boolean; + unreadCount: number; + onNavigate: () => void; +}) { + const bell = ( + } + label="Activity" + isActive={isActive} + onClick={onNavigate} + badge={} + /> + ); + + if (isActive) return bell; + return ; +} + export function ChannelNav() { const view = useAppView(); const loopsEnabled = useFeatureFlag(LOOPS_FLAG, import.meta.env.DEV); @@ -162,15 +210,6 @@ export function ChannelNav() { const isActivity = view.type === "activity"; const isCommandCenter = view.type === "command-center"; - // Clicking the bell navigates to Activity, but quill's trigger runs its own - // open after our handler and still inside the same click, so `isActivity` is - // false and the card records itself as open. The Activity page then only hides - // it, and it resurfaces over the next page you open. Swallowing that one open - // is what the ref is for — the sidebar's Activity row does the same. - const [activityOpen, setActivityOpen] = useState(false); - const suppressClickOpenRef = useRef(false); - const activityCardOpen = activityOpen && !isActivity; - return ( // One provider for the row: once any tooltip is up, moving to its // neighbour reveals that one immediately instead of serving the warm-up @@ -191,50 +230,11 @@ export function ChannelNav() { } /> - { - const suppressed = open && suppressClickOpenRef.current; - suppressClickOpenRef.current = false; - if (suppressed) return; - setActivityOpen(!isActivity && open); - }} - > - - } - label="Activity" - isActive={isActivity} - onClick={() => { - suppressClickOpenRef.current = true; - setActivityOpen(false); - withTrack("activity", navigateToActivity)(); - }} - badge={ - - } - /> - } - /> - {activityCardOpen && ( - setActivityOpen(false)} - /> - )} - + Date: Thu, 30 Jul 2026 15:18:23 -0400 Subject: [PATCH 3/3] refactor(canvas): drop comments from the Activity bell popover Generated-By: PostHog Code Task-Id: f89ba875-c0c8-4a08-b088-67b2a900e66d --- .../ui/src/features/canvas/components/ChannelNav.test.tsx | 3 --- packages/ui/src/features/canvas/components/ChannelNav.tsx | 6 ------ 2 files changed, 9 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelNav.test.tsx b/packages/ui/src/features/canvas/components/ChannelNav.test.tsx index 91af5d993e..c0a07f9162 100644 --- a/packages/ui/src/features/canvas/components/ChannelNav.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelNav.test.tsx @@ -108,14 +108,11 @@ describe("ChannelNav", () => { rerender(); await user.unhover(bell()); - // Opening a notification from the Activity page navigates to its task: the - // card must not come along for the ride. mocks.view = { type: "task-detail" }; rerender(); await new Promise((resolve) => setTimeout(resolve, 400)); expect(screen.queryByText("Recent activity card")).not.toBeInTheDocument(); - // ...and hover must still work there. await user.hover(bell()); expect( await screen.findByText("Recent activity card", {}, { timeout: 1_000 }), diff --git a/packages/ui/src/features/canvas/components/ChannelNav.tsx b/packages/ui/src/features/canvas/components/ChannelNav.tsx index 67f3a8fbde..a92e23a96b 100644 --- a/packages/ui/src/features/canvas/components/ChannelNav.tsx +++ b/packages/ui/src/features/canvas/components/ChannelNav.tsx @@ -138,12 +138,6 @@ function NavButton({ ); } -// Only mounted off the Activity page, so the card's open state is born fresh on -// every visit. That matters because refusing an open is not free: quill's -// trigger applies it internally before we see it, so a `false` we hand back -// leaves the trigger stuck in its pressed state with hover-open dead until it -// remounts. Nothing here refuses one — the click prevents the trigger's own -// open, and the Activity page renders the bell without a popover at all. function ActivityHoverPopover({ trigger }: { trigger: ReactElement }) { const [open, setOpen] = useState(false);