From e2c901e7538b4db5576eef222a8218a34aad9cd9 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 26 Jun 2026 18:12:00 -0600 Subject: [PATCH 1/2] Fix sidebar unread indicator placement Move the overflow unread indicator into the channel content area so pinned top sections do not affect its position, and add spacing below the pinned navigation. Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- .../src/features/sidebar/ui/AppSidebar.tsx | 499 ++++++++---------- .../sidebar/ui/AppSidebarPinnedHeader.tsx | 157 ++++++ 2 files changed, 366 insertions(+), 290 deletions(-) create mode 100644 desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 767c9c41a..bac071e3e 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,162 @@ 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} - /> - + +
+ {unreadAboveCount > 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 +688,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 + + + + + +
+ ); +} From 931b1a5a201e26a2df02f73890474e6a38834677 Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 28 Jun 2026 09:03:06 -0600 Subject: [PATCH 2/2] Tighten sidebar unread pill regression coverage Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- .../src/features/sidebar/ui/AppSidebar.tsx | 5 +++- .../e2e/sidebar-more-unread-overlap.spec.ts | 27 ++++++++++++------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index bac071e3e..46293a3d4 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -544,7 +544,10 @@ export function AppSidebar({ suggestionChannels={channels} /> -
+
{unreadAboveCount > 0 ? ( { 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); });