diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx
index 767c9c41a..46293a3d4 100644
--- a/desktop/src/features/sidebar/ui/AppSidebar.tsx
+++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx
@@ -1,17 +1,9 @@
// biome-ignore format: keep compact to stay within file size limit
-import {
- Activity,
- Bot,
- FolderGit2,
- Inbox,
- MessageCirclePlus,
- Zap,
-} from "lucide-react";
+import { MessageCirclePlus } from "lucide-react";
import * as React from "react";
import { AnimatePresence } from "motion/react";
import { FeatureGate } from "@/shared/features";
import { SidebarDndContext } from "@/features/sidebar/ui/SidebarDnd";
-import { TopbarSearch } from "@/features/search/ui/TopbarSearch";
import type { Workspace } from "@/features/workspaces/types";
import { AddWorkspaceDialog } from "@/features/workspaces/ui/AddWorkspaceDialog";
@@ -31,6 +23,7 @@ import {
RenameSectionDialog,
useLeaveChannelDialog,
} from "@/features/sidebar/ui/ChannelSectionDialogs";
+import { AppSidebarPinnedHeader } from "@/features/sidebar/ui/AppSidebarPinnedHeader";
import { MoreUnreadButton } from "@/features/sidebar/ui/MoreUnreadButton";
import { SidebarSection } from "@/features/sidebar/ui/SidebarSection";
import {
@@ -66,10 +59,7 @@ import {
Sidebar,
SidebarContent,
SidebarFooter,
- SidebarHeader,
SidebarMenu,
- SidebarMenuBadge,
- SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
useSidebar,
@@ -534,201 +524,165 @@ export function AppSidebar({
className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
data-testid="app-sidebar-scroll-anchor"
>
- {unreadAboveCount > 0 ? (
-
- ) : null}
+
+
-
onOpenDm({ pubkeys: [user.pubkey] })}
- onCreateAgent={onCreateAgent}
- onCreateChannel={handleOpenCreateChannel}
- suggestionChannels={channels}
- />
- 0 ? (
+
+ ) : null}
+
+
-
-
-
-
- Inbox
-
- {homeBadgeCount > 0 ? (
-
- {Math.min(homeBadgeCount, 99)}
-
+ {isLoading ? (
+
+ ) : null}
+
+ {!isLoading ? (
+ <>
+ {starredChannels.length > 0 ? (
+
+ unreadChannelIds.has(c.id),
+ )}
+ isCollapsed={collapsedGroups.starred}
+ isActiveChannel={selectedView === "channel"}
+ activeWorkingByChannelId={activeWorkingByChannelId}
+ items={starredChannels}
+ listTestId="starred-list"
+ onMarkAllRead={() => {
+ for (const channel of starredChannels) {
+ onMarkChannelRead(channel.id, channel.lastMessageAt);
+ }
+ }}
+ onMarkChannelRead={onMarkChannelRead}
+ onMarkChannelUnread={onMarkChannelUnread}
+ onSelectChannel={onSelectChannel}
+ onToggleCollapsed={() => toggleCollapsedGroup("starred")}
+ selectedChannelId={selectedChannelId}
+ title="Starred"
+ unreadChannelCounts={unreadChannelCounts}
+ unreadChannelIds={unreadChannelIds}
+ mutedChannelIds={mutedChannelIds}
+ onMuteChannel={onMuteChannel}
+ onUnmuteChannel={onUnmuteChannel}
+ starredChannelIds={starredChannelIds}
+ onStarChannel={onStarChannel}
+ onUnstarChannel={onUnstarChannel}
+ onLeaveChannel={requestLeaveChannel}
+ />
) : null}
-
-
-
-
-
- Pulse
-
-
-
-
-
-
-
- Projects
-
-
-
-
-
-
- Agents
-
-
-
-
-
-
- Workflows
-
-
-
-
-
-
-
-
- {isLoading ? (
-
- ) : null}
-
- {!isLoading ? (
- <>
- {starredChannels.length > 0 ? (
-
- unreadChannelIds.has(c.id),
- )}
- isCollapsed={collapsedGroups.starred}
- isActiveChannel={selectedView === "channel"}
- activeWorkingByChannelId={activeWorkingByChannelId}
- items={starredChannels}
- listTestId="starred-list"
- onMarkAllRead={() => {
- for (const channel of starredChannels) {
- onMarkChannelRead(channel.id, channel.lastMessageAt);
- }
- }}
- onMarkChannelRead={onMarkChannelRead}
- onMarkChannelUnread={onMarkChannelUnread}
- onSelectChannel={onSelectChannel}
- onToggleCollapsed={() => toggleCollapsedGroup("starred")}
- selectedChannelId={selectedChannelId}
- title="Starred"
- unreadChannelCounts={unreadChannelCounts}
- unreadChannelIds={unreadChannelIds}
- mutedChannelIds={mutedChannelIds}
- onMuteChannel={onMuteChannel}
- onUnmuteChannel={onUnmuteChannel}
- starredChannelIds={starredChannelIds}
- onStarChannel={onStarChannel}
- onUnstarChannel={onUnstarChannel}
- onLeaveChannel={requestLeaveChannel}
- />
- ) : null}
-
- {channelSections.map((section, idx) => (
-
- unreadChannelIds.has(c.id),
- ) ?? false
- }
- isCollapsed={collapsedSections[section.id] ?? false}
+ {channelSections.map((section, idx) => (
+
+ unreadChannelIds.has(c.id),
+ ) ?? false
+ }
+ isCollapsed={collapsedSections[section.id] ?? false}
+ isActiveChannel={selectedView === "channel"}
+ activeWorkingByChannelId={activeWorkingByChannelId}
+ selectedChannelId={selectedChannelId}
+ unreadChannelCounts={unreadChannelCounts}
+ unreadChannelIds={unreadChannelIds}
+ sections={channelSections}
+ assignments={channelAssignments}
+ isFirst={idx === 0}
+ isLast={idx === channelSections.length - 1}
+ onToggleCollapsed={() =>
+ toggleCollapsedSection(section.id)
+ }
+ onSelectChannel={onSelectChannel}
+ onMarkChannelRead={onMarkChannelRead}
+ onMarkChannelUnread={onMarkChannelUnread}
+ onMarkSectionRead={() => {
+ for (const channel of sectionBuckets.bySection[
+ section.id
+ ] ?? []) {
+ onMarkChannelRead(channel.id, channel.lastMessageAt);
+ }
+ }}
+ onAssignChannel={assignChannel}
+ onUnassignChannel={unassignChannel}
+ onCreateSectionForChannel={handleCreateSectionForChannel}
+ onRenameSection={() => setRenameSectionTarget(section)}
+ onDeleteSection={() => setDeleteSectionTarget(section)}
+ onMoveSectionUp={() => moveSectionUp(section.id)}
+ onMoveSectionDown={() => moveSectionDown(section.id)}
+ mutedChannelIds={mutedChannelIds}
+ onMuteChannel={onMuteChannel}
+ onUnmuteChannel={onUnmuteChannel}
+ starredChannelIds={starredChannelIds}
+ onStarChannel={onStarChannel}
+ onUnstarChannel={onUnstarChannel}
+ onLeaveChannel={requestLeaveChannel}
+ />
+ ))}
+ 0}
+ isCollapsed={collapsedGroups.channels}
isActiveChannel={selectedView === "channel"}
activeWorkingByChannelId={activeWorkingByChannelId}
+ items={sectionBuckets.unassigned}
+ listTestId="stream-list"
+ onBrowseClick={onBrowseChannels}
+ onCreateClick={() => openCreateDialog("stream")}
+ onMarkAllRead={onMarkAllChannelsRead}
+ onMarkChannelRead={onMarkChannelRead}
+ onMarkChannelUnread={onMarkChannelUnread}
+ onSelectChannel={onSelectChannel}
+ onToggleCollapsed={() => toggleCollapsedGroup("channels")}
selectedChannelId={selectedChannelId}
+ title="Channels"
unreadChannelCounts={unreadChannelCounts}
unreadChannelIds={unreadChannelIds}
sections={channelSections}
assignments={channelAssignments}
- isFirst={idx === 0}
- isLast={idx === channelSections.length - 1}
- onToggleCollapsed={() => toggleCollapsedSection(section.id)}
- onSelectChannel={onSelectChannel}
- onMarkChannelRead={onMarkChannelRead}
- onMarkChannelUnread={onMarkChannelUnread}
- onMarkSectionRead={() => {
- for (const channel of sectionBuckets.bySection[
- section.id
- ] ?? []) {
- onMarkChannelRead(channel.id, channel.lastMessageAt);
- }
- }}
onAssignChannel={assignChannel}
onUnassignChannel={unassignChannel}
onCreateSectionForChannel={handleCreateSectionForChannel}
- onRenameSection={() => setRenameSectionTarget(section)}
- onDeleteSection={() => setDeleteSectionTarget(section)}
- onMoveSectionUp={() => moveSectionUp(section.id)}
- onMoveSectionDown={() => moveSectionDown(section.id)}
mutedChannelIds={mutedChannelIds}
onMuteChannel={onMuteChannel}
onUnmuteChannel={onUnmuteChannel}
@@ -737,115 +691,83 @@ export function AppSidebar({
onUnstarChannel={onUnstarChannel}
onLeaveChannel={requestLeaveChannel}
/>
- ))}
- 0}
- isCollapsed={collapsedGroups.channels}
- isActiveChannel={selectedView === "channel"}
- activeWorkingByChannelId={activeWorkingByChannelId}
- items={sectionBuckets.unassigned}
- listTestId="stream-list"
- onBrowseClick={onBrowseChannels}
- onCreateClick={() => openCreateDialog("stream")}
- onMarkAllRead={onMarkAllChannelsRead}
- onMarkChannelRead={onMarkChannelRead}
- onMarkChannelUnread={onMarkChannelUnread}
- onSelectChannel={onSelectChannel}
- onToggleCollapsed={() => toggleCollapsedGroup("channels")}
- selectedChannelId={selectedChannelId}
- title="Channels"
- unreadChannelCounts={unreadChannelCounts}
- unreadChannelIds={unreadChannelIds}
- sections={channelSections}
- assignments={channelAssignments}
- onAssignChannel={assignChannel}
- onUnassignChannel={unassignChannel}
- onCreateSectionForChannel={handleCreateSectionForChannel}
- mutedChannelIds={mutedChannelIds}
- onMuteChannel={onMuteChannel}
- onUnmuteChannel={onUnmuteChannel}
- starredChannelIds={starredChannelIds}
- onStarChannel={onStarChannel}
- onUnstarChannel={onUnstarChannel}
- onLeaveChannel={requestLeaveChannel}
- />
-
-
- 0}
- isCollapsed={collapsedGroups.forums}
+
+
+ 0}
+ isCollapsed={collapsedGroups.forums}
+ isActiveChannel={selectedView === "channel"}
+ activeWorkingByChannelId={activeWorkingByChannelId}
+ items={forumChannels}
+ listTestId="forum-list"
+ onCreateClick={() => openCreateDialog("forum")}
+ onMarkAllRead={onMarkAllChannelsRead}
+ onMarkChannelRead={onMarkChannelRead}
+ onMarkChannelUnread={onMarkChannelUnread}
+ onSelectChannel={onSelectChannel}
+ onToggleCollapsed={() => toggleCollapsedGroup("forums")}
+ selectedChannelId={selectedChannelId}
+ title="Forums"
+ unreadChannelCounts={unreadChannelCounts}
+ unreadChannelIds={unreadChannelIds}
+ mutedChannelIds={mutedChannelIds}
+ onMuteChannel={onMuteChannel}
+ onUnmuteChannel={onUnmuteChannel}
+ />
+
+
+
+
+ }
+ dmParticipantsByChannelId={dmParticipantsByChannelId}
+ isCollapsed={collapsedGroups.directMessages}
isActiveChannel={selectedView === "channel"}
activeWorkingByChannelId={activeWorkingByChannelId}
- items={forumChannels}
- listTestId="forum-list"
- onCreateClick={() => openCreateDialog("forum")}
- onMarkAllRead={onMarkAllChannelsRead}
+ items={sortedDirectMessages}
+ channelLabels={dmChannelLabels}
+ onHideDm={onHideDm}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
onSelectChannel={onSelectChannel}
- onToggleCollapsed={() => toggleCollapsedGroup("forums")}
+ onToggleCollapsed={() =>
+ toggleCollapsedGroup("directMessages")
+ }
+ presenceByChannelId={dmPresenceByChannelId}
selectedChannelId={selectedChannelId}
- title="Forums"
+ testId="dm-list"
+ title="Direct messages"
unreadChannelCounts={unreadChannelCounts}
unreadChannelIds={unreadChannelIds}
mutedChannelIds={mutedChannelIds}
onMuteChannel={onMuteChannel}
onUnmuteChannel={onUnmuteChannel}
/>
-
-
-
-
- }
- dmParticipantsByChannelId={dmParticipantsByChannelId}
- isCollapsed={collapsedGroups.directMessages}
- isActiveChannel={selectedView === "channel"}
- activeWorkingByChannelId={activeWorkingByChannelId}
- items={sortedDirectMessages}
- channelLabels={dmChannelLabels}
- onHideDm={onHideDm}
- onMarkChannelRead={onMarkChannelRead}
- onMarkChannelUnread={onMarkChannelUnread}
- onSelectChannel={onSelectChannel}
- onToggleCollapsed={() => toggleCollapsedGroup("directMessages")}
- presenceByChannelId={dmPresenceByChannelId}
- selectedChannelId={selectedChannelId}
- testId="dm-list"
- title="Direct messages"
- unreadChannelCounts={unreadChannelCounts}
- unreadChannelIds={unreadChannelIds}
- mutedChannelIds={mutedChannelIds}
- onMuteChannel={onMuteChannel}
- onUnmuteChannel={onUnmuteChannel}
- />
- >
- ) : null}
+ >
+ ) : null}
- {errorMessage &&
- !sidebarRelayConnectionCard.hasRelayUnreachableError ? (
-
- {errorMessage}
-
- ) : null}
-
+ {errorMessage &&
+ !sidebarRelayConnectionCard.hasRelayUnreachableError ? (
+
+ {errorMessage}
+
+ ) : null}
+
+
{unreadBelowCount > 0 ? (
diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx
new file mode 100644
index 000000000..5d87190d2
--- /dev/null
+++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx
@@ -0,0 +1,157 @@
+import { Activity, Bot, FolderGit2, Inbox, Zap } from "lucide-react";
+
+import { TopbarSearch } from "@/features/search/ui/TopbarSearch";
+import { FeatureGate } from "@/shared/features";
+import type { Channel, SearchHit } from "@/shared/api/types";
+import {
+ SidebarHeader,
+ SidebarMenu,
+ SidebarMenuBadge,
+ SidebarMenuButton,
+ SidebarMenuItem,
+} from "@/shared/ui/sidebar";
+
+type SidebarSelectedView =
+ | "home"
+ | "channel"
+ | "agents"
+ | "workflows"
+ | "pulse"
+ | "projects";
+
+type AppSidebarPinnedHeaderProps = {
+ channelLabels: Record
;
+ currentPubkey?: string;
+ homeBadgeCount: number;
+ onCreateAgent: () => void;
+ onCreateChannel: () => void;
+ onOpenDm: (input: { pubkeys: string[] }) => Promise;
+ onOpenSearchResult: (hit: SearchHit) => void;
+ onSelectAgents: () => void;
+ onSelectChannel: (channelId: string) => void;
+ onSelectHome: () => void;
+ onSelectProjects: () => void;
+ onSelectPulse: () => void;
+ onSelectWorkflows: () => void;
+ searchChannels: Channel[];
+ searchFocusRequest: number;
+ selectedView: SidebarSelectedView;
+ suggestionChannels: Channel[];
+};
+
+export function AppSidebarPinnedHeader({
+ channelLabels,
+ currentPubkey,
+ homeBadgeCount,
+ onCreateAgent,
+ onCreateChannel,
+ onOpenDm,
+ onOpenSearchResult,
+ onSelectAgents,
+ onSelectChannel,
+ onSelectHome,
+ onSelectProjects,
+ onSelectPulse,
+ onSelectWorkflows,
+ searchChannels,
+ searchFocusRequest,
+ selectedView,
+ suggestionChannels,
+}: AppSidebarPinnedHeaderProps) {
+ return (
+
+
onOpenDm({ pubkeys: [user.pubkey] })}
+ onCreateAgent={onCreateAgent}
+ onCreateChannel={onCreateChannel}
+ suggestionChannels={suggestionChannels}
+ />
+
+
+
+
+
+ Inbox
+
+ {homeBadgeCount > 0 ? (
+
+ {Math.min(homeBadgeCount, 99)}
+
+ ) : null}
+
+
+
+
+
+ Pulse
+
+
+
+
+
+
+
+ Projects
+
+
+
+
+
+
+ Agents
+
+
+
+
+
+
+ Workflows
+
+
+
+
+
+
+ );
+}
diff --git a/desktop/tests/e2e/sidebar-more-unread-overlap.spec.ts b/desktop/tests/e2e/sidebar-more-unread-overlap.spec.ts
index b88b14185..868dc5cc2 100644
--- a/desktop/tests/e2e/sidebar-more-unread-overlap.spec.ts
+++ b/desktop/tests/e2e/sidebar-more-unread-overlap.spec.ts
@@ -10,8 +10,9 @@
* sidebar row, so `top-0` inside the sidebar starts below the traffic-light
* strip.
*
- * This spec injects a synthetic pill into the live sidebar's relative
- * container and asserts the pill clears the chrome strip.
+ * This spec injects a synthetic pill into the live sidebar channel-content
+ * container (the same relative wrapper that owns the real top unread pill) and
+ * asserts the pill clears the pinned header.
*/
import { expect, test } from "@playwright/test";
@@ -39,9 +40,9 @@ async function injectSyntheticPill(
await page.evaluate(
({ topClass, base, html, testId }) => {
const container = document.querySelector(
- '[data-testid="app-sidebar-scroll-anchor"]',
+ '[data-testid="sidebar-channel-content"]',
) as HTMLElement | null;
- if (!container) throw new Error("sidebar scroll anchor not found");
+ if (!container) throw new Error("sidebar channel content not found");
// Remove any prior injection so retries start from a clean sidebar.
container
@@ -68,16 +69,22 @@ test.describe("sidebar MoreUnreadButton top chrome overlap", () => {
await expect(page.getByTestId("app-sidebar")).toBeVisible();
});
- test("top pill clears the in-flow traffic-light strip", async ({ page }) => {
+ test("top pill clears the pinned header", async ({ page }) => {
await injectSyntheticPill(page, TOP_CLASS, "synthetic-top");
const pill = page.getByTestId("synthetic-top");
+ const pinnedHeader = page.getByTestId("sidebar-pinned-header");
await expect(pill).toBeVisible();
+ await expect(pinnedHeader).toBeVisible();
- const box = await pill.boundingBox();
- expect(box).not.toBeNull();
- // The pill is anchored at the top of the sidebar row, below the 40px
- // in-flow chrome strip.
- expect(box?.y ?? Number.NaN).toBeGreaterThanOrEqual(40);
+ const pillBox = await pill.boundingBox();
+ const pinnedHeaderBox = await pinnedHeader.boundingBox();
+ expect(pillBox).not.toBeNull();
+ expect(pinnedHeaderBox).not.toBeNull();
+ expect(pillBox?.y ?? Number.NaN).toBeGreaterThanOrEqual(
+ pinnedHeaderBox?.y == null || pinnedHeaderBox.height == null
+ ? Number.NaN
+ : pinnedHeaderBox.y + pinnedHeaderBox.height,
+ );
await waitForAnimations(page);
});