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); });