Skip to content

feat(channels): bold a channel name when it has unseen activity#3513

Merged
trunk-io[bot] merged 4 commits into
mainfrom
posthog-code/channel-unread-bold
Jul 17, 2026
Merged

feat(channels): bold a channel name when it has unseen activity#3513
trunk-io[bot] merged 4 commits into
mainfrom
posthog-code/channel-unread-bold

Conversation

@adamleithp

@adamleithp adamleithp commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Stacked on #3510 — review that one first; this PR's diff is only the last commit.

2026-07-16 15 33 00

What

  • Sidebar channel names (and #me) render bold when there's activity the viewer hasn't seen.
  • Opening that channel clears it. Per-channel, so reading one doesn't mark the rest read.

Scope: mentions, not "any message"

Bold currently triggers on @-mentions of you. That's a deliberate compromise, not the end state:

  • TaskChannel exposes no activity data — /task_channels/ returns {id, name, channel_type, created_at, created_by}. No last_activity_at, no unread count.
  • The mentions index is the only cross-channel, all-users feed the client has, and it's already what "notification" means here (it drives the Activity badge).
  • Deriving "any activity" from tasks means useTasks({showAllUsers: true}), which useTasks.ts documents as the app's heaviest poll (~2.2MB per response every 30s, "the app's largest memory/CPU drain") — currently gated behind an explicit toggle. Mounting it always-on in the sidebar was not a trade worth making, especially given that sidebar's existing render cost.

The seam is drawn so this upgrades cheaply: if a per-channel last_activity_at lands, only latestActivityByChannel changes shape. The unread comparison, the folder↔backend name join, the seen store and the bolding all stay.

How

  • packages/core/src/canvas/channelUnread.ts — pure: newest activity per channel, compared against per-channel last-seen. Covered by 9 tests (never-seen, newer, older, exactly-equal, per-channel independence, unordered input, channel-less mentions).
  • channelSeenStore.ts — persisted, keyed by backend channel id (so renaming a channel doesn't mark it unread again), and it never walks a timestamp backwards.
  • useUnreadChannelIds() — reads through the existing mentions query cache, so the sidebar adds no new fetch.
  • Sidebar 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 once per row.
  • WebsiteChannelHome stamps the channel seen with its newest activity (not "now"), so a mention landing while you're looking at the channel re-stamps it and remounts don't churn the store.

Testing

  • 9 new core tests; core (67) and UI canvas (96) suites pass. Typecheck + biome clean, no noRestrictedImports violations in core.
  • Driven against the real app: 3 of 50 channels bold, matching the channels with real mentions (including project-bluebird from the Activity feed). Opening one cleared just that one; the bold state survived a full reload.

Known gaps

  • Clearing is tied to the channel home feed. Landing directly on a sub-tab (CONTEXT.md, Artifacts) doesn't stamp it seen.
  • Bold is a per-viewer, client-side notion — it lives in local persisted storage, so it doesn't follow you between machines.

🤖 Generated with Claude Code

https://claude.ai/code/session_019G63f4afY9vsbKsvK654Wj

@greptile-apps

greptile-apps Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Reviews (1): Last reviewed commit: "feat(channels): bold a channel name when..." | Re-trigger Greptile

Comment on lines +43 to +45
for (const [channelId, activityAt] of latestActivityByChannel(items)) {
const seenAt = lastSeenByChannel[channelId];
if (!seenAt || activityAt > seenAt) unread.add(channelId);

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.

P1 Bounded Cache Drops Unread Channels

The mentions cache keeps only its newest 300 items, but this loop treats that cache as a complete channel index. After enough mentions arrive, an unread channel whose latest mention was evicted disappears from this set, so its sidebar row incorrectly returns to normal weight.

Comment on lines +734 to +740
const names = new Set<string>();
for (const channel of backendChannels) {
if (unreadChannelIds.has(channel.id)) names.add(channel.name);
}
return names;
}, [backendChannels, unreadChannelIds]);
const isUnread = (channel: Channel) =>

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.

P1 Name Join Merges Channel Identity

This converts backend IDs to names before matching folder rows, but normalizeChannelName is lossy: names such as My Channel and my-channel map to the same value. Activity for either backend channel then bolds both rows, and renaming a folder can also detach it from its original backend channel or attach it to another one.

Comment on lines +96 to +99
useEffect(() => {
if (!backendChannel?.id || !latestActivityAt) return;
markChannelSeen(backendChannel.id, latestActivityAt);
}, [backendChannel?.id, latestActivityAt, markChannelSeen]);

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.

P1 Hydration Can Lose Seen Update

On a cold start directly into a channel, this effect can run while the persisted store's first read is pending. The configured storage drops writes during that window, after which hydration can restore an older timestamp; the opened channel therefore remains bold or becomes bold again after reload.

@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown

React Doctor found no issues in the changed files. 🎉

Reviewed by React Doctor for commit 2cff652.

@adamleithp
adamleithp force-pushed the posthog-code/channel-unread-bold branch from f4f77f6 to 62e1ffe Compare July 16, 2026 14:36
@adamleithp adamleithp added the Stamphog This will request an autostamp by stamphog on small changes label Jul 16, 2026
github-actions[bot]
github-actions Bot previously approved these changes Jul 16, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Purely additive UI feature bolding unread channel names. The bot's three flagged edge-cases (cache bound, name-normalization collision, hydration race) are all cosmetic display-accuracy issues, not production crashes or data-loss risks. Architecture, boundaries, and test coverage look correct.

@github-actions
github-actions Bot dismissed their stale review July 16, 2026 14:44

New commits pushed (delta classified label_absent) — stamphog approval dismissed; re-review running automatically.

Base automatically changed from posthog-code/channels-sidebar-and-create-flow to main July 16, 2026 14:51
@trunk-io

trunk-io Bot commented Jul 16, 2026

Copy link
Copy Markdown

😎 Merged successfully - details.

adamleithp and others added 3 commits July 16, 2026 15:55
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
…annel

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019G63f4afY9vsbKsvK654Wj
…epth

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019G63f4afY9vsbKsvK654Wj
@adamleithp
adamleithp force-pushed the posthog-code/channel-unread-bold branch from 62e1ffe to 2cff652 Compare July 16, 2026 15:13

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Purely additive UI feature — bolds sidebar channel names on unseen activity. All three greptile concerns are on older commits/outdated, and the hydration-race one is directly addressed by the hasHydrated gate in useMarkChannelSeen and the custom merge function in channelSeenStore (with tests covering pre-hydration write retention). The remaining two concerns (cache eviction dropping a bold, name-normalization collision) are display-accuracy cosmetics with no data loss or crash risk. Architecture is sound: pure logic in @posthog/core, view state store in @posthog/ui, no boundary violations.

@adamleithp
adamleithp enabled auto-merge (squash) July 16, 2026 15:26
@github-actions

Copy link
Copy Markdown

React Doctor found no issues in the changed files. 🎉

Reviewed by React Doctor for commit bbc1c9a.

@adamleithp

Copy link
Copy Markdown
Contributor Author

/trunk merge

@trunk-io
trunk-io Bot merged commit f9e830c into main Jul 17, 2026
34 of 37 checks passed
@trunk-io
trunk-io Bot deleted the posthog-code/channel-unread-bold branch July 17, 2026 23:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Stamphog This will request an autostamp by stamphog on small changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant