Merge pull request #721 from AnishSarkar22/feat/inbox

feat: introduce inbox with some fixes
This commit is contained in:
Rohan Verma 2026-01-22 11:44:38 -08:00 committed by GitHub
commit 2065009bbd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 2578 additions and 1042 deletions

View file

@ -1,12 +1,15 @@
""" """
Notifications API routes. Notifications API routes.
These endpoints allow marking notifications as read. These endpoints allow marking notifications as read and fetching older notifications.
Electric SQL automatically syncs the changes to all connected clients. Electric SQL automatically syncs the changes to all connected clients for recent items.
For older items (beyond the sync window), use the list endpoint.
""" """
from fastapi import APIRouter, Depends, HTTPException, status from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select, update from sqlalchemy import desc, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db import Notification, User, get_async_session from app.db import Notification, User, get_async_session
@ -14,6 +17,36 @@ from app.users import current_active_user
router = APIRouter(prefix="/notifications", tags=["notifications"]) router = APIRouter(prefix="/notifications", tags=["notifications"])
# Must match frontend SYNC_WINDOW_DAYS in use-inbox.ts
SYNC_WINDOW_DAYS = 14
class NotificationResponse(BaseModel):
"""Response model for a single notification."""
id: int
user_id: str
search_space_id: int | None
type: str
title: str
message: str
read: bool
metadata: dict
created_at: str
updated_at: str | None
class Config:
from_attributes = True
class NotificationListResponse(BaseModel):
"""Response for listing notifications with pagination."""
items: list[NotificationResponse]
total: int
has_more: bool
next_offset: int | None
class MarkReadResponse(BaseModel): class MarkReadResponse(BaseModel):
"""Response for mark as read operations.""" """Response for mark as read operations."""
@ -30,6 +63,169 @@ class MarkAllReadResponse(BaseModel):
updated_count: int updated_count: int
class UnreadCountResponse(BaseModel):
"""Response for unread count with split between recent and older items."""
total_unread: int
recent_unread: int # Within SYNC_WINDOW_DAYS
@router.get("/unread-count", response_model=UnreadCountResponse)
async def get_unread_count(
search_space_id: int | None = Query(None, description="Filter by search space ID"),
user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
) -> UnreadCountResponse:
"""
Get the total unread notification count for the current user.
Returns both:
- total_unread: All unread notifications (for accurate badge count)
- recent_unread: Unread notifications within the sync window (last 14 days)
This allows the frontend to calculate:
- older_unread = total_unread - recent_unread (static until reconciliation)
- Display count = older_unread + live_recent_count (from Electric SQL)
"""
# Calculate cutoff date for sync window
cutoff_date = datetime.now(UTC) - timedelta(days=SYNC_WINDOW_DAYS)
# Base filter for user's unread notifications
base_filter = [
Notification.user_id == user.id,
Notification.read == False, # noqa: E712
]
# Add search space filter if provided (include null for global notifications)
if search_space_id is not None:
base_filter.append(
(Notification.search_space_id == search_space_id)
| (Notification.search_space_id.is_(None))
)
# Total unread count (all time)
total_query = select(func.count(Notification.id)).where(*base_filter)
total_result = await session.execute(total_query)
total_unread = total_result.scalar() or 0
# Recent unread count (within sync window)
recent_query = select(func.count(Notification.id)).where(
*base_filter,
Notification.created_at > cutoff_date,
)
recent_result = await session.execute(recent_query)
recent_unread = recent_result.scalar() or 0
return UnreadCountResponse(
total_unread=total_unread,
recent_unread=recent_unread,
)
@router.get("", response_model=NotificationListResponse)
async def list_notifications(
search_space_id: int | None = Query(None, description="Filter by search space ID"),
type_filter: str | None = Query(
None, alias="type", description="Filter by notification type"
),
before_date: str | None = Query(
None, description="Get notifications before this ISO date (for pagination)"
),
limit: int = Query(50, ge=1, le=100, description="Number of items to return"),
offset: int = Query(0, ge=0, description="Number of items to skip"),
user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
) -> NotificationListResponse:
"""
List notifications for the current user with pagination.
This endpoint is used as a fallback for older notifications that are
outside the Electric SQL sync window (2 weeks).
Use `before_date` to paginate through older notifications efficiently.
"""
# Build base query
query = select(Notification).where(Notification.user_id == user.id)
count_query = select(func.count(Notification.id)).where(
Notification.user_id == user.id
)
# Filter by search space (include null search_space_id for global notifications)
if search_space_id is not None:
query = query.where(
(Notification.search_space_id == search_space_id)
| (Notification.search_space_id.is_(None))
)
count_query = count_query.where(
(Notification.search_space_id == search_space_id)
| (Notification.search_space_id.is_(None))
)
# Filter by type
if type_filter:
query = query.where(Notification.type == type_filter)
count_query = count_query.where(Notification.type == type_filter)
# Filter by date (for efficient pagination of older items)
if before_date:
try:
before_datetime = datetime.fromisoformat(before_date.replace("Z", "+00:00"))
query = query.where(Notification.created_at < before_datetime)
count_query = count_query.where(Notification.created_at < before_datetime)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid date format. Use ISO format (e.g., 2024-01-15T00:00:00Z)",
) from None
# Get total count
total_result = await session.execute(count_query)
total = total_result.scalar() or 0
# Apply ordering and pagination
query = (
query.order_by(desc(Notification.created_at)).offset(offset).limit(limit + 1)
)
# Execute query
result = await session.execute(query)
notifications = result.scalars().all()
# Check if there are more items
has_more = len(notifications) > limit
if has_more:
notifications = notifications[:limit]
# Convert to response format
items = []
for notification in notifications:
items.append(
NotificationResponse(
id=notification.id,
user_id=str(notification.user_id),
search_space_id=notification.search_space_id,
type=notification.type,
title=notification.title,
message=notification.message,
read=notification.read,
metadata=notification.notification_metadata or {},
created_at=notification.created_at.isoformat()
if notification.created_at
else "",
updated_at=notification.updated_at.isoformat()
if notification.updated_at
else None,
)
)
return NotificationListResponse(
items=items,
total=total,
has_more=has_more,
next_offset=offset + limit if has_more else None,
)
@router.patch("/{notification_id}/read", response_model=MarkReadResponse) @router.patch("/{notification_id}/read", response_model=MarkReadResponse)
async def mark_notification_as_read( async def mark_notification_as_read(
notification_id: int, notification_id: int,

View file

@ -368,7 +368,7 @@ export default function NewChatPage() {
initializeThread(); initializeThread();
}, [initializeThread]); }, [initializeThread]);
// Handle scroll to comment from URL query params (e.g., from notification click) // Handle scroll to comment from URL query params (e.g., from inbox item click)
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const targetCommentId = searchParams.get("commentId"); const targetCommentId = searchParams.get("commentId");

View file

@ -3,29 +3,38 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { import {
Bot,
Calendar, Calendar,
Check, Check,
Clock, Clock,
Copy, Copy,
Crown, Crown,
Edit2, Edit2,
FileText,
Hash, Hash,
Link2, Link2,
LinkIcon, LinkIcon,
Loader2, Loader2,
Logs,
type LucideIcon,
MessageCircle,
MessageSquare,
Mic,
MoreHorizontal, MoreHorizontal,
Plug,
Plus, Plus,
RefreshCw, RefreshCw,
Search, Search,
Settings,
Shield, Shield,
ShieldCheck, ShieldCheck,
Trash2, Trash2,
User,
UserMinus, UserMinus,
UserPlus, UserPlus,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import Image from "next/image";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -512,6 +521,25 @@ export default function TeamManagementPage() {
// ============ Members Tab ============ // ============ Members Tab ============
// Helper function to get avatar initials
function getAvatarInitials(member: Membership): string {
// Try display name first
if (member.user_display_name) {
const parts = member.user_display_name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return member.user_display_name.slice(0, 2).toUpperCase();
}
// Try email
if (member.user_email) {
const emailName = member.user_email.split("@")[0];
return emailName.slice(0, 2).toUpperCase();
}
// Fallback
return "U";
}
function MembersTab({ function MembersTab({
members, members,
roles, roles,
@ -560,7 +588,7 @@ function MembersTab({
<div className="relative flex-1 max-w-sm"> <div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder="Search members..." placeholder="Search members"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9" className="pl-9"
@ -573,10 +601,30 @@ function MembersTab({
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="bg-muted/50"> <TableRow className="bg-muted/50">
<TableHead className="w-auto md:w-[300px] px-2 md:px-4">Member</TableHead> <TableHead className="w-auto md:w-[300px] px-2 md:px-4">
<TableHead className="px-2 md:px-4">Role</TableHead> <div className="flex items-center gap-2">
<TableHead className="hidden md:table-cell">Joined</TableHead> <Users className="h-4 w-4" />
<TableHead className="text-right">Actions</TableHead> Member
</div>
</TableHead>
<TableHead className="px-2 md:px-4">
<div className="flex items-center gap-2">
<Shield className="h-4 w-4" />
Role
</div>
</TableHead>
<TableHead className="hidden md:table-cell">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
Joined
</div>
</TableHead>
<TableHead className="text-right">
<div className="flex items-center justify-end gap-2">
<Settings className="h-4 w-4" />
Actions
</div>
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -601,19 +649,36 @@ function MembersTab({
<TableCell className="py-2 px-2 md:py-4 md:px-4 align-middle"> <TableCell className="py-2 px-2 md:py-4 md:px-4 align-middle">
<div className="flex items-center gap-1.5 md:gap-3"> <div className="flex items-center gap-1.5 md:gap-3">
<div className="relative"> <div className="relative">
<div className="h-8 w-8 md:h-10 md:w-10 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center ring-2 ring-background"> {member.user_avatar_url ? (
<User className="h-4 w-4 md:h-5 md:w-5 text-primary" /> <Image
</div> src={member.user_avatar_url}
alt={member.user_display_name || member.user_email || "User"}
width={40}
height={40}
className="h-8 w-8 md:h-10 md:w-10 rounded-full object-cover"
/>
) : (
<div className="h-8 w-8 md:h-10 md:w-10 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center">
<span className="text-xs md:text-sm font-medium text-primary">
{getAvatarInitials(member)}
</span>
</div>
)}
{member.is_owner && ( {member.is_owner && (
<div className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-amber-500 flex items-center justify-center ring-2 ring-background"> <div className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-amber-500 flex items-center justify-center">
<Crown className="h-3 w-3 text-white" /> <Crown className="h-3 w-3 text-white" />
</div> </div>
)} )}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="font-medium text-xs md:text-sm truncate"> <p className="font-medium text-xs md:text-sm truncate">
{member.user_email || "Unknown"} {member.user_display_name || member.user_email || "Unknown"}
</p> </p>
{member.user_display_name && member.user_email && (
<p className="text-[10px] md:text-xs text-muted-foreground truncate">
{member.user_email}
</p>
)}
{member.is_owner && ( {member.is_owner && (
<Badge <Badge
variant="outline" variant="outline"
@ -640,29 +705,21 @@ function MembersTab({
<SelectItem value="none">No role</SelectItem> <SelectItem value="none">No role</SelectItem>
{roles.map((role) => ( {roles.map((role) => (
<SelectItem key={role.id} value={role.id.toString()}> <SelectItem key={role.id} value={role.id.toString()}>
<div className="flex items-center gap-2"> {role.name}
<Shield className="h-3 w-3" />
{role.name}
</div>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
) : ( ) : (
<Badge <Badge variant="secondary" className="text-[10px] md:text-xs py-0 md:py-0.5">
variant="secondary"
className="gap-1 text-[10px] md:text-xs py-0 md:py-0.5"
>
<Shield className="h-2.5 w-2.5 md:h-3 md:w-3" />
{member.role?.name || "No role"} {member.role?.name || "No role"}
</Badge> </Badge>
)} )}
</TableCell> </TableCell>
<TableCell className="hidden md:table-cell"> <TableCell className="hidden md:table-cell">
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
<Calendar className="h-4 w-4" />
{new Date(member.joined_at).toLocaleDateString()} {new Date(member.joined_at).toLocaleDateString()}
</div> </span>
</TableCell> </TableCell>
<TableCell className="text-right py-2 px-2 md:py-4 md:px-4 align-middle"> <TableCell className="text-right py-2 px-2 md:py-4 md:px-4 align-middle">
{canRemove && !member.is_owner && ( {canRemove && !member.is_owner && (
@ -708,13 +765,137 @@ function MembersTab({
); );
} }
// ============ Role Permissions Display ============
const CATEGORY_CONFIG: Record<string, { label: string; icon: LucideIcon; order: number }> = {
documents: { label: "Documents", icon: FileText, order: 1 },
chats: { label: "Chats", icon: MessageSquare, order: 2 },
comments: { label: "Comments", icon: MessageCircle, order: 3 },
llm_configs: { label: "LLM Configs", icon: Bot, order: 4 },
podcasts: { label: "Podcasts", icon: Mic, order: 5 },
connectors: { label: "Connectors", icon: Plug, order: 6 },
logs: { label: "Logs", icon: Logs, order: 7 },
members: { label: "Members", icon: Users, order: 8 },
roles: { label: "Roles", icon: Shield, order: 9 },
settings: { label: "Settings", icon: Settings, order: 10 },
};
const ACTION_LABELS: Record<string, string> = {
create: "Create",
read: "Read",
update: "Update",
delete: "Delete",
invite: "Invite",
view: "View",
remove: "Remove",
manage_roles: "Manage Roles",
};
function RolePermissionsDisplay({ permissions }: { permissions: string[] }) {
if (permissions.includes("*")) {
return (
<div className="flex items-center gap-3 p-3 rounded-lg bg-gradient-to-r from-amber-500/10 to-orange-500/10 border border-amber-500/20">
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-amber-500 to-orange-500 flex items-center justify-center shrink-0">
<Crown className="h-5 w-5 text-white" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold">Full Access</p>
<p className="text-xs text-muted-foreground">All permissions granted</p>
</div>
</div>
);
}
// Group permissions by category
const grouped: Record<string, string[]> = {};
for (const perm of permissions) {
const [category, action] = perm.split(":");
if (!grouped[category]) grouped[category] = [];
grouped[category].push(action);
}
// Sort categories by predefined order
const sortedCategories = Object.keys(grouped).sort((a, b) => {
const orderA = CATEGORY_CONFIG[a]?.order ?? 99;
const orderB = CATEGORY_CONFIG[b]?.order ?? 99;
return orderA - orderB;
});
const categoryCount = sortedCategories.length;
return (
<Dialog>
<DialogTrigger asChild>
<button
type="button"
className="w-full flex items-center justify-between p-3 rounded-lg border border-border/50 bg-muted/30 hover:bg-muted/50 transition-colors cursor-pointer text-left"
>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<ShieldCheck className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-sm font-semibold">{permissions.length} Permissions</p>
<p className="text-xs text-muted-foreground">
Across {categoryCount} {categoryCount === 1 ? "category" : "categories"}
</p>
</div>
</div>
<div className="text-xs text-muted-foreground">View details</div>
</button>
</DialogTrigger>
<DialogContent className="w-[92vw] max-w-md p-0 gap-0">
<DialogHeader className="p-4 md:p-5 border-b">
<DialogTitle className="flex items-center gap-2 text-base">
<ShieldCheck className="h-4 w-4 text-primary" />
Role Permissions
</DialogTitle>
<DialogDescription className="text-xs">
{permissions.length} permissions across {categoryCount} categories
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[55vh]">
<div className="divide-y divide-border/50">
{sortedCategories.map((category) => {
const actions = grouped[category];
const config = CATEGORY_CONFIG[category] || { label: category, icon: FileText };
const IconComponent = config.icon;
return (
<div
key={category}
className="flex items-center justify-between gap-3 px-4 md:px-5 py-2.5"
>
<div className="flex items-center gap-2 shrink-0">
<IconComponent className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">{config.label}</span>
</div>
<div className="flex flex-wrap justify-end gap-1">
{actions.map((action) => (
<span
key={action}
className="px-1.5 py-0.5 rounded bg-primary/10 text-primary text-[11px] font-medium"
>
{ACTION_LABELS[action] || action.replace(/_/g, " ")}
</span>
))}
</div>
</div>
);
})}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}
// ============ Roles Tab ============ // ============ Roles Tab ============
function RolesTab({ function RolesTab({
roles, roles,
groupedPermissions, groupedPermissions: _groupedPermissions,
loading, loading,
onUpdateRole, onUpdateRole: _onUpdateRole,
onDeleteRole, onDeleteRole,
canUpdate, canUpdate,
canDelete, canDelete,
@ -852,32 +1033,7 @@ function RolesTab({
)} )}
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-2"> <RolePermissionsDisplay permissions={role.permissions} />
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
Permissions ({role.permissions.includes("*") ? "All" : role.permissions.length})
</Label>
<div className="flex flex-wrap gap-1">
{role.permissions.includes("*") ? (
<Badge
variant="default"
className="bg-gradient-to-r from-amber-500 to-orange-500"
>
Full Access
</Badge>
) : (
role.permissions.slice(0, 5).map((perm) => (
<Badge key={perm} variant="secondary" className="text-xs">
{perm.replace(":", " ")}
</Badge>
))
)}
{!role.permissions.includes("*") && role.permissions.length > 5 && (
<Badge variant="outline" className="text-xs">
+{role.permissions.length - 5} more
</Badge>
)}
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
</motion.div> </motion.div>
@ -1500,7 +1656,11 @@ function CreateRoleDialog({
return ( return (
<div key={category} className="space-y-2"> <div key={category} className="space-y-2">
<label className="flex items-center gap-2 cursor-pointer hover:bg-muted/50 p-1 rounded w-full text-left"> <button
type="button"
className="flex items-center gap-2 cursor-pointer hover:bg-muted/50 p-1 rounded w-full text-left"
onClick={() => toggleCategory(category)}
>
<Checkbox <Checkbox
checked={allSelected} checked={allSelected}
onCheckedChange={() => toggleCategory(category)} onCheckedChange={() => toggleCategory(category)}
@ -1508,19 +1668,21 @@ function CreateRoleDialog({
<span className="text-sm font-medium capitalize"> <span className="text-sm font-medium capitalize">
{category} ({categorySelected}/{perms.length}) {category} ({categorySelected}/{perms.length})
</span> </span>
</label> </button>
<div className="grid grid-cols-2 gap-2 ml-6"> <div className="grid grid-cols-2 gap-2 ml-6">
{perms.map((perm) => ( {perms.map((perm) => (
<label <button
type="button"
key={perm.value} key={perm.value}
className="flex items-center gap-2 cursor-pointer text-left" className="flex items-center gap-2 cursor-pointer text-left"
onClick={() => togglePermission(perm.value)}
> >
<Checkbox <Checkbox
checked={selectedPermissions.includes(perm.value)} checked={selectedPermissions.includes(perm.value)}
onCheckedChange={() => togglePermission(perm.value)} onCheckedChange={() => togglePermission(perm.value)}
/> />
<span className="text-xs">{perm.value.split(":")[1]}</span> <span className="text-xs">{perm.value.split(":")[1]}</span>
</label> </button>
))} ))}
</div> </div>
</div> </div>

View file

@ -69,7 +69,7 @@ export function CommentPanel({
style={!isMobile && effectiveMaxHeight ? { maxHeight: effectiveMaxHeight } : undefined} style={!isMobile && effectiveMaxHeight ? { maxHeight: effectiveMaxHeight } : undefined}
> >
{hasThreads && ( {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"> <div className="space-y-4 p-4">
{threads.map((thread) => ( {threads.map((thread) => (
<CommentThread <CommentThread
@ -106,7 +106,7 @@ export function CommentPanel({
</div> </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 <CommentComposer
members={members} members={members}
membersLoading={membersLoading} membersLoading={membersLoading}

View file

@ -1,6 +1,13 @@
"use client"; "use client";
import { MessageSquare } from "lucide-react"; 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 { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CommentPanelContainer } from "../comment-panel-container/comment-panel-container"; import { CommentPanelContainer } from "../comment-panel-container/comment-panel-container";
@ -15,22 +22,39 @@ export function CommentSheet({
}: CommentSheetProps) { }: CommentSheetProps) {
const isBottomSheet = side === "bottom"; 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 ( return (
<Sheet open={isOpen} onOpenChange={onOpenChange}> <Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetContent <SheetContent
side={side} side={side}
className={cn( className={cn("flex flex-col gap-0 overflow-hidden p-0 h-full w-full max-w-md")}
"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"
)}
> >
{/* Drag handle indicator - only for bottom sheet */} <SheetHeader className="flex-shrink-0 px-4 py-4">
{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")}>
<SheetTitle className="flex items-center gap-2 text-base font-semibold"> <SheetTitle className="flex items-center gap-2 text-base font-semibold">
<MessageSquare className="size-5" /> <MessageSquare className="size-5" />
Comments Comments

View file

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

View file

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

View file

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

View file

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

View file

@ -39,11 +39,11 @@ export function ChatListItem({ name, isActive, onClick, onDelete }: ChatListItem
</button> </button>
{/* Actions dropdown */} {/* 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> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6"> <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> <span className="sr-only">{t("more_options")}</span>
</Button> </Button>
</DropdownMenuTrigger> </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 { InboxItem } from "@/hooks/use-inbox";
import { useMediaQuery } from "@/hooks/use-media-query";
import {
type ConnectorIndexingMetadata,
type NewMentionMetadata,
isConnectorIndexingMetadata,
isNewMentionMetadata,
} from "@/contracts/types/inbox.types";
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" type="button"
onClick={() => onItemClick?.(item)} onClick={() => onItemClick?.(item)}
className={cn( 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", "hover:bg-accent hover:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
item.isActive && "bg-accent text-accent-foreground" item.isActive && "bg-accent text-accent-foreground"
@ -38,6 +38,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
{...joyrideAttr} {...joyrideAttr}
> >
<Icon className="h-4 w-4" /> <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> <span className="sr-only">{item.title}</span>
</button> </button>
</TooltipTrigger> </TooltipTrigger>
@ -64,7 +69,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
> >
<Icon className="h-4 w-4 shrink-0" /> <Icon className="h-4 w-4 shrink-0" />
<span className="flex-1 truncate">{item.title}</span> <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> </button>
); );
})} })}

View file

@ -37,7 +37,7 @@ export function SidebarSection({
{/* Action button - visible on hover (always visible on mobile) */} {/* Action button - visible on hover (always visible on mobile) */}
{action && ( {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} {action}
</div> </div>
)} )}

View file

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

View file

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

View file

@ -72,7 +72,6 @@ interface ModelSelectorProps {
export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProps) { export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [isSwitching, setIsSwitching] = useState(false);
// Fetch configs // Fetch configs
const { data: userConfigs, isLoading: userConfigsLoading } = useAtomValue(newLLMConfigsAtom); const { data: userConfigs, isLoading: userConfigsLoading } = useAtomValue(newLLMConfigsAtom);
@ -137,7 +136,6 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
return; return;
} }
setIsSwitching(true);
try { try {
await updatePreferences({ await updatePreferences({
search_space_id: Number(searchSpaceId), search_space_id: Number(searchSpaceId),
@ -150,8 +148,6 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
} catch (error) { } catch (error) {
console.error("Failed to switch model:", error); console.error("Failed to switch model:", error);
toast.error("Failed to switch model"); toast.error("Failed to switch model");
} finally {
setIsSwitching(false);
} }
}, },
[currentConfig, searchSpaceId, updatePreferences] [currentConfig, searchSpaceId, updatePreferences]
@ -216,23 +212,12 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
shouldFilter={false} 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" 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>
</div>
)}
<div className="flex items-center gap-1 md:gap-2 px-2 md:px-3 py-1.5 md:py-2"> <div className="flex items-center gap-1 md:gap-2 px-2 md:px-3 py-1.5 md:py-2">
<CommandInput <CommandInput
placeholder="Search models" placeholder="Search models"
value={searchQuery} value={searchQuery}
onValueChange={setSearchQuery} 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" 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> </div>

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 }) => ( render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-3 bg-muted/30"> <FormItem className="flex items-center justify-between rounded-lg border p-3 bg-muted/30">
<div className="space-y-0.5"> <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"> <FormDescription className="text-[10px] sm:text-xs">
Include [citation:id] references to source documents Include [citation:id] references to source documents
</FormDescription> </FormDescription>

View file

@ -0,0 +1,115 @@
"use client";
import * 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, className,
children, children,
side = "right", side = "right",
overlayClassName,
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & { }: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"; side?: "top" | "right" | "bottom" | "left";
overlayClassName?: string;
}) { }) {
return ( return (
<SheetPortal> <SheetPortal>
<SheetOverlay /> <SheetOverlay className={overlayClassName} />
<SheetPrimitive.Content <SheetPrimitive.Content
data-slot="sheet-content" data-slot="sheet-content"
className={cn( 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
)}
/>
);
}

View file

@ -5,11 +5,11 @@ description: Setting up Electric SQL for real-time data synchronization in SurfS
# Electric SQL # Electric SQL
[Electric SQL](https://electric-sql.com/) enables real-time data synchronization in SurfSense, providing instant updates for notifications, document indexing status, and connector sync progress without manual refresh. The frontend uses [PGlite](https://pglite.dev/) (a lightweight PostgreSQL in the browser) to maintain a local database that syncs with the backend via Electric SQL. [Electric SQL](https://electric-sql.com/) enables real-time data synchronization in SurfSense, providing instant updates for inbox items, document indexing status, and connector sync progress without manual refresh. The frontend uses [PGlite](https://pglite.dev/) (a lightweight PostgreSQL in the browser) to maintain a local database that syncs with the backend via Electric SQL.
## What Does Electric SQL Do? ## What Does Electric SQL Do?
When you index documents or receive notifications, Electric SQL pushes updates to your browser in real-time. The data flows like this: When you index documents or receive inbox updates, Electric SQL pushes updates to your browser in real-time. The data flows like this:
1. Backend writes data to PostgreSQL 1. Backend writes data to PostgreSQL
2. Electric SQL detects changes and streams them to the frontend 2. Electric SQL detects changes and streams them to the frontend
@ -18,7 +18,7 @@ When you index documents or receive notifications, Electric SQL pushes updates t
This means: This means:
- **Notifications appear instantly** - No need to refresh the page - **Inbox updates appear instantly** - No need to refresh the page
- **Document indexing progress updates live** - Watch your documents get processed - **Document indexing progress updates live** - Watch your documents get processed
- **Connector status syncs automatically** - See when connectors finish syncing - **Connector status syncs automatically** - See when connectors finish syncing
- **Offline support** - PGlite caches data locally, so previously loaded data remains accessible - **Offline support** - PGlite caches data locally, so previously loaded data remains accessible

View file

@ -0,0 +1,281 @@
import { z } from "zod";
import { searchSourceConnectorTypeEnum } from "./connector.types";
import { documentTypeEnum } from "./document.types";
/**
* Inbox item type enum - matches backend notification types
*/
export const inboxItemTypeEnum = z.enum([
"connector_indexing",
"document_processing",
"new_mention",
]);
/**
* Inbox item status enum - used in metadata
*/
export const inboxItemStatusEnum = z.enum(["in_progress", "completed", "failed"]);
/**
* Document processing stage enum
*/
export const documentProcessingStageEnum = z.enum([
"queued",
"parsing",
"chunking",
"embedding",
"storing",
"completed",
"failed",
]);
/**
* Base metadata schema shared across inbox item types
*/
export const baseInboxItemMetadata = z.object({
operation_id: z.string().optional(),
status: inboxItemStatusEnum.optional(),
started_at: z.string().optional(),
completed_at: z.string().optional(),
});
/**
* Connector indexing metadata schema
*/
export const connectorIndexingMetadata = baseInboxItemMetadata.extend({
connector_id: z.number(),
connector_name: z.string(),
connector_type: searchSourceConnectorTypeEnum,
start_date: z.string().nullable().optional(),
end_date: z.string().nullable().optional(),
indexed_count: z.number(),
total_count: z.number().optional(),
progress_percent: z.number().optional(),
error_message: z.string().nullable().optional(),
// Google Drive specific fields
folder_count: z.number().optional(),
file_count: z.number().optional(),
folder_names: z.array(z.string()).optional(),
file_names: z.array(z.string()).optional(),
});
/**
* Document processing metadata schema
*/
export const documentProcessingMetadata = baseInboxItemMetadata.extend({
document_type: documentTypeEnum,
document_name: z.string(),
processing_stage: documentProcessingStageEnum,
file_size: z.number().optional(),
chunks_count: z.number().optional(),
document_id: z.number().optional(),
error_message: z.string().nullable().optional(),
});
/**
* New mention metadata schema
*/
export const newMentionMetadata = z.object({
mention_id: z.number(),
comment_id: z.number(),
message_id: z.number(),
thread_id: z.number(),
thread_title: z.string(),
author_id: z.string(),
author_name: z.string(),
author_avatar_url: z.string().nullable().optional(),
author_email: z.string().optional(),
content_preview: z.string(),
});
/**
* Union of all inbox item metadata types
* Use this when the inbox item type is unknown
*/
export const inboxItemMetadata = z.union([
connectorIndexingMetadata,
documentProcessingMetadata,
newMentionMetadata,
baseInboxItemMetadata,
]);
/**
* Main inbox item schema
*/
export const inboxItem = z.object({
id: z.number(),
user_id: z.string(),
search_space_id: z.number().nullable(),
type: inboxItemTypeEnum,
title: z.string(),
message: z.string(),
read: z.boolean(),
metadata: z.record(z.string(), z.unknown()),
created_at: z.string(),
updated_at: z.string().nullable(),
});
/**
* Typed inbox item schemas for specific types
*/
export const connectorIndexingInboxItem = inboxItem.extend({
type: z.literal("connector_indexing"),
metadata: connectorIndexingMetadata,
});
export const documentProcessingInboxItem = inboxItem.extend({
type: z.literal("document_processing"),
metadata: documentProcessingMetadata,
});
export const newMentionInboxItem = inboxItem.extend({
type: z.literal("new_mention"),
metadata: newMentionMetadata,
});
// =============================================================================
// API Request/Response Schemas
// =============================================================================
/**
* Request schema for getting notifications
*/
export const getNotificationsRequest = z.object({
queryParams: z.object({
search_space_id: z.number().optional(),
type: inboxItemTypeEnum.optional(),
before_date: z.string().optional(),
limit: z.number().min(1).max(100).optional(),
offset: z.number().min(0).optional(),
}),
});
/**
* Response schema for listing notifications
*/
export const getNotificationsResponse = z.object({
items: z.array(inboxItem),
total: z.number(),
has_more: z.boolean(),
next_offset: z.number().nullable(),
});
/**
* Request schema for marking a single notification as read
*/
export const markNotificationReadRequest = z.object({
notificationId: z.number(),
});
/**
* Response schema for mark as read operations
*/
export const markNotificationReadResponse = z.object({
success: z.boolean(),
message: z.string(),
});
/**
* Response schema for mark all as read operation
*/
export const markAllNotificationsReadResponse = z.object({
success: z.boolean(),
message: z.string(),
updated_count: z.number(),
});
/**
* Request schema for getting unread count
*/
export const getUnreadCountRequest = z.object({
search_space_id: z.number().optional(),
});
/**
* Response schema for unread count
* Returns both total and recent counts for split tracking
*/
export const getUnreadCountResponse = z.object({
total_unread: z.number(),
recent_unread: z.number(), // Within SYNC_WINDOW_DAYS (14 days)
});
// =============================================================================
// Type Guards for Metadata
// =============================================================================
/**
* Type guard for ConnectorIndexingMetadata
*/
export function isConnectorIndexingMetadata(
metadata: unknown
): metadata is ConnectorIndexingMetadata {
return connectorIndexingMetadata.safeParse(metadata).success;
}
/**
* Type guard for DocumentProcessingMetadata
*/
export function isDocumentProcessingMetadata(
metadata: unknown
): metadata is DocumentProcessingMetadata {
return documentProcessingMetadata.safeParse(metadata).success;
}
/**
* Type guard for NewMentionMetadata
*/
export function isNewMentionMetadata(metadata: unknown): metadata is NewMentionMetadata {
return newMentionMetadata.safeParse(metadata).success;
}
/**
* Safe metadata parser - returns typed metadata or null
*/
export function parseInboxItemMetadata(
type: InboxItemTypeEnum,
metadata: unknown
): ConnectorIndexingMetadata | DocumentProcessingMetadata | NewMentionMetadata | null {
switch (type) {
case "connector_indexing": {
const result = connectorIndexingMetadata.safeParse(metadata);
return result.success ? result.data : null;
}
case "document_processing": {
const result = documentProcessingMetadata.safeParse(metadata);
return result.success ? result.data : null;
}
case "new_mention": {
const result = newMentionMetadata.safeParse(metadata);
return result.success ? result.data : null;
}
default:
return null;
}
}
// =============================================================================
// Inferred types
// =============================================================================
export type InboxItemTypeEnum = z.infer<typeof inboxItemTypeEnum>;
export type InboxItemStatusEnum = z.infer<typeof inboxItemStatusEnum>;
export type DocumentProcessingStageEnum = z.infer<typeof documentProcessingStageEnum>;
export type BaseInboxItemMetadata = z.infer<typeof baseInboxItemMetadata>;
export type ConnectorIndexingMetadata = z.infer<typeof connectorIndexingMetadata>;
export type DocumentProcessingMetadata = z.infer<typeof documentProcessingMetadata>;
export type NewMentionMetadata = z.infer<typeof newMentionMetadata>;
export type InboxItemMetadata = z.infer<typeof inboxItemMetadata>;
export type InboxItem = z.infer<typeof inboxItem>;
export type ConnectorIndexingInboxItem = z.infer<typeof connectorIndexingInboxItem>;
export type DocumentProcessingInboxItem = z.infer<typeof documentProcessingInboxItem>;
export type NewMentionInboxItem = z.infer<typeof newMentionInboxItem>;
// API Request/Response types
export type GetNotificationsRequest = z.infer<typeof getNotificationsRequest>;
export type GetNotificationsResponse = z.infer<typeof getNotificationsResponse>;
export type MarkNotificationReadRequest = z.infer<typeof markNotificationReadRequest>;
export type MarkNotificationReadResponse = z.infer<typeof markNotificationReadResponse>;
export type MarkAllNotificationsReadResponse = z.infer<typeof markAllNotificationsReadResponse>;
export type GetUnreadCountRequest = z.infer<typeof getUnreadCountRequest>;
export type GetUnreadCountResponse = z.infer<typeof getUnreadCountResponse>;

View file

@ -1,148 +0,0 @@
import { z } from "zod";
import { searchSourceConnectorTypeEnum } from "./connector.types";
import { documentTypeEnum } from "./document.types";
/**
* Notification type enum - matches backend notification types
*/
export const notificationTypeEnum = z.enum([
"connector_indexing",
"document_processing",
"new_mention",
]);
/**
* Notification status enum - used in metadata
*/
export const notificationStatusEnum = z.enum(["in_progress", "completed", "failed"]);
/**
* Document processing stage enum
*/
export const documentProcessingStageEnum = z.enum([
"queued",
"parsing",
"chunking",
"embedding",
"storing",
"completed",
"failed",
]);
/**
* Base metadata schema shared across notification types
*/
export const baseNotificationMetadata = z.object({
operation_id: z.string().optional(),
status: notificationStatusEnum.optional(),
started_at: z.string().optional(),
completed_at: z.string().optional(),
});
/**
* Connector indexing metadata schema
*/
export const connectorIndexingMetadata = baseNotificationMetadata.extend({
connector_id: z.number(),
connector_name: z.string(),
connector_type: searchSourceConnectorTypeEnum,
start_date: z.string().nullable().optional(),
end_date: z.string().nullable().optional(),
indexed_count: z.number(),
total_count: z.number().optional(),
progress_percent: z.number().optional(),
error_message: z.string().nullable().optional(),
// Google Drive specific fields
folder_count: z.number().optional(),
file_count: z.number().optional(),
folder_names: z.array(z.string()).optional(),
file_names: z.array(z.string()).optional(),
});
/**
* Document processing metadata schema
*/
export const documentProcessingMetadata = baseNotificationMetadata.extend({
document_type: documentTypeEnum,
document_name: z.string(),
processing_stage: documentProcessingStageEnum,
file_size: z.number().optional(),
chunks_count: z.number().optional(),
document_id: z.number().optional(),
error_message: z.string().nullable().optional(),
});
/**
* New mention metadata schema
*/
export const newMentionMetadata = z.object({
mention_id: z.number(),
comment_id: z.number(),
message_id: z.number(),
thread_id: z.number(),
thread_title: z.string(),
author_id: z.string(),
author_name: z.string(),
author_avatar_url: z.string().nullable().optional(),
author_email: z.string().optional(),
content_preview: z.string(),
});
/**
* Union of all notification metadata types
* Use this when the notification type is unknown
*/
export const notificationMetadata = z.union([
connectorIndexingMetadata,
documentProcessingMetadata,
newMentionMetadata,
baseNotificationMetadata,
]);
/**
* Main notification schema
*/
export const notification = z.object({
id: z.number(),
user_id: z.string(),
search_space_id: z.number().nullable(),
type: notificationTypeEnum,
title: z.string(),
message: z.string(),
read: z.boolean(),
metadata: z.record(z.string(), z.unknown()),
created_at: z.string(),
updated_at: z.string().nullable(),
});
/**
* Typed notification schemas for specific notification types
*/
export const connectorIndexingNotification = notification.extend({
type: z.literal("connector_indexing"),
metadata: connectorIndexingMetadata,
});
export const documentProcessingNotification = notification.extend({
type: z.literal("document_processing"),
metadata: documentProcessingMetadata,
});
export const newMentionNotification = notification.extend({
type: z.literal("new_mention"),
metadata: newMentionMetadata,
});
// Inferred types
export type NotificationTypeEnum = z.infer<typeof notificationTypeEnum>;
export type NotificationStatusEnum = z.infer<typeof notificationStatusEnum>;
export type DocumentProcessingStageEnum = z.infer<typeof documentProcessingStageEnum>;
export type BaseNotificationMetadata = z.infer<typeof baseNotificationMetadata>;
export type ConnectorIndexingMetadata = z.infer<typeof connectorIndexingMetadata>;
export type DocumentProcessingMetadata = z.infer<typeof documentProcessingMetadata>;
export type NewMentionMetadata = z.infer<typeof newMentionMetadata>;
export type NotificationMetadata = z.infer<typeof notificationMetadata>;
export type Notification = z.infer<typeof notification>;
export type ConnectorIndexingNotification = z.infer<typeof connectorIndexingNotification>;
export type DocumentProcessingNotification = z.infer<typeof documentProcessingNotification>;
export type NewMentionNotification = z.infer<typeof newMentionNotification>;

View file

@ -0,0 +1,523 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types";
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
import type { SyncHandle } from "@/lib/electric/client";
import { useElectricClient } from "@/lib/electric/context";
export type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types";
const PAGE_SIZE = 50;
const SYNC_WINDOW_DAYS = 14;
/**
* Check if an item is older than the sync window
*/
function isOlderThanSyncWindow(createdAt: string): boolean {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - SYNC_WINDOW_DAYS);
return new Date(createdAt) < cutoffDate;
}
/**
* Deduplicate by ID and sort by created_at descending.
* This is the SINGLE source of truth for deduplication - prevents race conditions.
*/
function deduplicateAndSort(items: InboxItem[]): InboxItem[] {
const seen = new Map<number, InboxItem>();
for (const item of items) {
if (!seen.has(item.id)) {
seen.set(item.id, item);
}
}
return Array.from(seen.values()).sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
}
/**
* Calculate the cutoff date for sync window
*/
function getSyncCutoffDate(): string {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - SYNC_WINDOW_DAYS);
return cutoff.toISOString();
}
/**
* Convert a date value to ISO string format
*/
function toISOString(date: string | Date | null | undefined): string | null {
if (!date) return null;
if (date instanceof Date) return date.toISOString();
if (typeof date === "string") {
if (date.includes("T")) return date;
try {
return new Date(date).toISOString();
} catch {
return date;
}
}
return null;
}
/**
* Hook for managing inbox items with Electric SQL real-time sync + API fallback
*
* Architecture (Simplified & Race-Condition Free):
* - Electric SQL: Syncs recent items (within SYNC_WINDOW_DAYS) for real-time updates
* - Live Query: Provides reactive first page from PGLite
* - API: Handles all pagination (more reliable than mixing with Electric)
*
* Key Design Decisions:
* 1. No mutable refs for cursor - cursor computed from current state
* 2. Single deduplicateAndSort function - prevents inconsistencies
* 3. Filter-based preservation in live query - prevents data loss
* 4. Auto-fetch from API when Electric returns 0 items
*
* @param userId - The user ID to fetch inbox items for
* @param searchSpaceId - The search space ID to filter inbox items
* @param typeFilter - Optional inbox item type to filter by
*/
export function useInbox(
userId: string | null,
searchSpaceId: number | null,
typeFilter: InboxItemTypeEnum | null = null
) {
const electricClient = useElectricClient();
const [inboxItems, setInboxItems] = useState<InboxItem[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<Error | null>(null);
// Split unread count tracking for accurate counts with 14-day sync window
// olderUnreadCount = unread items OLDER than sync window (from server, static until reconciliation)
// recentUnreadCount = unread items within sync window (from live query, real-time)
const [olderUnreadCount, setOlderUnreadCount] = useState(0);
const [recentUnreadCount, setRecentUnreadCount] = useState(0);
const syncHandleRef = useRef<SyncHandle | null>(null);
const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
const userSyncKeyRef = useRef<string | null>(null);
const unreadCountLiveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
// Total unread = older (static from server) + recent (live from Electric)
const totalUnreadCount = olderUnreadCount + recentUnreadCount;
// EFFECT 1: Electric SQL sync for real-time updates
useEffect(() => {
if (!userId || !electricClient) {
setLoading(!electricClient);
return;
}
const client = electricClient;
let mounted = true;
async function startSync() {
try {
const cutoffDate = getSyncCutoffDate();
const userSyncKey = `inbox_${userId}_${cutoffDate}`;
// Skip if already syncing with this key
if (userSyncKeyRef.current === userSyncKey) return;
// Clean up previous sync
if (syncHandleRef.current) {
syncHandleRef.current.unsubscribe();
syncHandleRef.current = null;
}
console.log("[useInbox] Starting sync for:", userId);
userSyncKeyRef.current = userSyncKey;
const handle = await client.syncShape({
table: "notifications",
where: `user_id = '${userId}' AND created_at > '${cutoffDate}'`,
primaryKey: ["id"],
});
// Wait for initial sync with timeout
if (!handle.isUpToDate && handle.initialSyncPromise) {
await Promise.race([
handle.initialSyncPromise,
new Promise((resolve) => setTimeout(resolve, 3000)),
]);
}
if (!mounted) {
handle.unsubscribe();
return;
}
syncHandleRef.current = handle;
setLoading(false);
setError(null);
} catch (err) {
if (!mounted) return;
console.error("[useInbox] Sync failed:", err);
setError(err instanceof Error ? err : new Error("Sync failed"));
setLoading(false);
}
}
startSync();
return () => {
mounted = false;
userSyncKeyRef.current = null;
if (syncHandleRef.current) {
syncHandleRef.current.unsubscribe();
syncHandleRef.current = null;
}
};
}, [userId, electricClient]);
// Reset when filters change
useEffect(() => {
setHasMore(true);
setInboxItems([]);
// Reset count states - will be refetched by the unread count effect
setOlderUnreadCount(0);
setRecentUnreadCount(0);
}, [userId, searchSpaceId, typeFilter]);
// EFFECT 2: Live query for real-time updates + auto-fetch from API if empty
useEffect(() => {
if (!userId || !electricClient) return;
const client = electricClient;
let mounted = true;
async function setupLiveQuery() {
// Clean up previous live query
if (liveQueryRef.current) {
liveQueryRef.current.unsubscribe();
liveQueryRef.current = null;
}
try {
const cutoff = getSyncCutoffDate();
const query = `SELECT * FROM notifications
WHERE user_id = $1
AND (search_space_id = $2 OR search_space_id IS NULL)
AND created_at > '${cutoff}'
${typeFilter ? "AND type = $3" : ""}
ORDER BY created_at DESC
LIMIT ${PAGE_SIZE}`;
const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
const db = client.db as any;
// Initial fetch from PGLite - no validation needed, schema is enforced by Electric SQL sync
const result = await client.db.query<InboxItem>(query, params);
if (mounted && result.rows) {
const items = deduplicateAndSort(result.rows);
setInboxItems(items);
// AUTO-FETCH: If Electric returned 0 items, check API for older items
// This handles the edge case where user has no recent notifications
// but has older ones outside the sync window
if (items.length === 0) {
console.log(
"[useInbox] Electric returned 0 items, checking API for older notifications"
);
try {
// Use the API service with proper Zod validation for API responses
const data = await notificationsApiService.getNotifications({
queryParams: {
search_space_id: searchSpaceId ?? undefined,
type: typeFilter ?? undefined,
limit: PAGE_SIZE,
},
});
if (mounted) {
if (data.items.length > 0) {
setInboxItems(data.items);
}
setHasMore(data.has_more);
}
} catch (err) {
console.error("[useInbox] API fallback failed:", err);
}
}
}
// Set up live query for real-time updates
if (db.live?.query) {
const liveQuery = await db.live.query(query, params);
if (!mounted) {
liveQuery.unsubscribe?.();
return;
}
if (liveQuery.subscribe) {
// Live query data comes from PGlite - no validation needed
liveQuery.subscribe((result: { rows: InboxItem[] }) => {
if (mounted && result.rows) {
const liveItems = result.rows;
setInboxItems((prev) => {
const liveItemIds = new Set(liveItems.map((item) => item.id));
// FIXED: Keep ALL items not in live result (not just slice)
// This prevents data loss when new notifications push items
// out of the LIMIT window
const itemsToKeep = prev.filter((item) => !liveItemIds.has(item.id));
return deduplicateAndSort([...liveItems, ...itemsToKeep]);
});
}
});
}
if (liveQuery.unsubscribe) {
liveQueryRef.current = liveQuery;
}
}
} catch (err) {
console.error("[useInbox] Live query error:", err);
}
}
setupLiveQuery();
return () => {
mounted = false;
if (liveQueryRef.current) {
liveQueryRef.current.unsubscribe();
liveQueryRef.current = null;
}
};
}, [userId, searchSpaceId, typeFilter, electricClient]);
// EFFECT 3: Dedicated unread count sync with split tracking
// - Fetches server count on mount (accurate total)
// - Sets up live query for recent count (real-time updates)
// - Handles items older than sync window separately
useEffect(() => {
if (!userId || !electricClient) return;
const client = electricClient;
let mounted = true;
async function setupUnreadCountSync() {
// Cleanup previous live query
if (unreadCountLiveQueryRef.current) {
unreadCountLiveQueryRef.current.unsubscribe();
unreadCountLiveQueryRef.current = null;
}
try {
// STEP 1: Fetch server counts (total and recent) - guaranteed accurate
console.log("[useInbox] Fetching unread count from server");
const serverCounts = await notificationsApiService.getUnreadCount(
searchSpaceId ?? undefined
);
if (mounted) {
// Calculate older count = total - recent
const olderCount = serverCounts.total_unread - serverCounts.recent_unread;
setOlderUnreadCount(olderCount);
setRecentUnreadCount(serverCounts.recent_unread);
console.log(
`[useInbox] Server counts: total=${serverCounts.total_unread}, recent=${serverCounts.recent_unread}, older=${olderCount}`
);
}
// STEP 2: Set up PGLite live query for RECENT unread count only
// This provides real-time updates for notifications within sync window
const db = client.db as any;
const cutoff = getSyncCutoffDate();
// Count query - NO LIMIT, counts all unread in synced window
const countQuery = `
SELECT COUNT(*) as count FROM notifications
WHERE user_id = $1
AND (search_space_id = $2 OR search_space_id IS NULL)
AND created_at > '${cutoff}'
AND read = false
${typeFilter ? "AND type = $3" : ""}
`;
const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
if (db.live?.query) {
const liveQuery = await db.live.query(countQuery, params);
if (!mounted) {
liveQuery.unsubscribe?.();
return;
}
if (liveQuery.subscribe) {
liveQuery.subscribe((result: { rows: Array<{ count: number | string }> }) => {
if (mounted && result.rows?.[0]) {
const liveCount = Number(result.rows[0].count) || 0;
// Update recent count from live query
// This fires in real-time when Electric syncs new/updated notifications
setRecentUnreadCount(liveCount);
}
});
}
if (liveQuery.unsubscribe) {
unreadCountLiveQueryRef.current = liveQuery;
}
}
} catch (err) {
console.error("[useInbox] Unread count sync error:", err);
// On error, counts will remain at 0 or previous values
// The items-based count will be the fallback
}
}
setupUnreadCountSync();
return () => {
mounted = false;
if (unreadCountLiveQueryRef.current) {
unreadCountLiveQueryRef.current.unsubscribe();
unreadCountLiveQueryRef.current = null;
}
};
}, [userId, searchSpaceId, typeFilter, electricClient]);
// loadMore - Pure cursor-based pagination, no race conditions
// Cursor is computed from current state, not stored in refs
const loadMore = useCallback(async () => {
// Removed inboxItems.length === 0 check to allow loading older items
// when Electric returns 0 items
if (!userId || loadingMore || !hasMore) return;
setLoadingMore(true);
try {
// Cursor is computed from current state - no stale refs possible
const oldestItem = inboxItems.length > 0 ? inboxItems[inboxItems.length - 1] : null;
const beforeDate = oldestItem ? toISOString(oldestItem.created_at) : null;
console.log("[useInbox] Loading more, before:", beforeDate ?? "none (initial)");
// Use the API service with proper Zod validation
const data = await notificationsApiService.getNotifications({
queryParams: {
search_space_id: searchSpaceId ?? undefined,
type: typeFilter ?? undefined,
before_date: beforeDate ?? undefined,
limit: PAGE_SIZE,
},
});
if (data.items.length > 0) {
// Functional update ensures we always merge with latest state
// Items are already validated by the API service
setInboxItems((prev) => deduplicateAndSort([...prev, ...data.items]));
}
// Use API's has_more flag
setHasMore(data.has_more);
} catch (err) {
console.error("[useInbox] Load more failed:", err);
} finally {
setLoadingMore(false);
}
}, [userId, searchSpaceId, typeFilter, loadingMore, hasMore, inboxItems]);
// Mark inbox item as read with optimistic update
// Handles both recent items (live query updates count) and older items (manual count decrement)
const markAsRead = useCallback(
async (itemId: number) => {
// Find the item to check if it's older than sync window
const item = inboxItems.find((i) => i.id === itemId);
const isOlderItem = item && !item.read && isOlderThanSyncWindow(item.created_at);
// Optimistic update: mark as read immediately for instant UI feedback
setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: true } : i)));
// If older item, manually decrement older count
// (live query won't see items outside sync window)
if (isOlderItem) {
setOlderUnreadCount((prev) => Math.max(0, prev - 1));
}
try {
// Use the API service with proper Zod validation
const result = await notificationsApiService.markAsRead({ notificationId: itemId });
if (!result.success) {
// Rollback on error
setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: false } : i)));
if (isOlderItem) {
setOlderUnreadCount((prev) => prev + 1);
}
}
// If successful, Electric SQL will sync the change and live query will update
// This ensures eventual consistency even if optimistic update was wrong
return result.success;
} catch (err) {
console.error("Failed to mark as read:", err);
// Rollback on error
setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: false } : i)));
if (isOlderItem) {
setOlderUnreadCount((prev) => prev + 1);
}
return false;
}
},
[inboxItems]
);
// Mark all inbox items as read with optimistic update
// Resets both older and recent counts to 0
const markAllAsRead = useCallback(async () => {
// Store previous counts for potential rollback
const prevOlderCount = olderUnreadCount;
const prevRecentCount = recentUnreadCount;
// Optimistic update: mark all as read immediately for instant UI feedback
setInboxItems((prev) => prev.map((item) => ({ ...item, read: true })));
setOlderUnreadCount(0);
setRecentUnreadCount(0);
try {
// Use the API service with proper Zod validation
const result = await notificationsApiService.markAllAsRead();
if (!result.success) {
console.error("Failed to mark all as read");
// Rollback counts on error
setOlderUnreadCount(prevOlderCount);
setRecentUnreadCount(prevRecentCount);
}
// Electric SQL will sync and live query will ensure consistency
return result.success;
} catch (err) {
console.error("Failed to mark all as read:", err);
// Rollback counts on error
setOlderUnreadCount(prevOlderCount);
setRecentUnreadCount(prevRecentCount);
return false;
}
}, [olderUnreadCount, recentUnreadCount]);
return {
inboxItems,
unreadCount: totalUnreadCount,
markAsRead,
markAllAsRead,
loading,
loadingMore,
hasMore,
loadMore,
isUsingApiFallback: true, // Always use API for pagination
error,
};
}

View file

@ -1,334 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { Notification, NotificationTypeEnum } from "@/contracts/types/notification.types";
import { authenticatedFetch } from "@/lib/auth-utils";
import type { SyncHandle } from "@/lib/electric/client";
import { useElectricClient } from "@/lib/electric/context";
export type { Notification, NotificationTypeEnum } from "@/contracts/types/notification.types";
/**
* Hook for managing notifications with Electric SQL real-time sync
*
* Uses the Electric client from context (provided by ElectricProvider)
* instead of initializing its own - prevents race conditions and memory leaks
*
* Architecture:
* - User-level sync: Syncs ALL notifications for a user (runs once per user)
* - Search-space-level query: Filters notifications by searchSpaceId (updates on search space change)
*
* This separation ensures smooth transitions when switching search spaces (no flash).
*
* @param userId - The user ID to fetch notifications for
* @param searchSpaceId - The search space ID to filter notifications (null shows global notifications only)
* @param typeFilter - Optional notification type to filter by (null shows all types)
*/
export function useNotifications(
userId: string | null,
searchSpaceId: number | null,
typeFilter: NotificationTypeEnum | null = null
) {
// Get Electric client from context - ElectricProvider handles initialization
const electricClient = useElectricClient();
const [notifications, setNotifications] = useState<Notification[]>([]);
const [totalUnreadCount, setTotalUnreadCount] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const syncHandleRef = useRef<SyncHandle | null>(null);
const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
const unreadCountLiveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
// Track user-level sync key to prevent duplicate sync subscriptions
const userSyncKeyRef = useRef<string | null>(null);
// EFFECT 1: User-level sync - runs once per user, syncs ALL notifications
useEffect(() => {
if (!userId || !electricClient) {
setLoading(!electricClient);
return;
}
const userSyncKey = `notifications_${userId}`;
if (userSyncKeyRef.current === userSyncKey) {
// Already syncing for this user
return;
}
let mounted = true;
userSyncKeyRef.current = userSyncKey;
async function startUserSync() {
try {
console.log("[useNotifications] Starting user-level sync for:", userId);
// Sync ALL notifications for this user (cached via syncShape caching)
const handle = await electricClient.syncShape({
table: "notifications",
where: `user_id = '${userId}'`,
primaryKey: ["id"],
});
console.log("[useNotifications] User sync started:", {
isUpToDate: handle.isUpToDate,
});
// Wait for initial sync with timeout
if (!handle.isUpToDate && handle.initialSyncPromise) {
try {
await Promise.race([
handle.initialSyncPromise,
new Promise((resolve) => setTimeout(resolve, 2000)),
]);
} catch (syncErr) {
console.error("[useNotifications] Initial sync failed:", syncErr);
}
}
if (!mounted) {
handle.unsubscribe();
return;
}
syncHandleRef.current = handle;
setLoading(false);
setError(null);
} catch (err) {
if (!mounted) return;
console.error("[useNotifications] Failed to start user sync:", err);
setError(err instanceof Error ? err : new Error("Failed to sync notifications"));
setLoading(false);
}
}
startUserSync();
return () => {
mounted = false;
userSyncKeyRef.current = null;
if (syncHandleRef.current) {
syncHandleRef.current.unsubscribe();
syncHandleRef.current = null;
}
};
}, [userId, electricClient]);
// EFFECT 2: Search-space-level query - updates when searchSpaceId or typeFilter changes
// This runs independently of sync, allowing smooth transitions between search spaces
useEffect(() => {
if (!userId || !electricClient) {
return;
}
let mounted = true;
async function updateQuery() {
// Clean up previous live query (but DON'T clear notifications - keep showing old until new arrive)
if (liveQueryRef.current) {
liveQueryRef.current.unsubscribe();
liveQueryRef.current = null;
}
try {
console.log(
"[useNotifications] Updating query for searchSpace:",
searchSpaceId,
"typeFilter:",
typeFilter
);
// Build query with optional type filter
const baseQuery = `SELECT * FROM notifications
WHERE user_id = $1
AND (search_space_id = $2 OR search_space_id IS NULL)`;
const typeClause = typeFilter ? ` AND type = $3` : "";
const orderClause = ` ORDER BY created_at DESC`;
const fullQuery = baseQuery + typeClause + orderClause;
const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
// Fetch notifications for current search space immediately
const result = await electricClient.db.query<Notification>(fullQuery, params);
if (mounted) {
setNotifications(result.rows || []);
}
// Set up live query for real-time updates
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = electricClient.db as any;
if (db.live?.query && typeof db.live.query === "function") {
const liveQuery = await db.live.query(fullQuery, params);
if (!mounted) {
liveQuery.unsubscribe?.();
return;
}
// Set initial results from live query
if (liveQuery.initialResults?.rows) {
setNotifications(liveQuery.initialResults.rows);
} else if (liveQuery.rows) {
setNotifications(liveQuery.rows);
}
// Subscribe to changes
if (typeof liveQuery.subscribe === "function") {
liveQuery.subscribe((result: { rows: Notification[] }) => {
if (mounted && result.rows) {
setNotifications(result.rows);
}
});
}
if (typeof liveQuery.unsubscribe === "function") {
liveQueryRef.current = liveQuery;
}
}
} catch (err) {
console.error("[useNotifications] Failed to update query:", err);
}
}
updateQuery();
return () => {
mounted = false;
if (liveQueryRef.current) {
liveQueryRef.current.unsubscribe();
liveQueryRef.current = null;
}
};
}, [userId, searchSpaceId, typeFilter, electricClient]);
// EFFECT 3: Total unread count - independent of type filter
// This ensures the badge count stays consistent regardless of active filter
useEffect(() => {
if (!userId || !electricClient) {
return;
}
let mounted = true;
async function updateUnreadCount() {
// Clean up previous live query
if (unreadCountLiveQueryRef.current) {
unreadCountLiveQueryRef.current.unsubscribe();
unreadCountLiveQueryRef.current = null;
}
try {
const countQuery = `SELECT COUNT(*) as count FROM notifications
WHERE user_id = $1
AND (search_space_id = $2 OR search_space_id IS NULL)
AND read = false`;
// Fetch initial count
const result = await electricClient.db.query<{ count: number }>(countQuery, [
userId,
searchSpaceId,
]);
if (mounted && result.rows?.[0]) {
setTotalUnreadCount(Number(result.rows[0].count) || 0);
}
// Set up live query for real-time updates
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = electricClient.db as any;
if (db.live?.query && typeof db.live.query === "function") {
const liveQuery = await db.live.query(countQuery, [userId, searchSpaceId]);
if (!mounted) {
liveQuery.unsubscribe?.();
return;
}
// Set initial results from live query
if (liveQuery.initialResults?.rows?.[0]) {
setTotalUnreadCount(Number(liveQuery.initialResults.rows[0].count) || 0);
} else if (liveQuery.rows?.[0]) {
setTotalUnreadCount(Number(liveQuery.rows[0].count) || 0);
}
// Subscribe to changes
if (typeof liveQuery.subscribe === "function") {
liveQuery.subscribe((result: { rows: { count: number }[] }) => {
if (mounted && result.rows?.[0]) {
setTotalUnreadCount(Number(result.rows[0].count) || 0);
}
});
}
if (typeof liveQuery.unsubscribe === "function") {
unreadCountLiveQueryRef.current = liveQuery;
}
}
} catch (err) {
console.error("[useNotifications] Failed to update unread count:", err);
}
}
updateUnreadCount();
return () => {
mounted = false;
if (unreadCountLiveQueryRef.current) {
unreadCountLiveQueryRef.current.unsubscribe();
unreadCountLiveQueryRef.current = null;
}
};
}, [userId, searchSpaceId, electricClient]);
// Mark notification as read via backend API
const markAsRead = useCallback(async (notificationId: number) => {
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${notificationId}/read`,
{ method: "PATCH" }
);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: "Failed to mark as read" }));
throw new Error(error.detail || "Failed to mark notification as read");
}
return true;
} catch (err) {
console.error("Failed to mark notification as read:", err);
return false;
}
}, []);
// Mark all notifications as read via backend API
const markAllAsRead = useCallback(async () => {
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/read-all`,
{ method: "PATCH" }
);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: "Failed to mark all as read" }));
throw new Error(error.detail || "Failed to mark all notifications as read");
}
return true;
} catch (err) {
console.error("Failed to mark all notifications as read:", err);
return false;
}
}, []);
return {
notifications,
unreadCount: totalUnreadCount,
markAsRead,
markAllAsRead,
loading,
error,
};
}

View file

@ -0,0 +1,110 @@
import {
type GetNotificationsRequest,
type GetNotificationsResponse,
type GetUnreadCountResponse,
type MarkAllNotificationsReadResponse,
type MarkNotificationReadRequest,
type MarkNotificationReadResponse,
getNotificationsRequest,
getNotificationsResponse,
getUnreadCountResponse,
markAllNotificationsReadResponse,
markNotificationReadRequest,
markNotificationReadResponse,
} from "@/contracts/types/inbox.types";
import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service";
class NotificationsApiService {
/**
* Get notifications with pagination
*/
getNotifications = async (
request: GetNotificationsRequest
): Promise<GetNotificationsResponse> => {
const parsedRequest = getNotificationsRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
const { queryParams } = parsedRequest.data;
// Build query string from params
const params = new URLSearchParams();
if (queryParams.search_space_id !== undefined) {
params.append("search_space_id", String(queryParams.search_space_id));
}
if (queryParams.type) {
params.append("type", queryParams.type);
}
if (queryParams.before_date) {
params.append("before_date", queryParams.before_date);
}
if (queryParams.limit !== undefined) {
params.append("limit", String(queryParams.limit));
}
if (queryParams.offset !== undefined) {
params.append("offset", String(queryParams.offset));
}
const queryString = params.toString();
return baseApiService.get(
`/api/v1/notifications${queryString ? `?${queryString}` : ""}`,
getNotificationsResponse
);
};
/**
* Mark a single notification as read
*/
markAsRead = async (
request: MarkNotificationReadRequest
): Promise<MarkNotificationReadResponse> => {
const parsedRequest = markNotificationReadRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
const { notificationId } = parsedRequest.data;
return baseApiService.patch(
`/api/v1/notifications/${notificationId}/read`,
markNotificationReadResponse
);
};
/**
* Mark all notifications as read
*/
markAllAsRead = async (): Promise<MarkAllNotificationsReadResponse> => {
return baseApiService.patch("/api/v1/notifications/read-all", markAllNotificationsReadResponse);
};
/**
* Get unread notification count with split between total and recent
* - total_unread: All unread notifications
* - recent_unread: Unread within sync window (last 14 days)
*/
getUnreadCount = async (searchSpaceId?: number): Promise<GetUnreadCountResponse> => {
const params = new URLSearchParams();
if (searchSpaceId !== undefined) {
params.append("search_space_id", String(searchSpaceId));
}
const queryString = params.toString();
return baseApiService.get(
`/api/v1/notifications/unread-count${queryString ? `?${queryString}` : ""}`,
getUnreadCountResponse
);
};
}
export const notificationsApiService = new NotificationsApiService();

View file

@ -53,8 +53,9 @@ const activeSyncHandles = new Map<string, SyncHandle>();
const pendingSyncs = new Map<string, Promise<SyncHandle>>(); const pendingSyncs = new Map<string, Promise<SyncHandle>>();
// Version for sync state - increment this to force fresh sync when Electric config changes // Version for sync state - increment this to force fresh sync when Electric config changes
// Set to v2 for user-specific database architecture // v2: user-specific database architecture
const SYNC_VERSION = 2; // v3: consistent cutoff date for sync+queries, visibility refresh support
const SYNC_VERSION = 3;
// Database name prefix for identifying SurfSense databases // Database name prefix for identifying SurfSense databases
const DB_PREFIX = "surfsense-"; const DB_PREFIX = "surfsense-";

View file

@ -692,7 +692,23 @@
"light": "Light", "light": "Light",
"dark": "Dark", "dark": "Dark",
"system": "System", "system": "System",
"logout": "Logout" "logout": "Logout",
"inbox": "Inbox",
"search_inbox": "Search inbox",
"mark_all_read": "Mark all as read",
"mark_as_read": "Mark as read",
"mentions": "Mentions",
"status": "Status",
"no_results_found": "No results found",
"no_mentions": "No mentions",
"no_mentions_hint": "You'll see mentions from others here",
"no_status_updates": "No status updates",
"no_status_updates_hint": "Document and connector updates will appear here",
"filter": "Filter",
"all": "All",
"unread": "Unread",
"connectors": "Connectors",
"all_connectors": "All connectors"
}, },
"errors": { "errors": {
"something_went_wrong": "Something went wrong", "something_went_wrong": "Something went wrong",

View file

@ -677,7 +677,23 @@
"light": "浅色", "light": "浅色",
"dark": "深色", "dark": "深色",
"system": "系统", "system": "系统",
"logout": "退出登录" "logout": "退出登录",
"inbox": "收件箱",
"search_inbox": "搜索收件箱...",
"mark_all_read": "全部标记为已读",
"mark_as_read": "标记为已读",
"mentions": "提及",
"status": "状态",
"no_results_found": "未找到结果",
"no_mentions": "没有提及",
"no_mentions_hint": "您会在这里看到他人的提及",
"no_status_updates": "没有状态更新",
"no_status_updates_hint": "文档和连接器更新将显示在这里",
"filter": "筛选",
"all": "全部",
"unread": "未读",
"connectors": "连接器",
"all_connectors": "所有连接器"
}, },
"errors": { "errors": {
"something_went_wrong": "出错了", "something_went_wrong": "出错了",

View file

@ -105,6 +105,7 @@
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"unist-util-visit": "^5.0.0", "unist-util-visit": "^5.0.0",
"vaul": "^1.1.2",
"zod": "^4.2.1", "zod": "^4.2.1",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },

View file

@ -260,6 +260,9 @@ importers:
unist-util-visit: unist-util-visit:
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.0.0 version: 5.0.0
vaul:
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
zod: zod:
specifier: ^4.2.1 specifier: ^4.2.1
version: 4.2.1 version: 4.2.1
@ -6384,6 +6387,12 @@ packages:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true hasBin: true
vaul@1.1.2:
resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
vfile-location@5.0.3: vfile-location@5.0.3:
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
@ -13451,6 +13460,15 @@ snapshots:
uuid@8.3.2: {} uuid@8.3.2: {}
vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
transitivePeerDependencies:
- '@types/react'
- '@types/react-dom'
vfile-location@5.0.3: vfile-location@5.0.3:
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3