refactor(layout): remove Inbox component and integrate NotificationsDropdown

- Eliminated the Inbox component and its related logic from FreeLayoutDataProvider and LayoutDataProvider.
- Introduced NotificationsDropdown to handle notifications in the sidebar and mobile sidebar.
- Updated Sidebar and LayoutShell components to accommodate the new notifications structure, enhancing user experience and code clarity.
This commit is contained in:
Anish Sarkar 2026-07-07 20:47:48 +05:30
parent 0592215bad
commit 93a09e1978
9 changed files with 420 additions and 219 deletions

View file

@ -1,14 +1,13 @@
"use client";
import { Inbox } from "lucide-react";
import { useRouter } from "next/navigation";
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useAnonymousMode } from "@/contexts/anonymous-mode";
import { useLoginGate } from "@/contexts/login-gate";
import { useAnnouncements } from "@/hooks/use-announcements";
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
import type { ChatItem, NavItem, PageUsage, Workspace } from "../types/layout.types";
import type { ChatItem, PageUsage, Workspace } from "../types/layout.types";
import { LayoutShell } from "../ui/shell";
interface FreeLayoutDataProviderProps {
@ -47,34 +46,12 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
const gatedAction = useCallback((feature: string) => () => gate(feature), [gate]);
const navItems: NavItem[] = useMemo(
() =>
(
[
{
title: "Inbox",
url: "#inbox",
icon: Inbox,
isActive: false,
},
] as (NavItem | null)[]
).filter((item): item is NavItem => item !== null),
[]
);
const pageUsage: PageUsage | undefined = quota
? { pagesUsed: quota.used, pagesLimit: quota.limit }
: undefined;
const handleChatSelect = useCallback((_chat: ChatItem) => gate("view chat history"), [gate]);
const handleNavItemClick = useCallback(
(item: NavItem) => {
if (item.title === "Inbox") gate("use the inbox");
},
[gate]
);
const handleAnnouncements = useCallback(() => gate("see what's new"), [gate]);
const handleWorkspaceSelect = useCallback((_id: number) => gate("switch workspaces"), [gate]);
@ -87,8 +64,7 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
onWorkspaceSettings={gatedAction("workspace settings")}
onAddWorkspace={gatedAction("create workspaces")}
workspace={GUEST_SPACE}
navItems={navItems}
onNavItemClick={handleNavItemClick}
navItems={[]}
chats={[]}
activeChatId={null}
onNewChat={resetChat}

View file

@ -2,7 +2,7 @@
import { useQuery } from "@tanstack/react-query";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { AlarmClock, AlertTriangle, Boxes, Inbox, SquareTerminal } from "lucide-react";
import { AlarmClock, AlertTriangle, Boxes, SquareTerminal } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
@ -60,17 +60,6 @@ interface LayoutDataProviderProps {
children: React.ReactNode;
}
/**
* Format count for display: shows numbers up to 999, then "1k+", "2k+", etc.
*/
function formatInboxCount(count: number): string {
if (count <= 999) {
return count.toString();
}
const thousands = Math.floor(count / 1000);
return `${thousands}k+`;
}
export function LayoutDataProvider({ workspaceId, children }: LayoutDataProviderProps) {
const t = useTranslations("dashboard");
const tCommon = useTranslations("common");
@ -125,12 +114,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
enabled: !!workspaceId,
});
// Unified slide-out panel state (only one can be open at a time)
type SlideoutPanel = "inbox" | null;
const [activeSlideoutPanel, setActiveSlideoutPanel] = useState<SlideoutPanel>(null);
const isInboxSidebarOpen = activeSlideoutPanel === "inbox";
// Search space dialog state
const [isCreateWorkspaceDialogOpen, setIsCreateWorkspaceDialogOpen] = useState(false);
@ -228,17 +211,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
const [isDeletingWorkspace, setIsDeletingWorkspace] = useState(false);
const [isLeavingWorkspace, setIsLeavingWorkspace] = useState(false);
// Reset transient slide-out panels when switching workspaces.
// Tabs intentionally persist across spaces — opening tabs from multiple
// workspaces is a supported flow (browser-tab semantics).
const prevWorkspaceIdRef = useRef(workspaceId);
useEffect(() => {
if (prevWorkspaceIdRef.current !== workspaceId) {
prevWorkspaceIdRef.current = workspaceId;
setActiveSlideoutPanel(null);
}
}, [workspaceId]);
const workspaces: Workspace[] = useMemo(() => {
if (!workspacesData || !Array.isArray(workspacesData)) return [];
return workspacesData.map((space) => ({
@ -318,9 +290,9 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
}, [threadsData, workspaceId]);
// Navigation items
// Inbox, Automations, and Artifacts are rendered explicitly below "New chat"
// in the sidebar (also surfaced in the icon rail's collapsed mode via this
// list). Documents is embedded below Recents; announcements live in the avatar dropdown.
// Automations and Artifacts are rendered explicitly below "New chat"
// in the sidebar. Documents is embedded below Recents; notifications and
// announcements live in the avatar rail/dropdown.
const isAutomationsActive = pathname?.includes("/automations") === true;
const isArtifactsActive = pathname?.endsWith("/artifacts") === true;
const isPlaygroundRoute = pathname?.includes("/playground") === true;
@ -328,13 +300,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
() =>
(
[
{
title: "Inbox",
url: "#inbox",
icon: Inbox,
isActive: isInboxSidebarOpen,
badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined,
},
{
title: "Automations",
url: `/dashboard/${workspaceId}/automations`,
@ -358,8 +323,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
] as (NavItem | null)[]
).filter((item): item is NavItem => item !== null),
[
isInboxSidebarOpen,
totalUnreadCount,
workspaceId,
isAutomationsActive,
isArtifactsActive,
@ -503,10 +466,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
const handleNavItemClick = useCallback(
(item: NavItem) => {
if (item.url === "#inbox") {
setActiveSlideoutPanel((prev) => (prev === "inbox" ? null : "inbox"));
return;
}
// Desktop: Playground is a persistent toggle, not a plain link — it just
// opens the second-level sidebar (which holds the whole API playground)
// and only closes on a second click, never navigating away from the
@ -518,7 +477,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
}
router.push(item.url);
},
[router, setPlaygroundSidebarOpen, setActiveSlideoutPanel, isMobile]
[router, setPlaygroundSidebarOpen, isMobile]
);
const handleNewChat = useCallback(() => {
@ -688,7 +647,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
const isPlaygroundPage = pathname?.includes("/playground") === true;
const isAllChatsPage = pathname?.endsWith("/chats") === true;
const handleViewAllChats = useCallback(() => {
setActiveSlideoutPanel(null);
router.push(
isAllChatsPage ? `/dashboard/${workspaceId}/new-chat` : `/dashboard/${workspaceId}/chats`
);
@ -764,10 +722,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
: undefined
}
isLoadingChats={isLoadingThreads}
activeSlideoutPanel={activeSlideoutPanel}
onSlideoutPanelChange={setActiveSlideoutPanel}
inbox={{
isOpen: isInboxSidebarOpen,
notifications={{
totalUnreadCount,
comments: {
items: commentsInbox.inboxItems,

View file

@ -6,6 +6,10 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { NavItem, User, Workspace } from "../../types/layout.types";
import {
NotificationsDropdown,
type NotificationsDropdownData,
} from "../sidebar/NotificationsDropdown";
import { SidebarUserProfile } from "../sidebar/SidebarUserProfile";
import { WorkspaceAvatar } from "./WorkspaceAvatar";
@ -24,6 +28,7 @@ interface IconRailProps {
onUserSettings?: () => void;
onAnnouncements?: () => void;
announcementUnreadCount?: number;
notifications?: NotificationsDropdownData;
onLogout?: () => void;
theme?: string;
setTheme?: (theme: "light" | "dark" | "system") => void;
@ -45,6 +50,7 @@ export function IconRail({
onUserSettings,
onAnnouncements,
announcementUnreadCount = 0,
notifications,
onLogout,
theme,
setTheme,
@ -145,6 +151,9 @@ export function IconRail({
isCollapsed
theme={theme}
setTheme={setTheme}
topContent={
notifications ? <NotificationsDropdown notifications={notifications} /> : undefined
}
/>
</div>
);

View file

@ -1,14 +1,12 @@
"use client";
import { useAtomValue } from "jotai";
import { AnimatePresence, motion } from "motion/react";
import dynamic from "next/dynamic";
import { useCallback, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { activeTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
import { Logo } from "@/components/Logo";
import { Spinner } from "@/components/ui/spinner";
import { TooltipProvider } from "@/components/ui/tooltip";
import type { InboxItem } from "@/hooks/use-inbox";
import { useIsMobile } from "@/hooks/use-mobile";
import { useElectronAPI } from "@/hooks/use-platform";
import { cn } from "@/lib/utils";
@ -27,14 +25,13 @@ import {
RightPanelToggleButton,
} from "../right-panel/RightPanel";
import {
InboxSidebarContent,
MobileSidebar,
MobileSidebarTrigger,
PlaygroundSidebar,
Sidebar,
SidebarCollapseButton,
} from "../sidebar";
import { SidebarSlideOutPanel } from "../sidebar/SidebarSlideOutPanel";
import type { NotificationsDropdownData } from "../sidebar/NotificationsDropdown";
import { TabBar } from "../tabs/TabBar";
import { WorkspacePanel } from "./WorkspacePanel";
@ -80,28 +77,6 @@ function MacDesktopTitleBar({
);
}
// Per-tab data source
interface TabDataSource {
items: InboxItem[];
unreadCount: number;
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
loadMore: () => void;
markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>;
}
export type ActiveSlideoutPanel = "inbox" | null;
// Inbox-related props — per-tab data sources with independent loading/pagination
interface InboxProps {
isOpen: boolean;
totalUnreadCount: number;
comments: TabDataSource;
status: TabDataSource;
}
interface LayoutShellProps {
workspaces: Workspace[];
activeWorkspaceId: number | null;
@ -140,11 +115,7 @@ interface LayoutShellProps {
workspacePanelContentClassName?: string;
children: React.ReactNode;
className?: string;
// Unified slide-out panel state
activeSlideoutPanel?: ActiveSlideoutPanel;
onSlideoutPanelChange?: (panel: ActiveSlideoutPanel) => void;
// Inbox props
inbox?: InboxProps;
notifications?: NotificationsDropdownData;
isLoadingChats?: boolean;
onTabSwitch?: (tab: Tab) => void;
onTabPrefetch?: (tab: Tab) => void;
@ -245,9 +216,7 @@ export function LayoutShell({
workspacePanelContentClassName,
children,
className,
activeSlideoutPanel = null,
onSlideoutPanelChange,
inbox,
notifications,
isLoadingChats = false,
onTabSwitch,
onTabPrefetch,
@ -269,17 +238,6 @@ export function LayoutShell({
[isCollapsed, setIsCollapsed, toggleCollapsed]
);
const closeSlideout = useCallback(
(open: boolean) => {
if (!open) onSlideoutPanelChange?.(null);
},
[onSlideoutPanelChange]
);
const anySlideOutOpen = activeSlideoutPanel !== null;
const panelAriaLabel = activeSlideoutPanel === "inbox" ? "Inbox" : "Panel";
// Mobile layout
if (isMobile) {
return (
@ -316,6 +274,7 @@ export function LayoutShell({
onUserSettings={onUserSettings}
onAnnouncements={onAnnouncements}
announcementUnreadCount={announcementUnreadCount}
notifications={notifications}
onLogout={onLogout}
pageUsage={pageUsage}
theme={theme}
@ -335,34 +294,6 @@ export function LayoutShell({
{children}
</main>
)}
{/* Mobile unified slide-out panel */}
<SidebarSlideOutPanel
open={anySlideOutOpen}
onOpenChange={closeSlideout}
ariaLabel={panelAriaLabel}
>
<AnimatePresence mode="popLayout" initial={false}>
{activeSlideoutPanel === "inbox" && inbox && (
<motion.div
key="inbox"
className="h-full flex flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<InboxSidebarContent
onOpenChange={(open) => closeSlideout(open)}
comments={inbox.comments}
status={inbox.status}
totalUnreadCount={inbox.totalUnreadCount}
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
/>
</motion.div>
)}
</AnimatePresence>
</SidebarSlideOutPanel>
</div>
</TooltipProvider>
</SidebarProvider>
@ -400,6 +331,7 @@ export function LayoutShell({
onUserSettings={onUserSettings}
onAnnouncements={onAnnouncements}
announcementUnreadCount={announcementUnreadCount}
notifications={notifications}
onLogout={onLogout}
theme={theme}
setTheme={setTheme}
@ -425,10 +357,7 @@ export function LayoutShell({
className={cn(
"relative hidden md:flex shrink-0 z-20 -mr-2 bg-panel",
isMacDesktop
? cn(
"border-t border-r",
!showPlaygroundSidebar && "rounded-tl-xl border-l"
)
? cn("border-t border-r", !showPlaygroundSidebar && "rounded-tl-xl border-l")
: "border-r"
)}
>
@ -491,33 +420,6 @@ export function LayoutShell({
)}
/>
)}
{/* Unified slide-out panel — shell stays open, content cross-fades */}
<SidebarSlideOutPanel
open={anySlideOutOpen}
onOpenChange={closeSlideout}
ariaLabel={panelAriaLabel}
>
<AnimatePresence mode="popLayout" initial={false}>
{activeSlideoutPanel === "inbox" && inbox && (
<motion.div
key="inbox"
className="h-full flex flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<InboxSidebarContent
onOpenChange={(open) => closeSlideout(open)}
comments={inbox.comments}
status={inbox.status}
totalUnreadCount={inbox.totalUnreadCount}
/>
</motion.div>
)}
</AnimatePresence>
</SidebarSlideOutPanel>
</div>
<DesktopWorkspaceRegion>

View file

@ -6,6 +6,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import type { ChatItem, NavItem, PageUsage, User, Workspace } from "../../types/layout.types";
import { WorkspaceAvatar } from "../icon-rail/WorkspaceAvatar";
import { NotificationsDropdown, type NotificationsDropdownData } from "./NotificationsDropdown";
import { Sidebar } from "./Sidebar";
import { SidebarUserProfile } from "./SidebarUserProfile";
@ -35,6 +36,7 @@ interface MobileSidebarProps {
onUserSettings?: () => void;
onAnnouncements?: () => void;
announcementUnreadCount?: number;
notifications?: NotificationsDropdownData;
onLogout?: () => void;
pageUsage?: PageUsage;
theme?: string;
@ -83,6 +85,7 @@ export function MobileSidebar({
onUserSettings,
onAnnouncements,
announcementUnreadCount = 0,
notifications,
onLogout,
pageUsage,
theme,
@ -161,6 +164,14 @@ export function MobileSidebar({
isCollapsed
theme={theme}
setTheme={setTheme}
topContent={
notifications ? (
<NotificationsDropdown
notifications={notifications}
onCloseMobileSidebar={() => onOpenChange(false)}
/>
) : undefined
}
/>
</div>

View file

@ -0,0 +1,378 @@
"use client";
import { useAtom } from "jotai";
import { Bell } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
isCommentReplyMetadata,
isInsufficientCreditsMetadata,
isNewMentionMetadata,
} from "@/contracts/types/inbox.types";
import type { InboxItem } from "@/hooks/use-inbox";
import { cn } from "@/lib/utils";
export interface NotificationsDataSource {
items: InboxItem[];
unreadCount: number;
loading: boolean;
loadingMore?: boolean;
hasMore?: boolean;
loadMore?: () => void;
markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>;
}
export interface NotificationsDropdownData {
totalUnreadCount: number;
comments: NotificationsDataSource;
status: NotificationsDataSource;
}
interface NotificationsDropdownProps {
notifications: NotificationsDropdownData;
onCloseMobileSidebar?: () => void;
}
type NotificationFilter = "mentions" | "status" | null;
function formatNotificationCount(count: number): string {
if (count <= 999) {
return count.toString();
}
const thousands = Math.floor(count / 1000);
return `${thousands}k+`;
}
function formatTime(dateString: string): string {
try {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMins < 1) return "now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return `${Math.floor(diffDays / 7)}w ago`;
} catch {
return "now";
}
}
function isCommentNotification(item: InboxItem): boolean {
return item.type === "new_mention" || item.type === "comment_reply";
}
export function NotificationsDropdown({
notifications,
onCloseMobileSidebar,
}: NotificationsDropdownProps) {
const router = useRouter();
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
const [open, setOpen] = useState(false);
const [activeFilter, setActiveFilter] = useState<NotificationFilter>(null);
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
const [markingAllAsRead, setMarkingAllAsRead] = useState(false);
const unreadLabel = formatNotificationCount(notifications.totalUnreadCount);
const visibleUnreadCount =
activeFilter === "mentions"
? notifications.comments.unreadCount
: activeFilter === "status"
? notifications.status.unreadCount
: notifications.totalUnreadCount;
const visibleUnreadLabel = formatNotificationCount(visibleUnreadCount);
const isLoading =
activeFilter === "mentions"
? notifications.comments.loading
: activeFilter === "status"
? notifications.status.loading
: notifications.comments.loading || notifications.status.loading;
const items = useMemo(() => {
const sourceItems =
activeFilter === "mentions"
? notifications.comments.items
: activeFilter === "status"
? notifications.status.items
: [...notifications.comments.items, ...notifications.status.items];
return sourceItems
.toSorted((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.slice(0, 8);
}, [activeFilter, notifications.comments.items, notifications.status.items]);
const markItemAsRead = useCallback(
async (item: InboxItem) => {
if (item.read) return;
setMarkingAsReadId(item.id);
try {
await (isCommentNotification(item)
? notifications.comments.markAsRead(item.id)
: notifications.status.markAsRead(item.id));
} finally {
setMarkingAsReadId(null);
}
},
[notifications.comments, notifications.status]
);
const handleItemClick = useCallback(
async (item: InboxItem) => {
await markItemAsRead(item);
if (item.type === "new_mention" && isNewMentionMetadata(item.metadata)) {
const threadId = item.metadata.thread_id;
const commentId = item.metadata.comment_id;
if (item.workspace_id && threadId) {
if (commentId) setTargetCommentId(commentId);
setOpen(false);
onCloseMobileSidebar?.();
router.push(
commentId
? `/dashboard/${item.workspace_id}/new-chat/${threadId}?commentId=${commentId}`
: `/dashboard/${item.workspace_id}/new-chat/${threadId}`
);
}
return;
}
if (item.type === "comment_reply" && isCommentReplyMetadata(item.metadata)) {
const threadId = item.metadata.thread_id;
const replyId = item.metadata.reply_id;
if (item.workspace_id && threadId) {
if (replyId) setTargetCommentId(replyId);
setOpen(false);
onCloseMobileSidebar?.();
router.push(
replyId
? `/dashboard/${item.workspace_id}/new-chat/${threadId}?commentId=${replyId}`
: `/dashboard/${item.workspace_id}/new-chat/${threadId}`
);
}
return;
}
if (item.type === "insufficient_credits" && isInsufficientCreditsMetadata(item.metadata)) {
if (item.metadata.action_url) {
setOpen(false);
onCloseMobileSidebar?.();
router.push(item.metadata.action_url);
}
}
},
[markItemAsRead, onCloseMobileSidebar, router, setTargetCommentId]
);
const handleMarkAllAsRead = useCallback(async () => {
if (visibleUnreadCount === 0 || markingAllAsRead) return;
setMarkingAllAsRead(true);
try {
if (activeFilter === "mentions") {
await notifications.comments.markAllAsRead();
} else if (activeFilter === "status") {
await notifications.status.markAllAsRead();
} else {
await Promise.all([
notifications.comments.markAllAsRead(),
notifications.status.markAllAsRead(),
]);
}
} finally {
setMarkingAllAsRead(false);
}
}, [activeFilter, markingAllAsRead, notifications, visibleUnreadCount]);
const handleFilterClick = useCallback((filter: Exclude<NotificationFilter, null>) => {
setActiveFilter((current) => (current === filter ? null : filter));
}, []);
const emptyStateCopy =
activeFilter === "mentions"
? {
title: "No mentions",
description: "Mentions and replies will appear here.",
}
: activeFilter === "status"
? {
title: "No status updates",
description: "Connector and document updates will appear here.",
}
: {
title: "No notifications",
description: "Mentions, replies, and status updates will appear here.",
};
return (
<Popover open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={
notifications.totalUnreadCount > 0
? `Notifications, ${unreadLabel} unread`
: "Notifications"
}
className={cn(
"relative h-10 w-10 rounded-lg text-muted-foreground hover:bg-accent hover:text-accent-foreground",
open && "bg-accent text-accent-foreground"
)}
>
<Bell className="h-4 w-4" />
{notifications.totalUnreadCount > 0 ? (
<span className="absolute right-1 top-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold leading-none text-destructive-foreground">
{unreadLabel}
</span>
) : null}
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Notifications
</TooltipContent>
</Tooltip>
<PopoverContent
side="right"
align="end"
sideOffset={10}
className="z-80 flex max-h-[min(520px,calc(100vh-2rem))] w-[360px] flex-col overflow-hidden rounded-xl border bg-popover p-0 text-popover-foreground shadow-lg"
>
<div className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
<div className="min-w-0">
<h2 className="text-base font-semibold">Notifications</h2>
<p className="text-xs text-muted-foreground">
{visibleUnreadCount > 0 ? `${visibleUnreadLabel} unread` : "You're all caught up"}
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleMarkAllAsRead}
disabled={visibleUnreadCount === 0 || markingAllAsRead}
className="h-8 shrink-0 gap-1.5 px-2 text-xs text-muted-foreground hover:text-accent-foreground"
>
{markingAllAsRead ? <Spinner size="xs" /> : null}
Mark all read
</Button>
</div>
<div className="flex shrink-0 items-center gap-2 border-b px-3 py-2">
<Button
type="button"
variant="ghost"
size="sm"
aria-pressed={activeFilter === "mentions"}
onClick={() => handleFilterClick("mentions")}
className={cn(
"h-7 rounded-full px-2.5 text-xs text-muted-foreground",
activeFilter === "mentions" && "bg-accent text-accent-foreground"
)}
>
Mentions
{notifications.comments.unreadCount > 0 ? (
<span className="ml-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary/15 px-1 text-[10px] font-medium text-primary">
{formatNotificationCount(notifications.comments.unreadCount)}
</span>
) : null}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
aria-pressed={activeFilter === "status"}
onClick={() => handleFilterClick("status")}
className={cn(
"h-7 rounded-full px-2.5 text-xs text-muted-foreground",
activeFilter === "status" && "bg-accent text-accent-foreground"
)}
>
Status
{notifications.status.unreadCount > 0 ? (
<span className="ml-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary/15 px-1 text-[10px] font-medium text-primary">
{formatNotificationCount(notifications.status.unreadCount)}
</span>
) : null}
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{isLoading ? (
<div className="space-y-1">
{[82, 64, 74].map((width) => (
<div key={width} className="flex h-[72px] items-center rounded-lg px-2 py-2">
<div className="min-w-0 flex-1 space-y-2">
<Skeleton className="h-3 rounded" style={{ width: `${width}%` }} />
<Skeleton className="h-2.5 w-1/2 rounded" />
</div>
</div>
))}
</div>
) : items.length > 0 ? (
<div className="space-y-1">
{items.map((item) => {
const isMarkingAsRead = markingAsReadId === item.id;
return (
<Button
key={item.id}
type="button"
variant="ghost"
disabled={isMarkingAsRead}
onClick={() => handleItemClick(item)}
className={cn(
"group h-auto w-full justify-start rounded-lg px-2 py-2 text-left",
"hover:bg-accent hover:text-accent-foreground",
!item.read && "bg-accent/40"
)}
style={{ contentVisibility: "auto", containIntrinsicSize: "0 72px" }}
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-start gap-2">
<p
className={cn(
"line-clamp-1 flex-1 text-sm font-medium",
!item.read && "font-semibold"
)}
>
{item.title}
</p>
{!item.read ? (
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary" />
) : null}
</div>
<p className="mt-0.5 line-clamp-2 text-xs font-normal text-muted-foreground group-hover:text-accent-foreground/80">
{item.message}
</p>
<p className="mt-1 text-[11px] font-normal text-muted-foreground/70">
{formatTime(item.created_at)}
</p>
</div>
{isMarkingAsRead ? <Spinner size="xs" className="shrink-0" /> : null}
</Button>
);
})}
</div>
) : (
<div className="flex flex-col items-center justify-center px-6 py-10 text-center">
<p className="text-sm font-medium">{emptyStateCopy.title}</p>
<p className="mt-1 text-xs text-muted-foreground">{emptyStateCopy.description}</p>
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}

View file

@ -46,21 +46,6 @@ function ChatListSkeletonRows() {
);
}
function CollapsedInboxIcon({ item }: { item: NavItem }) {
const Icon = item.icon;
return (
<span className="relative flex h-3.5 w-3.5 items-center justify-center">
<Icon className="h-3.5 w-3.5" />
{typeof item.badge === "string" ? (
<span className="absolute right-0 top-0 flex min-w-3.5 -translate-y-1/2 translate-x-1/2 items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-medium leading-3 text-destructive-foreground">
{item.badge}
</span>
) : null}
</span>
);
}
interface SidebarProps {
workspace: Workspace | null;
isCollapsed?: boolean;
@ -138,10 +123,9 @@ export function Sidebar({
const [openDropdownChatId, setOpenDropdownChatId] = useState<number | null>(null);
const [isSidebarNavScrolled, setIsSidebarNavScrolled] = useState(false);
// Inbox, Automations, and Artifacts are rendered explicitly right below
// Automations, Artifacts, and Playground are rendered explicitly right below
// New Chat. Pull them out of the nav items list so they don't also appear
// in the bottom NavSection. Documents is embedded below Recents.
const inboxItem = useMemo(() => navItems.find((item) => item.url === "#inbox"), [navItems]);
const automationsItem = useMemo(
() => navItems.find((item) => item.url.endsWith("/automations")),
[navItems]
@ -158,7 +142,6 @@ export function Sidebar({
() =>
navItems.filter(
(item) =>
item.url !== "#inbox" &&
!item.url.endsWith("/automations") &&
!item.url.endsWith("/artifacts") &&
!item.url.endsWith("/playground")
@ -235,23 +218,6 @@ export function Sidebar({
onScroll={(event) => setIsSidebarNavScrolled(event.currentTarget.scrollTop > 0)}
>
<div className="flex flex-col gap-0.5 pt-0.5 pb-1.5">
{inboxItem && (
<SidebarButton
icon={inboxItem.icon}
label={inboxItem.title}
onClick={() => onNavItemClick?.(inboxItem)}
isCollapsed={isCollapsed}
isActive={inboxItem.isActive}
badge={inboxItem.badge}
collapsedIconNode={<CollapsedInboxIcon item={inboxItem} />}
tooltipContent={isCollapsed ? inboxItem.title : undefined}
buttonProps={
{
"data-joyride": "inbox-sidebar",
} as React.ButtonHTMLAttributes<HTMLButtonElement>
}
/>
)}
{automationsItem && (
<SidebarButton
icon={automationsItem.icon}

View file

@ -16,6 +16,7 @@ import {
} from "lucide-react";
import Image from "next/image";
import { useTranslations } from "next-intl";
import type React from "react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
@ -72,6 +73,7 @@ interface SidebarUserProfileProps {
isCollapsed?: boolean;
theme?: string;
setTheme?: (theme: "light" | "dark" | "system") => void;
topContent?: React.ReactNode;
}
function formatAnnouncementCount(count: number): string {
@ -134,6 +136,7 @@ export function SidebarUserProfile({
isCollapsed = false,
theme,
setTheme,
topContent,
}: SidebarUserProfileProps) {
const t = useTranslations("sidebar");
const { locale, setLocale } = useLocaleContext();
@ -171,6 +174,7 @@ export function SidebarUserProfile({
return (
<div className="w-full border-t px-1.5 py-2">
<div className="flex flex-col items-center gap-2">
{topContent}
{showDownloadCta && (
<Tooltip>
<TooltipTrigger asChild>

View file

@ -2,9 +2,9 @@ export { AllChatsWorkspaceContent } from "./AllChatsSidebar";
export { ChatListItem } from "./ChatListItem";
export { CreditBalanceDisplay } from "./CreditBalanceDisplay";
export { DocumentsSidebar } from "./DocumentsSidebar";
export { InboxSidebar, InboxSidebarContent } from "./InboxSidebar";
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
export { NavSection } from "./NavSection";
export { NotificationsDropdown } from "./NotificationsDropdown";
export { PlaygroundSidebar } from "./PlaygroundSidebar";
export { Sidebar } from "./Sidebar";
export { SidebarCollapseButton } from "./SidebarCollapseButton";