Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

feat: add recent activity hover popover - #3874

Merged
trunk-io[bot] merged 8 commits into
mainfrom
posthog-code/recent-activity-popover
Jul 28, 2026
Merged

feat: add recent activity hover popover#3874
trunk-io[bot] merged 8 commits into
mainfrom
posthog-code/recent-activity-popover

Conversation

@puemos

@puemos puemos commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Problem

Recent task activity takes a full-page navigation to review.

Why: People should be able to quickly scan recent updates from the activity bell without leaving their current view.

Changes

  • Add a delayed, right-aligned activity popover inspired by Slack’s compact activity panel.
  • Show recent activity using the existing shared feed API and cache.
  • Reuse existing row navigation and per-task mark-read behavior, including a batched “Mark all as read” action for visible unread rows.

How did you test this?

  • Focused activity and hover tests: 10 passed.
  • UI, API-client, and web-host typechecks passed.
  • Biome checks passed for changed files.

Automatic notifications

  • Publish to changelog?
  • Alert Sales and Marketing teams?

Created with PostHog Code

@trunk-io

trunk-io Bot commented Jul 28, 2026

Copy link
Copy Markdown

😎 Merged directly without going through the merge queue, as the queue was empty and the PR was up to date with the target branch - details.

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown

React Doctor found 1 issue in 1 file · 1 warning.

1 warning

src/features/canvas/components/ActivityHoverCard.tsx

Reviewed by React Doctor for commit 3137fae.

@posthog

posthog Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

🦔 ReviewHog reviewed this pull request

Found 0 must fix, 1 should fix, 1 consider.

Published 2 findings (view the review).

@puemos
puemos marked this pull request as ready for review July 28, 2026 14:21
@posthog

posthog Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

ReviewHog Alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReviewHog Report

Changes

Issues: 2 issues

Files (4)
  • packages/ui/src/features/canvas/components/ActivityHoverCard.tsx
  • packages/ui/src/features/canvas/components/ActivityView.tsx
  • packages/ui/src/features/canvas/components/ChannelNav.tsx
  • packages/ui/src/features/sidebar/components/items/ActivityItem.tsx

Comment thread packages/ui/src/features/canvas/components/ActivityHoverCard.tsx Outdated
Comment thread packages/ui/src/features/canvas/components/ChannelNav.tsx Outdated
@puemos
puemos requested a review from a team July 28, 2026 15:35

@k11kirky k11kirky left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review pass (8 findings, verified against the real @base-ui/react 1.3.0 stack where behaviour was in question). Ranked most-severe first as inline comments.

The two highest-impact items are functional regressions in the trigger wiring: disabled={isActivity} genuinely disables the nav bell, and the sidebar trigger has no isActive guard so a plain click both navigates and pops the card open. Both reproduced empirically rather than inferred.

Also checked and refuted (no action needed): the unreadItems.length === unreadCount label logic (both are unread-task counts and the optimistic update decrements them in lockstep — hasNextPage would be strictly worse); the pr-14 "dead click strip" (it's padding inside the button, and the overlay is pointer-events-none, so clicks land fine); and import { ActivityHoverCard } from "./ActivityHoverCard" (AGENTS.md prohibits deep relative imports — same-directory ./ is established practice in that folder).

One lower-severity item not worth its own thread: the view_activity mount effect fires on every 300ms hover-open (both call sites conditionally mount the card), merging cheap hovers into an existing metric that only surface distinguishes. I searched project 2's saved insights and found no currently-broken chart, so it's a trap for future queries rather than a live break — but worth a surface filter in any dashboard that uses it.


Created with PostHog Code

openOnHover
delay={300}
closeDelay={100}
disabled={isActivity}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

disabled reaches the DOM and genuinely disables the Activity bell.

Base UI's PopoverTrigger defaults nativeButton = true and passes disabled through useButtonuseFocusableWhenDisabled, which sets additionalProps.disabled = disabled; useRenderElement then merges it into the render element. NavButton destructures only icon, label, isActive, onClick, badge, className, ref, so disabled stays in ...buttonProps and is spread onto <button>.

On the Activity view the bell drops out of the tab order and its onClick (track + navigateToActivity) can never fire — unlike Inbox and Command Center, which stay clickable while active.

It's also redundant: open={!isActivity && activityOpen} plus the onOpenChange guard already make the popover unopenable here. Deleting this line loses nothing and restores a clickable, tabbable bell.

@puemos puemos Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: removed the disabled prop; the Activity bell stays clickable and tabbable while the open-state guard suppresses the popover.

if (isActive) return item;

return (
<Popover open={open} onOpenChange={setOpen}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No isActive guard on open/onOpenChange, so a plain click both navigates and toggles the card open over the page it just navigated to.

Reproduced against the real quill / @base-ui/react 1.3.0 stack: a single click with no hover yields navCalls=1 and the hover card present, trigger at aria-expanded="true".

useClick is enabled unconditionally on the trigger (openOnHover only adds a separate hover interaction), and nothing stops propagation between SidebarItem's inner <button> and the trigger — so setOpen(false); onClick() runs, then the trigger toggles the closed popover open.

isActive derives from async router state (navigateToActivity is a fire-and-forget router.navigate), so React commits open=true before navigation lands and the 380px card flashes over the Activity page. if (isActive) return item only rescues this once the route commits.

ChannelNav guards both directions explicitly; this call site has neither.

@puemos puemos Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: click-driven opening is suppressed, so clicking navigates without flashing the hover card.

/>
))}
{hasNextPage && (
<div ref={loadMoreRef} className="flex h-8 justify-center py-2">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conditionally-mounted sentinel + mount-only observer = auto-pagination permanently dead for that popover instance.

useInView does const el = ref.current; if (!el) return; observer.observe(el) with deps [rootMargin, once] — no callback ref, no re-run when the node appears later. This sentinel only renders under items.length > 0 && hasNextPage.

Open the card while the first useTaskActivity page is in flight (cold start, slow network), or before the auth client resolves (enabled: !!clientisLoading false with zero items, so the empty branch renders): loadMoreRef.current is null, the observer is never created, loadMoreInView stays false forever, and scrolling the 480px list never calls fetchNextPage.

The new test can't catch this — ActivityHoverCard.test.tsx:46 mocks useInView to [{ current: null }, true], stubbing out the exact wiring that's broken. The other useInView call sites (ChannelFeedView, WebsiteDashboardsIndex) attach the ref to an unconditionally rendered element; doing the same here (render the sentinel container always, gate only the spinner) fixes it.

@puemos puemos Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: the sentinel is always mounted, and the observer reconnects when its element appears.

openOnHover
delay={300}
closeDelay={100}
render={<div className="w-full">{item}</div>}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

render={<div>} without nativeButton={false} → invalid type on a div, popup ARIA on a roleless wrapper, a dead extra tab stop, and no keyboard path to the card.

Verified by rendering this markup against @posthog/quill@0.3.0-beta.24 / @base-ui/react@1.3.0. Emitted DOM:

<div class="w-full" type="button" tabindex="0" aria-haspopup="dialog" aria-expanded="false">
  <button type="button" tabindex="0">

Base UI also logs a dev-mode error: "A component that acts as a button expected a native <button> because the nativeButton prop is true… Use a real <button> in the render prop, or set nativeButton to false."

Focusing the div and dispatching Enter and Space gives navCalls=0 hovercard=false, because getButtonProps' key synthesis is gated on !isNativeButton. Net: a keyboard or screen-reader user gets two tab stops for one control, is told a popup exists on the one that can't open it, and can never reach the hover card.

ChannelNav's sibling trigger renders a real <button> and is unaffected. Fix is either nativeButton={false} or making the SidebarItem button itself the trigger.

@puemos puemos Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: SidebarItem is now the native popover trigger, removing the invalid wrapper and duplicate tab stop.


return (
<PopoverContent
side="right"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded side="right" makes the 380px panel cover the Command Center icon on the horizontal nav rail.

ChannelNav renders <div className="flex shrink-0 gap-2 px-2 pt-2 pb-1"> with three size-8 buttons in order Inbox, Activity, Command Center. gap-2 is 8px and sideOffset={8}, so the panel's left edge lands exactly on the Lightning button's left edge and its 380px width fully covers the Command Center icon and its unread badge while open.

Base UI's positioner only flips on viewport collision, and there's ample room in the left sidebar, so nothing rescues it.

ActivityHoverCard({ onClose }) exposes no side/align escape hatch, so the two call sites — which genuinely want different placements (side="right" is correct for the vertical sidebar row) — can't differ. Worth lifting placement to a prop, or to the caller's PopoverContent.

@puemos puemos Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: the top rail opens below the bell, while the vertical sidebar opens to the right.

delay={300}
closeDelay={100}
disabled={isActivity}
render={

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Activity bell silently loses its tooltip.

NavIcon wraps every icon in <Tooltip content={label} shortcut={shortcut} side="bottom">, and Inbox and Command Center still go through it. This branch calls NavButton directly from render, and NavButton only emits <button aria-label={label}>Tooltip is now used solely by NavIcon.

Hovering the bell on the Activity view shows nothing at all (the popover is suppressed there); off it, the icon-only bell has no name until a 380px panel opens after 300ms. The aria-label survives so the a11y name is intact, but the visual affordance is gone and the rail is inconsistent.

(The old Activity NavIcon passed no shortcut, so no keyboard hint is lost — just the label.)

@puemos puemos Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: restored the Activity tooltip, including on the Activity page.

surface: "activity_panel",
});
}, []);
useEffect(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isFetchingNextPage in the deps makes each settled page re-trigger the fetch, chaining unrequested pages on one 300ms hover.

useInView defaults once = false and keeps inView true while intersecting, so isFetchingNextPage flipping true→false re-runs this effect and fires fetchNextPage() again — no scroll, no click. Compact rows are ~44-46px so ~11 fill the 480px container; if the backend page size is below that, the chain continues until hasNextPage is false and one hover pulls the entire feed sequentially. Bounded above that (overflow clipping empties the sentinel's intersection rect once rows exceed 480px), so a single spurious extra page per hover is the common case.

I couldn't confirm the server page size — getTaskActivity sends no limit and the default lives in the backend repo — so flagging this as plausible rather than certain. Worth checking against the real page size.

Separately: rootMargin: "100px 0px" is inert here. useInView sets no IntersectionObserver root, so the margin expands the viewport rect, not this popover's overflow-y-auto clip — there's no early prefetch, just a spinner stall at the bottom. The pre-existing ActivityView deliberately used an explicit "Load more" button instead.

@puemos puemos Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: pagination observes the popover scroll container and no longer retriggers when a request settles.

import { useEffect, useMemo } from "react";

export function ActivityHoverCard({ onClose }: { onClose: () => void }) {
const client = useOptionalAuthenticatedClient();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The whole activity-feed shell is duplicated from ActivityView, and the two copies have already diverged.

Lines 16-61 mirror ActivityView.tsx:258-296: the same useTaskActivity / useMarkTaskActivityRead / useChannels destructuring, an identical new Map(channels.map(c => [normalizeChannelName(c.name), c.id])), hand-built { task_id, seen_before } payloads, and the same mount-effect track(CHANNEL_ACTION, { action_type: "view_activity" }).

They already differ in a way that's a live bug: this popover relabels its button "Mark visible as read" when loaded unread rows don't cover unreadCount, while the page unconditionally says "Mark all as read" from the same partially-loaded items — so the page's label is now wrong and there's no single place to fix it.

The empty state here is also a hand-rolled div where ActivityView uses quill's <Empty>, which AGENTS.md:235 requires: "Empty/placeholder/loading screens (canvas and elsewhere) are a @posthog/quill <Empty> … Don't hand-roll the centered Flex + dashed icon box." Same PR, two different empty states for the same data.

Extracting a shared useActivityFeed(surface) + ActivityList would remove ~35 duplicated lines and leave each surface only its chrome.

@puemos puemos Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: both surfaces now share unread payload, label, and channel-lookup helpers; the popover also uses Quill Empty components.

@puemos puemos closed this Jul 28, 2026
@puemos puemos reopened this Jul 28, 2026
@puemos
puemos force-pushed the posthog-code/recent-activity-popover branch from 6ef8485 to b3b9f68 Compare July 28, 2026 18:02

@k11kirky k11kirky left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets get this in!

puemos added 7 commits July 28, 2026 21:47
Generated-By: PostHog Code
Task-Id: 4a1c53d9-0ca7-4ebe-9936-0c6dab92ca96
Generated-By: PostHog Code
Task-Id: 4a1c53d9-0ca7-4ebe-9936-0c6dab92ca96
Show recent activity from the shared feed cache and batch unread rows through the existing mark-read endpoint. Update the popover layout to a compact Slack-inspired activity list.

Generated-By: PostHog Code
Task-Id: 4a1c53d9-0ca7-4ebe-9936-0c6dab92ca96
Use a compact row layout with a fixed top-right timestamp and visible hover state. Close promptly on pointer leave and disable the popover while Activity is already open.

Generated-By: PostHog Code
Task-Id: 4a1c53d9-0ca7-4ebe-9936-0c6dab92ca96
Label partial read cleanup accurately when unloaded activity remains, preserve pointer travel into the card, and migrate the trigger button to React 19 ref handling.

Generated-By: PostHog Code
Task-Id: 4a1c53d9-0ca7-4ebe-9936-0c6dab92ca96
Fetch the next activity cursor page when a bottom sentinel enters view and show a compact loading indicator while it loads.

Generated-By: PostHog Code
Task-Id: 4a1c53d9-0ca7-4ebe-9936-0c6dab92ca96
Generated-By: PostHog Code
Task-Id: 4a1c53d9-0ca7-4ebe-9936-0c6dab92ca96
@puemos
puemos force-pushed the posthog-code/recent-activity-popover branch from b3b9f68 to 7e5c21a Compare July 28, 2026 19:47
Generated-By: PostHog Code
Task-Id: 4a1c53d9-0ca7-4ebe-9936-0c6dab92ca96
@trunk-io
trunk-io Bot merged commit 608b6d5 into main Jul 28, 2026
32 checks passed
@trunk-io
trunk-io Bot deleted the posthog-code/recent-activity-popover branch July 28, 2026 19:59
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants