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({
-
- 🐝
+
+ 🐝
{workspaceLabel}
@@ -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" ? (
-
) : null}
@@ -225,6 +256,7 @@ export function SidebarSection({
selectedChannelId,
title,
testId,
+ unreadChannelCounts,
unreadChannelIds,
onHideDm,
onMarkChannelRead,
@@ -246,6 +278,7 @@ export function SidebarSection({
selectedChannelId: string | null;
title: string;
testId: string;
+ unreadChannelCounts: ReadonlyMap;
unreadChannelIds: ReadonlySet;
onHideDm?: (channelId: string) => void;
onMarkChannelRead?: (
@@ -279,13 +312,14 @@ export function SidebarSection({
type="button"
>
{title}
-
+
+
+
) : (
title
@@ -307,6 +341,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 +353,13 @@ export function SidebarSection({
{channel.channelType === "dm" &&
unreadChannelIds.has(channel.id) &&
!(isActiveChannel && selectedChannelId === channel.id) ? (
-
) : null}
{channel.channelType === "dm" && onHideDm ? (
diff --git a/desktop/src/features/user-status/ui/StatusEmoji.tsx b/desktop/src/features/user-status/ui/StatusEmoji.tsx
index 3717118b8..0324c15aa 100644
--- a/desktop/src/features/user-status/ui/StatusEmoji.tsx
+++ b/desktop/src/features/user-status/ui/StatusEmoji.tsx
@@ -43,10 +43,7 @@ export function StatusEmoji({ value, className }: StatusEmojiProps) {
alt={value}
title={displayName}
src={rewriteRelayUrl(found.url)}
- className={cn(
- "inline-block object-contain align-text-bottom",
- className,
- )}
+ className={cn("inline-block object-contain align-middle", className)}
draggable={false}
/>
);
@@ -57,7 +54,13 @@ export function StatusEmoji({ value, className }: StatusEmojiProps) {
// Thread the caller's className through so native statuses keep the spacing
// (e.g. `mr-1`) every display site applies to the image branch above.
return (
-
+
{value}
);
diff --git a/desktop/src/features/workspaces/ui/WorkspaceSwitcher.tsx b/desktop/src/features/workspaces/ui/WorkspaceSwitcher.tsx
index 851236b14..a2655ea04 100644
--- a/desktop/src/features/workspaces/ui/WorkspaceSwitcher.tsx
+++ b/desktop/src/features/workspaces/ui/WorkspaceSwitcher.tsx
@@ -52,6 +52,14 @@ type WorkspaceSwitcherProps = {
onRemoveWorkspace: (id: string) => void;
};
+function WorkspaceEmojiIcon({ className }: { className: string }) {
+ return (
+
+ 🐝
+
+ );
+}
+
export function WorkspaceSwitcher({
activeWorkspace,
workspaces,
@@ -129,15 +137,13 @@ export function WorkspaceSwitcher({
) : (
-
- 🐝
-
+ />
)}
{
await installMockBridge(page);
});
-test("browse channels button opens the channel browser dialog", async ({
- page,
-}) => {
- await page.goto("/");
- await expect(page.getByTestId("app-sidebar")).toBeVisible();
-
- await page.getByTestId("browse-channels").click();
- await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
-});
-
test("keyboard shortcut opens the channel browser dialog", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("app-sidebar")).toBeVisible();
@@ -45,7 +35,7 @@ test("keyboard shortcut opens the channel browser dialog", async ({ page }) => {
test("channel browser shows channels not yet joined", async ({ page }) => {
await page.goto("/");
- await page.getByTestId("browse-channels").click();
+ await openChannelBrowser(page);
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
// "design" and "sales" are open channels the mock user is NOT a member of
@@ -59,7 +49,7 @@ test("channel browser shows channels not yet joined", async ({ page }) => {
test("channel browser search filters by name", async ({ page }) => {
await page.goto("/");
- await page.getByTestId("browse-channels").click();
+ await openChannelBrowser(page);
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
await page.getByTestId("channel-browser-search").fill("design");
@@ -72,7 +62,7 @@ test("channel browser search filters by name", async ({ page }) => {
test("channel browser search filters by description", async ({ page }) => {
await page.goto("/");
- await page.getByTestId("browse-channels").click();
+ await openChannelBrowser(page);
await page.getByTestId("channel-browser-search").fill("pipeline");
// "sales" has "pipeline" in its description
@@ -85,7 +75,7 @@ test("channel browser shows no results for unmatched search", async ({
}) => {
await page.goto("/");
- await page.getByTestId("browse-channels").click();
+ await openChannelBrowser(page);
await page.getByTestId("channel-browser-search").fill("zzz-nonexistent");
await expect(page.getByText("No channels match your search")).toBeVisible();
@@ -101,7 +91,7 @@ test("joining a channel from browser adds it to the sidebar", async ({
await expect(streamList).not.toContainText("design");
// Open browser and join
- await page.getByTestId("browse-channels").click();
+ await openChannelBrowser(page);
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
await page
.getByTestId("browse-channel-design")
@@ -122,7 +112,7 @@ test("clicking a joined channel in browser navigates to it", async ({
}) => {
await page.goto("/");
- await page.getByTestId("browse-channels").click();
+ await openChannelBrowser(page);
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
// "general" is already joined — clicking should navigate without join
@@ -138,7 +128,7 @@ test("channel browser does not show DM or private channels", async ({
}) => {
await page.goto("/");
- await page.getByTestId("browse-channels").click();
+ await openChannelBrowser(page);
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
// DM channels should not appear
@@ -152,7 +142,7 @@ test("channel browser does not show DM or private channels", async ({
test("channel browser closes on escape", async ({ page }) => {
await page.goto("/");
- await page.getByTestId("browse-channels").click();
+ await openChannelBrowser(page);
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
await page.keyboard.press("Escape");
@@ -162,7 +152,7 @@ test("channel browser closes on escape", async ({ page }) => {
test("keyboard navigation works in channel browser", async ({ page }) => {
await page.goto("/");
- await page.getByTestId("browse-channels").click();
+ await openChannelBrowser(page);
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
// Filter to unjoined channels only to get a predictable list
diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts
index 3437b0f18..692d57895 100644
--- a/desktop/tests/e2e/channels.spec.ts
+++ b/desktop/tests/e2e/channels.spec.ts
@@ -1,7 +1,11 @@
import { expect, test } from "@playwright/test";
import { KIND_TYPING_INDICATOR } from "../../src/shared/constants/kinds";
-import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge";
+import {
+ TEST_IDENTITIES,
+ installMockBridge,
+ openChannelBrowser,
+} from "../helpers/bridge";
const MOCK_IDENTITY_PUBKEY = "deadbeef".repeat(8);
@@ -331,7 +335,7 @@ test("sidebar shows all channel types", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("app-sidebar")).toBeVisible();
- await expect(page.getByTestId("sidebar-agents-count")).toHaveText("0");
+ await expect(page.getByTestId("sidebar-agents-count")).toHaveCount(0);
// Streams
const streamList = page.getByTestId("stream-list");
@@ -1536,7 +1540,7 @@ test("bulk remove stays hidden when row-level remove is not allowed", async ({
// Join the "design" channel (unjoined by default) via the channel browser.
// The user becomes a regular member — not admin/owner.
- await page.getByTestId("browse-channels").click();
+ await openChannelBrowser(page);
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
await page
.getByTestId("browse-channel-design")
@@ -1565,7 +1569,7 @@ test("open channel management supports join and leave", async ({ page }) => {
await page.goto("/");
// Navigate to "design" (an unjoined channel) via the channel browser
- await page.getByTestId("browse-channels").click();
+ await openChannelBrowser(page);
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
await page
.getByTestId("browse-channel-design")
@@ -1592,7 +1596,7 @@ test("open channel management supports join and leave", async ({ page }) => {
await expect(page.getByTestId("channel-management-sheet")).not.toBeVisible();
// After leaving, the app navigates away — re-open browser and find design
- await page.getByTestId("browse-channels").click();
+ await openChannelBrowser(page);
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
// "design" should be back in the unjoined section with a Join button
@@ -1618,7 +1622,7 @@ test("manage channel can archive and unarchive a stream", async ({ page }) => {
);
await expect(page.getByTestId("send-message")).toBeDisabled();
- await page.getByTestId("browse-channels").click();
+ await openChannelBrowser(page);
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
await expect(page.getByTestId("browse-channel-general")).toContainText(
"archived",
diff --git a/desktop/tests/e2e/integration.spec.ts b/desktop/tests/e2e/integration.spec.ts
index 63d019e32..774e5c033 100644
--- a/desktop/tests/e2e/integration.spec.ts
+++ b/desktop/tests/e2e/integration.spec.ts
@@ -1,6 +1,10 @@
import { expect, test, type Browser } from "@playwright/test";
-import { installRelayBridge, TEST_IDENTITIES } from "../helpers/bridge";
+import {
+ installRelayBridge,
+ openChannelBrowser,
+ TEST_IDENTITIES,
+} from "../helpers/bridge";
import { openSettings } from "../helpers/settings";
import { assertRelaySeeded } from "../helpers/seed";
@@ -194,7 +198,7 @@ test("two users see the same channel", async ({
await expect(pageOne.getByTestId("stream-list")).toContainText(channelName);
await pageTwo.goto("/");
- await pageTwo.getByTestId("browse-channels").click();
+ await openChannelBrowser(pageTwo);
await expect(pageTwo.getByTestId("channel-browser-dialog")).toBeVisible();
await pageTwo
.getByTestId(`browse-channel-${channelName}`)
@@ -531,7 +535,7 @@ test("manage sheet archive and unarchive survives a reload through the relay", a
await page.reload();
await expect(page.getByTestId("stream-list")).not.toContainText(channelName);
- await page.getByTestId("browse-channels").click();
+ await openChannelBrowser(page);
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
await expect(page.getByTestId(`browse-channel-${channelName}`)).toContainText(
"archived",
diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts
index 133f483d3..8c851931a 100644
--- a/desktop/tests/e2e/mentions.spec.ts
+++ b/desktop/tests/e2e/mentions.spec.ts
@@ -1,6 +1,10 @@
import { expect, test } from "@playwright/test";
-import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
+import {
+ installMockBridge,
+ openChannelBrowser,
+ TEST_IDENTITIES,
+} from "../helpers/bridge";
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
@@ -379,7 +383,7 @@ test("relay-profile agents with member roles use the agent composer style", asyn
}) => {
await page.goto("/");
- await page.getByTestId("browse-channels").click();
+ await openChannelBrowser(page);
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
await page
.getByTestId("browse-channel-sales")
diff --git a/desktop/tests/e2e/stream.spec.ts b/desktop/tests/e2e/stream.spec.ts
index 3a8b08871..e82f5ab0a 100644
--- a/desktop/tests/e2e/stream.spec.ts
+++ b/desktop/tests/e2e/stream.spec.ts
@@ -1,6 +1,10 @@
import { expect, test, type Browser, type Page } from "@playwright/test";
-import { installRelayBridge, TEST_IDENTITIES } from "../helpers/bridge";
+import {
+ installRelayBridge,
+ openChannelBrowser,
+ TEST_IDENTITIES,
+} from "../helpers/bridge";
import { assertRelaySeeded } from "../helpers/seed";
const isCi = Boolean(process.env.CI);
@@ -61,7 +65,7 @@ async function createAndJoinSharedStream(
await expect(ownerPage.getByTestId("stream-list")).toContainText(channelName);
await expect(ownerPage.getByTestId("chat-title")).toHaveText(channelName);
- await memberPage.getByTestId("browse-channels").click();
+ await openChannelBrowser(memberPage);
await expect(memberPage.getByTestId("channel-browser-dialog")).toBeVisible();
await memberPage
.getByTestId(`browse-channel-${channelName}`)
diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts
index 1d79e3cf4..a42b90895 100644
--- a/desktop/tests/helpers/bridge.ts
+++ b/desktop/tests/helpers/bridge.ts
@@ -479,3 +479,24 @@ export async function installRelayBridge(
seedPreviewFeatures: options?.seedPreviewFeatures,
});
}
+
+// The sidebar no longer renders a "browse channels" icon button; the channel
+// browser is opened via the primary-modifier + Shift + O keyboard shortcut.
+export async function openChannelBrowser(page: Page) {
+ await page.getByTestId("app-sidebar").waitFor({ state: "visible" });
+ const isMacBrowser = await page.evaluate(() =>
+ /mac|iphone|ipad|ipod/i.test(navigator.platform),
+ );
+ await page.evaluate((isMac) => {
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ bubbles: true,
+ cancelable: true,
+ ctrlKey: !isMac,
+ key: "O",
+ metaKey: isMac,
+ shiftKey: true,
+ }),
+ );
+ }, isMacBrowser);
+}