This commit is contained in:
Manoj Aggarwal 2026-01-22 20:57:48 -08:00
commit 49d51ba569
70 changed files with 4266 additions and 1362 deletions

View file

@ -0,0 +1,50 @@
"use client";
import { Loader2 } from "lucide-react";
import type { FC } from "react";
import { cn } from "@/lib/utils";
interface ChatSessionStatusProps {
isAiResponding: boolean;
respondingToUserId: string | null;
currentUserId: string | null;
members: Array<{
user_id: string;
user_display_name?: string | null;
user_email?: string | null;
}>;
className?: string;
}
export const ChatSessionStatus: FC<ChatSessionStatusProps> = ({
isAiResponding,
respondingToUserId,
currentUserId,
members,
className,
}) => {
if (!isAiResponding || !respondingToUserId) {
return null;
}
if (respondingToUserId === currentUserId) {
return null;
}
const respondingUser = members.find((m) => m.user_id === respondingToUserId);
const displayName =
respondingUser?.user_display_name || respondingUser?.user_email || "another user";
return (
<div
className={cn(
"flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground bg-muted/50 rounded-lg",
"animate-in fade-in slide-in-from-bottom-2 duration-300 ease-out",
className
)}
>
<Loader2 className="size-3.5 animate-spin" />
<span>Currently responding to {displayName}</span>
</div>
);
};

View file

@ -186,12 +186,10 @@ export const ConnectorIndicator: FC = () => {
) : viewingComposio && searchSpaceId ? (
<ComposioToolkitView
searchSpaceId={searchSpaceId}
connectedToolkits={
(connectors || [])
.filter((c: SearchSourceConnector) => c.connector_type === "COMPOSIO_CONNECTOR")
.map((c: SearchSourceConnector) => c.config?.toolkit_id as string)
.filter(Boolean)
}
connectedToolkits={(connectors || [])
.filter((c: SearchSourceConnector) => c.connector_type === "COMPOSIO_CONNECTOR")
.map((c: SearchSourceConnector) => c.config?.toolkit_id as string)
.filter(Boolean)}
onBack={handleBackFromComposio}
onConnectToolkit={handleConnectComposioToolkit}
isConnecting={connectingComposioToolkit !== null}

View file

@ -6,7 +6,12 @@ import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { isSelfHosted } from "@/lib/env-config";
import { ConnectorCard } from "../components/connector-card";
import { ComposioConnectorCard } from "../components/composio-connector-card";
import { CRAWLERS, OAUTH_CONNECTORS, OTHER_CONNECTORS, COMPOSIO_CONNECTORS } from "../constants/connector-constants";
import {
COMPOSIO_CONNECTORS,
CRAWLERS,
OAUTH_CONNECTORS,
OTHER_CONNECTORS,
} from "../constants/connector-constants";
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
/**
@ -164,7 +169,7 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
)}
{/* Composio Integrations */}
{filteredComposio.length > 0 && onOpenComposio && (
{/* {filteredComposio.length > 0 && onOpenComposio && (
<section>
<div className="flex items-center gap-2 mb-4">
<h3 className="text-sm font-semibold text-muted-foreground">Managed OAuth</h3>
@ -185,7 +190,7 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
))}
</div>
</section>
)}
)} */}
{/* More Integrations */}
{filteredOther.length > 0 && (

View file

@ -5,12 +5,12 @@ import {
Calendar,
Check,
ExternalLink,
FileText,
Github,
HardDrive,
Loader2,
Mail,
HardDrive,
MessageSquare,
FileText,
Zap,
} from "lucide-react";
import Image from "next/image";
@ -82,17 +82,65 @@ const getToolkitIcon = (toolkitId: string, className?: string) => {
switch (toolkitId) {
case "googledrive":
return <Image src="/connectors/google-drive.svg" alt="Google Drive" width={20} height={20} className={iconClass} />;
return (
<Image
src="/connectors/google-drive.svg"
alt="Google Drive"
width={20}
height={20}
className={iconClass}
/>
);
case "gmail":
return <Image src="/connectors/google-gmail.svg" alt="Gmail" width={20} height={20} className={iconClass} />;
return (
<Image
src="/connectors/google-gmail.svg"
alt="Gmail"
width={20}
height={20}
className={iconClass}
/>
);
case "googlecalendar":
return <Image src="/connectors/google-calendar.svg" alt="Google Calendar" width={20} height={20} className={iconClass} />;
return (
<Image
src="/connectors/google-calendar.svg"
alt="Google Calendar"
width={20}
height={20}
className={iconClass}
/>
);
case "slack":
return <Image src="/connectors/slack.svg" alt="Slack" width={20} height={20} className={iconClass} />;
return (
<Image
src="/connectors/slack.svg"
alt="Slack"
width={20}
height={20}
className={iconClass}
/>
);
case "notion":
return <Image src="/connectors/notion.svg" alt="Notion" width={20} height={20} className={iconClass} />;
return (
<Image
src="/connectors/notion.svg"
alt="Notion"
width={20}
height={20}
className={iconClass}
/>
);
case "github":
return <Image src="/connectors/github.svg" alt="GitHub" width={20} height={20} className={iconClass} />;
return (
<Image
src="/connectors/github.svg"
alt="GitHub"
width={20}
height={20}
className={iconClass}
/>
);
default:
return <Zap className={iconClass} />;
}
@ -139,9 +187,7 @@ export const ComposioToolkitView: FC<ComposioToolkitViewProps> = ({
/>
</div>
<div className="flex-1 min-w-0">
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight">
Composio
</h2>
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight">Composio</h2>
<p className="text-xs sm:text-sm text-muted-foreground mt-1">
Connect 100+ apps with managed OAuth - no verification needed
</p>
@ -165,12 +211,16 @@ export const ComposioToolkitView: FC<ComposioToolkitViewProps> = ({
<section className="mb-8">
<div className="flex items-center gap-2 mb-4">
<h3 className="text-sm font-semibold text-foreground">Google Services</h3>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-5 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20">
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0 h-5 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
>
Indexable
</Badge>
</div>
<p className="text-xs text-muted-foreground mb-4">
Connect Google services via Composio&apos;s verified OAuth app. Your data will be indexed and searchable.
Connect Google services via Composio&apos;s verified OAuth app. Your data will be
indexed and searchable.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{indexableToolkits.map((toolkit) => {
@ -201,16 +251,17 @@ export const ComposioToolkitView: FC<ComposioToolkitViewProps> = ({
{getToolkitIcon(toolkit.id, "size-5")}
</div>
{isConnected && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-5 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20">
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0 h-5 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
>
<Check className="size-3 mr-0.5" />
Connected
</Badge>
)}
</div>
<h4 className="text-sm font-medium mb-1">{toolkit.name}</h4>
<p className="text-xs text-muted-foreground mb-4 flex-1">
{toolkit.description}
</p>
<p className="text-xs text-muted-foreground mb-4 flex-1">{toolkit.description}</p>
<Button
size="sm"
variant={isConnected ? "secondary" : "default"}
@ -242,12 +293,16 @@ export const ComposioToolkitView: FC<ComposioToolkitViewProps> = ({
<section>
<div className="flex items-center gap-2 mb-4">
<h3 className="text-sm font-semibold text-foreground">More Integrations</h3>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-5 bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20">
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0 h-5 bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20"
>
Coming Soon
</Badge>
</div>
<p className="text-xs text-muted-foreground mb-4">
Connect these services for future indexing support. Currently available for connection only.
Connect these services for future indexing support. Currently available for connection
only.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 opacity-60">
{nonIndexableToolkits.map((toolkit) => (
@ -264,9 +319,7 @@ export const ComposioToolkitView: FC<ComposioToolkitViewProps> = ({
</Badge>
</div>
<h4 className="text-sm font-medium mb-1">{toolkit.name}</h4>
<p className="text-xs text-muted-foreground mb-4 flex-1">
{toolkit.description}
</p>
<p className="text-xs text-muted-foreground mb-4 flex-1">{toolkit.description}</p>
<Button
size="sm"
variant="outline"
@ -289,8 +342,9 @@ export const ComposioToolkitView: FC<ComposioToolkitViewProps> = ({
<div>
<h4 className="text-sm font-medium mb-1">Why use Composio?</h4>
<p className="text-xs text-muted-foreground leading-relaxed">
Composio provides pre-verified OAuth apps, so you don&apos;t need to wait for Google app verification.
Your data is securely processed through Composio&apos;s managed authentication.
Composio provides pre-verified OAuth apps, so you don&apos;t need to wait for Google
app verification. Your data is securely processed through Composio&apos;s managed
authentication.
</p>
</div>
</div>

View file

@ -26,11 +26,13 @@ import {
import { useParams } from "next/navigation";
import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
import { showCommentsGutterAtom } from "@/atoms/chat/current-thread.atom";
import {
mentionedDocumentIdsAtom,
mentionedDocumentsAtom,
} from "@/atoms/chat/mentioned-documents.atom";
import { membersAtom } from "@/atoms/members/members-query.atoms";
import {
globalNewLLMConfigsAtom,
llmPreferencesAtom,
@ -39,6 +41,7 @@ import {
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
import { ComposerAddAttachment, ComposerAttachments } from "@/components/assistant-ui/attachment";
import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status";
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
import {
InlineMentionEditor,
@ -59,6 +62,7 @@ import {
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
import { Button } from "@/components/ui/button";
import type { Document } from "@/contracts/types/document.types";
import { useCommentsElectric } from "@/hooks/use-comments-electric";
import { cn } from "@/lib/utils";
interface ThreadProps {
@ -86,6 +90,7 @@ const ThreadContent: FC<{ header?: React.ReactNode }> = ({ header }) => {
>
<ThreadPrimitive.Viewport
turnAnchor="top"
autoScroll
className={cn(
"aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4 transition-[padding] duration-300 ease-out",
showGutter && "lg:pr-30"
@ -215,7 +220,7 @@ const Composer: FC = () => {
const editorRef = useRef<InlineMentionEditorRef>(null);
const editorContainerRef = useRef<HTMLDivElement>(null);
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
const { search_space_id } = useParams();
const { search_space_id, chat_id } = useParams();
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
const composerRuntime = useComposerRuntime();
const hasAutoFocusedRef = useRef(false);
@ -223,6 +228,23 @@ const Composer: FC = () => {
const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty);
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
// Live collaboration state
const { data: currentUser } = useAtomValue(currentUserAtom);
const { data: members } = useAtomValue(membersAtom);
const threadId = useMemo(() => {
if (Array.isArray(chat_id) && chat_id.length > 0) {
return Number.parseInt(chat_id[0], 10) || null;
}
return typeof chat_id === "string" ? Number.parseInt(chat_id, 10) || null : null;
}, [chat_id]);
const sessionState = useAtomValue(chatSessionStateAtom);
const isAiResponding = sessionState?.isAiResponding ?? false;
const respondingToUserId = sessionState?.respondingToUserId ?? null;
const isBlockedByOtherUser = isAiResponding && respondingToUserId !== currentUser?.id;
// Sync comments for the entire thread via Electric SQL (one subscription per thread)
useCommentsElectric(threadId);
// Auto-focus editor on new chat page after mount
useEffect(() => {
if (isThreadEmpty && !hasAutoFocusedRef.current && editorRef.current) {
@ -298,9 +320,9 @@ const Composer: FC = () => {
[showDocumentPopover]
);
// Submit message (blocked during streaming or when document picker is open)
// Submit message (blocked during streaming, document picker open, or AI responding to another user)
const handleSubmit = useCallback(() => {
if (isThreadRunning) {
if (isThreadRunning || isBlockedByOtherUser) {
return;
}
if (!showDocumentPopover) {
@ -315,6 +337,7 @@ const Composer: FC = () => {
}, [
showDocumentPopover,
isThreadRunning,
isBlockedByOtherUser,
composerRuntime,
setMentionedDocuments,
setMentionedDocumentIds,
@ -374,7 +397,13 @@ const Composer: FC = () => {
);
return (
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col gap-2">
<ChatSessionStatus
isAiResponding={isAiResponding}
respondingToUserId={respondingToUserId}
currentUserId={currentUser?.id ?? null}
members={members ?? []}
/>
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone flex w-full flex-col rounded-2xl border-input bg-muted px-1 pt-2 outline-none transition-shadow data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50">
<ComposerAttachments />
{/* Inline editor with @mention support */}
@ -417,13 +446,17 @@ const Composer: FC = () => {
/>,
document.body
)}
<ComposerAction />
<ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} />
</ComposerPrimitive.AttachmentDropzone>
</ComposerPrimitive.Root>
);
};
const ComposerAction: FC = () => {
interface ComposerActionProps {
isBlockedByOtherUser?: boolean;
}
const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false }) => {
// Check if any attachments are still being processed (running AND progress < 100)
// When progress is 100, processing is done but waiting for send()
const hasProcessingAttachments = useAssistantState(({ composer }) =>
@ -458,7 +491,8 @@ const ComposerAction: FC = () => {
return userConfigs?.some((c) => c.id === agentLlmId) ?? false;
}, [preferences, globalConfigs, userConfigs]);
const isSendDisabled = hasProcessingAttachments || isComposerEmpty || !hasModelConfigured;
const isSendDisabled =
hasProcessingAttachments || isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser;
return (
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
@ -487,13 +521,15 @@ const ComposerAction: FC = () => {
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
<TooltipIconButton
tooltip={
!hasModelConfigured
? "Please select a model from the header to start chatting"
: hasProcessingAttachments
? "Wait for attachments to process"
: isComposerEmpty
? "Enter a message to send"
: "Send message"
isBlockedByOtherUser
? "Wait for AI to finish responding"
: !hasModelConfigured
? "Please select a model from the header to start chatting"
: hasProcessingAttachments
? "Wait for attachments to process"
: isComposerEmpty
? "Enter a message to send"
: "Send message"
}
side="bottom"
type="submit"

View file

@ -69,7 +69,7 @@ export function CommentPanel({
style={!isMobile && effectiveMaxHeight ? { maxHeight: effectiveMaxHeight } : undefined}
>
{hasThreads && (
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
<div className={cn("min-h-0 flex-1 overflow-y-auto scrollbar-thin", isMobile && "pb-24")}>
<div className="space-y-4 p-4">
{threads.map((thread) => (
<CommentThread
@ -106,7 +106,7 @@ export function CommentPanel({
</div>
)}
<div className="p-3">
<div className={cn("p-3", isMobile && "fixed bottom-0 left-0 right-0 z-50 bg-card border-t")}>
<CommentComposer
members={members}
membersLoading={membersLoading}

View file

@ -1,6 +1,13 @@
"use client";
import { MessageSquare } from "lucide-react";
import {
Drawer,
DrawerContent,
DrawerHandle,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { CommentPanelContainer } from "../comment-panel-container/comment-panel-container";
@ -15,22 +22,39 @@ export function CommentSheet({
}: CommentSheetProps) {
const isBottomSheet = side === "bottom";
// Use Drawer for mobile (bottom), Sheet for medium screens (right)
if (isBottomSheet) {
return (
<Drawer open={isOpen} onOpenChange={onOpenChange} shouldScaleBackground={false}>
<DrawerContent className="h-[85vh] max-h-[85vh] z-80" overlayClassName="z-80">
<DrawerHandle />
<DrawerHeader className="px-4 pb-3 pt-2">
<DrawerTitle className="flex items-center gap-2 text-base font-semibold">
<MessageSquare className="size-5" />
Comments
{commentCount > 0 && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{commentCount}
</span>
)}
</DrawerTitle>
</DrawerHeader>
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
<CommentPanelContainer messageId={messageId} isOpen={true} variant="mobile" />
</div>
</DrawerContent>
</Drawer>
);
}
// Use Sheet for medium screens (right side)
return (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetContent
side={side}
className={cn(
"flex flex-col gap-0 overflow-hidden p-0",
isBottomSheet ? "h-[85vh] max-h-[85vh] rounded-t-xl" : "h-full w-full max-w-md"
)}
className={cn("flex flex-col gap-0 overflow-hidden p-0 h-full w-full max-w-md")}
>
{/* Drag handle indicator - only for bottom sheet */}
{isBottomSheet && (
<div className="flex justify-center pt-3 pb-1">
<div className="h-1 w-10 rounded-full bg-muted-foreground/30" />
</div>
)}
<SheetHeader className={cn("flex-shrink-0 border-b px-4", isBottomSheet ? "pb-3" : "py-4")}>
<SheetHeader className="flex-shrink-0 px-4 py-4">
<SheetTitle className="flex items-center gap-2 text-base font-semibold">
<MessageSquare className="size-5" />
Comments

View file

@ -179,7 +179,7 @@ export function DashboardBreadcrumb() {
const breadcrumbs = generateBreadcrumbs(pathname);
if (breadcrumbs.length <= 1) {
if (breadcrumbs.length === 0) {
return null; // Don't show breadcrumbs for root dashboard
}

View file

@ -2,7 +2,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { LogOut, Logs, SquareLibrary, Trash2 } from "lucide-react";
import { Inbox, LogOut, SquareLibrary, Trash2 } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
@ -19,6 +19,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useInbox } from "@/hooks/use-inbox";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence";
import { cleanupElectric } from "@/lib/electric/client";
@ -29,6 +30,7 @@ import { CreateSearchSpaceDialog } from "../ui/dialogs";
import { LayoutShell } from "../ui/shell";
import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar";
import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar";
import { InboxSidebar } from "../ui/sidebar/InboxSidebar";
interface LayoutDataProviderProps {
searchSpaceId: string;
@ -59,8 +61,8 @@ export function LayoutDataProvider({
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
: null;
// Fetch current search space
const { data: searchSpace } = useQuery({
// Fetch current search space (for caching purposes)
useQuery({
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId),
queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }),
enabled: !!searchSpaceId,
@ -77,9 +79,25 @@ export function LayoutDataProvider({
const [isAllSharedChatsSidebarOpen, setIsAllSharedChatsSidebarOpen] = useState(false);
const [isAllPrivateChatsSidebarOpen, setIsAllPrivateChatsSidebarOpen] = useState(false);
// Inbox sidebar state
const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false);
// Search space dialog state
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
// Inbox hook
const userId = user?.id ? String(user.id) : null;
const {
inboxItems,
unreadCount,
loading: inboxLoading,
loadingMore: inboxLoadingMore,
hasMore: inboxHasMore,
loadMore: inboxLoadMore,
markAsRead,
markAllAsRead,
} = useInbox(userId, Number(searchSpaceId) || null, null);
// Delete dialogs state
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
@ -150,13 +168,14 @@ export function LayoutDataProvider({
isActive: pathname?.includes("/documents"),
},
{
title: "Logs",
url: `/dashboard/${searchSpaceId}/logs`,
icon: Logs,
isActive: pathname?.includes("/logs"),
title: "Inbox",
url: "#inbox", // Special URL to indicate this is handled differently
icon: Inbox,
isActive: isInboxSidebarOpen,
badge: unreadCount > 0 ? (unreadCount > 99 ? "99+" : unreadCount) : undefined,
},
],
[searchSpaceId, pathname]
[searchSpaceId, pathname, isInboxSidebarOpen, unreadCount]
);
// Handlers
@ -248,6 +267,11 @@ export function LayoutDataProvider({
const handleNavItemClick = useCallback(
(item: NavItem) => {
// Handle inbox specially - open sidebar instead of navigating
if (item.url === "#inbox") {
setIsInboxSidebarOpen(true);
return;
}
router.push(item.url);
},
[router]
@ -517,6 +541,20 @@ export function LayoutDataProvider({
searchSpaceId={searchSpaceId}
/>
{/* Inbox Sidebar */}
<InboxSidebar
open={isInboxSidebarOpen}
onOpenChange={setIsInboxSidebarOpen}
inboxItems={inboxItems}
unreadCount={unreadCount}
loading={inboxLoading}
loadingMore={inboxLoadingMore}
hasMore={inboxHasMore}
loadMore={inboxLoadMore}
markAsRead={markAsRead}
markAllAsRead={markAllAsRead}
/>
{/* Create Search Space Dialog */}
<CreateSearchSpaceDialog
open={isCreateSearchSpaceDialogOpen}

View file

@ -4,7 +4,6 @@ import { useAtomValue } from "jotai";
import { usePathname } from "next/navigation";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { ChatShareButton } from "@/components/new-chat/chat-share-button";
import { NotificationButton } from "@/components/notifications/NotificationButton";
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
interface HeaderProps {
@ -55,8 +54,6 @@ export function Header({ breadcrumb, mobileMenuTrigger }: HeaderProps) {
{/* Right side - Actions */}
<div className="flex items-center gap-4">
{/* Notifications */}
<NotificationButton />
{/* Share button - only show on chat pages when thread exists */}
{hasThread && (
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />

View file

@ -28,6 +28,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import {
@ -237,20 +238,9 @@ export function AllPrivateChatsSidebar({
aria-label={t("chats") || "Private Chats"}
>
<div className="shrink-0 p-4 pb-2 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<User className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">{t("chats") || "Private Chats"}</h2>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => onOpenChange(false)}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
<div className="flex items-center gap-2">
<User className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">{t("chats") || "Private Chats"}</h2>
</div>
<div className="relative">
@ -277,32 +267,38 @@ export function AllPrivateChatsSidebar({
</div>
{!isSearchMode && (
<div className="shrink-0 flex border-b mx-4">
<button
type="button"
onClick={() => setShowArchived(false)}
className={cn(
"flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
!showArchived
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
)}
>
Active ({activeCount})
</button>
<button
type="button"
onClick={() => setShowArchived(true)}
className={cn(
"flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
showArchived
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
)}
>
Archived ({archivedCount})
</button>
</div>
<Tabs
value={showArchived ? "archived" : "active"}
onValueChange={(value) => setShowArchived(value === "archived")}
className="shrink-0 mx-4"
>
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
<TabsTrigger
value="active"
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
<MessageCircleMore className="h-4 w-4" />
<span>Active</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
{activeCount}
</span>
</span>
</TabsTrigger>
<TabsTrigger
value="archived"
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
<ArchiveIcon className="h-4 w-4" />
<span>Archived</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
{archivedCount}
</span>
</span>
</TabsTrigger>
</TabsList>
</Tabs>
)}
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
@ -371,7 +367,7 @@ export function AllPrivateChatsSidebar({
{isDeleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<MoreHorizontal className="h-3.5 w-3.5" />
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="sr-only">{t("more_options") || "More options"}</span>
</Button>

View file

@ -28,6 +28,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import {
@ -237,20 +238,9 @@ export function AllSharedChatsSidebar({
aria-label={t("shared_chats") || "Shared Chats"}
>
<div className="shrink-0 p-4 pb-2 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">{t("shared_chats") || "Shared Chats"}</h2>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => onOpenChange(false)}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
<div className="flex items-center gap-2">
<Users className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">{t("shared_chats") || "Shared Chats"}</h2>
</div>
<div className="relative">
@ -277,32 +267,38 @@ export function AllSharedChatsSidebar({
</div>
{!isSearchMode && (
<div className="shrink-0 flex border-b mx-4">
<button
type="button"
onClick={() => setShowArchived(false)}
className={cn(
"flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
!showArchived
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
)}
>
Active ({activeCount})
</button>
<button
type="button"
onClick={() => setShowArchived(true)}
className={cn(
"flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
showArchived
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
)}
>
Archived ({archivedCount})
</button>
</div>
<Tabs
value={showArchived ? "archived" : "active"}
onValueChange={(value) => setShowArchived(value === "archived")}
className="shrink-0 mx-4"
>
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
<TabsTrigger
value="active"
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
<MessageCircleMore className="h-4 w-4" />
<span>Active</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
{activeCount}
</span>
</span>
</TabsTrigger>
<TabsTrigger
value="archived"
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
<ArchiveIcon className="h-4 w-4" />
<span>Archived</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
{archivedCount}
</span>
</span>
</TabsTrigger>
</TabsList>
</Tabs>
)}
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
@ -371,7 +367,7 @@ export function AllSharedChatsSidebar({
{isDeleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<MoreHorizontal className="h-3.5 w-3.5" />
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="sr-only">{t("more_options") || "More options"}</span>
</Button>

View file

@ -39,11 +39,11 @@ export function ChatListItem({ name, isActive, onClick, onDelete }: ChatListItem
</button>
{/* Actions dropdown */}
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/item:opacity-100 transition-opacity">
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-100 md:opacity-0 md:group-hover/item:opacity-100 transition-opacity">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<MoreHorizontal className="h-3.5 w-3.5" />
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
<span className="sr-only">{t("more_options")}</span>
</Button>
</DropdownMenuTrigger>

View file

@ -0,0 +1,854 @@
"use client";
import {
AlertCircle,
AtSign,
BellDot,
Check,
CheckCheck,
CheckCircle2,
History,
Inbox,
LayoutGrid,
ListFilter,
Search,
X,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerContent,
DrawerHandle,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import {
type ConnectorIndexingMetadata,
isConnectorIndexingMetadata,
isNewMentionMetadata,
type NewMentionMetadata,
} from "@/contracts/types/inbox.types";
import type { InboxItem } from "@/hooks/use-inbox";
import { useMediaQuery } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils";
/**
* Get initials from name or email for avatar fallback
*/
function getInitials(name: string | null | undefined, email: string | null | undefined): string {
if (name) {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
if (email) {
const localPart = email.split("@")[0];
return localPart.slice(0, 2).toUpperCase();
}
return "U";
}
/**
* Get display name for connector type
*/
function getConnectorTypeDisplayName(connectorType: string): string {
const displayNames: Record<string, string> = {
GITHUB_CONNECTOR: "GitHub",
GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
GOOGLE_GMAIL_CONNECTOR: "Gmail",
GOOGLE_DRIVE_CONNECTOR: "Google Drive",
LINEAR_CONNECTOR: "Linear",
NOTION_CONNECTOR: "Notion",
SLACK_CONNECTOR: "Slack",
TEAMS_CONNECTOR: "Microsoft Teams",
DISCORD_CONNECTOR: "Discord",
JIRA_CONNECTOR: "Jira",
CONFLUENCE_CONNECTOR: "Confluence",
BOOKSTACK_CONNECTOR: "BookStack",
CLICKUP_CONNECTOR: "ClickUp",
AIRTABLE_CONNECTOR: "Airtable",
LUMA_CONNECTOR: "Luma",
ELASTICSEARCH_CONNECTOR: "Elasticsearch",
WEBCRAWLER_CONNECTOR: "Web Crawler",
YOUTUBE_CONNECTOR: "YouTube",
CIRCLEBACK_CONNECTOR: "Circleback",
MCP_CONNECTOR: "MCP",
TAVILY_API: "Tavily",
SEARXNG_API: "SearXNG",
LINKUP_API: "Linkup",
BAIDU_SEARCH_API: "Baidu",
};
return (
displayNames[connectorType] ||
connectorType
.replace(/_/g, " ")
.replace(/CONNECTOR|API/gi, "")
.trim()
);
}
type InboxTab = "mentions" | "status";
type InboxFilter = "all" | "unread";
interface InboxSidebarProps {
open: boolean;
onOpenChange: (open: boolean) => void;
inboxItems: InboxItem[];
unreadCount: number;
loading: boolean;
loadingMore?: boolean;
hasMore?: boolean;
loadMore?: () => void;
markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>;
onCloseMobileSidebar?: () => void;
}
export function InboxSidebar({
open,
onOpenChange,
inboxItems,
unreadCount,
loading,
loadingMore = false,
hasMore = false,
loadMore,
markAsRead,
markAllAsRead,
onCloseMobileSidebar,
}: InboxSidebarProps) {
const t = useTranslations("sidebar");
const router = useRouter();
const isMobile = !useMediaQuery("(min-width: 640px)");
const [searchQuery, setSearchQuery] = useState("");
const [activeTab, setActiveTab] = useState<InboxTab>("mentions");
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
const [selectedConnector, setSelectedConnector] = useState<string | null>(null);
const [mounted, setMounted] = useState(false);
// Dropdown state for filter menu (desktop only)
const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null);
// Drawer state for filter menu (mobile only)
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
// Prefetch trigger ref - placed on item near the end
const prefetchTriggerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
onOpenChange(false);
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange]);
useEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [open]);
// Reset connector filter when switching away from status tab
useEffect(() => {
if (activeTab !== "status") {
setSelectedConnector(null);
}
}, [activeTab]);
// Split items by type
const mentionItems = useMemo(
() => inboxItems.filter((item) => item.type === "new_mention"),
[inboxItems]
);
const statusItems = useMemo(
() =>
inboxItems.filter(
(item) => item.type === "connector_indexing" || item.type === "document_processing"
),
[inboxItems]
);
// Get unique connector types from status items for filtering
const uniqueConnectorTypes = useMemo(() => {
const connectorTypes = new Set<string>();
statusItems
.filter((item) => item.type === "connector_indexing")
.forEach((item) => {
// Use type guard for safe metadata access
if (isConnectorIndexingMetadata(item.metadata)) {
connectorTypes.add(item.metadata.connector_type);
}
});
return Array.from(connectorTypes).map((type) => ({
type,
displayName: getConnectorTypeDisplayName(type),
}));
}, [statusItems]);
// Get items for current tab
const currentTabItems = activeTab === "mentions" ? mentionItems : statusItems;
// Filter items based on filter type, connector filter, and search query
const filteredItems = useMemo(() => {
let items = currentTabItems;
// Apply read/unread filter
if (activeFilter === "unread") {
items = items.filter((item) => !item.read);
}
// Apply connector filter (only for status tab)
if (activeTab === "status" && selectedConnector) {
items = items.filter((item) => {
if (item.type === "connector_indexing") {
// Use type guard for safe metadata access
if (isConnectorIndexingMetadata(item.metadata)) {
return item.metadata.connector_type === selectedConnector;
}
return false;
}
return false; // Hide document_processing when a specific connector is selected
});
}
// Apply search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
items = items.filter(
(item) =>
item.title.toLowerCase().includes(query) || item.message.toLowerCase().includes(query)
);
}
return items;
}, [currentTabItems, activeFilter, activeTab, selectedConnector, searchQuery]);
// Intersection Observer for infinite scroll with prefetching
// Only active when not searching (search results are client-side filtered)
useEffect(() => {
if (!loadMore || !hasMore || loadingMore || !open || searchQuery.trim()) return;
const observer = new IntersectionObserver(
(entries) => {
// When trigger element is visible, load more
if (entries[0]?.isIntersecting) {
loadMore();
}
},
{
root: null, // viewport
rootMargin: "100px", // Start loading 100px before visible
threshold: 0,
}
);
if (prefetchTriggerRef.current) {
observer.observe(prefetchTriggerRef.current);
}
return () => observer.disconnect();
}, [loadMore, hasMore, loadingMore, open, searchQuery, filteredItems.length]);
// Count unread items per tab
const unreadMentionsCount = useMemo(() => {
return mentionItems.filter((item) => !item.read).length;
}, [mentionItems]);
const unreadStatusCount = useMemo(() => {
return statusItems.filter((item) => !item.read).length;
}, [statusItems]);
const handleItemClick = useCallback(
async (item: InboxItem) => {
if (!item.read) {
setMarkingAsReadId(item.id);
await markAsRead(item.id);
setMarkingAsReadId(null);
}
if (item.type === "new_mention") {
// Use type guard for safe metadata access
if (isNewMentionMetadata(item.metadata)) {
const searchSpaceId = item.search_space_id;
const threadId = item.metadata.thread_id;
const commentId = item.metadata.comment_id;
if (searchSpaceId && threadId) {
const url = commentId
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
onOpenChange(false);
onCloseMobileSidebar?.();
router.push(url);
}
}
}
},
[markAsRead, router, onOpenChange, onCloseMobileSidebar]
);
const handleMarkAllAsRead = useCallback(async () => {
await markAllAsRead();
}, [markAllAsRead]);
const handleClearSearch = useCallback(() => {
setSearchQuery("");
}, []);
const formatTime = (dateString: 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`;
if (diffHours < 24) return `${diffHours}h`;
if (diffDays < 7) return `${diffDays}d`;
return `${Math.floor(diffDays / 7)}w`;
} catch {
return "now";
}
};
const getStatusIcon = (item: InboxItem) => {
// For mentions, show the author's avatar with initials fallback
if (item.type === "new_mention") {
// Use type guard for safe metadata access
if (isNewMentionMetadata(item.metadata)) {
const authorName = item.metadata.author_name;
const avatarUrl = item.metadata.author_avatar_url;
const authorEmail = item.metadata.author_email;
return (
<Avatar className="h-8 w-8">
{avatarUrl && <AvatarImage src={avatarUrl} alt={authorName || "User"} />}
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
{getInitials(authorName, authorEmail)}
</AvatarFallback>
</Avatar>
);
}
// Fallback for invalid metadata
return (
<Avatar className="h-8 w-8">
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
{getInitials(null, null)}
</AvatarFallback>
</Avatar>
);
}
// For status items (connector/document), show status icons
// Safely access status from metadata
const metadata = item.metadata as Record<string, unknown>;
const status = typeof metadata?.status === "string" ? metadata.status : undefined;
switch (status) {
case "in_progress":
return (
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-muted">
<Spinner size="sm" className="text-foreground" />
</div>
);
case "completed":
return (
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-green-500/10">
<CheckCircle2 className="h-4 w-4 text-green-500" />
</div>
);
case "failed":
return (
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-red-500/10">
<AlertCircle className="h-4 w-4 text-red-500" />
</div>
);
default:
return (
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-muted">
<History className="h-4 w-4 text-muted-foreground" />
</div>
);
}
};
const getEmptyStateMessage = () => {
if (activeTab === "mentions") {
return {
title: t("no_mentions") || "No mentions",
hint: t("no_mentions_hint") || "You'll see mentions from others here",
};
}
return {
title: t("no_status_updates") || "No status updates",
hint: t("no_status_updates_hint") || "Document and connector updates will appear here",
};
};
if (!mounted) return null;
return createPortal(
<AnimatePresence>
{open && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-70 bg-black/50"
onClick={() => onOpenChange(false)}
aria-hidden="true"
/>
<motion.div
initial={{ x: "-100%" }}
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
className="fixed inset-y-0 left-0 z-70 w-90 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
role="dialog"
aria-modal="true"
aria-label={t("inbox") || "Inbox"}
>
<div className="shrink-0 p-4 pb-2 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Inbox className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">{t("inbox") || "Inbox"}</h2>
</div>
<div className="flex items-center gap-1">
{/* Mobile: Button that opens bottom drawer */}
{isMobile ? (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => setFilterDrawerOpen(true)}
>
<ListFilter className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("filter") || "Filter"}</span>
</Button>
</TooltipTrigger>
<TooltipContent className="z-80">{t("filter") || "Filter"}</TooltipContent>
</Tooltip>
<Drawer
open={filterDrawerOpen}
onOpenChange={setFilterDrawerOpen}
shouldScaleBackground={false}
>
<DrawerContent className="max-h-[70vh] z-80" overlayClassName="z-80">
<DrawerHandle />
<DrawerHeader className="px-4 pb-3 pt-2">
<DrawerTitle className="flex items-center gap-2 text-base font-semibold">
<ListFilter className="size-5" />
{t("filter") || "Filter"}
</DrawerTitle>
</DrawerHeader>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Filter section */}
<div className="space-y-2">
<p className="text-xs text-muted-foreground/80 font-medium px-1">
{t("filter") || "Filter"}
</p>
<div className="space-y-1">
<button
type="button"
onClick={() => {
setActiveFilter("all");
setFilterDrawerOpen(false);
}}
className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
activeFilter === "all"
? "bg-primary/10 text-primary"
: "hover:bg-muted"
)}
>
<span className="flex items-center gap-2">
<Inbox className="h-4 w-4" />
<span>{t("all") || "All"}</span>
</span>
{activeFilter === "all" && <Check className="h-4 w-4" />}
</button>
<button
type="button"
onClick={() => {
setActiveFilter("unread");
setFilterDrawerOpen(false);
}}
className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
activeFilter === "unread"
? "bg-primary/10 text-primary"
: "hover:bg-muted"
)}
>
<span className="flex items-center gap-2">
<BellDot className="h-4 w-4" />
<span>{t("unread") || "Unread"}</span>
</span>
{activeFilter === "unread" && <Check className="h-4 w-4" />}
</button>
</div>
</div>
{/* Connectors section - only for status tab */}
{activeTab === "status" && uniqueConnectorTypes.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground/80 font-medium px-1">
{t("connectors") || "Connectors"}
</p>
<div className="space-y-1">
<button
type="button"
onClick={() => {
setSelectedConnector(null);
setFilterDrawerOpen(false);
}}
className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
selectedConnector === null
? "bg-primary/10 text-primary"
: "hover:bg-muted"
)}
>
<span className="flex items-center gap-2">
<LayoutGrid className="h-4 w-4" />
<span>{t("all_connectors") || "All connectors"}</span>
</span>
{selectedConnector === null && <Check className="h-4 w-4" />}
</button>
{uniqueConnectorTypes.map((connector) => (
<button
key={connector.type}
type="button"
onClick={() => {
setSelectedConnector(connector.type);
setFilterDrawerOpen(false);
}}
className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
selectedConnector === connector.type
? "bg-primary/10 text-primary"
: "hover:bg-muted"
)}
>
<span className="flex items-center gap-2">
{getConnectorIcon(connector.type, "h-4 w-4")}
<span>{connector.displayName}</span>
</span>
{selectedConnector === connector.type && (
<Check className="h-4 w-4" />
)}
</button>
))}
</div>
</div>
)}
</div>
</DrawerContent>
</Drawer>
</>
) : (
/* Desktop: Dropdown menu */
<DropdownMenu
open={openDropdown === "filter"}
onOpenChange={(isOpen) => setOpenDropdown(isOpen ? "filter" : null)}
>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
<ListFilter className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("filter") || "Filter"}</span>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent className="z-80">{t("filter") || "Filter"}</TooltipContent>
</Tooltip>
<DropdownMenuContent
align="end"
className={cn("z-80", activeTab === "status" ? "w-52" : "w-44")}
>
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal">
{t("filter") || "Filter"}
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => setActiveFilter("all")}
className="flex items-center justify-between"
>
<span className="flex items-center gap-2">
<Inbox className="h-4 w-4" />
<span>{t("all") || "All"}</span>
</span>
{activeFilter === "all" && <Check className="h-4 w-4" />}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setActiveFilter("unread")}
className="flex items-center justify-between"
>
<span className="flex items-center gap-2">
<BellDot className="h-4 w-4" />
<span>{t("unread") || "Unread"}</span>
</span>
{activeFilter === "unread" && <Check className="h-4 w-4" />}
</DropdownMenuItem>
{activeTab === "status" && uniqueConnectorTypes.length > 0 && (
<>
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal mt-2">
{t("connectors") || "Connectors"}
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => setSelectedConnector(null)}
className="flex items-center justify-between"
>
<span className="flex items-center gap-2">
<LayoutGrid className="h-4 w-4" />
<span>{t("all_connectors") || "All connectors"}</span>
</span>
{selectedConnector === null && <Check className="h-4 w-4" />}
</DropdownMenuItem>
{uniqueConnectorTypes.map((connector) => (
<DropdownMenuItem
key={connector.type}
onClick={() => setSelectedConnector(connector.type)}
className="flex items-center justify-between"
>
<span className="flex items-center gap-2">
{getConnectorIcon(connector.type, "h-4 w-4")}
<span>{connector.displayName}</span>
</span>
{selectedConnector === connector.type && (
<Check className="h-4 w-4" />
)}
</DropdownMenuItem>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={handleMarkAllAsRead}
disabled={unreadCount === 0}
>
<CheckCheck className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("mark_all_read") || "Mark all as read"}</span>
</Button>
</TooltipTrigger>
<TooltipContent className="z-80">
{t("mark_all_read") || "Mark all as read"}
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder={t("search_inbox") || "Search inbox"}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-8 h-9"
/>
{searchQuery && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
onClick={handleClearSearch}
>
<X className="h-3.5 w-3.5" />
<span className="sr-only">{t("clear_search") || "Clear search"}</span>
</Button>
)}
</div>
</div>
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as InboxTab)}
className="shrink-0 mx-4"
>
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
<TabsTrigger
value="mentions"
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
<AtSign className="h-4 w-4" />
<span>{t("mentions") || "Mentions"}</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
{unreadMentionsCount}
</span>
</span>
</TabsTrigger>
<TabsTrigger
value="status"
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
<History className="h-4 w-4" />
<span>{t("status") || "Status"}</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
{unreadStatusCount}
</span>
</span>
</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
{loading ? (
<div className="flex items-center justify-center py-8">
<Spinner size="md" className="text-muted-foreground" />
</div>
) : filteredItems.length > 0 ? (
<div className="space-y-2">
{filteredItems.map((item, index) => {
const isMarkingAsRead = markingAsReadId === item.id;
// Place prefetch trigger on 5th item from end (only if not searching)
const isPrefetchTrigger =
!searchQuery && hasMore && index === filteredItems.length - 5;
return (
<div
key={item.id}
ref={isPrefetchTrigger ? prefetchTriggerRef : undefined}
className={cn(
"group flex items-center gap-3 rounded-lg px-3 py-3 text-sm h-[80px] overflow-hidden",
"hover:bg-accent hover:text-accent-foreground",
"transition-colors cursor-pointer",
isMarkingAsRead && "opacity-50 pointer-events-none"
)}
>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handleItemClick(item)}
disabled={isMarkingAsRead}
className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden"
>
<div className="shrink-0">{getStatusIcon(item)}</div>
<div className="flex-1 min-w-0 overflow-hidden">
<p
className={cn(
"text-xs font-medium line-clamp-2",
!item.read && "font-semibold"
)}
>
{item.title}
</p>
<p className="text-[11px] text-muted-foreground line-clamp-2 mt-0.5">
{convertRenderedToDisplay(item.message)}
</p>
</div>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-w-[250px]">
<p className="font-medium">{item.title}</p>
<p className="text-muted-foreground mt-1">
{convertRenderedToDisplay(item.message)}
</p>
</TooltipContent>
</Tooltip>
{/* Time and unread dot - fixed width to prevent content shift */}
<div className="flex items-center justify-end gap-1.5 shrink-0 w-10">
<span className="text-[10px] text-muted-foreground">
{formatTime(item.created_at)}
</span>
{!item.read && (
<span className="h-2 w-2 rounded-full bg-blue-500 shrink-0" />
)}
</div>
</div>
);
})}
{/* Fallback trigger at the very end if less than 5 items and not searching */}
{!searchQuery && filteredItems.length < 5 && hasMore && (
<div ref={prefetchTriggerRef} className="h-1" />
)}
</div>
) : searchQuery ? (
<div className="text-center py-8">
<Search className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
{t("no_results_found") || "No results found"}
</p>
<p className="text-xs text-muted-foreground/70 mt-1">
{t("try_different_search") || "Try a different search term"}
</p>
</div>
) : (
<div className="text-center py-8">
{activeTab === "mentions" ? (
<AtSign className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
) : (
<History className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
)}
<p className="text-sm text-muted-foreground">{getEmptyStateMessage().title}</p>
<p className="text-xs text-muted-foreground/70 mt-1">
{getEmptyStateMessage().hint}
</p>
</div>
)}
</div>
</motion.div>
</>
)}
</AnimatePresence>,
document.body
);
}

View file

@ -30,7 +30,7 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
type="button"
onClick={() => onItemClick?.(item)}
className={cn(
"flex h-10 w-10 items-center justify-center rounded-md transition-colors",
"relative flex h-10 w-10 items-center justify-center rounded-md transition-colors",
"hover:bg-accent hover:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
item.isActive && "bg-accent text-accent-foreground"
@ -38,6 +38,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
{...joyrideAttr}
>
<Icon className="h-4 w-4" />
{item.badge && (
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-medium">
{item.badge}
</span>
)}
<span className="sr-only">{item.title}</span>
</button>
</TooltipTrigger>
@ -64,7 +69,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
>
<Icon className="h-4 w-4 shrink-0" />
<span className="flex-1 truncate">{item.title}</span>
{item.badge && <span className="text-xs text-muted-foreground">{item.badge}</span>}
{item.badge && (
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-red-500 text-white text-xs font-medium">
{item.badge}
</span>
)}
</button>
);
})}

View file

@ -119,11 +119,6 @@ export function Sidebar({
)}
</div>
{/* Platform navigation */}
{navItems.length > 0 && (
<NavSection items={navItems} onItemClick={onNavItemClick} isCollapsed={isCollapsed} />
)}
{/* Scrollable content */}
<ScrollArea className="flex-1">
{isCollapsed ? (
@ -235,7 +230,12 @@ export function Sidebar({
</ScrollArea>
{/* Footer */}
<div className="mt-auto">
<div className="mt-auto border-t">
{/* Platform navigation */}
{navItems.length > 0 && (
<NavSection items={navItems} onItemClick={onNavItemClick} isCollapsed={isCollapsed} />
)}
{pageUsage && !isCollapsed && (
<PageUsageDisplay pagesUsed={pageUsage.pagesUsed} pagesLimit={pageUsage.pagesLimit} />
)}

View file

@ -1,6 +1,7 @@
"use client";
import { ChevronsUpDown, Settings, Users } from "lucide-react";
import { ChevronsUpDown, ScrollText, Settings, Users } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
@ -29,6 +30,9 @@ export function SidebarHeader({
className,
}: SidebarHeaderProps) {
const t = useTranslations("sidebar");
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
return (
<div className={cn("flex shrink-0 items-center", className)}>
@ -52,6 +56,10 @@ export function SidebarHeader({
<Users className="mr-2 h-4 w-4" />
{t("manage_members")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push(`/dashboard/${searchSpaceId}/logs`)}>
<ScrollText className="mr-2 h-4 w-4" />
{t("logs")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onSettings}>
<Settings className="mr-2 h-4 w-4" />

View file

@ -37,7 +37,7 @@ export function SidebarSection({
{/* Action button - visible on hover (always visible on mobile) */}
{action && (
<div className="shrink-0 opacity-0 group-hover/section:opacity-100 transition-opacity pr-1 flex items-center">
<div className="shrink-0 opacity-100 md:opacity-0 md:group-hover/section:opacity-100 transition-opacity pr-1 flex items-center">
{action}
</div>
)}

View file

@ -1,6 +1,6 @@
"use client";
import { ChevronUp, Laptop, Languages, LogOut, Moon, Settings, Sun } from "lucide-react";
import { ChevronUp, Languages, Laptop, LogOut, Moon, Settings, Sun } from "lucide-react";
import { useTranslations } from "next-intl";
import {
DropdownMenu,

View file

@ -1,6 +1,7 @@
export { AllPrivateChatsSidebar } from "./AllPrivateChatsSidebar";
export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar";
export { ChatListItem } from "./ChatListItem";
export { InboxSidebar } from "./InboxSidebar";
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
export { NavSection } from "./NavSection";
export { PageUsageDisplay } from "./PageUsageDisplay";

View file

@ -2,7 +2,7 @@
import { useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import { Loader2, User, Users } from "lucide-react";
import { User, Users } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom";
@ -45,7 +45,6 @@ const visibilityOptions: {
export function ChatShareButton({ thread, onVisibilityChange, className }: ChatShareButtonProps) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
// Use Jotai atom for visibility (single source of truth)
const currentThreadState = useAtomValue(currentThreadAtom);
@ -62,7 +61,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
return;
}
setIsUpdating(true);
// Update Jotai atom immediately for instant UI feedback
setThreadVisibility(newVisibility);
@ -84,8 +82,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
// Revert Jotai state on error
setThreadVisibility(thread.visibility ?? "PRIVATE");
toast.error("Failed to update sharing settings");
} finally {
setIsUpdating(false);
}
},
[thread, currentVisibility, onVisibilityChange, queryClient, setThreadVisibility]
@ -128,16 +124,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="p-1.5 space-y-1">
{/* Updating overlay */}
{isUpdating && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-lg">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Updating</span>
</div>
</div>
)}
{visibilityOptions.map((option) => {
const isSelected = currentVisibility === option.value;
const Icon = option.icon;
@ -147,7 +133,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
type="button"
key={option.value}
onClick={() => handleVisibilityChange(option.value)}
disabled={isUpdating}
className={cn(
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
"hover:bg-accent/50 cursor-pointer",

View file

@ -72,7 +72,6 @@ interface ModelSelectorProps {
export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProps) {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [isSwitching, setIsSwitching] = useState(false);
// Fetch configs
const { data: userConfigs, isLoading: userConfigsLoading } = useAtomValue(newLLMConfigsAtom);
@ -124,6 +123,11 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
);
}, [userConfigs, searchQuery]);
// Total model count for conditional search display
const totalModels = useMemo(() => {
return (globalConfigs?.length ?? 0) + (userConfigs?.length ?? 0);
}, [globalConfigs, userConfigs]);
const handleSelectConfig = useCallback(
async (config: NewLLMConfigPublic | GlobalNewLLMConfig) => {
// If already selected, just close
@ -137,7 +141,6 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
return;
}
setIsSwitching(true);
try {
await updatePreferences({
search_space_id: Number(searchSpaceId),
@ -150,8 +153,6 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
} catch (error) {
console.error("Failed to switch model:", error);
toast.error("Failed to switch model");
} finally {
setIsSwitching(false);
}
},
[currentConfig, searchSpaceId, updatePreferences]
@ -216,26 +217,17 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
shouldFilter={false}
className="rounded-lg relative [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
>
{/* Switching overlay */}
{isSwitching && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-lg">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Switching model...</span>
</div>
{totalModels > 3 && (
<div className="flex items-center gap-1 md:gap-2 px-2 md:px-3 py-1.5 md:py-2">
<CommandInput
placeholder="Search models"
value={searchQuery}
onValueChange={setSearchQuery}
className="h-7 md:h-8 text-xs md:text-sm border-0 bg-transparent focus:ring-0 placeholder:text-muted-foreground/60"
/>
</div>
)}
<div className="flex items-center gap-1 md:gap-2 px-2 md:px-3 py-1.5 md:py-2">
<CommandInput
placeholder="Search models"
value={searchQuery}
onValueChange={setSearchQuery}
className="h-7 md:h-8 text-xs md:text-sm border-0 bg-transparent focus:ring-0 placeholder:text-muted-foreground/60"
disabled={isSwitching}
/>
</div>
<CommandList className="max-h-[300px] md:max-h-[400px] overflow-y-auto">
<CommandEmpty className="py-8 text-center">
<div className="flex flex-col items-center gap-2">
@ -260,7 +252,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
value={`global-${config.id}`}
onSelect={() => handleSelectConfig(config)}
className={cn(
"mx-2 rounded-lg mb-1 cursor-pointer transition-all",
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
"hover:bg-accent/50",
isSelected && "bg-accent/80"
)}
@ -291,7 +283,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0 rounded-md hover:bg-muted"
className="size-7 shrink-0 rounded-md hover:bg-muted opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => handleEditConfig(e, config, true)}
>
<Edit3 className="size-3.5 text-muted-foreground" />
@ -322,7 +314,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
value={`user-${config.id}`}
onSelect={() => handleSelectConfig(config)}
className={cn(
"mx-2 rounded-lg mb-1 cursor-pointer transition-all",
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
"hover:bg-accent/50",
isSelected && "bg-accent/80"
)}
@ -353,7 +345,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0 rounded-md hover:bg-muted"
className="size-7 shrink-0 rounded-md hover:bg-muted opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => handleEditConfig(e, config, false)}
>
<Edit3 className="size-3.5 text-muted-foreground" />

View file

@ -1,103 +0,0 @@
"use client";
import { useAtomValue } from "jotai";
import { Bell } from "lucide-react";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useNotifications, type NotificationTypeEnum } from "@/hooks/use-notifications";
import { cn } from "@/lib/utils";
import { NotificationPopup } from "./NotificationPopup";
const NOTIFICATION_FILTER_STORAGE_KEY = "surfsense_notification_filter";
export function NotificationButton() {
const [open, setOpen] = useState(false);
const { data: user } = useAtomValue(currentUserAtom);
const params = useParams();
// Filter state - null means show all, otherwise filter by type
const [activeFilter, setActiveFilter] = useState<NotificationTypeEnum | null>(null);
// Load filter from localStorage on mount
useEffect(() => {
try {
const stored = localStorage.getItem(NOTIFICATION_FILTER_STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (
parsed === null ||
["new_mention", "connector_indexing", "document_processing"].includes(parsed)
) {
setActiveFilter(parsed);
}
}
} catch {
// Ignore localStorage errors
}
}, []);
// Handle filter toggle - clicking same pill again shows all
const handleFilterChange = useCallback((filter: NotificationTypeEnum | null) => {
setActiveFilter((current) => {
const newFilter = current === filter ? null : filter;
try {
localStorage.setItem(NOTIFICATION_FILTER_STORAGE_KEY, JSON.stringify(newFilter));
} catch {
// Ignore localStorage errors
}
return newFilter;
});
}, []);
const userId = user?.id ? String(user.id) : null;
// Get searchSpaceId from URL params - the component is rendered within /dashboard/[search_space_id]/
const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null;
const { notifications, unreadCount, loading, markAsRead, markAllAsRead } = useNotifications(
userId,
searchSpaceId,
activeFilter
);
return (
<Popover open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8 relative border-0">
<Bell className="h-4 w-4" />
{unreadCount > 0 && (
<span
className={cn(
"absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-black text-[10px] font-medium text-white dark:bg-zinc-800 dark:text-zinc-50",
unreadCount > 9 && "px-1"
)}
>
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
<span className="sr-only">Notifications</span>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Notifications</TooltipContent>
</Tooltip>
<PopoverContent align="end" className="w-80 p-0">
<NotificationPopup
notifications={notifications}
unreadCount={unreadCount}
loading={loading}
markAsRead={markAsRead}
markAllAsRead={markAllAsRead}
onClose={() => setOpen(false)}
activeFilter={activeFilter}
onFilterChange={handleFilterChange}
/>
</PopoverContent>
</Popover>
);
}

View file

@ -1,246 +0,0 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import {
AlertCircle,
AtSign,
Bell,
Cable,
CheckCheck,
CheckCircle2,
FileText,
Loader2,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import type { Notification, NotificationTypeEnum } from "@/hooks/use-notifications";
import { cn } from "@/lib/utils";
/**
* Filter configuration for notification types
*/
const NOTIFICATION_FILTERS = {
new_mention: { label: "Mentions", icon: AtSign },
connector_indexing: { label: "Connectors", icon: Cable },
document_processing: { label: "Documents", icon: FileText },
} as const;
/**
* Get initials from name or email for avatar fallback
*/
function getInitials(name: string | null | undefined, email: string | null | undefined): string {
if (name) {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
if (email) {
const localPart = email.split("@")[0];
return localPart.slice(0, 2).toUpperCase();
}
return "U";
}
interface NotificationPopupProps {
notifications: Notification[];
unreadCount: number;
loading: boolean;
markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>;
onClose?: () => void;
activeFilter: NotificationTypeEnum | null;
onFilterChange: (filter: NotificationTypeEnum | null) => void;
}
export function NotificationPopup({
notifications,
unreadCount,
loading,
markAsRead,
markAllAsRead,
onClose,
activeFilter,
onFilterChange,
}: NotificationPopupProps) {
const router = useRouter();
const handleMarkAllAsRead = async () => {
await markAllAsRead();
};
const handleNotificationClick = async (notification: Notification) => {
if (!notification.read) {
await markAsRead(notification.id);
}
if (notification.type === "new_mention") {
const metadata = notification.metadata as {
thread_id?: number;
comment_id?: number;
};
const searchSpaceId = notification.search_space_id;
const threadId = metadata?.thread_id;
const commentId = metadata?.comment_id;
if (searchSpaceId && threadId) {
const url = commentId
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
onClose?.();
router.push(url);
}
}
};
const formatTime = (dateString: string) => {
try {
return formatDistanceToNow(new Date(dateString), { addSuffix: true });
} catch {
return "Recently";
}
};
const getStatusIcon = (notification: Notification) => {
// For mentions, show the author's avatar with initials fallback
if (notification.type === "new_mention") {
const metadata = notification.metadata as {
author_name?: string;
author_avatar_url?: string | null;
author_email?: string;
};
const authorName = metadata?.author_name;
const avatarUrl = metadata?.author_avatar_url;
const authorEmail = metadata?.author_email;
return (
<Avatar className="h-6 w-6">
{avatarUrl && <AvatarImage src={avatarUrl} alt={authorName || "User"} />}
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
{getInitials(authorName, authorEmail)}
</AvatarFallback>
</Avatar>
);
}
// For other notification types, show status icons
const status = notification.metadata?.status as string | undefined;
switch (status) {
case "in_progress":
return <Loader2 className="h-4 w-4 text-foreground animate-spin" />;
case "completed":
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
case "failed":
return <AlertCircle className="h-4 w-4 text-red-500" />;
default:
return <Bell className="h-4 w-4 text-muted-foreground" />;
}
};
return (
<div className="flex flex-col w-80 max-w-[calc(100vw-2rem)]">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm">Notifications</h3>
</div>
{unreadCount > 0 && (
<Button variant="ghost" size="sm" onClick={handleMarkAllAsRead} className="h-7 text-xs">
<CheckCheck className="h-3.5 w-3.5 mr-0" />
Mark all read
</Button>
)}
</div>
{/* Filter Pills */}
<div className="flex items-center gap-1.5 px-4 py-2 overflow-x-auto">
{(
Object.entries(NOTIFICATION_FILTERS) as [
NotificationTypeEnum,
(typeof NOTIFICATION_FILTERS)[keyof typeof NOTIFICATION_FILTERS],
][]
).map(([key, { label, icon: Icon }]) => {
const isActive = activeFilter === key;
return (
<button
key={key}
type="button"
onClick={() => onFilterChange(key)}
className={cn(
"inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-medium transition-colors whitespace-nowrap",
"border focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
isActive
? "bg-primary text-primary-foreground border-primary"
: "bg-transparent text-muted-foreground border-border hover:bg-accent hover:text-accent-foreground"
)}
>
<Icon className="h-3 w-3" />
{label}
</button>
);
})}
</div>
{/* Notifications List */}
<ScrollArea className="h-[400px]">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-foreground" />
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 px-4 text-center">
<Bell className="h-8 w-8 text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">No notifications</p>
</div>
) : (
<div className="pt-0 pb-2">
{notifications.map((notification, index) => (
<div key={notification.id}>
<button
type="button"
onClick={() => handleNotificationClick(notification)}
className={cn(
"w-full px-4 py-3 text-left hover:bg-accent transition-colors",
!notification.read && "bg-accent/50"
)}
>
<div className="flex items-start gap-3 overflow-hidden">
<div className="flex-shrink-0 mt-0.5">{getStatusIcon(notification)}</div>
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-start justify-between gap-2 mb-1">
<p
className={cn(
"text-xs font-medium break-all",
!notification.read && "font-semibold"
)}
>
{notification.title}
</p>
</div>
<p className="text-[11px] text-muted-foreground break-all line-clamp-2">
{convertRenderedToDisplay(notification.message)}
</p>
<div className="flex items-center justify-between mt-2">
<span className="text-[10px] text-muted-foreground">
{formatTime(notification.created_at)}
</span>
</div>
</div>
</div>
</button>
{index < notifications.length - 1 && <Separator />}
</div>
))}
</div>
)}
</ScrollArea>
</div>
);
}

View file

@ -551,7 +551,9 @@ export function LLMConfigForm({
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-3 bg-muted/30">
<div className="space-y-0.5">
<FormLabel className="text-xs sm:text-sm font-medium">Enable Citations</FormLabel>
<FormLabel className="text-xs sm:text-sm font-medium">
Enable Citations
</FormLabel>
<FormDescription className="text-[10px] sm:text-xs">
Include [citation:id] references to source documents
</FormDescription>

View file

@ -0,0 +1,115 @@
"use client";
import type * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
function Drawer({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />;
}
Drawer.displayName = "Drawer";
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
);
}
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
function DrawerContent({
className,
children,
overlayClassName,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content> & {
overlayClassName?: string;
}) {
return (
<DrawerPortal>
<DrawerOverlay className={overlayClassName} />
<DrawerPrimitive.Content
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
>
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
}
DrawerContent.displayName = "DrawerContent";
function DrawerHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} {...props} />;
}
DrawerHeader.displayName = "DrawerHeader";
function DrawerFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />;
}
DrawerFooter.displayName = "DrawerFooter";
function DrawerTitle({ className, ...props }: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
);
}
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
function DrawerHandle({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("mx-auto mt-4 h-1.5 w-12 rounded-full bg-muted-foreground/40", className)}
{...props}
/>
);
}
DrawerHandle.displayName = "DrawerHandle";
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
DrawerHandle,
};

View file

@ -42,13 +42,15 @@ function SheetContent({
className,
children,
side = "right",
overlayClassName,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
overlayClassName?: string;
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetOverlay className={overlayClassName} />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(

View file

@ -0,0 +1,33 @@
import { cn } from "@/lib/utils";
interface SpinnerProps {
/** Size of the spinner */
size?: "xs" | "sm" | "md" | "lg" | "xl";
/** Whether to hide the track behind the spinner arc */
hideTrack?: boolean;
/** Additional classes to apply */
className?: string;
}
const sizeClasses = {
xs: "h-3 w-3 border-[1.5px]",
sm: "h-4 w-4 border-2",
md: "h-6 w-6 border-2",
lg: "h-8 w-8 border-[3px]",
xl: "h-10 w-10 border-4",
};
export function Spinner({ size = "md", hideTrack = false, className }: SpinnerProps) {
return (
<output
aria-label="Loading"
className={cn(
"block animate-spin rounded-full",
hideTrack ? "border-transparent" : "border-current/20",
"border-t-current",
sizeClasses[size],
className
)}
/>
);
}