From 8aa7a1f36073c944f08d5fc6c059385945ad70d2 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Thu, 16 Jul 2026 15:06:13 +0100 Subject: [PATCH 1/3] feat(channels): bold a channel name when it has unseen activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar channel names (and #me) go bold when there's activity the viewer hasn't seen, and clear when they open that channel. "Activity" is an @-mention for now: that's the only cross-channel, all-users feed the client has, and it's what "notification" already means here (it drives the Activity badge). The backend exposes no per-channel activity timestamp, so a broader "any new message" signal would mean mounting the all-users full-task poll — ~2.2MB/30s, documented in useTasks.ts as the app's heaviest and deliberately retired. If that timestamp lands, only latestActivityByChannel changes shape; the bolding, the name join and the seen store stay as they are. Seen state is per channel (unlike the Activity page's single lastSeenAt), keyed by backend channel id so a rename doesn't mark a channel unread again, and it never walks a timestamp backwards. The sidebar's rows are folder channels while activity is keyed by backend id, so they're joined by name — the same bridge useBackendChannel walks — resolved once per list rather than per row. Unread reads through the existing mentions query cache, so the sidebar adds no fetch. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019G63f4afY9vsbKsvK654Wj --- .../core/src/canvas/channelUnread.test.ts | 101 ++++++++++++++++++ packages/core/src/canvas/channelUnread.ts | 57 ++++++++++ .../canvas/components/ChannelsList.tsx | 62 +++++++++-- .../canvas/components/WebsiteChannelHome.tsx | 17 +++ .../canvas/hooks/useUnreadChannels.ts | 18 ++++ .../canvas/stores/channelSeenStore.ts | 35 ++++++ 6 files changed, 283 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/canvas/channelUnread.test.ts create mode 100644 packages/core/src/canvas/channelUnread.ts create mode 100644 packages/ui/src/features/canvas/hooks/useUnreadChannels.ts create mode 100644 packages/ui/src/features/canvas/stores/channelSeenStore.ts diff --git a/packages/core/src/canvas/channelUnread.test.ts b/packages/core/src/canvas/channelUnread.test.ts new file mode 100644 index 0000000000..8e0f50169e --- /dev/null +++ b/packages/core/src/canvas/channelUnread.test.ts @@ -0,0 +1,101 @@ +import { + latestActivityForChannel, + unreadChannelIds, +} from "@posthog/core/canvas/channelUnread"; +import type { MentionActivityItem } from "@posthog/core/canvas/mentionActivity"; +import { describe, expect, it } from "vitest"; + +function mention( + overrides: Partial & { createdAt: string }, +): MentionActivityItem { + return { + messageId: `m-${overrides.createdAt}`, + taskId: "t1", + taskTitle: "Task", + channelId: "c1", + channelName: "mobile", + author: null, + content: "hey @adam", + ...overrides, + }; +} + +describe("unreadChannelIds", () => { + const cases: { + name: string; + lastSeen: Record; + expected: string[]; + }[] = [ + { + name: "a channel never seen is unread", + lastSeen: {}, + expected: ["c1"], + }, + { + name: "activity newer than the last visit is unread", + lastSeen: { c1: "2026-07-16T09:00:00.000Z" }, + expected: ["c1"], + }, + { + name: "activity older than the last visit is read", + lastSeen: { c1: "2026-07-16T11:00:00.000Z" }, + expected: [], + }, + { + name: "activity exactly at the last visit is read", + lastSeen: { c1: "2026-07-16T10:00:00.000Z" }, + expected: [], + }, + ]; + it.each(cases)("$name", ({ lastSeen, expected }) => { + const items = [mention({ createdAt: "2026-07-16T10:00:00.000Z" })]; + expect([...unreadChannelIds(items, lastSeen)]).toEqual(expected); + }); + + it("compares each channel against its own last visit", () => { + const items = [ + mention({ channelId: "c1", createdAt: "2026-07-16T10:00:00.000Z" }), + mention({ channelId: "c2", createdAt: "2026-07-16T10:00:00.000Z" }), + ]; + const unread = unreadChannelIds(items, { + c1: "2026-07-16T11:00:00.000Z", + c2: "2026-07-16T09:00:00.000Z", + }); + expect([...unread]).toEqual(["c2"]); + }); + + it("uses the newest item in a channel, whatever the order", () => { + const items = [ + mention({ messageId: "old", createdAt: "2026-07-16T08:00:00.000Z" }), + mention({ messageId: "new", createdAt: "2026-07-16T12:00:00.000Z" }), + ]; + expect([ + ...unreadChannelIds(items, { c1: "2026-07-16T10:00:00.000Z" }), + ]).toEqual(["c1"]); + }); + + it("ignores channel-less mentions", () => { + const items = [ + mention({ channelId: null, createdAt: "2026-07-16T10:00Z" }), + ]; + expect([...unreadChannelIds(items, {})]).toEqual([]); + }); +}); + +describe("latestActivityForChannel", () => { + it("returns the newest timestamp for that channel only", () => { + const items = [ + mention({ channelId: "c1", createdAt: "2026-07-16T08:00:00.000Z" }), + mention({ channelId: "c1", createdAt: "2026-07-16T12:00:00.000Z" }), + mention({ channelId: "c2", createdAt: "2026-07-16T13:00:00.000Z" }), + ]; + expect(latestActivityForChannel(items, "c1")).toBe( + "2026-07-16T12:00:00.000Z", + ); + }); + + it("is undefined for a channel with no activity, or no channel", () => { + expect(latestActivityForChannel([], "c1")).toBeUndefined(); + expect(latestActivityForChannel([], undefined)).toBeUndefined(); + }); +}); diff --git a/packages/core/src/canvas/channelUnread.ts b/packages/core/src/canvas/channelUnread.ts new file mode 100644 index 0000000000..cc7e73f529 --- /dev/null +++ b/packages/core/src/canvas/channelUnread.ts @@ -0,0 +1,57 @@ +import type { MentionActivityItem } from "@posthog/core/canvas/mentionActivity"; + +/** + * Which channels have activity the viewer hasn't seen — the signal behind the + * sidebar's bold channel names. + * + * "Activity" is currently an @-mention: that's the only cross-channel, + * all-users feed the client has (the mentions index), and it's what + * "notification" means elsewhere in the app. The backend exposes no per-channel + * activity timestamp, so a broader "any new message" signal would mean polling + * every user's full task list — the app's heaviest poll, deliberately retired. + * If that timestamp lands, only `latestActivityByChannel` changes shape; the + * unread comparison and the seen bookkeeping stay as they are. + * + * Keyed by backend channel id rather than name, so renaming a channel doesn't + * silently mark it unread again. + */ + +/** Newest activity per channel id. Ignores items with no channel. */ +export function latestActivityByChannel( + items: readonly MentionActivityItem[], +): Map { + const latest = new Map(); + for (const item of items) { + if (!item.channelId) continue; + const current = latest.get(item.channelId); + if (!current || item.createdAt > current) { + latest.set(item.channelId, item.createdAt); + } + } + return latest; +} + +/** + * Channel ids whose newest activity postdates the viewer's last visit. A + * channel never visited is unread as soon as it has any activity. + */ +export function unreadChannelIds( + items: readonly MentionActivityItem[], + lastSeenByChannel: Readonly>, +): Set { + const unread = new Set(); + for (const [channelId, activityAt] of latestActivityByChannel(items)) { + const seenAt = lastSeenByChannel[channelId]; + if (!seenAt || activityAt > seenAt) unread.add(channelId); + } + return unread; +} + +/** The newest activity in one channel, for stamping it seen while it's open. */ +export function latestActivityForChannel( + items: readonly MentionActivityItem[], + channelId: string | undefined, +): string | undefined { + if (!channelId) return undefined; + return latestActivityByChannel(items).get(channelId); +} diff --git a/packages/ui/src/features/canvas/components/ChannelsList.tsx b/packages/ui/src/features/canvas/components/ChannelsList.tsx index ca4c269794..4d6b0ddead 100644 --- a/packages/ui/src/features/canvas/components/ChannelsList.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsList.tsx @@ -57,16 +57,25 @@ import { } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useCreateAndOpenDashboard } from "@posthog/ui/features/canvas/hooks/useDashboards"; import { + normalizeChannelName, PERSONAL_CHANNEL_NAME, useTaskChannels, } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { useUnreadChannelIds } from "@posthog/ui/features/canvas/hooks/useUnreadChannels"; import { copyChannelLink } from "@posthog/ui/features/canvas/utils/copyChannelLink"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; import { toast } from "@posthog/ui/primitives/toast"; import { track } from "@posthog/ui/shell/analytics"; import { Box, Flex } from "@radix-ui/themes"; import { useNavigate, useRouterState } from "@tanstack/react-router"; -import { Fragment, type ReactNode, useEffect, useRef, useState } from "react"; +import { + Fragment, + type ReactNode, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { hostClient } from "../hostClient"; // One actionable entry in a channel's menu, rendered the same whether it @@ -291,7 +300,14 @@ function ChannelMenu({ // One channel in the list: a "# name" row that navigates to the channel home. // No expansion — the channel's surfaces live in the in-channel top nav. -function ChannelSection({ channel }: { channel: Channel }) { +function ChannelSection({ + channel, + isUnread, +}: { + channel: Channel; + /** Bolds the name: activity here the viewer hasn't seen. */ + isUnread?: boolean; +}) { const navigate = useNavigate(); const pathname = useRouterState({ select: (s) => s.location.pathname }); const base = `/website/${channel.id}`; @@ -343,7 +359,8 @@ function ChannelSection({ channel }: { channel: Channel }) { @@ -495,9 +512,12 @@ function PersonalChannelRow() { const { channels } = useChannels(); const { createChannel, isCreating } = useChannelMutations(); // Listing backend channels lazily provisions the personal channel server-side. - useTaskChannels(); + const { personalChannel } = useTaskChannels(); // The "+" dropdown (New task / New canvas), mirroring a shared channel row. const [newMenuOpen, setNewMenuOpen] = useState(false); + const unreadChannelIds = useUnreadChannelIds(); + const isUnread = + !!personalChannel && unreadChannelIds.has(personalChannel.id); const meFolder = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME); const createAndOpenCanvas = useCreateAndOpenDashboard(meFolder?.id); @@ -561,7 +581,12 @@ function PersonalChannelRow() { className="w-full min-w-0 justify-start gap-2 data-selected:bg-fill-selected data-selected:text-gray-12" > - + {PERSONAL_CHANNEL_NAME} {/* The lock and the hover "+" share the right edge, so fade the lock @@ -703,6 +728,21 @@ export function ChannelsList() { const { channels: allChannels, isLoading } = useChannels(); const { starredRefToShortcutId } = useChannelStars(); + // Unread activity is keyed by backend channel id, while these rows are folder + // channels — joined by name, the same bridge useBackendChannel walks. Resolved + // once here rather than per row, so the list mounts one lookup, not 46. + const { channels: backendChannels } = useTaskChannels(); + const unreadChannelIds = useUnreadChannelIds(); + const unreadNames = useMemo(() => { + const names = new Set(); + for (const channel of backendChannels) { + if (unreadChannelIds.has(channel.id)) names.add(channel.name); + } + return names; + }, [backendChannels, unreadChannelIds]); + const isUnread = (channel: Channel) => + unreadNames.has(normalizeChannelName(channel.name)); + // The "me" folder renders as the pinned personal row, not a shared channel. const channels = allChannels.filter((c) => c.name !== PERSONAL_CHANNEL_NAME); const starred = channels.filter((c) => starredRefToShortcutId.has(c.path)); @@ -733,7 +773,11 @@ export function ChannelsList() { {starred.length > 0 && ( {starred.map((channel) => ( - + ))} )} @@ -745,7 +789,11 @@ export function ChannelsList() { )} {others.map((channel) => ( - + ))} diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index 25e00f8bc4..6d0f87173e 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -1,3 +1,4 @@ +import { latestActivityForChannel } from "@posthog/core/canvas/channelUnread"; import { insertTaskDedup } from "@posthog/core/tasks/taskDelete"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { Task } from "@posthog/shared/domain-types"; @@ -30,10 +31,12 @@ import { import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useChannelTaskMutations } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; import { useFolderInstructions } from "@posthog/ui/features/canvas/hooks/useFolderInstructions"; +import { useMentionActivity } from "@posthog/ui/features/canvas/hooks/useMentionActivity"; import { PERSONAL_CHANNEL_NAME, useBackendChannel, } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { useChannelSeenStore } from "@posthog/ui/features/canvas/stores/channelSeenStore"; import { useThreadPanelStore } from "@posthog/ui/features/canvas/stores/threadPanelStore"; import { SuggestedPromptCard } from "@posthog/ui/features/task-detail/components/SuggestedPromptCard"; import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; @@ -75,6 +78,20 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { // identity-resolution window (settling if the resolve fails), so fold it in: // we can't call a channel empty until we know which channel it is. const isLoading = isLoadingChannels || isResolvingChannel || isLoadingFeed; + // Viewing a channel reads it: stamp it seen so the sidebar drops its bold. + // Keyed on the newest activity rather than "now", so a mention landing while + // the channel is open re-stamps it (and so remounts don't churn the store). + const { items: mentionItems } = useMentionActivity(); + const latestActivityAt = useMemo( + () => latestActivityForChannel(mentionItems, backendChannel?.id), + [mentionItems, backendChannel?.id], + ); + const markChannelSeen = useChannelSeenStore((s) => s.markChannelSeen); + useEffect(() => { + if (!backendChannel?.id || !latestActivityAt) return; + markChannelSeen(backendChannel.id, latestActivityAt); + }, [backendChannel?.id, latestActivityAt, markChannelSeen]); + // Durable "PostHog agent" rows (CONTEXT.md being built, …) live on the // backend channel — the same id the feed tasks use, not the folder id. const { messages: feedMessages } = useChannelFeedMessages(backendChannel?.id); diff --git a/packages/ui/src/features/canvas/hooks/useUnreadChannels.ts b/packages/ui/src/features/canvas/hooks/useUnreadChannels.ts new file mode 100644 index 0000000000..39e29eeccd --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useUnreadChannels.ts @@ -0,0 +1,18 @@ +import { unreadChannelIds } from "@posthog/core/canvas/channelUnread"; +import { useMentionActivity } from "@posthog/ui/features/canvas/hooks/useMentionActivity"; +import { useChannelSeenStore } from "@posthog/ui/features/canvas/stores/channelSeenStore"; +import { useMemo } from "react"; + +/** + * Backend channel ids with activity the viewer hasn't seen. Shares the mentions + * query with the Activity badge through the react-query cache, so mounting this + * in the sidebar costs no extra fetch. + */ +export function useUnreadChannelIds(): Set { + const { items } = useMentionActivity(); + const lastSeenByChannel = useChannelSeenStore((s) => s.lastSeenByChannel); + return useMemo( + () => unreadChannelIds(items, lastSeenByChannel), + [items, lastSeenByChannel], + ); +} diff --git a/packages/ui/src/features/canvas/stores/channelSeenStore.ts b/packages/ui/src/features/canvas/stores/channelSeenStore.ts new file mode 100644 index 0000000000..dabedbf385 --- /dev/null +++ b/packages/ui/src/features/canvas/stores/channelSeenStore.ts @@ -0,0 +1,35 @@ +import { electronStorage } from "@posthog/ui/shell/rendererStorage"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +// When the viewer last had each channel open, keyed by backend channel id. +// Activity newer than this bolds the channel in the sidebar; opening the +// channel clears it. Per-channel (unlike the Activity page's single +// `lastSeenAt`) so reading one channel doesn't mark every other one read. +interface ChannelSeenState { + lastSeenByChannel: Record; + markChannelSeen: (channelId: string, at: string) => void; +} + +export const useChannelSeenStore = create()( + persist( + (set) => ({ + lastSeenByChannel: {}, + markChannelSeen: (channelId, at) => + set((state) => { + // Never walk the timestamp backwards: a channel visited after its + // newest activity is read, and re-stamping it with an older mention + // would bold it again. + const current = state.lastSeenByChannel[channelId]; + if (current && current >= at) return state; + return { + lastSeenByChannel: { ...state.lastSeenByChannel, [channelId]: at }, + }; + }), + }), + { + name: "channels-seen", + storage: electronStorage, + }, + ), +); From 1009a4d03fc2981b904cab31f81d3cbfa84cf71d Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Thu, 16 Jul 2026 15:33:41 +0100 Subject: [PATCH 2/3] feat(channels): dim the channel list, brighten unread and the open channel Channel rows and their # sit at muted-foreground, brightening on hover, so the list reads quietly by default. Two rows lift out of it: - unread: font-bold + foreground (bold stays unread's alone) - the channel you're viewing: foreground, normal weight Both already sit at full contrast, so they skip the hover brighten. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019G63f4afY9vsbKsvK654Wj --- .../canvas/components/ChannelsList.tsx | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelsList.tsx b/packages/ui/src/features/canvas/components/ChannelsList.tsx index 4d6b0ddead..74cebef934 100644 --- a/packages/ui/src/features/canvas/components/ChannelsList.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsList.tsx @@ -356,11 +356,26 @@ function ChannelSection({ }} className="w-full min-w-0 justify-start gap-2 data-selected:bg-fill-selected data-selected:text-gray-12" > - + @@ -580,11 +595,23 @@ function PersonalChannelRow() { onClick={() => void open()} className="w-full min-w-0 justify-start gap-2 data-selected:bg-fill-selected data-selected:text-gray-12" > - + {PERSONAL_CHANNEL_NAME} @@ -594,7 +621,7 @@ function PersonalChannelRow() { Date: Thu, 16 Jul 2026 16:13:16 +0100 Subject: [PATCH 3/3] =?UTF-8?q?fix(channels):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20hydration=20race,=20double-submit,=20seen=20depth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven findings from a review of this stack. - The seen store's storage is async (IPC), so both sides raced hydration. Reads: an empty map is indistinguishable from "nothing ever read", so every channel with activity bolded for the first frames of each boot. Writes: a channel opened during boot stamped itself seen, then zustand's default merge replaced that stamp with what was on disk and lost it — the channel you just read stayed bold. Gate reads on `hasHydrated` and merge the two maps (later visit per channel wins) instead of replacing. - CreateChannelModal relied on `disabled={busy}`, which only lands a render after the mutation starts, so a double-click (or a held ⌘Enter) fired two creates — and folder creation isn't idempotent by path, so that's two channels of the same name. Latch synchronously. - ensurePersonalChannel's in-flight guard settled when the POST returned, but callers pass the `channels` from their last render — a click in that gap saw no existing "me" and no in-flight create, and made a second. Remember what was created until the list catches up. - Marking a channel read only happened on its feed, so reading it via Artifacts/Recents/CONTEXT.md left it bold. Moved into ChannelHeader, which every channel surface renders — a new surface now gets it free. - ChannelGroup's onOpenChange ignored the value Base UI emits and blind toggled, so a redundant event would invert the section. - Unread was resolved two ways (by name for shared rows, by id for #me). One predicate now mirrors useBackendChannel's mapping for both. - latestActivityForChannel built a map of every channel to read one key. Covered by 10 new tests: the store's hydration merge (the clobber case fails without the fix) and ensurePersonalChannel's races. Not addressed, deliberately: the seen store stays in @posthog/ui rather than moving to core per the layering rule. Every persisted store in the app lives in ui because persistence goes through electronStorage, a ui/shell adapter; core has no persisted store to follow, and its sibling (activitySeenStore, the same concept for the Activity page) sits in ui. Moving it needs a platform storage interface — worth doing, but as its own change rather than smuggled into this one. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019G63f4afY9vsbKsvK654Wj --- packages/core/src/canvas/channelUnread.ts | 13 ++- .../canvas/components/ChannelHeader.tsx | 4 + .../canvas/components/ChannelsList.tsx | 44 +++-------- .../canvas/components/CreateChannelModal.tsx | 28 +++++-- .../canvas/components/WebsiteChannelHome.tsx | 18 +---- .../canvas/ensurePersonalChannel.test.ts | 79 +++++++++++++++++++ .../features/canvas/ensurePersonalChannel.ts | 25 +++++- .../canvas/hooks/useMarkChannelSeen.ts | 38 +++++++++ .../canvas/hooks/useUnreadChannels.ts | 50 +++++++++++- .../canvas/stores/channelSeenStore.test.ts | 75 ++++++++++++++++++ .../canvas/stores/channelSeenStore.ts | 40 +++++++++- 11 files changed, 349 insertions(+), 65 deletions(-) create mode 100644 packages/ui/src/features/canvas/ensurePersonalChannel.test.ts create mode 100644 packages/ui/src/features/canvas/hooks/useMarkChannelSeen.ts create mode 100644 packages/ui/src/features/canvas/stores/channelSeenStore.test.ts diff --git a/packages/core/src/canvas/channelUnread.ts b/packages/core/src/canvas/channelUnread.ts index cc7e73f529..1c7fafe7ba 100644 --- a/packages/core/src/canvas/channelUnread.ts +++ b/packages/core/src/canvas/channelUnread.ts @@ -47,11 +47,20 @@ export function unreadChannelIds( return unread; } -/** The newest activity in one channel, for stamping it seen while it's open. */ +/** + * The newest activity in one channel, for stamping it seen while it's open. + * Scans for the one channel rather than reusing `latestActivityByChannel`, + * which would build (and throw away) a map of every other channel to answer. + */ export function latestActivityForChannel( items: readonly MentionActivityItem[], channelId: string | undefined, ): string | undefined { if (!channelId) return undefined; - return latestActivityByChannel(items).get(channelId); + let latest: string | undefined; + for (const item of items) { + if (item.channelId !== channelId) continue; + if (!latest || item.createdAt > latest) latest = item.createdAt; + } + return latest; } diff --git a/packages/ui/src/features/canvas/components/ChannelHeader.tsx b/packages/ui/src/features/canvas/components/ChannelHeader.tsx index 3f8161fc7c..bc08328a46 100644 --- a/packages/ui/src/features/canvas/components/ChannelHeader.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHeader.tsx @@ -2,6 +2,7 @@ import { HashIcon } from "@phosphor-icons/react"; import { Button, cn } from "@posthog/quill"; import { ChannelTabs } from "@posthog/ui/features/canvas/components/ChannelTabs"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useMarkChannelSeen } from "@posthog/ui/features/canvas/hooks/useMarkChannelSeen"; import { Text } from "@radix-ui/themes"; import { useNavigate, useRouterState } from "@tanstack/react-router"; @@ -17,6 +18,9 @@ export function ChannelHeader({ channelId }: { channelId: string }) { const channelName = channels.find((c) => c.id === channelId)?.name; const pathname = useRouterState({ select: (s) => s.location.pathname }); const isHome = pathname === `/website/${channelId}`; + // Every channel surface renders this header, so it is where "the viewer is + // in this channel" is known — and therefore where the channel is marked read. + useMarkChannelSeen(channelName); return (
diff --git a/packages/ui/src/features/canvas/components/ChannelsList.tsx b/packages/ui/src/features/canvas/components/ChannelsList.tsx index 74cebef934..9279e0e8e2 100644 --- a/packages/ui/src/features/canvas/components/ChannelsList.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsList.tsx @@ -57,25 +57,17 @@ import { } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useCreateAndOpenDashboard } from "@posthog/ui/features/canvas/hooks/useDashboards"; import { - normalizeChannelName, PERSONAL_CHANNEL_NAME, useTaskChannels, } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; -import { useUnreadChannelIds } from "@posthog/ui/features/canvas/hooks/useUnreadChannels"; +import { useIsChannelUnread } from "@posthog/ui/features/canvas/hooks/useUnreadChannels"; import { copyChannelLink } from "@posthog/ui/features/canvas/utils/copyChannelLink"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; import { toast } from "@posthog/ui/primitives/toast"; import { track } from "@posthog/ui/shell/analytics"; import { Box, Flex } from "@radix-ui/themes"; import { useNavigate, useRouterState } from "@tanstack/react-router"; -import { - Fragment, - type ReactNode, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import { Fragment, type ReactNode, useEffect, useRef, useState } from "react"; import { hostClient } from "../hostClient"; // One actionable entry in a channel's menu, rendered the same whether it @@ -527,12 +519,10 @@ function PersonalChannelRow() { const { channels } = useChannels(); const { createChannel, isCreating } = useChannelMutations(); // Listing backend channels lazily provisions the personal channel server-side. - const { personalChannel } = useTaskChannels(); + useTaskChannels(); // The "+" dropdown (New task / New canvas), mirroring a shared channel row. const [newMenuOpen, setNewMenuOpen] = useState(false); - const unreadChannelIds = useUnreadChannelIds(); - const isUnread = - !!personalChannel && unreadChannelIds.has(personalChannel.id); + const isUnread = useIsChannelUnread()(PERSONAL_CHANNEL_NAME); const meFolder = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME); const createAndOpenCanvas = useCreateAndOpenDashboard(meFolder?.id); @@ -707,7 +697,12 @@ function ChannelGroup({ return ( toggleSection(sectionId)} + // The store only exposes a toggle, so drive it from the requested value: + // an event for the state we're already in is then a no-op rather than an + // inversion. + onOpenChange={(open) => { + if (open !== isOpen) toggleSection(sectionId); + }} className={className} > {/* MenuLabel carries the sidebar's label styling; `render` keeps it a @@ -755,20 +750,7 @@ export function ChannelsList() { const { channels: allChannels, isLoading } = useChannels(); const { starredRefToShortcutId } = useChannelStars(); - // Unread activity is keyed by backend channel id, while these rows are folder - // channels — joined by name, the same bridge useBackendChannel walks. Resolved - // once here rather than per row, so the list mounts one lookup, not 46. - const { channels: backendChannels } = useTaskChannels(); - const unreadChannelIds = useUnreadChannelIds(); - const unreadNames = useMemo(() => { - const names = new Set(); - for (const channel of backendChannels) { - if (unreadChannelIds.has(channel.id)) names.add(channel.name); - } - return names; - }, [backendChannels, unreadChannelIds]); - const isUnread = (channel: Channel) => - unreadNames.has(normalizeChannelName(channel.name)); + const isUnread = useIsChannelUnread(); // The "me" folder renders as the pinned personal row, not a shared channel. const channels = allChannels.filter((c) => c.name !== PERSONAL_CHANNEL_NAME); @@ -803,7 +785,7 @@ export function ChannelsList() { ))} @@ -819,7 +801,7 @@ export function ChannelsList() { ))} diff --git a/packages/ui/src/features/canvas/components/CreateChannelModal.tsx b/packages/ui/src/features/canvas/components/CreateChannelModal.tsx index 5920379390..9c80bb03e3 100644 --- a/packages/ui/src/features/canvas/components/CreateChannelModal.tsx +++ b/packages/ui/src/features/canvas/components/CreateChannelModal.tsx @@ -20,7 +20,7 @@ import { useGenerateContext } from "@posthog/ui/features/canvas/hooks/useGenerat import { toast } from "@posthog/ui/primitives/toast"; import { track } from "@posthog/ui/shell/analytics"; import { useNavigate } from "@tanstack/react-router"; -import { type CSSProperties, useState } from "react"; +import { type CSSProperties, useRef, useState } from "react"; // Matches Slack's "Create a channel" naming constraint. const MAX_CONTEXT_NAME_LENGTH = 80; @@ -84,6 +84,21 @@ export function CreateChannelModal({ // way through without one. const canDescribe = !busy && !!trimmedDescription; + // `busy` only disables the buttons a render after the mutation starts, so a + // double-click lands two creates before it applies — and folder creation is + // not idempotent by path, so that is two channels of the same name. Latch + // synchronously; the buttons stay the user-visible half of this. + const submittingRef = useRef(false); + const submitOnce = async (submit: () => Promise) => { + if (submittingRef.current) return; + submittingRef.current = true; + try { + await submit(); + } finally { + submittingRef.current = false; + } + }; + // Create the channel and land in its feed — the intro (name, creation line, // context.md card) and "joined" row there are derived from the channel row. // With a description, also launch the plan session that builds context.md. @@ -186,10 +201,11 @@ export function CreateChannelModal({ disabled={busy} onChange={(e) => setDescription(e.target.value)} onKeyDown={(e) => { - // ⌘/Ctrl+Enter submits; a bare Enter stays a newline. + // ⌘/Ctrl+Enter submits; a bare Enter stays a newline. Held down it + // repeats, so it goes through the same latch as the buttons. if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); - void submitDescribeStep(); + void submitOnce(submitDescribeStep); } }} /> @@ -225,7 +241,7 @@ export function CreateChannelModal({ variant="primary" disabled={!canDescribe} loading={busy} - onClick={submitDescribeStep} + onClick={() => void submitOnce(submitDescribeStep)} > Create @@ -335,7 +351,7 @@ export function CreateChannelModal({ @@ -343,7 +359,7 @@ export function CreateChannelModal({ variant="primary" disabled={!canDescribe} loading={busy} - onClick={submitDescribeStep} + onClick={() => void submitOnce(submitDescribeStep)} > Create diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index 6d0f87173e..087da027a4 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -1,4 +1,3 @@ -import { latestActivityForChannel } from "@posthog/core/canvas/channelUnread"; import { insertTaskDedup } from "@posthog/core/tasks/taskDelete"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { Task } from "@posthog/shared/domain-types"; @@ -31,12 +30,10 @@ import { import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useChannelTaskMutations } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; import { useFolderInstructions } from "@posthog/ui/features/canvas/hooks/useFolderInstructions"; -import { useMentionActivity } from "@posthog/ui/features/canvas/hooks/useMentionActivity"; import { PERSONAL_CHANNEL_NAME, useBackendChannel, } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; -import { useChannelSeenStore } from "@posthog/ui/features/canvas/stores/channelSeenStore"; import { useThreadPanelStore } from "@posthog/ui/features/canvas/stores/threadPanelStore"; import { SuggestedPromptCard } from "@posthog/ui/features/task-detail/components/SuggestedPromptCard"; import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; @@ -78,19 +75,8 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { // identity-resolution window (settling if the resolve fails), so fold it in: // we can't call a channel empty until we know which channel it is. const isLoading = isLoadingChannels || isResolvingChannel || isLoadingFeed; - // Viewing a channel reads it: stamp it seen so the sidebar drops its bold. - // Keyed on the newest activity rather than "now", so a mention landing while - // the channel is open re-stamps it (and so remounts don't churn the store). - const { items: mentionItems } = useMentionActivity(); - const latestActivityAt = useMemo( - () => latestActivityForChannel(mentionItems, backendChannel?.id), - [mentionItems, backendChannel?.id], - ); - const markChannelSeen = useChannelSeenStore((s) => s.markChannelSeen); - useEffect(() => { - if (!backendChannel?.id || !latestActivityAt) return; - markChannelSeen(backendChannel.id, latestActivityAt); - }, [backendChannel?.id, latestActivityAt, markChannelSeen]); + // Marking this channel read lives in ChannelHeader (rendered by every channel + // surface), so opening Artifacts or CONTEXT.md counts as reading it too. // Durable "PostHog agent" rows (CONTEXT.md being built, …) live on the // backend channel — the same id the feed tasks use, not the folder id. diff --git a/packages/ui/src/features/canvas/ensurePersonalChannel.test.ts b/packages/ui/src/features/canvas/ensurePersonalChannel.test.ts new file mode 100644 index 0000000000..655faf65cc --- /dev/null +++ b/packages/ui/src/features/canvas/ensurePersonalChannel.test.ts @@ -0,0 +1,79 @@ +import type { Channel } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { beforeEach, expect, it, vi } from "vitest"; +import { ensurePersonalChannel } from "./ensurePersonalChannel"; + +function channel(id: string, name = "me"): Channel { + return { id, name, path: `/${name}`, type: "folder" } as Channel; +} + +// The module memoises the created folder, so each test needs a fresh copy. +beforeEach(() => { + vi.resetModules(); +}); + +it("returns the existing folder without creating", async () => { + const create = vi.fn(); + const existing = channel("1"); + await expect(ensurePersonalChannel([existing], create)).resolves.toBe( + existing, + ); + expect(create).not.toHaveBeenCalled(); +}); + +it("shares one create between callers racing before it settles", async () => { + const { ensurePersonalChannel: ensure } = await import( + "./ensurePersonalChannel" + ); + const create = vi.fn( + () => new Promise((r) => setTimeout(() => r(channel("1")), 5)), + ); + + const [a, b] = await Promise.all([ensure([], create), ensure([], create)]); + + expect(create).toHaveBeenCalledTimes(1); + expect(a).toBe(b); +}); + +it("doesn't create a second folder for a caller still holding the pre-create list", async () => { + const { ensurePersonalChannel: ensure } = await import( + "./ensurePersonalChannel" + ); + const create = vi.fn(async () => channel("1")); + + // First caller creates it. The cache is seeded, but a component that hasn't + // re-rendered yet still passes the empty list it captured last render. + await ensure([], create); + const second = await ensure([], create); + + expect(create).toHaveBeenCalledTimes(1); + expect(second.id).toBe("1"); +}); + +it("prefers the list once it carries the folder, so a recreated me isn't stale", async () => { + const { ensurePersonalChannel: ensure } = await import( + "./ensurePersonalChannel" + ); + const create = vi.fn(async () => channel("1")); + await ensure([], create); + + // "me" was deleted and remade elsewhere; the list is authoritative. + const fresh = channel("2"); + await expect(ensure([fresh], create)).resolves.toBe(fresh); + // …and the stale memo is dropped rather than resurfacing afterwards. + await expect(ensure([fresh], create)).resolves.toBe(fresh); + expect(create).toHaveBeenCalledTimes(1); +}); + +it("lets a later caller retry after a failed create", async () => { + const { ensurePersonalChannel: ensure } = await import( + "./ensurePersonalChannel" + ); + const create = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValueOnce(channel("1")); + + await expect(ensure([], create)).rejects.toThrow("offline"); + await expect(ensure([], create)).resolves.toEqual(channel("1")); + expect(create).toHaveBeenCalledTimes(2); +}); diff --git a/packages/ui/src/features/canvas/ensurePersonalChannel.ts b/packages/ui/src/features/canvas/ensurePersonalChannel.ts index 88713ba0ea..8d1fe49dc9 100644 --- a/packages/ui/src/features/canvas/ensurePersonalChannel.ts +++ b/packages/ui/src/features/canvas/ensurePersonalChannel.ts @@ -8,6 +8,12 @@ import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTask // menu), so they share one in-flight create rather than guarding separately: // per-caller guards would still race each other. let inFlight: Promise | null = null; +// The in-flight promise alone isn't enough: it settles the moment the POST +// returns, but callers pass the `channels` from their last render, which hasn't +// re-rendered with the seeded cache yet. A click landing in that gap sees +// neither an existing "me" nor an in-flight create, and makes a second one. +// Remember what was created until the list catches up. +let created: Channel | null = null; /** * The user's "me" folder, creating it once if it doesn't exist yet. Concurrent @@ -19,11 +25,22 @@ export async function ensurePersonalChannel( createChannel: (name: string) => Promise, ): Promise { const existing = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME); - if (existing) return existing; + if (existing) { + // The list is authoritative once it carries the folder: drop the memo, so a + // deleted-then-recreated "me" resolves fresh rather than to a dead id. + created = null; + return existing; + } + if (created) return created; if (!inFlight) { - inFlight = createChannel(PERSONAL_CHANNEL_NAME).finally(() => { - inFlight = null; - }); + inFlight = createChannel(PERSONAL_CHANNEL_NAME) + .then((channel) => { + created = channel; + return channel; + }) + .finally(() => { + inFlight = null; + }); } return inFlight; } diff --git a/packages/ui/src/features/canvas/hooks/useMarkChannelSeen.ts b/packages/ui/src/features/canvas/hooks/useMarkChannelSeen.ts new file mode 100644 index 0000000000..917ac01477 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useMarkChannelSeen.ts @@ -0,0 +1,38 @@ +import { latestActivityForChannel } from "@posthog/core/canvas/channelUnread"; +import { useMentionActivity } from "@posthog/ui/features/canvas/hooks/useMentionActivity"; +import { useBackendChannel } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { useChannelSeenStore } from "@posthog/ui/features/canvas/stores/channelSeenStore"; +import { useEffect } from "react"; + +/** + * Looking at a channel reads it: stamp it seen so the sidebar drops its bold. + * + * Called from ChannelHeader, which every channel surface renders — the feed, + * Artifacts, Recents and CONTEXT.md all count as being in the channel, and + * hanging this off the header means a new surface gets it for free rather than + * having to remember. + * + * Stamped with the newest activity rather than "now": a mention landing while + * you're looking re-stamps it, remounts don't churn the store, and the store + * can't record having seen something that hasn't happened yet. + */ +export function useMarkChannelSeen(channelName: string | undefined): void { + const { channel: backendChannel } = useBackendChannel(channelName); + const { items: mentionItems } = useMentionActivity(); + const markChannelSeen = useChannelSeenStore((s) => s.markChannelSeen); + // Writing before the persisted map lands would be merged against an empty + // map; the store folds the two, but waiting keeps the write ordered behind + // the read it is meant to supersede. + const hasHydrated = useChannelSeenStore((s) => s.hasHydrated); + + const backendChannelId = backendChannel?.id; + const latestActivityAt = latestActivityForChannel( + mentionItems, + backendChannelId, + ); + + useEffect(() => { + if (!hasHydrated || !backendChannelId || !latestActivityAt) return; + markChannelSeen(backendChannelId, latestActivityAt); + }, [hasHydrated, backendChannelId, latestActivityAt, markChannelSeen]); +} diff --git a/packages/ui/src/features/canvas/hooks/useUnreadChannels.ts b/packages/ui/src/features/canvas/hooks/useUnreadChannels.ts index 39e29eeccd..256553e073 100644 --- a/packages/ui/src/features/canvas/hooks/useUnreadChannels.ts +++ b/packages/ui/src/features/canvas/hooks/useUnreadChannels.ts @@ -1,18 +1,62 @@ import { unreadChannelIds } from "@posthog/core/canvas/channelUnread"; import { useMentionActivity } from "@posthog/ui/features/canvas/hooks/useMentionActivity"; +import { + normalizeChannelName, + PERSONAL_CHANNEL_NAME, + useTaskChannels, +} from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { useChannelSeenStore } from "@posthog/ui/features/canvas/stores/channelSeenStore"; import { useMemo } from "react"; +const NONE: ReadonlySet = new Set(); + /** * Backend channel ids with activity the viewer hasn't seen. Shares the mentions * query with the Activity badge through the react-query cache, so mounting this * in the sidebar costs no extra fetch. + * + * Nothing is unread until the seen map is back from storage: an empty map reads + * exactly like "never opened anything", which would bold every channel with + * activity for the first frames of every boot. */ -export function useUnreadChannelIds(): Set { +export function useUnreadChannelIds(): ReadonlySet { const { items } = useMentionActivity(); const lastSeenByChannel = useChannelSeenStore((s) => s.lastSeenByChannel); + const hasHydrated = useChannelSeenStore((s) => s.hasHydrated); return useMemo( - () => unreadChannelIds(items, lastSeenByChannel), - [items, lastSeenByChannel], + () => (hasHydrated ? unreadChannelIds(items, lastSeenByChannel) : NONE), + [items, lastSeenByChannel, hasHydrated], ); } + +/** + * Is this folder channel unread, by display name? + * + * Unread is keyed by backend channel id while the sidebar's rows are folder + * channels, so something has to bridge the two. This mirrors the mapping + * useBackendChannel walks — "me" is the personal channel (matched by type, as + * its name is the backend's business), everything else matches a public channel + * by normalized name — and does it once for the whole list rather than + * resolving per row, which would fire a resolve per channel. + */ +export function useIsChannelUnread(): (channelName: string) => boolean { + const { channels: backendChannels, personalChannel } = useTaskChannels(); + const unreadIds = useUnreadChannelIds(); + + return useMemo(() => { + const unreadNames = new Set(); + for (const channel of backendChannels) { + if (channel.channel_type === "public" && unreadIds.has(channel.id)) { + unreadNames.add(channel.name); + } + } + const personalUnread = + !!personalChannel && unreadIds.has(personalChannel.id); + return (channelName: string) => { + const normalized = normalizeChannelName(channelName); + return normalized === PERSONAL_CHANNEL_NAME + ? personalUnread + : unreadNames.has(normalized); + }; + }, [backendChannels, personalChannel, unreadIds]); +} diff --git a/packages/ui/src/features/canvas/stores/channelSeenStore.test.ts b/packages/ui/src/features/canvas/stores/channelSeenStore.test.ts new file mode 100644 index 0000000000..c188a24e97 --- /dev/null +++ b/packages/ui/src/features/canvas/stores/channelSeenStore.test.ts @@ -0,0 +1,75 @@ +import { expect, it, vi } from "vitest"; + +// Storage is IPC-backed and async in the app, so reads land after first paint. +// Everything here turns on that gap. +const readDelayMs = 5; +async function freshStore(stored: Record | null) { + vi.resetModules(); + vi.doMock("@posthog/ui/shell/rendererStorage", () => ({ + electronStorage: { + getItem: async () => { + await new Promise((r) => setTimeout(r, readDelayMs)); + return stored + ? { state: { lastSeenByChannel: stored }, version: 0 } + : null; + }, + setItem: async () => {}, + removeItem: async () => {}, + }, + })); + const { useChannelSeenStore } = await import("./channelSeenStore"); + return useChannelSeenStore; +} + +const settle = () => new Promise((r) => setTimeout(r, readDelayMs * 4)); + +it("is not hydrated until the persisted map arrives", async () => { + const store = await freshStore({ c1: "2026-01-01T00:00:00.000Z" }); + expect(store.getState().hasHydrated).toBe(false); + expect(store.getState().lastSeenByChannel).toEqual({}); + + await settle(); + expect(store.getState().hasHydrated).toBe(true); + expect(store.getState().lastSeenByChannel.c1).toBe( + "2026-01-01T00:00:00.000Z", + ); +}); + +it("keeps a stamp written before hydration instead of losing it to the stored map", async () => { + const store = await freshStore({ c1: "2026-01-01T00:00:00.000Z" }); + + // A channel opened during boot marks itself read before storage answers. + store.getState().markChannelSeen("c2", "2026-07-16T10:00:00.000Z"); + await settle(); + + expect(store.getState().lastSeenByChannel).toEqual({ + c1: "2026-01-01T00:00:00.000Z", + c2: "2026-07-16T10:00:00.000Z", + }); +}); + +it("keeps the later visit when a channel is stamped on both sides of hydration", async () => { + const store = await freshStore({ c1: "2026-01-01T00:00:00.000Z" }); + store.getState().markChannelSeen("c1", "2026-07-16T10:00:00.000Z"); + await settle(); + expect(store.getState().lastSeenByChannel.c1).toBe( + "2026-07-16T10:00:00.000Z", + ); +}); + +it("hydrates even with nothing stored, so a first run isn't stuck unhydrated", async () => { + const store = await freshStore(null); + await settle(); + expect(store.getState().hasHydrated).toBe(true); + expect(store.getState().lastSeenByChannel).toEqual({}); +}); + +it("never walks a stamp backwards", async () => { + const store = await freshStore(null); + await settle(); + store.getState().markChannelSeen("c1", "2026-07-16T10:00:00.000Z"); + store.getState().markChannelSeen("c1", "2026-07-16T09:00:00.000Z"); + expect(store.getState().lastSeenByChannel.c1).toBe( + "2026-07-16T10:00:00.000Z", + ); +}); diff --git a/packages/ui/src/features/canvas/stores/channelSeenStore.ts b/packages/ui/src/features/canvas/stores/channelSeenStore.ts index dabedbf385..ab5a9d07d2 100644 --- a/packages/ui/src/features/canvas/stores/channelSeenStore.ts +++ b/packages/ui/src/features/canvas/stores/channelSeenStore.ts @@ -8,18 +8,33 @@ import { persist } from "zustand/middleware"; // `lastSeenAt`) so reading one channel doesn't mark every other one read. interface ChannelSeenState { lastSeenByChannel: Record; + /** False until the persisted map is back from storage — see `merge` below. */ + hasHydrated: boolean; markChannelSeen: (channelId: string, at: string) => void; } +/** Keep whichever visit is later, so a stamp is never walked backwards. */ +function latestSeen( + a: Record, + b: Record, +): Record { + const merged = { ...a }; + for (const [channelId, at] of Object.entries(b)) { + const current = merged[channelId]; + if (!current || at > current) merged[channelId] = at; + } + return merged; +} + export const useChannelSeenStore = create()( persist( (set) => ({ lastSeenByChannel: {}, + hasHydrated: false, markChannelSeen: (channelId, at) => set((state) => { - // Never walk the timestamp backwards: a channel visited after its - // newest activity is read, and re-stamping it with an older mention - // would bold it again. + // A channel visited after its newest activity is read; re-stamping it + // with an older mention would bold it again. const current = state.lastSeenByChannel[channelId]; if (current && current >= at) return state; return { @@ -30,6 +45,25 @@ export const useChannelSeenStore = create()( { name: "channels-seen", storage: electronStorage, + partialize: (state) => ({ lastSeenByChannel: state.lastSeenByChannel }), + // Storage is async (IPC), so a channel opened during boot can stamp + // itself seen before the persisted map arrives. zustand's default merge + // would then replace that stamp with what was on disk and lose it, so + // fold the two together instead and keep the later visit per channel. + merge: (persisted, current) => ({ + ...current, + lastSeenByChannel: latestSeen( + current.lastSeenByChannel, + (persisted as Partial)?.lastSeenByChannel ?? {}, + ), + }), + onRehydrateStorage: () => (state) => { + // Readers gate on this: before it flips, an empty map is + // indistinguishable from "nothing has ever been read", which would + // bold every channel that has any activity. + useChannelSeenStore.setState({ hasHydrated: true }); + return state; + }, }, ), );