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

Commit f4f77f6

Browse files
adamleithpclaude
andcommitted
feat(channels): bold a channel name when it has unseen activity
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019G63f4afY9vsbKsvK654Wj
1 parent 8ce968a commit f4f77f6

6 files changed

Lines changed: 283 additions & 7 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import {
2+
latestActivityForChannel,
3+
unreadChannelIds,
4+
} from "@posthog/core/canvas/channelUnread";
5+
import type { MentionActivityItem } from "@posthog/core/canvas/mentionActivity";
6+
import { describe, expect, it } from "vitest";
7+
8+
function mention(
9+
overrides: Partial<MentionActivityItem> & { createdAt: string },
10+
): MentionActivityItem {
11+
return {
12+
messageId: `m-${overrides.createdAt}`,
13+
taskId: "t1",
14+
taskTitle: "Task",
15+
channelId: "c1",
16+
channelName: "mobile",
17+
author: null,
18+
content: "hey @adam",
19+
...overrides,
20+
};
21+
}
22+
23+
describe("unreadChannelIds", () => {
24+
const cases: {
25+
name: string;
26+
lastSeen: Record<string, string>;
27+
expected: string[];
28+
}[] = [
29+
{
30+
name: "a channel never seen is unread",
31+
lastSeen: {},
32+
expected: ["c1"],
33+
},
34+
{
35+
name: "activity newer than the last visit is unread",
36+
lastSeen: { c1: "2026-07-16T09:00:00.000Z" },
37+
expected: ["c1"],
38+
},
39+
{
40+
name: "activity older than the last visit is read",
41+
lastSeen: { c1: "2026-07-16T11:00:00.000Z" },
42+
expected: [],
43+
},
44+
{
45+
name: "activity exactly at the last visit is read",
46+
lastSeen: { c1: "2026-07-16T10:00:00.000Z" },
47+
expected: [],
48+
},
49+
];
50+
it.each(cases)("$name", ({ lastSeen, expected }) => {
51+
const items = [mention({ createdAt: "2026-07-16T10:00:00.000Z" })];
52+
expect([...unreadChannelIds(items, lastSeen)]).toEqual(expected);
53+
});
54+
55+
it("compares each channel against its own last visit", () => {
56+
const items = [
57+
mention({ channelId: "c1", createdAt: "2026-07-16T10:00:00.000Z" }),
58+
mention({ channelId: "c2", createdAt: "2026-07-16T10:00:00.000Z" }),
59+
];
60+
const unread = unreadChannelIds(items, {
61+
c1: "2026-07-16T11:00:00.000Z",
62+
c2: "2026-07-16T09:00:00.000Z",
63+
});
64+
expect([...unread]).toEqual(["c2"]);
65+
});
66+
67+
it("uses the newest item in a channel, whatever the order", () => {
68+
const items = [
69+
mention({ messageId: "old", createdAt: "2026-07-16T08:00:00.000Z" }),
70+
mention({ messageId: "new", createdAt: "2026-07-16T12:00:00.000Z" }),
71+
];
72+
expect([
73+
...unreadChannelIds(items, { c1: "2026-07-16T10:00:00.000Z" }),
74+
]).toEqual(["c1"]);
75+
});
76+
77+
it("ignores channel-less mentions", () => {
78+
const items = [
79+
mention({ channelId: null, createdAt: "2026-07-16T10:00Z" }),
80+
];
81+
expect([...unreadChannelIds(items, {})]).toEqual([]);
82+
});
83+
});
84+
85+
describe("latestActivityForChannel", () => {
86+
it("returns the newest timestamp for that channel only", () => {
87+
const items = [
88+
mention({ channelId: "c1", createdAt: "2026-07-16T08:00:00.000Z" }),
89+
mention({ channelId: "c1", createdAt: "2026-07-16T12:00:00.000Z" }),
90+
mention({ channelId: "c2", createdAt: "2026-07-16T13:00:00.000Z" }),
91+
];
92+
expect(latestActivityForChannel(items, "c1")).toBe(
93+
"2026-07-16T12:00:00.000Z",
94+
);
95+
});
96+
97+
it("is undefined for a channel with no activity, or no channel", () => {
98+
expect(latestActivityForChannel([], "c1")).toBeUndefined();
99+
expect(latestActivityForChannel([], undefined)).toBeUndefined();
100+
});
101+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { MentionActivityItem } from "@posthog/core/canvas/mentionActivity";
2+
3+
/**
4+
* Which channels have activity the viewer hasn't seen — the signal behind the
5+
* sidebar's bold channel names.
6+
*
7+
* "Activity" is currently an @-mention: that's the only cross-channel,
8+
* all-users feed the client has (the mentions index), and it's what
9+
* "notification" means elsewhere in the app. The backend exposes no per-channel
10+
* activity timestamp, so a broader "any new message" signal would mean polling
11+
* every user's full task list — the app's heaviest poll, deliberately retired.
12+
* If that timestamp lands, only `latestActivityByChannel` changes shape; the
13+
* unread comparison and the seen bookkeeping stay as they are.
14+
*
15+
* Keyed by backend channel id rather than name, so renaming a channel doesn't
16+
* silently mark it unread again.
17+
*/
18+
19+
/** Newest activity per channel id. Ignores items with no channel. */
20+
export function latestActivityByChannel(
21+
items: readonly MentionActivityItem[],
22+
): Map<string, string> {
23+
const latest = new Map<string, string>();
24+
for (const item of items) {
25+
if (!item.channelId) continue;
26+
const current = latest.get(item.channelId);
27+
if (!current || item.createdAt > current) {
28+
latest.set(item.channelId, item.createdAt);
29+
}
30+
}
31+
return latest;
32+
}
33+
34+
/**
35+
* Channel ids whose newest activity postdates the viewer's last visit. A
36+
* channel never visited is unread as soon as it has any activity.
37+
*/
38+
export function unreadChannelIds(
39+
items: readonly MentionActivityItem[],
40+
lastSeenByChannel: Readonly<Record<string, string>>,
41+
): Set<string> {
42+
const unread = new Set<string>();
43+
for (const [channelId, activityAt] of latestActivityByChannel(items)) {
44+
const seenAt = lastSeenByChannel[channelId];
45+
if (!seenAt || activityAt > seenAt) unread.add(channelId);
46+
}
47+
return unread;
48+
}
49+
50+
/** The newest activity in one channel, for stamping it seen while it's open. */
51+
export function latestActivityForChannel(
52+
items: readonly MentionActivityItem[],
53+
channelId: string | undefined,
54+
): string | undefined {
55+
if (!channelId) return undefined;
56+
return latestActivityByChannel(items).get(channelId);
57+
}

packages/ui/src/features/canvas/components/ChannelsList.tsx

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,16 +56,25 @@ import {
5656
} from "@posthog/ui/features/canvas/hooks/useChannels";
5757
import { useCreateAndOpenDashboard } from "@posthog/ui/features/canvas/hooks/useDashboards";
5858
import {
59+
normalizeChannelName,
5960
PERSONAL_CHANNEL_NAME,
6061
useTaskChannels,
6162
} from "@posthog/ui/features/canvas/hooks/useTaskChannels";
63+
import { useUnreadChannelIds } from "@posthog/ui/features/canvas/hooks/useUnreadChannels";
6264
import { copyChannelLink } from "@posthog/ui/features/canvas/utils/copyChannelLink";
6365
import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore";
6466
import { toast } from "@posthog/ui/primitives/toast";
6567
import { track } from "@posthog/ui/shell/analytics";
6668
import { Box, Flex } from "@radix-ui/themes";
6769
import { useNavigate, useRouterState } from "@tanstack/react-router";
68-
import { Fragment, type ReactNode, useEffect, useRef, useState } from "react";
70+
import {
71+
Fragment,
72+
type ReactNode,
73+
useEffect,
74+
useMemo,
75+
useRef,
76+
useState,
77+
} from "react";
6978
import { hostClient } from "../hostClient";
7079

7180
// One actionable entry in a channel's menu, rendered the same whether it
@@ -290,7 +299,14 @@ function ChannelMenu({
290299

291300
// One channel in the list: a "# name" row that navigates to the channel home.
292301
// No expansion — the channel's surfaces live in the in-channel top nav.
293-
function ChannelSection({ channel }: { channel: Channel }) {
302+
function ChannelSection({
303+
channel,
304+
isUnread,
305+
}: {
306+
channel: Channel;
307+
/** Bolds the name: activity here the viewer hasn't seen. */
308+
isUnread?: boolean;
309+
}) {
294310
const navigate = useNavigate();
295311
const pathname = useRouterState({ select: (s) => s.location.pathname });
296312
const base = `/website/${channel.id}`;
@@ -342,7 +358,8 @@ function ChannelSection({ channel }: { channel: Channel }) {
342358
<HashIcon size={14} className="shrink-0 text-gray-9" />
343359
<span
344360
className={cn(
345-
"truncate font-medium text-[13px] text-gray-12 group-hover/chan:pr-8",
361+
"truncate text-[13px] text-gray-12 group-hover/chan:pr-8",
362+
isUnread ? "font-bold" : "font-medium",
346363
menuOpen && "pr-8",
347364
)}
348365
>
@@ -494,9 +511,12 @@ function PersonalChannelRow() {
494511
const { channels } = useChannels();
495512
const { createChannel, isCreating } = useChannelMutations();
496513
// Listing backend channels lazily provisions the personal channel server-side.
497-
useTaskChannels();
514+
const { personalChannel } = useTaskChannels();
498515
// The "+" dropdown (New task / New canvas), mirroring a shared channel row.
499516
const [newMenuOpen, setNewMenuOpen] = useState(false);
517+
const unreadChannelIds = useUnreadChannelIds();
518+
const isUnread =
519+
!!personalChannel && unreadChannelIds.has(personalChannel.id);
500520

501521
const meFolder = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME);
502522
const createAndOpenCanvas = useCreateAndOpenDashboard(meFolder?.id);
@@ -558,7 +578,12 @@ function PersonalChannelRow() {
558578
className="w-full min-w-0 justify-start gap-2 data-selected:bg-fill-selected data-selected:text-gray-12"
559579
>
560580
<HashIcon size={14} className="shrink-0 text-gray-9" />
561-
<span className="truncate font-medium text-[13px] text-gray-12">
581+
<span
582+
className={cn(
583+
"truncate text-[13px] text-gray-12",
584+
isUnread ? "font-bold" : "font-medium",
585+
)}
586+
>
562587
{PERSONAL_CHANNEL_NAME}
563588
</span>
564589
{/* The lock and the hover "+" share the right edge, so fade the lock
@@ -700,6 +725,21 @@ export function ChannelsList() {
700725
const { channels: allChannels, isLoading } = useChannels();
701726
const { starredRefToShortcutId } = useChannelStars();
702727

728+
// Unread activity is keyed by backend channel id, while these rows are folder
729+
// channels — joined by name, the same bridge useBackendChannel walks. Resolved
730+
// once here rather than per row, so the list mounts one lookup, not 46.
731+
const { channels: backendChannels } = useTaskChannels();
732+
const unreadChannelIds = useUnreadChannelIds();
733+
const unreadNames = useMemo(() => {
734+
const names = new Set<string>();
735+
for (const channel of backendChannels) {
736+
if (unreadChannelIds.has(channel.id)) names.add(channel.name);
737+
}
738+
return names;
739+
}, [backendChannels, unreadChannelIds]);
740+
const isUnread = (channel: Channel) =>
741+
unreadNames.has(normalizeChannelName(channel.name));
742+
703743
// The "me" folder renders as the pinned personal row, not a shared channel.
704744
const channels = allChannels.filter((c) => c.name !== PERSONAL_CHANNEL_NAME);
705745
const starred = channels.filter((c) => starredRefToShortcutId.has(c.path));
@@ -730,7 +770,11 @@ export function ChannelsList() {
730770
{starred.length > 0 && (
731771
<ChannelGroup sectionId={STARRED_SECTION_ID} label="Starred">
732772
{starred.map((channel) => (
733-
<ChannelSection key={channel.id} channel={channel} />
773+
<ChannelSection
774+
key={channel.id}
775+
channel={channel}
776+
isUnread={isUnread(channel)}
777+
/>
734778
))}
735779
</ChannelGroup>
736780
)}
@@ -744,7 +788,11 @@ export function ChannelsList() {
744788
</Empty>
745789
)}
746790
{others.map((channel) => (
747-
<ChannelSection key={channel.id} channel={channel} />
791+
<ChannelSection
792+
key={channel.id}
793+
channel={channel}
794+
isUnread={isUnread(channel)}
795+
/>
748796
))}
749797
</ChannelGroup>
750798
</Flex>

packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { latestActivityForChannel } from "@posthog/core/canvas/channelUnread";
12
import { insertTaskDedup } from "@posthog/core/tasks/taskDelete";
23
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
34
import type { Task } from "@posthog/shared/domain-types";
@@ -30,10 +31,12 @@ import {
3031
import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels";
3132
import { useChannelTaskMutations } from "@posthog/ui/features/canvas/hooks/useChannelTasks";
3233
import { useFolderInstructions } from "@posthog/ui/features/canvas/hooks/useFolderInstructions";
34+
import { useMentionActivity } from "@posthog/ui/features/canvas/hooks/useMentionActivity";
3335
import {
3436
PERSONAL_CHANNEL_NAME,
3537
useBackendChannel,
3638
} from "@posthog/ui/features/canvas/hooks/useTaskChannels";
39+
import { useChannelSeenStore } from "@posthog/ui/features/canvas/stores/channelSeenStore";
3740
import { useThreadPanelStore } from "@posthog/ui/features/canvas/stores/threadPanelStore";
3841
import { SuggestedPromptCard } from "@posthog/ui/features/task-detail/components/SuggestedPromptCard";
3942
import { taskDetailQuery } from "@posthog/ui/features/tasks/queries";
@@ -81,6 +84,20 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) {
8184
isResolvingChannel ||
8285
(!!channelName && !backendChannel) ||
8386
isLoadingFeed;
87+
// Viewing a channel reads it: stamp it seen so the sidebar drops its bold.
88+
// Keyed on the newest activity rather than "now", so a mention landing while
89+
// the channel is open re-stamps it (and so remounts don't churn the store).
90+
const { items: mentionItems } = useMentionActivity();
91+
const latestActivityAt = useMemo(
92+
() => latestActivityForChannel(mentionItems, backendChannel?.id),
93+
[mentionItems, backendChannel?.id],
94+
);
95+
const markChannelSeen = useChannelSeenStore((s) => s.markChannelSeen);
96+
useEffect(() => {
97+
if (!backendChannel?.id || !latestActivityAt) return;
98+
markChannelSeen(backendChannel.id, latestActivityAt);
99+
}, [backendChannel?.id, latestActivityAt, markChannelSeen]);
100+
84101
// Durable "PostHog agent" rows (CONTEXT.md being built, …) live on the
85102
// backend channel — the same id the feed tasks use, not the folder id.
86103
const { messages: feedMessages } = useChannelFeedMessages(backendChannel?.id);
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { unreadChannelIds } from "@posthog/core/canvas/channelUnread";
2+
import { useMentionActivity } from "@posthog/ui/features/canvas/hooks/useMentionActivity";
3+
import { useChannelSeenStore } from "@posthog/ui/features/canvas/stores/channelSeenStore";
4+
import { useMemo } from "react";
5+
6+
/**
7+
* Backend channel ids with activity the viewer hasn't seen. Shares the mentions
8+
* query with the Activity badge through the react-query cache, so mounting this
9+
* in the sidebar costs no extra fetch.
10+
*/
11+
export function useUnreadChannelIds(): Set<string> {
12+
const { items } = useMentionActivity();
13+
const lastSeenByChannel = useChannelSeenStore((s) => s.lastSeenByChannel);
14+
return useMemo(
15+
() => unreadChannelIds(items, lastSeenByChannel),
16+
[items, lastSeenByChannel],
17+
);
18+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { electronStorage } from "@posthog/ui/shell/rendererStorage";
2+
import { create } from "zustand";
3+
import { persist } from "zustand/middleware";
4+
5+
// When the viewer last had each channel open, keyed by backend channel id.
6+
// Activity newer than this bolds the channel in the sidebar; opening the
7+
// channel clears it. Per-channel (unlike the Activity page's single
8+
// `lastSeenAt`) so reading one channel doesn't mark every other one read.
9+
interface ChannelSeenState {
10+
lastSeenByChannel: Record<string, string>;
11+
markChannelSeen: (channelId: string, at: string) => void;
12+
}
13+
14+
export const useChannelSeenStore = create<ChannelSeenState>()(
15+
persist(
16+
(set) => ({
17+
lastSeenByChannel: {},
18+
markChannelSeen: (channelId, at) =>
19+
set((state) => {
20+
// Never walk the timestamp backwards: a channel visited after its
21+
// newest activity is read, and re-stamping it with an older mention
22+
// would bold it again.
23+
const current = state.lastSeenByChannel[channelId];
24+
if (current && current >= at) return state;
25+
return {
26+
lastSeenByChannel: { ...state.lastSeenByChannel, [channelId]: at },
27+
};
28+
}),
29+
}),
30+
{
31+
name: "channels-seen",
32+
storage: electronStorage,
33+
},
34+
),
35+
);

0 commit comments

Comments
 (0)