diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index cca89f471..6e8882c0f 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -306,6 +306,7 @@ export function AppShell() { markChannelRead, markChannelUnread, unreadChannelIds, + unreadChannelCounts, highPriorityUnreadChannelIds, getEffectiveTimestamp: getChannelReadAt, readStateVersion, @@ -406,10 +407,6 @@ export function AppShell() { setBrowseDialogType("stream"); void refetchChannels(); }, [refetchChannels]); - const handleOpenBrowseForums = React.useCallback(() => { - setBrowseDialogType("forum"); - void refetchChannels(); - }, [refetchChannels]); const handleOpenSearch = React.useCallback(() => { setSearchFocusRequest((request) => request + 1); void refetchChannels(); @@ -867,8 +864,6 @@ export function AppShell() { onMarkAllChannelsRead={markAllChannelsRead} onMarkChannelRead={markChannelRead} onMarkChannelUnread={markChannelUnread} - onOpenBrowseChannels={handleOpenBrowseChannels} - onOpenBrowseForums={handleOpenBrowseForums} onOpenDm={async ({ pubkeys }) => { const directMessage = await openDmMutation.mutateAsync({ @@ -908,6 +903,7 @@ export function AppShell() { selectedChannelId={selectedChannelId} selectedView={selectedView} unreadChannelIds={unreadChannelIds} + unreadChannelCounts={unreadChannelCounts} mutedChannelIds={mutedChannelIds} onMuteChannel={muteChannel} onUnmuteChannel={unmuteChannel} diff --git a/desktop/src/app/AppTopChrome.tsx b/desktop/src/app/AppTopChrome.tsx index 17c68ab60..29457553b 100644 --- a/desktop/src/app/AppTopChrome.tsx +++ b/desktop/src/app/AppTopChrome.tsx @@ -4,6 +4,7 @@ import { PanelLeftClose, PanelLeftOpen, } from "lucide-react"; +import * as React from "react"; import { TopbarSearch } from "@/features/search/ui/TopbarSearch"; import type { Channel, SearchHit } from "@/shared/api/types"; @@ -98,6 +99,7 @@ function CenterColumnTopbarSearch({ const TOP_CHROME_ICON_BUTTON_CLASS = "h-7 w-7 rounded-[4px] text-muted-foreground/70 hover:bg-border/45 hover:text-foreground [&_svg]:size-4"; +const TOP_CHROME_WHEEL_GUARD_HEIGHT = 40; function TopChromeSidebarTrigger() { const sidebar = useOptionalSidebar(); @@ -134,6 +136,22 @@ export function AppTopChrome({ searchFocusRequest, searchLoading = false, }: AppTopChromeProps) { + React.useEffect(() => { + const handleWheel = (event: WheelEvent) => { + if (event.clientY <= TOP_CHROME_WHEEL_GUARD_HEIGHT) { + event.preventDefault(); + } + }; + + document.addEventListener("wheel", handleWheel, { + capture: true, + passive: false, + }); + return () => { + document.removeEventListener("wheel", handleWheel, { capture: true }); + }; + }, []); + return ( <>
, + 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 b440a6fa4..ccd7e1e8a 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, @@ -173,53 +181,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, @@ -252,6 +213,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(), ); @@ -330,6 +294,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(); @@ -383,6 +348,7 @@ export function useUnreadChannels( // guard suppresses the readStateVersion bump. if (clearObserved) { latestByChannelRef.current.delete(channelId); + observedUnreadEventsByChannelRef.current.delete(channelId); latestHighPriorityByChannelRef.current.delete(channelId); bumpLatestVersion(); } @@ -428,8 +394,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); @@ -462,7 +443,12 @@ export function useUnreadChannels( callerOnChannelMessage?.(channelId, event); }, - [callerOnChannelMessage, normalizedPubkey, recordMentionedRoot], + [ + callerOnChannelMessage, + normalizedPubkey, + recordMentionedRoot, + recordUnreadEvent, + ], ); const handleSelfChannelMessage = React.useCallback( @@ -607,6 +593,7 @@ export function useUnreadChannels( ok: true; maxExternal: number; maxHighPriority: number; + unreadEvents: ObservedUnreadEvent[]; threadReplies: ThreadActivityItem[]; } | { channelId: string; ok: false }; @@ -658,6 +645,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; @@ -687,6 +675,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 && @@ -716,6 +705,7 @@ export function useUnreadChannels( ok: true, maxExternal, maxHighPriority, + unreadEvents, threadReplies, }; } catch { @@ -734,9 +724,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) { @@ -804,6 +805,7 @@ export function useUnreadChannels( getEffectiveTimestamp, isReadStateReady, normalizedPubkey, + recordUnreadEvent, relayClient, ]); @@ -821,11 +823,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 @@ -843,6 +847,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; } @@ -857,6 +862,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") { @@ -878,6 +888,7 @@ export function useUnreadChannels( return { unreadChannelIds: unread, highPriorityUnreadChannelIds: highPriority, + unreadChannelCounts: counts, }; }, [ activeChannelId, @@ -893,6 +904,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, @@ -910,6 +924,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; @@ -951,6 +973,7 @@ export function useUnreadChannels( return { unreadChannelIds, + unreadChannelCounts, highPriorityUnreadChannelIds, markAllChannelsRead, markChannelRead, diff --git a/desktop/src/features/profile/ui/ProfilePopover.tsx b/desktop/src/features/profile/ui/ProfilePopover.tsx index 79a4b7112..88fcffe4d 100644 --- a/desktop/src/features/profile/ui/ProfilePopover.tsx +++ b/desktop/src/features/profile/ui/ProfilePopover.tsx @@ -184,7 +184,7 @@ export function ProfilePopover({ {userStatusEmoji ? ( ) : null} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 9262d27e3..6ff0a987b 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -6,7 +6,7 @@ import { Bot, FolderGit2, Home, - MessageCirclePlus, + Plus, Zap, } from "lucide-react"; import * as React from "react"; @@ -14,7 +14,6 @@ import { AnimatePresence } from "motion/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"; @@ -45,7 +44,10 @@ import { SidebarLoadingContent, useSidebarLoadingShape, } from "@/features/sidebar/ui/sidebarLoadingSkeleton"; -import { SECTION_ICON_BUTTON_CLASS } from "@/features/sidebar/ui/sidebarSectionStyles"; +import { + SECTION_ACTION_VISIBILITY_CLASS, + SECTION_ICON_BUTTON_CLASS, +} from "@/features/sidebar/ui/sidebarSectionStyles"; import { SidebarUpdateCard } from "@/features/settings/SidebarUpdateCard"; import { useUpdaterContext } from "@/features/settings/hooks/UpdaterProvider"; import { shouldShowSidebarUpdateCard } from "@/features/settings/sidebarUpdateCardVisibility"; @@ -56,7 +58,6 @@ import type { Profile, UserStatus, } from "@/shared/api/types"; -import { cn } from "@/shared/lib/cn"; import { Sidebar, SidebarContent, @@ -103,6 +104,7 @@ type AppSidebarProps = { | "workflows" | "pulse" | "projects"; + unreadChannelCounts: ReadonlyMap; unreadChannelIds: ReadonlySet; workspaces: Workspace[]; onAddWorkspace: (workspace: Workspace) => void; @@ -122,8 +124,6 @@ type AppSidebarProps = { templateId?: string; }) => Promise; onOpenAddWorkspace: () => void; - onOpenBrowseChannels: () => void; - onOpenBrowseForums: () => void; onHideDm: (channelId: string) => void; onMarkChannelUnread: (channelId: string) => void; onMarkChannelRead: ( @@ -182,6 +182,7 @@ export function AppSidebar({ errorMessage, selectedChannelId, selectedView, + unreadChannelCounts, unreadChannelIds, workspaces, onAddWorkspace, @@ -189,8 +190,6 @@ export function AppSidebar({ onCreateChannel, onCreateForum, onOpenAddWorkspace, - onOpenBrowseChannels, - onOpenBrowseForums, onHideDm, onMarkChannelUnread, onMarkChannelRead, @@ -235,12 +234,6 @@ export function AppSidebar({ const sidebarFooterCardCount = (sidebarRelayConnectionCard.showSidebarRelayConnectionCard ? 1 : 0) + (showSidebarUpdateCard ? 1 : 0); - const sidebarContentBottomPaddingClass = - sidebarFooterCardCount >= 2 - ? "pb-[18rem]" - : sidebarFooterCardCount >= 1 - ? "pb-52" - : "pb-32"; const unreadBelowBottomClass = sidebarFooterCardCount >= 2 ? "bottom-56" @@ -252,6 +245,45 @@ export function AppSidebar({ const setIsNewDmOpen = onNewDmOpenChange ?? setIsNewDmOpenInternal; const scrollRef = React.useRef(null); useSidebarScrollLock(scrollRef); + + React.useEffect(() => { + const scrollElement = scrollRef.current; + if (!scrollElement) return; + + const handleWheel = (event: WheelEvent) => { + if (event.deltaY === 0) return; + + const maxScrollTop = + scrollElement.scrollHeight - scrollElement.clientHeight; + if (maxScrollTop <= 0) { + event.preventDefault(); + event.stopPropagation(); + return; + } + + const atTop = scrollElement.scrollTop <= 0; + const atBottom = scrollElement.scrollTop >= maxScrollTop - 1; + const scrollingPastTop = event.deltaY < 0 && atTop; + const scrollingPastBottom = event.deltaY > 0 && atBottom; + + if (scrollingPastTop || scrollingPastBottom) { + event.preventDefault(); + event.stopPropagation(); + scrollElement.scrollTop = scrollingPastTop ? 0 : maxScrollTop; + } + }; + + scrollElement.addEventListener("wheel", handleWheel, { + capture: true, + passive: false, + }); + return () => { + scrollElement.removeEventListener("wheel", handleWheel, { + capture: true, + }); + }; + }, []); + const [createDialogKind, setCreateDialogKind] = React.useState(null); @@ -411,16 +443,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() || @@ -463,95 +485,6 @@ export function AppSidebar({ data-testid="app-sidebar" variant="sidebar" > - - - - - - Home - - {homeBadgeCount > 0 ? ( - - {Math.min(homeBadgeCount, 99)} - - ) : null} - - - - - - Pulse - - - - - - - - Projects - - - - - - - Agents - - {shouldShowAgentCount ? ( - - {totalAgentCount} - - ) : null} - - - - - - Workflows - - - - - -
{unreadAboveCount > 0 ? ( ) : null} + + + + + + Home + + {homeBadgeCount > 0 ? ( + + {Math.min(homeBadgeCount, 99)} + + ) : null} + + + + + + Pulse + + + + + + + + Projects + + + + + + + Agents + + + + + + + Workflows + + + + + + {isLoading ? ( ) : null} @@ -574,7 +588,6 @@ export function AppSidebar({ <> {starredChannels.length > 0 ? ( unreadChannelIds.has(c.id), @@ -594,6 +607,7 @@ export function AppSidebar({ onToggleCollapsed={() => toggleCollapsedGroup("starred")} selectedChannelId={selectedChannelId} title="Starred" + unreadChannelCounts={unreadChannelCounts} unreadChannelIds={unreadChannelIds} mutedChannelIds={mutedChannelIds} onMuteChannel={onMuteChannel} @@ -624,6 +638,7 @@ export function AppSidebar({ isCollapsed={collapsedSections[section.id] ?? false} isActiveChannel={selectedView === "channel"} selectedChannelId={selectedChannelId} + unreadChannelCounts={unreadChannelCounts} unreadChannelIds={unreadChannelIds} sections={channelSections} assignments={channelAssignments} @@ -656,8 +671,6 @@ export function AppSidebar({ /> ))} setCreateDialogKind("stream")} onMarkAllRead={onMarkAllChannelsRead} onMarkChannelRead={onMarkChannelRead} @@ -677,6 +689,7 @@ export function AppSidebar({ onToggleCollapsed={() => toggleCollapsedGroup("channels")} selectedChannelId={selectedChannelId} title="Channels" + unreadChannelCounts={unreadChannelCounts} unreadChannelIds={unreadChannelIds} sections={channelSections} assignments={channelAssignments} @@ -693,15 +706,12 @@ export function AppSidebar({ 0} isCollapsed={collapsedGroups.forums} isActiveChannel={selectedView === "channel"} items={forumChannels} listTestId="forum-list" - onBrowse={onOpenBrowseForums} onCreateClick={() => setCreateDialogKind("forum")} onMarkAllRead={onMarkAllChannelsRead} onMarkChannelRead={onMarkChannelRead} @@ -710,6 +720,7 @@ export function AppSidebar({ onToggleCollapsed={() => toggleCollapsedGroup("forums")} selectedChannelId={selectedChannelId} title="Forums" + unreadChannelCounts={unreadChannelCounts} unreadChannelIds={unreadChannelIds} mutedChannelIds={mutedChannelIds} onMuteChannel={onMuteChannel} @@ -722,7 +733,7 @@ export function AppSidebar({
} @@ -748,6 +759,7 @@ export function AppSidebar({ selectedChannelId={selectedChannelId} testId="dm-list" title="Direct Messages" + unreadChannelCounts={unreadChannelCounts} unreadChannelIds={unreadChannelIds} mutedChannelIds={mutedChannelIds} onMuteChannel={onMuteChannel} @@ -775,7 +787,7 @@ export function AppSidebar({ /> ) : null} - + {sidebarRelayConnectionCard.showSidebarRelayConnectionCard ? ( void; onCreateClick?: () => void; onMarkAllRead?: () => void; }) { @@ -258,21 +253,13 @@ function SectionHeaderActions({ ) : null} - {onBrowse ? ( - - ) : null} {onCreateClick ? ( @@ -468,6 +451,7 @@ export function CustomChannelSection({ isCollapsed, isActiveChannel, selectedChannelId, + unreadChannelCounts, unreadChannelIds, sections, assignments, @@ -498,6 +482,7 @@ export function CustomChannelSection({ isCollapsed: boolean; isActiveChannel: boolean; selectedChannelId: string | null; + unreadChannelCounts: ReadonlyMap; unreadChannelIds: ReadonlySet; sections: ChannelSection[]; assignments: Record; @@ -643,6 +628,9 @@ export function CustomChannelSection({ - @@ -180,7 +183,7 @@ export function SidebarProfileCard({ > {selfUserStatus?.emoji ? ( ) : null} diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 8666ec8d6..8df6f2be2 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -35,12 +35,41 @@ import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; const SECTION_LABEL_BUTTON_CLASS = "group/section-label flex w-fit max-w-[calc(100%-3rem)] cursor-pointer appearance-none items-center gap-1 text-left transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground"; const SECTION_LABEL_CHEVRON_CLASS = - "h-2.5 w-2.5 shrink-0 opacity-0 text-sidebar-foreground/45 transition-[color,opacity,transform] group-hover/section-label:opacity-100 group-hover/section-label:text-sidebar-foreground group-focus-visible/section-label:opacity-100 group-focus-visible/section-label:text-sidebar-foreground"; + "relative size-2.5 shrink-0 opacity-0 text-sidebar-foreground/45 transition-[color,opacity] group-hover/sidebar-section:opacity-100 group-hover/sidebar-section:text-sidebar-foreground group-hover/section-label:opacity-100 group-hover/section-label:text-sidebar-foreground group-focus-within/sidebar-section:opacity-100 group-focus-within/sidebar-section:text-sidebar-foreground group-focus-visible/section-label:opacity-100 group-focus-visible/section-label:text-sidebar-foreground"; +const SECTION_LABEL_CHEVRON_ICON_CLASS = + "absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2"; const SIDEBAR_ROW_ACTION_VISIBILITY_CLASS = "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 md:opacity-0"; const SIDEBAR_ROW_ICON_ACTION_CLASS = "flex size-6 items-center justify-center p-1 text-sidebar-foreground/45 transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-sidebar-ring peer-data-[active=true]/menu-button:text-sidebar-active-foreground/75 peer-data-[active=true]/menu-button:hover:text-sidebar-active-foreground [&>svg]:size-4 [&>svg]:shrink-0"; +function formatUnreadCount(count: number): string { + return count > 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 +173,7 @@ export function ChannelMenuButton({ label, isActive, hasUnread, + unreadCount = 0, isMuted, dmParticipants, presenceStatus, @@ -153,6 +183,7 @@ export function ChannelMenuButton({ label?: string; isActive: boolean; hasUnread: boolean; + unreadCount?: number; isMuted?: boolean; dmParticipants?: SidebarDmParticipant[]; presenceStatus?: PresenceStatus; @@ -203,10 +234,10 @@ export function ChannelMenuButton({ /> ) : null} {hasUnread && !isActive && channel.channelType !== "dm" ? ( -