Merge remote-tracking branch 'upstream/dev' into fix/documents

This commit is contained in:
Anish Sarkar 2026-02-06 05:36:32 +05:30
commit c132e5ddb0
49 changed files with 1625 additions and 354 deletions

View file

@ -2,7 +2,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import { AlertTriangle, Inbox, LogOut, SquareLibrary, Trash2 } from "lucide-react";
import { AlertTriangle, Inbox, LogOut, PencilIcon, SquareLibrary, Trash2 } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
@ -21,10 +21,12 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
import { useInbox } from "@/hooks/use-inbox";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-persistence";
import { logout } from "@/lib/auth-utils";
import { cleanupElectric } from "@/lib/electric/client";
import { resetUser, trackLogout } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
@ -109,7 +111,6 @@ export function LayoutDataProvider({
// This ensures each tab has independent pagination and data loading
const userId = user?.id ? String(user.id) : null;
// Mentions: Only fetch "new_mention" type notifications
const {
inboxItems: mentionItems,
unreadCount: mentionUnreadCount,
@ -121,11 +122,9 @@ export function LayoutDataProvider({
markAllAsRead: markAllMentionsAsRead,
} = useInbox(userId, Number(searchSpaceId) || null, "new_mention");
// Status: Fetch all types (will be filtered client-side to status types)
// We pass null to get all, then InboxSidebar filters to status types
const {
inboxItems: statusItems,
unreadCount: statusUnreadCount,
unreadCount: allUnreadCount,
loading: statusLoading,
loadingMore: statusLoadingMore,
hasMore: statusHasMore,
@ -134,8 +133,8 @@ export function LayoutDataProvider({
markAllAsRead: markAllStatusAsRead,
} = useInbox(userId, Number(searchSpaceId) || null, null);
// Combined unread count for nav badge (mentions take priority for visibility)
const totalUnreadCount = mentionUnreadCount + statusUnreadCount;
const totalUnreadCount = allUnreadCount;
const statusOnlyUnreadCount = Math.max(0, allUnreadCount - mentionUnreadCount);
// Track seen notification IDs to detect new page_limit_exceeded notifications
const seenPageLimitNotifications = useRef<Set<number>>(new Set());
@ -207,6 +206,12 @@ export function LayoutDataProvider({
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
const [isDeletingChat, setIsDeletingChat] = useState(false);
// Rename dialog state
const [showRenameChatDialog, setShowRenameChatDialog] = useState(false);
const [chatToRename, setChatToRename] = useState<{ id: number; name: string } | null>(null);
const [newChatTitle, setNewChatTitle] = useState("");
const [isRenamingChat, setIsRenamingChat] = useState(false);
// Delete/Leave search space dialog state
const [showDeleteSearchSpaceDialog, setShowDeleteSearchSpaceDialog] = useState(false);
const [showLeaveSearchSpaceDialog, setShowLeaveSearchSpaceDialog] = useState(false);
@ -421,6 +426,12 @@ export function LayoutDataProvider({
setShowDeleteChatDialog(true);
}, []);
const handleChatRename = useCallback((chat: ChatItem) => {
setChatToRename({ id: chat.id, name: chat.name });
setNewChatTitle(chat.name);
setShowRenameChatDialog(true);
}, []);
const handleChatArchive = useCallback(
async (chat: ChatItem) => {
const newArchivedState = !chat.archived;
@ -464,12 +475,15 @@ export function LayoutDataProvider({
console.warn("[Logout] Electric cleanup failed (will be handled on next login):", err);
}
// Revoke refresh token on server and clear all tokens from localStorage
await logout();
if (typeof window !== "undefined") {
localStorage.removeItem("surfsense_bearer_token");
router.push("/");
}
} catch (error) {
console.error("Error during logout:", error);
await logout();
router.push("/");
}
}, [router]);
@ -501,6 +515,29 @@ export function LayoutDataProvider({
}
}, [chatToDelete, queryClient, searchSpaceId, router, currentChatId]);
// Rename handler
const confirmRenameChat = useCallback(async () => {
if (!chatToRename || !newChatTitle.trim()) return;
setIsRenamingChat(true);
try {
await updateThread(chatToRename.id, { title: newChatTitle.trim() });
toast.success(tSidebar("chat_renamed") || "Chat renamed");
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
// Invalidate thread detail for breadcrumb update
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId, "detail", String(chatToRename.id)] });
} catch (error) {
console.error("Error renaming thread:", error);
toast.error(tSidebar("error_renaming_chat") || "Failed to rename chat");
} finally {
setIsRenamingChat(false);
setShowRenameChatDialog(false);
setChatToRename(null);
setNewChatTitle("");
}
}, [chatToRename, newChatTitle, queryClient, searchSpaceId, tSidebar]);
// Page usage
const pageUsage = user
? {
@ -529,6 +566,7 @@ export function LayoutDataProvider({
activeChatId={currentChatId}
onNewChat={handleNewChat}
onChatSelect={handleChatSelect}
onChatRename={handleChatRename}
onChatDelete={handleChatDelete}
onChatArchive={handleChatArchive}
onViewAllSharedChats={handleViewAllSharedChats}
@ -562,7 +600,7 @@ export function LayoutDataProvider({
},
status: {
items: statusItems,
unreadCount: statusUnreadCount,
unreadCount: statusOnlyUnreadCount,
loading: statusLoading,
loadingMore: statusLoadingMore,
hasMore: statusHasMore,
@ -621,6 +659,57 @@ export function LayoutDataProvider({
</DialogContent>
</Dialog>
{/* Rename Chat Dialog */}
<Dialog open={showRenameChatDialog} onOpenChange={setShowRenameChatDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<PencilIcon className="h-5 w-5" />
<span>{tSidebar("rename_chat") || "Rename Chat"}</span>
</DialogTitle>
<DialogDescription>
{tSidebar("rename_chat_description") || "Enter a new name for this conversation."}
</DialogDescription>
</DialogHeader>
<Input
value={newChatTitle}
onChange={(e) => setNewChatTitle(e.target.value)}
placeholder={tSidebar("chat_title_placeholder") || "Chat title"}
onKeyDown={(e) => {
if (e.key === "Enter" && !isRenamingChat && newChatTitle.trim()) {
confirmRenameChat();
}
}}
/>
<DialogFooter className="flex gap-2 sm:justify-end">
<Button
variant="outline"
onClick={() => setShowRenameChatDialog(false)}
disabled={isRenamingChat}
>
{tCommon("cancel")}
</Button>
<Button
onClick={confirmRenameChat}
disabled={isRenamingChat || !newChatTitle.trim()}
className="gap-2"
>
{isRenamingChat ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{tSidebar("renaming") || "Renaming..."}
</>
) : (
<>
<PencilIcon className="h-4 w-4" />
{tSidebar("rename") || "Rename"}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Search Space Dialog */}
<Dialog open={showDeleteSearchSpaceDialog} onOpenChange={setShowDeleteSearchSpaceDialog}>
<DialogContent className="sm:max-w-md">

View file

@ -54,6 +54,7 @@ interface LayoutShellProps {
activeChatId?: number | null;
onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void;
onChatRename?: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onChatArchive?: (chat: ChatItem) => void;
onViewAllSharedChats?: () => void;
@ -91,6 +92,7 @@ export function LayoutShell({
activeChatId,
onNewChat,
onChatSelect,
onChatRename,
onChatDelete,
onChatArchive,
onViewAllSharedChats,
@ -149,6 +151,7 @@ export function LayoutShell({
activeChatId={activeChatId}
onNewChat={onNewChat}
onChatSelect={onChatSelect}
onChatRename={onChatRename}
onChatDelete={onChatDelete}
onChatArchive={onChatArchive}
onViewAllSharedChats={onViewAllSharedChats}
@ -218,6 +221,7 @@ export function LayoutShell({
activeChatId={activeChatId}
onNewChat={onNewChat}
onChatSelect={onChatSelect}
onChatRename={onChatRename}
onChatDelete={onChatDelete}
onChatArchive={onChatArchive}
onViewAllSharedChats={onViewAllSharedChats}

View file

@ -1,6 +1,6 @@
"use client";
import { ArchiveIcon, MessageSquare, MoreHorizontal, RotateCcwIcon, Trash2 } from "lucide-react";
import { ArchiveIcon, MessageSquare, MoreHorizontal, PencilIcon, RotateCcwIcon, Trash2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
@ -17,6 +17,7 @@ interface ChatListItemProps {
isActive?: boolean;
archived?: boolean;
onClick?: () => void;
onRename?: () => void;
onArchive?: () => void;
onDelete?: () => void;
}
@ -26,6 +27,7 @@ export function ChatListItem({
isActive,
archived,
onClick,
onRename,
onArchive,
onDelete,
}: ChatListItemProps) {
@ -57,15 +59,26 @@ export function ChatListItem({
<span className="sr-only">{t("more_options")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="right">
{onArchive && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onArchive();
}}
>
{archived ? (
<DropdownMenuContent align="end" side="right">
{onRename && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onRename();
}}
>
<PencilIcon className="mr-2 h-4 w-4" />
<span>{t("rename") || "Rename"}</span>
</DropdownMenuItem>
)}
{onArchive && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onArchive();
}}
>
{archived ? (
<>
<RotateCcwIcon className="mr-2 h-4 w-4" />
<span>{t("unarchive") || "Restore"}</span>

View file

@ -4,7 +4,6 @@ import { useAtom } from "jotai";
import {
AlertCircle,
AlertTriangle,
AtSign,
BellDot,
Check,
CheckCheck,
@ -15,6 +14,7 @@ import {
Inbox,
LayoutGrid,
ListFilter,
MessageSquare,
Search,
X,
} from "lucide-react";
@ -46,6 +46,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import {
isCommentReplyMetadata,
isConnectorIndexingMetadata,
isNewMentionMetadata,
isPageLimitExceededMetadata,
@ -133,7 +134,7 @@ function getConnectorTypeDisplayName(connectorType: string): string {
);
}
type InboxTab = "mentions" | "status";
type InboxTab = "comments" | "status";
type InboxFilter = "all" | "unread";
// Tab-specific data source with independent pagination
@ -186,7 +187,7 @@ export function InboxSidebar({
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
const [searchQuery, setSearchQuery] = useState("");
const [activeTab, setActiveTab] = useState<InboxTab>("mentions");
const [activeTab, setActiveTab] = useState<InboxTab>("comments");
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
const [selectedConnector, setSelectedConnector] = useState<string | null>(null);
const [mounted, setMounted] = useState(false);
@ -233,12 +234,17 @@ export function InboxSidebar({
}
}, [activeTab]);
// Get current tab's data source - each tab has independent data and pagination
const currentDataSource = activeTab === "mentions" ? mentions : status;
const { loading, loadingMore = false, hasMore = false, loadMore } = currentDataSource;
// Both tabs now derive items from status (all types), so use status for pagination
const { loading, loadingMore = false, hasMore = false, loadMore } = status;
// Status tab includes: connector indexing, document processing, page limit exceeded, connector deletion
// Filter to only show status notification types
// Comments tab: mentions and comment replies
const commentsItems = useMemo(
() =>
status.items.filter((item) => item.type === "new_mention" || item.type === "comment_reply"),
[status.items]
);
// Status tab: connector indexing, document processing, page limit exceeded, connector deletion
const statusItems = useMemo(
() =>
status.items.filter(
@ -270,8 +276,8 @@ export function InboxSidebar({
}));
}, [statusItems]);
// Get items for current tab - mentions use their source directly, status uses filtered items
const displayItems = activeTab === "mentions" ? mentions.items : statusItems;
// Get items for current tab
const displayItems = activeTab === "comments" ? commentsItems : statusItems;
// Filter items based on filter type, connector filter, and search query
const filteredItems = useMemo(() => {
@ -334,9 +340,15 @@ export function InboxSidebar({
return () => observer.disconnect();
}, [loadMore, hasMore, loadingMore, open, searchQuery]);
// Use unread counts from data sources (more accurate than client-side counting)
const unreadMentionsCount = mentions.unreadCount;
const unreadStatusCount = status.unreadCount;
// Unread counts derived from filtered items
const unreadCommentsCount = useMemo(
() => commentsItems.filter((item) => !item.read).length,
[commentsItems]
);
const unreadStatusCount = useMemo(
() => statusItems.filter((item) => !item.read).length,
[statusItems]
);
const handleItemClick = useCallback(
async (item: InboxItem) => {
@ -347,19 +359,15 @@ export function InboxSidebar({
}
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) {
// Pre-set target comment ID before navigation
// This also ensures comments panel is not collapsed
if (commentId) {
setTargetCommentId(commentId);
}
const url = commentId
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
@ -368,6 +376,24 @@ export function InboxSidebar({
router.push(url);
}
}
} else if (item.type === "comment_reply") {
if (isCommentReplyMetadata(item.metadata)) {
const searchSpaceId = item.search_space_id;
const threadId = item.metadata.thread_id;
const replyId = item.metadata.reply_id;
if (searchSpaceId && threadId) {
if (replyId) {
setTargetCommentId(replyId);
}
const url = replyId
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${replyId}`
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
onOpenChange(false);
onCloseMobileSidebar?.();
router.push(url);
}
}
} else if (item.type === "page_limit_exceeded") {
// Navigate to the upgrade/more-pages page
if (isPageLimitExceededMetadata(item.metadata)) {
@ -411,24 +437,29 @@ export function InboxSidebar({
};
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;
// For mentions and comment replies, show the author's avatar
if (item.type === "new_mention" || item.type === "comment_reply") {
const metadata =
item.type === "new_mention"
? isNewMentionMetadata(item.metadata)
? item.metadata
: null
: isCommentReplyMetadata(item.metadata)
? item.metadata
: null;
if (metadata) {
return (
<Avatar className="h-8 w-8">
{avatarUrl && <AvatarImage src={avatarUrl} alt={authorName || "User"} />}
{metadata.author_avatar_url && (
<AvatarImage src={metadata.author_avatar_url} alt={metadata.author_name || "User"} />
)}
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
{getInitials(authorName, authorEmail)}
{getInitials(metadata.author_name, metadata.author_email)}
</AvatarFallback>
</Avatar>
);
}
// Fallback for invalid metadata
return (
<Avatar className="h-8 w-8">
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
@ -481,10 +512,10 @@ export function InboxSidebar({
};
const getEmptyStateMessage = () => {
if (activeTab === "mentions") {
if (activeTab === "comments") {
return {
title: t("no_mentions") || "No mentions",
hint: t("no_mentions_hint") || "You'll see mentions from others here",
title: t("no_comments") || "No comments",
hint: t("no_comments_hint") || "You'll see mentions and replies here",
};
}
return {
@ -823,14 +854,14 @@ export function InboxSidebar({
>
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
<TabsTrigger
value="mentions"
value="comments"
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>
<MessageSquare className="h-4 w-4" />
<span>{t("comments") || "Comments"}</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">
{formatInboxCount(unreadMentionsCount)}
{formatInboxCount(unreadCommentsCount)}
</span>
</span>
</TabsTrigger>
@ -932,8 +963,8 @@ export function InboxSidebar({
</div>
) : (
<div className="text-center py-8">
{activeTab === "mentions" ? (
<AtSign className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
{activeTab === "comments" ? (
<MessageSquare 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" />
)}

View file

@ -24,6 +24,7 @@ interface MobileSidebarProps {
activeChatId?: number | null;
onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void;
onChatRename?: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onChatArchive?: (chat: ChatItem) => void;
onViewAllSharedChats?: () => void;
@ -65,6 +66,7 @@ export function MobileSidebar({
activeChatId,
onNewChat,
onChatSelect,
onChatRename,
onChatDelete,
onChatArchive,
onViewAllSharedChats,
@ -144,6 +146,7 @@ export function MobileSidebar({
onOpenChange(false);
}}
onChatSelect={handleChatSelect}
onChatRename={onChatRename}
onChatDelete={onChatDelete}
onChatArchive={onChatArchive}
onViewAllSharedChats={onViewAllSharedChats}

View file

@ -35,6 +35,7 @@ interface SidebarProps {
activeChatId?: number | null;
onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void;
onChatRename?: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onChatArchive?: (chat: ChatItem) => void;
onViewAllSharedChats?: () => void;
@ -62,6 +63,7 @@ export function Sidebar({
activeChatId,
onNewChat,
onChatSelect,
onChatRename,
onChatDelete,
onChatArchive,
onViewAllSharedChats,
@ -183,6 +185,7 @@ export function Sidebar({
isActive={chat.id === activeChatId}
archived={chat.archived}
onClick={() => onChatSelect(chat)}
onRename={() => onChatRename?.(chat)}
onArchive={() => onChatArchive?.(chat)}
onDelete={() => onChatDelete?.(chat)}
/>
@ -243,6 +246,7 @@ export function Sidebar({
isActive={chat.id === activeChatId}
archived={chat.archived}
onClick={() => onChatSelect(chat)}
onRename={() => onChatRename?.(chat)}
onArchive={() => onChatArchive?.(chat)}
onDelete={() => onChatDelete?.(chat)}
/>

View file

@ -1,7 +1,8 @@
"use client";
import { Check, ChevronUp, Languages, Laptop, LogOut, Moon, Settings, Sun } from "lucide-react";
import { Check, ChevronUp, Languages, Laptop, Loader2, LogOut, Moon, Settings, Sun } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
@ -124,6 +125,7 @@ export function SidebarUserProfile({
}: SidebarUserProfileProps) {
const t = useTranslations("sidebar");
const { locale, setLocale } = useLocaleContext();
const [isLoggingOut, setIsLoggingOut] = useState(false);
const bgColor = stringToColor(user.email);
const initials = getInitials(user.email);
const displayName = user.name || user.email.split("@")[0];
@ -136,6 +138,16 @@ export function SidebarUserProfile({
setTheme?.(newTheme);
};
const handleLogout = async () => {
if (isLoggingOut || !onLogout) return;
setIsLoggingOut(true);
try {
await onLogout();
} finally {
setIsLoggingOut(false);
}
};
// Collapsed view - just show avatar with dropdown
if (isCollapsed) {
return (
@ -242,9 +254,13 @@ export function SidebarUserProfile({
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onLogout}>
<LogOut className="mr-2 h-4 w-4" />
{t("logout")}
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
{isLoggingOut ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<LogOut className="mr-2 h-4 w-4" />
)}
{isLoggingOut ? t("loggingOut") : t("logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -360,9 +376,13 @@ export function SidebarUserProfile({
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onLogout}>
<LogOut className="mr-2 h-4 w-4" />
{t("logout")}
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
{isLoggingOut ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<LogOut className="mr-2 h-4 w-4" />
)}
{isLoggingOut ? t("loggingOut") : t("logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>