From ce533db5097a974fa4d1ce99e5ddd62b86833ac8 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Wed, 17 Jun 2026 13:39:51 -0400 Subject: [PATCH 1/5] fix(desktop): refine sidebar unread badges and footer layout Show unread message counts in channel badges and let the sidebar footer participate in normal layout so the scroll area ends cleanly above it. --- desktop/src/app/AppShell.tsx | 2 + .../features/channels/unreadChannelCounts.ts | 86 +++++++++++++ .../channels/unreadReadMarker.test.mjs | 4 +- .../features/channels/useUnreadChannels.ts | 118 ++++++++++-------- .../src/features/sidebar/ui/AppSidebar.tsx | 30 ++--- .../sidebar/ui/CustomChannelSection.tsx | 9 ++ .../features/sidebar/ui/SidebarSection.tsx | 51 ++++++-- 7 files changed, 219 insertions(+), 81 deletions(-) create mode 100644 desktop/src/features/channels/unreadChannelCounts.ts diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index ee6fa492c..f5b3e235f 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -298,6 +298,7 @@ export function AppShell() { markChannelRead, markChannelUnread, unreadChannelIds, + unreadChannelCounts, highPriorityUnreadChannelIds, getEffectiveTimestamp: getChannelReadAt, readStateVersion, @@ -897,6 +898,7 @@ export function AppShell() { selectedChannelId={selectedChannelId} selectedView={selectedView} unreadChannelIds={unreadChannelIds} + unreadChannelCounts={unreadChannelCounts} mutedChannelIds={mutedChannelIds} onMuteChannel={muteChannel} onUnmuteChannel={unmuteChannel} diff --git a/desktop/src/features/channels/unreadChannelCounts.ts b/desktop/src/features/channels/unreadChannelCounts.ts new file mode 100644 index 000000000..991495a34 --- /dev/null +++ b/desktop/src/features/channels/unreadChannelCounts.ts @@ -0,0 +1,86 @@ +export type ObservedUnreadEvent = { + id: string; + createdAt: number; +}; + +export function mapsEqual( + a: ReadonlyMap, + b: ReadonlyMap, +): boolean { + if (a.size !== b.size) return false; + for (const [key, value] of a) { + if (b.get(key) !== value) return false; + } + return true; +} + +export function recordObservedUnreadEvent( + eventsByChannel: Map>, + channelId: string, + event: ObservedUnreadEvent, + limit: number, +): void { + let eventsById = eventsByChannel.get(channelId); + if (!eventsById) { + eventsById = new Map(); + eventsByChannel.set(channelId, eventsById); + } + if (eventsById.has(event.id)) return; + + eventsById.set(event.id, event.createdAt); + if (eventsById.size <= limit) return; + + const oldest = [...eventsById.entries()].sort((a, b) => a[1] - b[1])[0]?.[0]; + if (oldest) { + eventsById.delete(oldest); + } +} + +export function countUnreadObservedEvents( + eventsById: ReadonlyMap | undefined, + readAt: number | null, +): number { + if (!eventsById) return 0; + let count = 0; + for (const createdAt of eventsById.values()) { + if (readAt === null || createdAt > readAt) count += 1; + } + return count; +} + +export function buildChannelThreadRoots< + T extends { channelId: string; tags: string[][] }, +>( + items: readonly T[], + getRootId: (tags: string[][]) => string | null, +): Map> { + const byChannel = new Map>(); + for (const item of items) { + const rootId = getRootId(item.tags); + if (rootId === null) continue; + let roots = byChannel.get(item.channelId); + if (!roots) { + roots = new Set(); + byChannel.set(item.channelId, roots); + } + roots.add(rootId); + } + return byChannel; +} + +export function channelUnreadFrontier( + channelMarker: number | null, + threadRoots: ReadonlySet | undefined, + getThreadOwnMarker: (rootId: string) => number | null, +): number | null { + let frontier = channelMarker; + if (threadRoots) { + for (const rootId of threadRoots) { + const own = getThreadOwnMarker(rootId); + if (own !== null && (frontier === null || own > frontier)) { + frontier = own; + } + } + } + return frontier; +} diff --git a/desktop/src/features/channels/unreadReadMarker.test.mjs b/desktop/src/features/channels/unreadReadMarker.test.mjs index ebb99bb85..d072320ac 100644 --- a/desktop/src/features/channels/unreadReadMarker.test.mjs +++ b/desktop/src/features/channels/unreadReadMarker.test.mjs @@ -5,8 +5,8 @@ import { computeChannelUnreadMarker } from "../messages/lib/unreadMarker.ts"; import { buildChannelThreadRoots, channelUnreadFrontier, - resolveChannelReadMarker, -} from "./useUnreadChannels.ts"; +} from "./unreadChannelCounts.ts"; +import { resolveChannelReadMarker } from "./useUnreadChannels.ts"; function topLevel(id, createdAt) { return { id, createdAt, author: "a", time: "", body: "", depth: 0 }; diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index d9342c772..1399fd2eb 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -4,6 +4,14 @@ import { useLiveChannelUpdates, type UseLiveChannelUpdatesOptions, } from "@/features/channels/useLiveChannelUpdates"; +import { + buildChannelThreadRoots, + channelUnreadFrontier, + countUnreadObservedEvents, + mapsEqual, + recordObservedUnreadEvent, + type ObservedUnreadEvent, +} from "@/features/channels/unreadChannelCounts"; import { useReadState } from "@/features/channels/readState/useReadState"; import { getThreadReference, @@ -245,53 +253,6 @@ function setsEqual(a: ReadonlySet, b: ReadonlySet): boolean { return true; } -// Build channelId -> set of thread rootIds observed in that channel, derived -// from the thread-activity log (the same items that feed latestByChannelRef). -// Used by the sidebar unread scan to fold per-thread read markers into a -// channel's effective frontier so opening a thread clears the channel dot. -export function buildChannelThreadRoots( - items: readonly ThreadActivityItem[], - getRootId: (tags: string[][]) => string | null, -): Map> { - const byChannel = new Map>(); - for (const item of items) { - const rootId = getRootId(item.tags); - if (rootId === null) continue; - let roots = byChannel.get(item.channelId); - if (!roots) { - roots = new Set(); - byChannel.set(item.channelId, roots); - } - roots.add(rootId); - } - return byChannel; -} - -// The channel's effective read frontier for sidebar-unread purposes: its own -// channel marker folded with the highest OWN thread marker among its observed -// thread roots. Using the thread OWN marker (not the hierarchical effective -// value) is deliberate — the hierarchical resolver maps every thread to the -// ACTIVE channel, so it would borrow the wrong marker for a background channel. -// An unread reply in a thread keeps the dot until that thread is opened -// (advancing the thread marker past the reply); a never-read thread (no own -// marker) contributes nothing and the channel marker governs. -export function channelUnreadFrontier( - channelMarker: number | null, - threadRoots: ReadonlySet | undefined, - getThreadOwnMarker: (rootId: string) => number | null, -): number | null { - let frontier = channelMarker; - if (threadRoots) { - for (const rootId of threadRoots) { - const own = getThreadOwnMarker(rootId); - if (own !== null && (frontier === null || own > frontier)) { - frontier = own; - } - } - } - return frontier; -} - export function useUnreadChannels( channels: Channel[], activeChannel: Channel | null, @@ -324,6 +285,9 @@ export function useUnreadChannels( // change. Stale entries for channels the user has left are silently // ignored by the memo (it iterates the current channels list, not the map). const latestByChannelRef = React.useRef(new Map()); + const observedUnreadEventsByChannelRef = React.useRef( + new Map>(), + ); const latestHighPriorityByChannelRef = React.useRef( new Map(), ); @@ -388,6 +352,7 @@ export function useUnreadChannels( // biome-ignore lint/correctness/useExhaustiveDependencies: pubkey/relayClient are intentional reset signals React.useEffect(() => { latestByChannelRef.current = new Map(); + observedUnreadEventsByChannelRef.current = new Map(); latestHighPriorityByChannelRef.current = new Map(); forcedUnreadRef.current = new Set(); caughtUpChannelsRef.current = new Set(); @@ -437,6 +402,7 @@ export function useUnreadChannels( // guard suppresses the readStateVersion bump. if (clearObserved) { latestByChannelRef.current.delete(channelId); + observedUnreadEventsByChannelRef.current.delete(channelId); latestHighPriorityByChannelRef.current.delete(channelId); bumpLatestVersion(); } @@ -460,8 +426,23 @@ export function useUnreadChannels( // and external authors, so the map is always a strict subset of "newest // external trigger message this client has observed." const callerOnChannelMessage = liveUpdateOptions.onChannelMessage; + const recordUnreadEvent = React.useCallback( + (channelId: string, event: ObservedUnreadEvent) => { + recordObservedUnreadEvent( + observedUnreadEventsByChannelRef.current, + channelId, + event, + CATCH_UP_LIMIT, + ); + }, + [], + ); const handleChannelMessage = React.useCallback( (channelId: string, event: RelayEvent) => { + recordUnreadEvent(channelId, { + id: event.id, + createdAt: event.created_at, + }); const current = latestByChannelRef.current.get(channelId) ?? 0; if (event.created_at > current) { latestByChannelRef.current.set(channelId, event.created_at); @@ -488,7 +469,7 @@ export function useUnreadChannels( callerOnChannelMessage?.(channelId, event); }, - [callerOnChannelMessage, normalizedPubkey], + [callerOnChannelMessage, normalizedPubkey, recordUnreadEvent], ); const handleSelfChannelMessage = React.useCallback( @@ -618,6 +599,7 @@ export function useUnreadChannels( ok: true; maxExternal: number; maxHighPriority: number; + unreadEvents: ObservedUnreadEvent[]; threadReplies: ThreadActivityItem[]; } | { channelId: string; ok: false }; @@ -669,6 +651,7 @@ export function useUnreadChannels( // applying the notification filter to both. let maxExternal = 0; let maxHighPriority = 0; + const unreadEvents: ObservedUnreadEvent[] = []; const threadReplies: ThreadActivityItem[] = []; const ch = channels.find((c) => c.id === channelId); const chType = ch?.channelType; @@ -698,6 +681,7 @@ export function useUnreadChannels( if (event.created_at > maxExternal) { maxExternal = event.created_at; } + unreadEvents.push({ id: event.id, createdAt: event.created_at }); if ( chType === "dm" || (normalizedPubkey !== null && @@ -727,6 +711,7 @@ export function useUnreadChannels( ok: true, maxExternal, maxHighPriority, + unreadEvents, threadReplies, }; } catch { @@ -745,9 +730,20 @@ export function useUnreadChannels( caughtUpChannelsRef.current.delete(result.channelId); continue; } - const { channelId, maxExternal, maxHighPriority, threadReplies } = - result; + const { + channelId, + maxExternal, + maxHighPriority, + unreadEvents, + threadReplies, + } = result; allThreadReplies.push(...threadReplies); + if (unreadEvents.length > 0) { + for (const event of unreadEvents) { + recordUnreadEvent(channelId, event); + } + didAdvance = true; + } if (maxExternal > 0) { const readAtNow = getEffectiveTimestamp(channelId) ?? 0; if (maxExternal > readAtNow) { @@ -808,6 +804,7 @@ export function useUnreadChannels( getEffectiveTimestamp, isReadStateReady, normalizedPubkey, + recordUnreadEvent, relayClient, ]); @@ -825,11 +822,13 @@ export function useUnreadChannels( return { unreadChannelIds: new Set(), highPriorityUnreadChannelIds: new Set(), + unreadChannelCounts: new Map(), }; } const unread = new Set(); const highPriority = new Set(); + const counts = new Map(); // Map each channel to the thread roots observed in it, so a channel's // frontier can fold in per-thread read markers (Option A): opening a @@ -847,6 +846,7 @@ export function useUnreadChannels( if (forcedUnreadRef.current.has(channel.id)) { // Forced-unread is dot tier only — not high-priority. unread.add(channel.id); + counts.set(channel.id, 1); continue; } @@ -861,6 +861,11 @@ export function useUnreadChannels( if (readAt !== null && latest <= readAt) continue; unread.add(channel.id); + const observedEvents = observedUnreadEventsByChannelRef.current.get( + channel.id, + ); + const unreadCount = countUnreadObservedEvents(observedEvents, readAt); + counts.set(channel.id, Math.max(unreadCount, 1)); // DM channels: any unread DM is high-priority. if (channel.channelType === "dm") { @@ -882,6 +887,7 @@ export function useUnreadChannels( return { unreadChannelIds: unread, highPriorityUnreadChannelIds: highPriority, + unreadChannelCounts: counts, }; }, [ activeChannelId, @@ -897,6 +903,9 @@ export function useUnreadChannels( // so downstream memos don't re-run on every render when sets are equal. const prevUnreadRef = React.useRef>(new Set()); const prevHighPriorityRef = React.useRef>(new Set()); + const prevUnreadCountsRef = React.useRef>( + new Map(), + ); const unreadChannelIds = setsEqual( rawUnread.unreadChannelIds, @@ -914,6 +923,14 @@ export function useUnreadChannels( : rawUnread.highPriorityUnreadChannelIds; prevHighPriorityRef.current = highPriorityUnreadChannelIds; + const unreadChannelCounts = mapsEqual( + rawUnread.unreadChannelCounts, + prevUnreadCountsRef.current, + ) + ? prevUnreadCountsRef.current + : rawUnread.unreadChannelCounts; + prevUnreadCountsRef.current = unreadChannelCounts; + const unreadChannelIdsRef = React.useRef(unreadChannelIds); unreadChannelIdsRef.current = unreadChannelIds; @@ -935,6 +952,7 @@ export function useUnreadChannels( return { unreadChannelIds, + unreadChannelCounts, highPriorityUnreadChannelIds, markAllChannelsRead, markChannelRead, diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 290997d41..56687c20d 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -23,7 +23,6 @@ import * as React from "react"; import { FeatureGate } from "@/shared/features"; import { SidebarDndContext } from "@/features/sidebar/ui/SidebarDnd"; -import { useManagedAgentsQuery } from "@/features/agents/hooks"; import type { Workspace } from "@/features/workspaces/types"; import { AddWorkspaceDialog } from "@/features/workspaces/ui/AddWorkspaceDialog"; import { useDeferredLoad } from "@/shared/hooks/useDeferredStartup"; @@ -107,6 +106,7 @@ type AppSidebarProps = { | "workflows" | "pulse" | "projects"; + unreadChannelCounts: ReadonlyMap; unreadChannelIds: ReadonlySet; workspaces: Workspace[]; onAddWorkspace: (workspace: Workspace) => void; @@ -187,6 +187,7 @@ export function AppSidebar({ errorMessage, selectedChannelId, selectedView, + unreadChannelCounts, unreadChannelIds, workspaces, onAddWorkspace, @@ -400,16 +401,6 @@ export function AppSidebar({ isLoading, streamChannels, }); - const shouldLoadAgentCount = useDeferredLoad({ - immediate: selectedView === "agents", - timeoutMs: 250, - }); - const managedAgentsQuery = useManagedAgentsQuery({ - enabled: shouldLoadAgentCount, - }); - const totalAgentCount = managedAgentsQuery.data?.length ?? 0; - const shouldShowAgentCount = - totalAgentCount > 0 || managedAgentsQuery.isFetched; const resolvedDisplayName = profile?.displayName?.trim() || fallbackDisplayName?.trim() || @@ -515,14 +506,6 @@ export function AppSidebar({ Agents - {shouldShowAgentCount ? ( - - {totalAgentCount} - - ) : null} ) : null} - + {isLoading ? ( ) : null} @@ -592,6 +575,7 @@ export function AppSidebar({ onToggleCollapsed={() => toggleCollapsedGroup("starred")} selectedChannelId={selectedChannelId} title="Starred" + unreadChannelCounts={unreadChannelCounts} unreadChannelIds={unreadChannelIds} mutedChannelIds={mutedChannelIds} onMuteChannel={onMuteChannel} @@ -622,6 +606,7 @@ export function AppSidebar({ isCollapsed={collapsedSections[section.id] ?? false} isActiveChannel={selectedView === "channel"} selectedChannelId={selectedChannelId} + unreadChannelCounts={unreadChannelCounts} unreadChannelIds={unreadChannelIds} sections={channelSections} assignments={channelAssignments} @@ -675,6 +660,7 @@ export function AppSidebar({ onToggleCollapsed={() => toggleCollapsedGroup("channels")} selectedChannelId={selectedChannelId} title="Channels" + unreadChannelCounts={unreadChannelCounts} unreadChannelIds={unreadChannelIds} sections={channelSections} assignments={channelAssignments} @@ -708,6 +694,7 @@ export function AppSidebar({ onToggleCollapsed={() => toggleCollapsedGroup("forums")} selectedChannelId={selectedChannelId} title="Forums" + unreadChannelCounts={unreadChannelCounts} unreadChannelIds={unreadChannelIds} mutedChannelIds={mutedChannelIds} onMuteChannel={onMuteChannel} @@ -746,6 +733,7 @@ export function AppSidebar({ selectedChannelId={selectedChannelId} testId="dm-list" title="Direct Messages" + unreadChannelCounts={unreadChannelCounts} unreadChannelIds={unreadChannelIds} mutedChannelIds={mutedChannelIds} onMuteChannel={onMuteChannel} @@ -790,7 +778,7 @@ export function AppSidebar({ /> ) : null} - + void; selectedChannelId: string | null; title: string; + unreadChannelCounts: ReadonlyMap; unreadChannelIds: ReadonlySet; hasUnread?: boolean; onMarkAllRead?: () => void; @@ -369,6 +371,7 @@ export function ChannelGroupSection({ ; unreadChannelIds: ReadonlySet; sections: ChannelSection[]; assignments: Record; @@ -643,6 +649,9 @@ export function CustomChannelSection({ 99 ? "99+" : String(count); +} + +function UnreadCountBadge({ + channelName, + className, + count, +}: { + channelName: string; + className?: string; + count: number; +}) { + return ( + + {formatUnreadCount(count)} + new comment{count === 1 ? "" : "s"} + + ); +} + export type SidebarDmParticipant = { avatarUrl: string | null; label: string; @@ -144,6 +171,7 @@ export function ChannelMenuButton({ label, isActive, hasUnread, + unreadCount = 0, isMuted, dmParticipants, presenceStatus, @@ -153,6 +181,7 @@ export function ChannelMenuButton({ label?: string; isActive: boolean; hasUnread: boolean; + unreadCount?: number; isMuted?: boolean; dmParticipants?: SidebarDmParticipant[]; presenceStatus?: PresenceStatus; @@ -203,10 +232,10 @@ export function ChannelMenuButton({ /> ) : null} {hasUnread && !isActive && channel.channelType !== "dm" ? ( - @@ -225,6 +254,7 @@ export function SidebarSection({ selectedChannelId, title, testId, + unreadChannelCounts, unreadChannelIds, onHideDm, onMarkChannelRead, @@ -246,6 +276,7 @@ export function SidebarSection({ selectedChannelId: string | null; title: string; testId: string; + unreadChannelCounts: ReadonlyMap; unreadChannelIds: ReadonlySet; onHideDm?: (channelId: string) => void; onMarkChannelRead?: ( @@ -307,6 +338,7 @@ export function SidebarSection({ channel={channel} dmParticipants={dmParticipantsByChannelId?.[channel.id]} hasUnread={unreadChannelIds.has(channel.id)} + unreadCount={unreadChannelCounts.get(channel.id) ?? 0} isMuted={mutedChannelIds?.has(channel.id)} isActive={ isActiveChannel && selectedChannelId === channel.id @@ -318,10 +350,13 @@ export function SidebarSection({ {channel.channelType === "dm" && unreadChannelIds.has(channel.id) && !(isActiveChannel && selectedChannelId === channel.id) ? ( -