refactor(chat): consolidate chat handling by merging shared and private chat sections into a unified chat view, update related components and translations

This commit is contained in:
Anish Sarkar 2026-06-03 00:07:13 +05:30
parent 9daaf12658
commit c002f45c8e
15 changed files with 116 additions and 826 deletions

View file

@ -3,7 +3,7 @@
import { Inbox, LibraryBig } from "lucide-react";
import { useRouter } from "next/navigation";
import type { ReactNode } from "react";
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useAnonymousMode } from "@/contexts/anonymous-mode";
import { useLoginGate } from "@/contexts/login-gate";
import { useAnnouncements } from "@/hooks/use-announcements";
@ -110,15 +110,13 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
navItems={navItems}
onNavItemClick={handleNavItemClick}
chats={[]}
sharedChats={[]}
activeChatId={null}
onNewChat={resetChat}
onChatSelect={handleChatSelect}
onChatRename={gatedAction("rename chats")}
onChatDelete={gatedAction("delete chats")}
onChatArchive={gatedAction("archive chats")}
onViewAllSharedChats={gatedAction("view shared chats")}
onViewAllPrivateChats={gatedAction("view chat history")}
onViewAllChats={gatedAction("view chat history")}
user={{
email: "Guest",
name: "Guest",
@ -137,7 +135,7 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
onOpenChange: setIsDocsSidebarOpen,
}}
>
<Fragment>{children}</Fragment>
{children}
</LayoutShell>
);
}

View file

@ -121,7 +121,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
});
// Unified slide-out panel state (only one can be open at a time)
type SlideoutPanel = "inbox" | "shared" | "private" | null;
type SlideoutPanel = "inbox" | "chats" | null;
const [activeSlideoutPanel, setActiveSlideoutPanel] = useState<SlideoutPanel>(null);
const isInboxSidebarOpen = activeSlideoutPanel === "inbox";
@ -304,34 +304,17 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
});
}, [currentChatId, searchSpaceId, threadsData?.threads, syncChatTab]);
// Transform and split chats into private and shared based on visibility
const { myChats, sharedChats } = useMemo(() => {
if (!threadsData?.threads) return { myChats: [], sharedChats: [] };
const chats = useMemo(() => {
if (!threadsData?.threads) return [];
const privateChats: ChatItem[] = [];
const sharedChatsList: ChatItem[] = [];
for (const thread of threadsData.threads) {
const chatItem: ChatItem = {
id: thread.id,
name: thread.title || `Chat ${thread.id}`,
url: `/dashboard/${searchSpaceId}/new-chat/${thread.id}`,
visibility: thread.visibility,
isOwnThread: thread.is_own_thread,
archived: thread.archived,
};
// Split based on visibility, not ownership:
// - PRIVATE chats go to "Private Chats" section
// - SEARCH_SPACE chats go to "Shared Chats" section
if (thread.visibility === "SEARCH_SPACE") {
sharedChatsList.push(chatItem);
} else {
privateChats.push(chatItem);
}
}
return { myChats: privateChats, sharedChats: sharedChatsList };
return threadsData.threads.map<ChatItem>((thread) => ({
id: thread.id,
name: thread.title || `Chat ${thread.id}`,
url: `/dashboard/${searchSpaceId}/new-chat/${thread.id}`,
visibility: thread.visibility,
isOwnThread: thread.is_own_thread,
archived: thread.archived,
}));
}, [threadsData, searchSpaceId]);
// Navigation items
@ -599,12 +582,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
}
}, [router]);
const handleViewAllSharedChats = useCallback(() => {
setActiveSlideoutPanel((prev) => (prev === "shared" ? null : "shared"));
}, []);
const handleViewAllPrivateChats = useCallback(() => {
setActiveSlideoutPanel((prev) => (prev === "private" ? null : "private"));
const handleViewAllChats = useCallback(() => {
setActiveSlideoutPanel((prev) => (prev === "chats" ? null : "chats"));
}, []);
// Delete handlers
@ -695,16 +674,14 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
searchSpace={activeSearchSpace}
navItems={navItems}
onNavItemClick={handleNavItemClick}
chats={myChats}
sharedChats={sharedChats}
chats={chats}
activeChatId={currentChatId}
onNewChat={handleNewChat}
onChatSelect={handleChatSelect}
onChatRename={handleChatRename}
onChatDelete={handleChatDelete}
onChatArchive={handleChatArchive}
onViewAllSharedChats={handleViewAllSharedChats}
onViewAllPrivateChats={handleViewAllPrivateChats}
onViewAllChats={handleViewAllChats}
user={{
email: user?.email || "",
name: user?.display_name || user?.email?.split("@")[0],
@ -759,10 +736,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
markAllAsRead: statusInbox.markAllAsRead,
},
}}
allSharedChatsPanel={{
searchSpaceId,
}}
allPrivateChatsPanel={{
allChatsPanel={{
searchSpaceId,
}}
documentsPanel={{

View file

@ -70,8 +70,7 @@ export interface ChatsSectionProps {
activeChatId?: number | null;
onChatSelect: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onViewAllSharedChats?: () => void;
onViewAllPrivateChats?: () => void;
onViewAllChats?: () => void;
searchSpaceId?: string;
}
@ -96,13 +95,11 @@ export interface SidebarProps {
searchSpaceId?: string;
navItems: NavItem[];
chats: ChatItem[];
sharedChats?: ChatItem[];
activeChatId?: number | null;
onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onViewAllSharedChats?: () => void;
onViewAllPrivateChats?: () => void;
onViewAllChats?: () => void;
user: User;
theme?: string;
onSettings?: () => void;

View file

@ -27,8 +27,7 @@ import {
RightPanelToggleButton,
} from "../right-panel/RightPanel";
import {
AllPrivateChatsSidebarContent,
AllSharedChatsSidebarContent,
AllChatsSidebarContent,
DocumentsSidebar,
InboxSidebarContent,
MobileSidebar,
@ -94,7 +93,7 @@ interface TabDataSource {
markAllAsRead: () => Promise<boolean>;
}
export type ActiveSlideoutPanel = "inbox" | "shared" | "private" | null;
export type ActiveSlideoutPanel = "inbox" | "chats" | null;
// Inbox-related props — per-tab data sources with independent loading/pagination
interface InboxProps {
@ -115,15 +114,13 @@ interface LayoutShellProps {
navItems: NavItem[];
onNavItemClick?: (item: NavItem) => void;
chats: ChatItem[];
sharedChats?: ChatItem[];
activeChatId?: number | null;
onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void;
onChatRename?: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onChatArchive?: (chat: ChatItem) => void;
onViewAllSharedChats?: () => void;
onViewAllPrivateChats?: () => void;
onViewAllChats?: () => void;
user: User;
onSettings?: () => void;
onManageMembers?: () => void;
@ -148,10 +145,7 @@ interface LayoutShellProps {
inbox?: InboxProps;
isLoadingChats?: boolean;
// All chats panel props
allSharedChatsPanel?: {
searchSpaceId: string;
};
allPrivateChatsPanel?: {
allChatsPanel?: {
searchSpaceId: string;
};
documentsPanel?: {
@ -226,15 +220,13 @@ export function LayoutShell({
navItems,
onNavItemClick,
chats,
sharedChats,
activeChatId,
onNewChat,
onChatSelect,
onChatRename,
onChatDelete,
onChatArchive,
onViewAllSharedChats,
onViewAllPrivateChats,
onViewAllChats,
user,
onSettings,
onManageMembers,
@ -256,8 +248,7 @@ export function LayoutShell({
onSlideoutPanelChange,
inbox,
isLoadingChats = false,
allSharedChatsPanel,
allPrivateChatsPanel,
allChatsPanel,
documentsPanel,
onTabSwitch,
}: LayoutShellProps) {
@ -288,13 +279,7 @@ export function LayoutShell({
const anySlideOutOpen = activeSlideoutPanel !== null;
const panelAriaLabel =
activeSlideoutPanel === "inbox"
? "Inbox"
: activeSlideoutPanel === "shared"
? "Shared Chats"
: activeSlideoutPanel === "private"
? "Private Chats"
: "Panel";
activeSlideoutPanel === "inbox" ? "Inbox" : activeSlideoutPanel === "chats" ? "Chats" : "Panel";
// Mobile layout
if (isMobile) {
@ -317,17 +302,14 @@ export function LayoutShell({
navItems={navItems}
onNavItemClick={onNavItemClick}
chats={chats}
sharedChats={sharedChats}
activeChatId={activeChatId}
onNewChat={onNewChat}
onChatSelect={onChatSelect}
onChatRename={onChatRename}
onChatDelete={onChatDelete}
onChatArchive={onChatArchive}
onViewAllSharedChats={onViewAllSharedChats}
onViewAllPrivateChats={onViewAllPrivateChats}
isSharedChatsPanelOpen={activeSlideoutPanel === "shared"}
isPrivateChatsPanelOpen={activeSlideoutPanel === "private"}
onViewAllChats={onViewAllChats}
isChatsPanelOpen={activeSlideoutPanel === "chats"}
user={user}
onSettings={onSettings}
onManageMembers={onManageMembers}
@ -379,34 +361,18 @@ export function LayoutShell({
/>
</motion.div>
)}
{activeSlideoutPanel === "shared" && allSharedChatsPanel && (
{activeSlideoutPanel === "chats" && allChatsPanel && (
<motion.div
key="shared"
key="chats"
className="h-full flex flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<AllSharedChatsSidebarContent
<AllChatsSidebarContent
onOpenChange={(open) => closeSlideout(open)}
searchSpaceId={allSharedChatsPanel.searchSpaceId}
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
/>
</motion.div>
)}
{activeSlideoutPanel === "private" && allPrivateChatsPanel && (
<motion.div
key="private"
className="h-full flex flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<AllPrivateChatsSidebarContent
onOpenChange={(open) => closeSlideout(open)}
searchSpaceId={allPrivateChatsPanel.searchSpaceId}
searchSpaceId={allChatsPanel.searchSpaceId}
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
/>
</motion.div>
@ -478,17 +444,14 @@ export function LayoutShell({
navItems={navItems}
onNavItemClick={onNavItemClick}
chats={chats}
sharedChats={sharedChats}
activeChatId={activeChatId}
onNewChat={onNewChat}
onChatSelect={onChatSelect}
onChatRename={onChatRename}
onChatDelete={onChatDelete}
onChatArchive={onChatArchive}
onViewAllSharedChats={onViewAllSharedChats}
onViewAllPrivateChats={onViewAllPrivateChats}
isSharedChatsPanelOpen={activeSlideoutPanel === "shared"}
isPrivateChatsPanelOpen={activeSlideoutPanel === "private"}
onViewAllChats={onViewAllChats}
isChatsPanelOpen={activeSlideoutPanel === "chats"}
user={user}
onSettings={onSettings}
onManageMembers={onManageMembers}
@ -554,33 +517,18 @@ export function LayoutShell({
/>
</motion.div>
)}
{activeSlideoutPanel === "shared" && allSharedChatsPanel && (
{activeSlideoutPanel === "chats" && allChatsPanel && (
<motion.div
key="shared"
key="chats"
className="h-full flex flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<AllSharedChatsSidebarContent
<AllChatsSidebarContent
onOpenChange={(open) => closeSlideout(open)}
searchSpaceId={allSharedChatsPanel.searchSpaceId}
/>
</motion.div>
)}
{activeSlideoutPanel === "private" && allPrivateChatsPanel && (
<motion.div
key="private"
className="h-full flex flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<AllPrivateChatsSidebarContent
onOpenChange={(open) => closeSlideout(open)}
searchSpaceId={allPrivateChatsPanel.searchSpaceId}
searchSpaceId={allChatsPanel.searchSpaceId}
/>
</motion.div>
)}

View file

@ -12,6 +12,7 @@ import {
Search,
Trash2,
User,
Users,
X,
} from "lucide-react";
import { useParams, useRouter } from "next/navigation";
@ -52,21 +53,21 @@ import { formatThreadTimestamp } from "@/lib/format-date";
import { cn } from "@/lib/utils";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
export interface AllPrivateChatsSidebarContentProps {
export interface AllChatsSidebarContentProps {
onOpenChange: (open: boolean) => void;
searchSpaceId: string;
onCloseMobileSidebar?: () => void;
}
interface AllPrivateChatsSidebarProps extends AllPrivateChatsSidebarContentProps {
interface AllChatsSidebarProps extends AllChatsSidebarContentProps {
open: boolean;
}
export function AllPrivateChatsSidebarContent({
export function AllChatsSidebarContent({
onOpenChange,
searchSpaceId,
onCloseMobileSidebar,
}: AllPrivateChatsSidebarContentProps) {
}: AllChatsSidebarContentProps) {
const t = useTranslations("sidebar");
const router = useRouter();
const params = useParams();
@ -122,28 +123,20 @@ export function AllPrivateChatsSidebarContent({
enabled: !!searchSpaceId && isSearchMode,
});
// Filter to only private chats (PRIVATE visibility or no visibility set)
const { activeChats, archivedChats } = useMemo(() => {
if (isSearchMode) {
const privateSearchResults = (searchData ?? []).filter(
(thread) => thread.visibility !== "SEARCH_SPACE"
);
return {
activeChats: privateSearchResults.filter((t) => !t.archived),
archivedChats: privateSearchResults.filter((t) => t.archived),
activeChats: (searchData ?? []).filter((t) => !t.archived),
archivedChats: (searchData ?? []).filter((t) => t.archived),
};
}
if (!threadsData) return { activeChats: [], archivedChats: [] };
const activePrivate = threadsData.threads.filter(
(thread) => thread.visibility !== "SEARCH_SPACE"
);
const archivedPrivate = threadsData.archived_threads.filter(
(thread) => thread.visibility !== "SEARCH_SPACE"
);
return { activeChats: activePrivate, archivedChats: archivedPrivate };
return {
activeChats: threadsData.threads,
archivedChats: threadsData.archived_threads,
};
}, [threadsData, searchData, isSearchMode]);
const threads = showArchived ? archivedChats : activeChats;
@ -265,7 +258,7 @@ export function AllPrivateChatsSidebarContent({
<span className="sr-only">{t("close") || "Close"}</span>
</Button>
)}
<h2 className="text-lg font-semibold">{t("chats") || "Private Chats"}</h2>
<h2 className="text-lg font-semibold">{t("chats") || "Chats"}</h2>
</div>
<div className="relative">
@ -370,7 +363,13 @@ export function AllPrivateChatsSidebarContent({
isBusy && "opacity-50 pointer-events-none"
)}
>
<span className="truncate">{thread.title || "New Chat"}</span>
<span className="min-w-0 flex-1 truncate">{thread.title || "New Chat"}</span>
{thread.visibility === "SEARCH_SPACE" ? (
<Users
aria-label={t("shared_chat") || "Shared chat"}
className="h-3 w-3 shrink-0 text-muted-foreground/50"
/>
) : null}
</Button>
) : (
<Tooltip delayDuration={600}>
@ -388,7 +387,15 @@ export function AllPrivateChatsSidebarContent({
isBusy && "opacity-50 pointer-events-none"
)}
>
<span className="truncate">{thread.title || "New Chat"}</span>
<span className="min-w-0 flex-1 truncate">
{thread.title || "New Chat"}
</span>
{thread.visibility === "SEARCH_SPACE" ? (
<Users
aria-label={t("shared_chat") || "Shared chat"}
className="h-3 w-3 shrink-0 text-muted-foreground/50"
/>
) : null}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" align="start">
@ -486,7 +493,7 @@ export function AllPrivateChatsSidebarContent({
<p className="text-xs text-muted-foreground">
{showArchived
? t("no_archived_chats") || "No archived chats"
: t("no_chats") || "No private chats"}
: t("no_chats") || "No chats"}
</p>
{!showArchived && (
<p className="mt-1 text-[11px] text-muted-foreground/70">
@ -545,21 +552,17 @@ export function AllPrivateChatsSidebarContent({
);
}
export function AllPrivateChatsSidebar({
export function AllChatsSidebar({
open,
onOpenChange,
searchSpaceId,
onCloseMobileSidebar,
}: AllPrivateChatsSidebarProps) {
}: AllChatsSidebarProps) {
const t = useTranslations("sidebar");
return (
<SidebarSlideOutPanel
open={open}
onOpenChange={onOpenChange}
ariaLabel={t("chats") || "Private Chats"}
>
<AllPrivateChatsSidebarContent
<SidebarSlideOutPanel open={open} onOpenChange={onOpenChange} ariaLabel={t("chats") || "Chats"}>
<AllChatsSidebarContent
onOpenChange={onOpenChange}
searchSpaceId={searchSpaceId}
onCloseMobileSidebar={onCloseMobileSidebar}

View file

@ -1,568 +0,0 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useSetAtom } from "jotai";
import {
ArchiveIcon,
ChevronLeft,
MessageCircleMore,
MoreHorizontal,
Pencil,
RotateCcwIcon,
Search,
Trash2,
Users,
X,
} from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { removeChatTabAtom } from "@/atoms/tabs/tabs.atom";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useLongPress } from "@/hooks/use-long-press";
import { useIsMobile } from "@/hooks/use-mobile";
import {
deleteThread,
fetchThreads,
searchThreads,
updateThread,
} from "@/lib/chat/thread-persistence";
import { formatThreadTimestamp } from "@/lib/format-date";
import { cn } from "@/lib/utils";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
export interface AllSharedChatsSidebarContentProps {
onOpenChange: (open: boolean) => void;
searchSpaceId: string;
onCloseMobileSidebar?: () => void;
}
interface AllSharedChatsSidebarProps extends AllSharedChatsSidebarContentProps {
open: boolean;
}
export function AllSharedChatsSidebarContent({
onOpenChange,
searchSpaceId,
onCloseMobileSidebar,
}: AllSharedChatsSidebarContentProps) {
const t = useTranslations("sidebar");
const router = useRouter();
const params = useParams();
const queryClient = useQueryClient();
const isMobile = useIsMobile();
const removeChatTab = useSetAtom(removeChatTabAtom);
const currentChatId = Array.isArray(params.chat_id)
? Number(params.chat_id[0])
: params.chat_id
? Number(params.chat_id)
: null;
const [deletingThreadId, setDeletingThreadId] = useState<number | null>(null);
const [archivingThreadId, setArchivingThreadId] = useState<number | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [showArchived, setShowArchived] = useState(false);
const [openDropdownId, setOpenDropdownId] = useState<number | null>(null);
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [renamingThread, setRenamingThread] = useState<{ id: number; title: string } | null>(null);
const [newTitle, setNewTitle] = useState("");
const [isRenaming, setIsRenaming] = useState(false);
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
const pendingThreadIdRef = useRef<number | null>(null);
const { handlers: longPressHandlers, wasLongPress } = useLongPress(
useCallback(() => {
if (pendingThreadIdRef.current !== null) {
setOpenDropdownId(pendingThreadIdRef.current);
}
}, [])
);
const isSearchMode = !!debouncedSearchQuery.trim();
const {
data: threadsData,
error: threadsError,
isLoading: isLoadingThreads,
} = useQuery({
queryKey: ["all-threads", searchSpaceId],
queryFn: () => fetchThreads(Number(searchSpaceId)),
enabled: !!searchSpaceId && !isSearchMode,
});
const {
data: searchData,
error: searchError,
isLoading: isLoadingSearch,
} = useQuery({
queryKey: ["search-threads", searchSpaceId, debouncedSearchQuery],
queryFn: () => searchThreads(Number(searchSpaceId), debouncedSearchQuery.trim()),
enabled: !!searchSpaceId && isSearchMode,
});
// Filter to only shared chats (SEARCH_SPACE visibility)
const { activeChats, archivedChats } = useMemo(() => {
if (isSearchMode) {
const sharedSearchResults = (searchData ?? []).filter(
(thread) => thread.visibility === "SEARCH_SPACE"
);
return {
activeChats: sharedSearchResults.filter((t) => !t.archived),
archivedChats: sharedSearchResults.filter((t) => t.archived),
};
}
if (!threadsData) return { activeChats: [], archivedChats: [] };
const activeShared = threadsData.threads.filter(
(thread) => thread.visibility === "SEARCH_SPACE"
);
const archivedShared = threadsData.archived_threads.filter(
(thread) => thread.visibility === "SEARCH_SPACE"
);
return { activeChats: activeShared, archivedChats: archivedShared };
}, [threadsData, searchData, isSearchMode]);
const threads = showArchived ? archivedChats : activeChats;
const handleThreadClick = useCallback(
(threadId: number) => {
router.push(`/dashboard/${searchSpaceId}/new-chat/${threadId}`);
onOpenChange(false);
onCloseMobileSidebar?.();
},
[router, onOpenChange, searchSpaceId, onCloseMobileSidebar]
);
const handleDeleteThread = useCallback(
async (threadId: number) => {
setDeletingThreadId(threadId);
try {
await deleteThread(threadId);
const fallbackTab = removeChatTab(threadId);
toast.success(t("chat_deleted") || "Chat deleted successfully");
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
if (currentChatId === threadId) {
onOpenChange(false);
setTimeout(() => {
if (fallbackTab?.type === "chat" && fallbackTab.chatUrl) {
router.push(fallbackTab.chatUrl);
return;
}
router.push(`/dashboard/${searchSpaceId}/new-chat`);
}, 250);
}
} catch (error) {
console.error("Error deleting thread:", error);
toast.error(t("error_deleting_chat") || "Failed to delete chat");
} finally {
setDeletingThreadId(null);
}
},
[queryClient, searchSpaceId, t, currentChatId, router, onOpenChange, removeChatTab]
);
const handleToggleArchive = useCallback(
async (threadId: number, currentlyArchived: boolean) => {
setArchivingThreadId(threadId);
try {
await updateThread(threadId, { archived: !currentlyArchived });
toast.success(
currentlyArchived
? t("chat_unarchived") || "Chat restored"
: t("chat_archived") || "Chat archived"
);
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
} catch (error) {
console.error("Error archiving thread:", error);
toast.error(t("error_archiving_chat") || "Failed to archive chat");
} finally {
setArchivingThreadId(null);
}
},
[queryClient, searchSpaceId, t]
);
const handleStartRename = useCallback((threadId: number, title: string) => {
setRenamingThread({ id: threadId, title });
setNewTitle(title);
setShowRenameDialog(true);
}, []);
const handleConfirmRename = useCallback(async () => {
if (!renamingThread || !newTitle.trim()) return;
setIsRenaming(true);
try {
await updateThread(renamingThread.id, { title: newTitle.trim() });
toast.success(t("chat_renamed") || "Chat renamed");
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
queryClient.invalidateQueries({
queryKey: ["threads", searchSpaceId, "detail", String(renamingThread.id)],
});
} catch (error) {
console.error("Error renaming thread:", error);
toast.error(t("error_renaming_chat") || "Failed to rename chat");
} finally {
setIsRenaming(false);
setShowRenameDialog(false);
setRenamingThread(null);
setNewTitle("");
}
}, [renamingThread, newTitle, queryClient, searchSpaceId, t]);
const handleClearSearch = useCallback(() => {
setSearchQuery("");
}, []);
const isLoading = isSearchMode ? isLoadingSearch : isLoadingThreads;
const error = isSearchMode ? searchError : threadsError;
const activeCount = activeChats.length;
const archivedCount = archivedChats.length;
return (
<>
<div className="shrink-0 p-3 pb-1.5 space-y-2">
<div className="flex items-center gap-2">
{isMobile && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full text-muted-foreground hover:text-accent-foreground"
onClick={() => onOpenChange(false)}
>
<ChevronLeft className="h-4 w-4" />
<span className="sr-only">{t("close") || "Close"}</span>
</Button>
)}
<h2 className="text-lg font-semibold">{t("shared_chats") || "Shared Chats"}</h2>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
type="text"
placeholder={t("search_chats") || "Search chats..."}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 border-0 bg-muted pl-8 pr-7 text-sm shadow-none"
/>
{searchQuery && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 h-5 w-5 -translate-y-1/2 rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
onClick={handleClearSearch}
>
<X className="h-3.5 w-3.5" />
<span className="sr-only">{t("clear_search") || "Clear search"}</span>
</Button>
)}
</div>
</div>
{!isSearchMode && (
<Tabs
value={showArchived ? "archived" : "active"}
onValueChange={(value) => setShowArchived(value === "archived")}
className="shrink-0 mx-3 mt-1.5"
>
<TabsList stretch showBottomBorder size="sm">
<TabsTrigger value="active">
<span className="inline-flex items-center gap-1.5">
<MessageCircleMore className="h-3.5 w-3.5" />
<span>Active</span>
<span className="inline-flex h-4.5 min-w-4.5 items-center justify-center rounded-full bg-primary/20 px-1 text-[10px] font-medium text-muted-foreground">
{activeCount}
</span>
</span>
</TabsTrigger>
<TabsTrigger value="archived">
<span className="inline-flex items-center gap-1.5">
<ArchiveIcon className="h-3.5 w-3.5" />
<span>Archived</span>
<span className="inline-flex h-4.5 min-w-4.5 items-center justify-center rounded-full bg-primary/20 px-1 text-[10px] font-medium text-muted-foreground">
{archivedCount}
</span>
</span>
</TabsTrigger>
</TabsList>
</Tabs>
)}
<div className="flex-1 overflow-y-auto overflow-x-hidden p-1.5">
{isLoading ? (
<div className="space-y-1">
{[75, 90, 55, 80, 65, 85].map((titleWidth) => (
<div
key={`skeleton-${titleWidth}`}
className="flex items-center gap-2 rounded-md px-2 py-1.5"
>
<Skeleton className="h-4 w-4 shrink-0 rounded" />
<Skeleton className="h-4 rounded" style={{ width: `${titleWidth}%` }} />
</div>
))}
</div>
) : error ? (
<div className="text-center py-8 text-sm text-destructive">
{t("error_loading_chats") || "Error loading chats"}
</div>
) : threads.length > 0 ? (
<div className="space-y-1">
{threads.map((thread) => {
const isDeleting = deletingThreadId === thread.id;
const isArchiving = archivingThreadId === thread.id;
const isBusy = isDeleting || isArchiving;
const isActive = currentChatId === thread.id;
return (
<div key={thread.id} className="group/item relative w-full">
{isMobile ? (
<Button
type="button"
variant="ghost"
onClick={() => {
if (wasLongPress()) return;
handleThreadClick(thread.id);
}}
onTouchStart={() => {
pendingThreadIdRef.current = thread.id;
longPressHandlers.onTouchStart();
}}
onTouchEnd={longPressHandlers.onTouchEnd}
onTouchMove={longPressHandlers.onTouchMove}
disabled={isBusy}
className={cn(
"h-auto w-full justify-start gap-2 overflow-hidden rounded-md px-2 py-1.5 text-sm text-left",
"group-hover/item:bg-accent group-hover/item:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
isActive && "bg-accent text-accent-foreground",
isBusy && "opacity-50 pointer-events-none"
)}
>
<span className="truncate">{thread.title || "New Chat"}</span>
</Button>
) : (
<Tooltip delayDuration={600}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
onClick={() => handleThreadClick(thread.id)}
disabled={isBusy}
className={cn(
"h-auto w-full justify-start gap-2 overflow-hidden rounded-md px-2 py-1.5 text-sm text-left",
"group-hover/item:bg-accent group-hover/item:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
isActive && "bg-accent text-accent-foreground",
isBusy && "opacity-50 pointer-events-none"
)}
>
<span className="truncate">{thread.title || "New Chat"}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" align="start">
<p>
{t("updated") || "Updated"}: {formatThreadTimestamp(thread.updatedAt)}
</p>
</TooltipContent>
</Tooltip>
)}
<div
className={cn(
"pointer-events-none absolute right-0 top-0 bottom-0 flex items-center rounded-r-md pl-6 pr-1",
isActive
? "bg-gradient-to-l from-accent from-60% to-transparent"
: "bg-gradient-to-l from-sidebar from-60% to-transparent group-hover/item:from-accent",
isMobile
? "opacity-0"
: openDropdownId === thread.id
? "opacity-100"
: "opacity-0 group-hover/item:opacity-100"
)}
>
<DropdownMenu
open={openDropdownId === thread.id}
onOpenChange={(isOpen) => setOpenDropdownId(isOpen ? thread.id : null)}
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"pointer-events-auto h-6 w-6 hover:bg-transparent",
openDropdownId === thread.id && "bg-accent hover:bg-accent"
)}
disabled={isBusy}
>
{isDeleting ? (
<Spinner size="xs" />
) : (
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="sr-only">{t("more_options") || "More options"}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 z-80">
{!thread.archived && (
<DropdownMenuItem
onClick={() => handleStartRename(thread.id, thread.title || "New Chat")}
>
<Pencil className="mr-2 h-4 w-4" />
<span>{t("rename") || "Rename"}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => handleToggleArchive(thread.id, thread.archived)}
disabled={isArchiving}
>
{thread.archived ? (
<>
<RotateCcwIcon className="mr-2 h-4 w-4" />
<span>{t("unarchive") || "Restore"}</span>
</>
) : (
<>
<ArchiveIcon className="mr-2 h-4 w-4" />
<span>{t("archive") || "Archive"}</span>
</>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDeleteThread(thread.id)}>
<Trash2 className="mr-2 h-4 w-4" />
<span>{t("delete") || "Delete"}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
})}
</div>
) : isSearchMode ? (
<div className="text-center py-8">
<Search className="mx-auto mb-2.5 h-10 w-10 text-muted-foreground" />
<p className="text-xs text-muted-foreground">
{t("no_chats_found") || "No chats found"}
</p>
<p className="mt-1 text-[11px] text-muted-foreground/70">
{t("try_different_search") || "Try a different search term"}
</p>
</div>
) : (
<div className="text-center py-8">
<Users className="mx-auto mb-2.5 h-10 w-10 text-muted-foreground" />
<p className="text-xs text-muted-foreground">
{showArchived
? t("no_archived_chats") || "No archived chats"
: t("no_shared_chats") || "No shared chats"}
</p>
{!showArchived && (
<p className="mt-1 text-[11px] text-muted-foreground/70">
Share a chat to collaborate with your team
</p>
)}
</div>
)}
</div>
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<span>{t("rename_chat") || "Rename Chat"}</span>
</DialogTitle>
<DialogDescription>
{t("rename_chat_description") || "Enter a new name for this conversation."}
</DialogDescription>
</DialogHeader>
<Input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder={t("chat_title_placeholder") || "Chat title"}
onKeyDown={(e) => {
if (e.key === "Enter" && !isRenaming && newTitle.trim()) {
handleConfirmRename();
}
}}
/>
<DialogFooter className="flex sm:justify-end">
<Button
variant="secondary"
onClick={() => setShowRenameDialog(false)}
disabled={isRenaming}
>
{t("cancel")}
</Button>
<Button
onClick={handleConfirmRename}
disabled={isRenaming || !newTitle.trim()}
className="gap-2"
>
{isRenaming ? (
<>
<Spinner size="xs" />
<span>{t("renaming") || "Renaming"}</span>
</>
) : (
<span>{t("rename") || "Rename"}</span>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
export function AllSharedChatsSidebar({
open,
onOpenChange,
searchSpaceId,
onCloseMobileSidebar,
}: AllSharedChatsSidebarProps) {
const t = useTranslations("sidebar");
return (
<SidebarSlideOutPanel
open={open}
onOpenChange={onOpenChange}
ariaLabel={t("shared_chats") || "Shared Chats"}
>
<AllSharedChatsSidebarContent
onOpenChange={onOpenChange}
searchSpaceId={searchSpaceId}
onCloseMobileSidebar={onCloseMobileSidebar}
/>
</SidebarSlideOutPanel>
);
}

View file

@ -1,6 +1,6 @@
"use client";
import { ArchiveIcon, MoreHorizontal, Pencil, RotateCcwIcon, Trash2 } from "lucide-react";
import { ArchiveIcon, MoreHorizontal, Pencil, RotateCcwIcon, Trash2, Users } from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useState } from "react";
import { Button } from "@/components/ui/button";
@ -18,6 +18,7 @@ import { cn } from "@/lib/utils";
interface ChatListItemProps {
name: string;
isActive?: boolean;
isShared?: boolean;
archived?: boolean;
dropdownOpen?: boolean;
onDropdownOpenChange?: (open: boolean) => void;
@ -30,6 +31,7 @@ interface ChatListItemProps {
export function ChatListItem({
name,
isActive,
isShared,
archived,
dropdownOpen: controlledOpen,
onDropdownOpenChange,
@ -68,7 +70,13 @@ export function ChatListItem({
isActive && "bg-accent text-accent-foreground"
)}
>
<span className="truncate">{animatedName}</span>
<span className="min-w-0 flex-1 truncate">{animatedName}</span>
{isShared ? (
<Users
aria-label={t("shared_chat") || "Shared chat"}
className="h-3 w-3 shrink-0 text-muted-foreground/50"
/>
) : null}
</Button>
{/* Actions dropdown - trigger hidden on mobile, long-press opens it instead */}

View file

@ -19,17 +19,14 @@ interface MobileSidebarProps {
navItems: NavItem[];
onNavItemClick?: (item: NavItem) => void;
chats: ChatItem[];
sharedChats?: ChatItem[];
activeChatId?: number | null;
onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void;
onChatRename?: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onChatArchive?: (chat: ChatItem) => void;
onViewAllSharedChats?: () => void;
onViewAllPrivateChats?: () => void;
isSharedChatsPanelOpen?: boolean;
isPrivateChatsPanelOpen?: boolean;
onViewAllChats?: () => void;
isChatsPanelOpen?: boolean;
user: User;
onSettings?: () => void;
onManageMembers?: () => void;
@ -69,17 +66,14 @@ export function MobileSidebar({
navItems,
onNavItemClick,
chats,
sharedChats,
activeChatId,
onNewChat,
onChatSelect,
onChatRename,
onChatDelete,
onChatArchive,
onViewAllSharedChats,
onViewAllPrivateChats,
isSharedChatsPanelOpen = false,
isPrivateChatsPanelOpen = false,
onViewAllChats,
isChatsPanelOpen = false,
user,
onSettings,
onManageMembers,
@ -152,7 +146,6 @@ export function MobileSidebar({
navItems={navItems}
onNavItemClick={handleNavItemClick}
chats={chats}
sharedChats={sharedChats}
activeChatId={activeChatId}
onNewChat={() => {
onNewChat();
@ -162,24 +155,15 @@ export function MobileSidebar({
onChatRename={onChatRename}
onChatDelete={onChatDelete}
onChatArchive={onChatArchive}
onViewAllSharedChats={
onViewAllSharedChats
onViewAllChats={
onViewAllChats
? () => {
onOpenChange(false);
onViewAllSharedChats();
onViewAllChats();
}
: undefined
}
onViewAllPrivateChats={
onViewAllPrivateChats
? () => {
onOpenChange(false);
onViewAllPrivateChats();
}
: undefined
}
isSharedChatsPanelOpen={isSharedChatsPanelOpen}
isPrivateChatsPanelOpen={isPrivateChatsPanelOpen}
isChatsPanelOpen={isChatsPanelOpen}
user={user}
onSettings={
onSettings

View file

@ -67,17 +67,14 @@ interface SidebarProps {
navItems: NavItem[];
onNavItemClick?: (item: NavItem) => void;
chats: ChatItem[];
sharedChats?: ChatItem[];
activeChatId?: number | null;
onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void;
onChatRename?: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onChatArchive?: (chat: ChatItem) => void;
onViewAllSharedChats?: () => void;
onViewAllPrivateChats?: () => void;
isSharedChatsPanelOpen?: boolean;
isPrivateChatsPanelOpen?: boolean;
onViewAllChats?: () => void;
isChatsPanelOpen?: boolean;
user: User;
onSettings?: () => void;
onManageMembers?: () => void;
@ -106,17 +103,14 @@ export function Sidebar({
navItems,
onNavItemClick,
chats,
sharedChats = [],
activeChatId,
onNewChat,
onChatSelect,
onChatRename,
onChatDelete,
onChatArchive,
onViewAllSharedChats,
onViewAllPrivateChats,
isSharedChatsPanelOpen = false,
isPrivateChatsPanelOpen = false,
onViewAllChats,
isChatsPanelOpen = false,
user,
onSettings,
onManageMembers,
@ -264,73 +258,20 @@ export function Sidebar({
<div className="flex-1 w-full" />
) : (
<div className="flex-1 flex flex-col gap-1 pt-2 w-full min-h-0 overflow-hidden">
{/* Shared Chats Section - takes only space needed, max 50% */}
<SidebarSection
title={t("shared_chats")}
defaultOpen={true}
fillHeight={false}
className="shrink-0 max-h-[50%] flex flex-col"
alwaysShowAction={!disableTooltips && isSharedChatsPanelOpen}
action={
onViewAllSharedChats ? (
<Button
type="button"
variant="ghost"
onClick={onViewAllSharedChats}
className="h-auto cursor-pointer whitespace-nowrap bg-transparent p-0 text-xs font-medium text-muted-foreground/60 transition-colors hover:bg-transparent hover:text-muted-foreground"
>
{!disableTooltips && isSharedChatsPanelOpen ? t("hide") : t("show_all")}
</Button>
) : undefined
}
>
{isLoadingChats ? (
<ChatListSkeletonRows />
) : sharedChats.length > 0 ? (
<div className="relative min-h-0 flex-1">
<div
className={`flex flex-col gap-0.5 max-h-full overflow-y-auto scrollbar-thin scrollbar-thumb-muted-foreground/20 scrollbar-track-transparent ${sharedChats.length > 4 ? "pb-2" : ""}`}
>
{sharedChats.slice(0, 20).map((chat) => (
<ChatListItem
key={chat.id}
name={chat.name}
isActive={chat.id === activeChatId}
archived={chat.archived}
dropdownOpen={openDropdownChatId === chat.id}
onDropdownOpenChange={(open) => setOpenDropdownChatId(open ? chat.id : null)}
onClick={() => onChatSelect(chat)}
onRename={() => onChatRename?.(chat)}
onArchive={() => onChatArchive?.(chat)}
onDelete={() => onChatDelete?.(chat)}
/>
))}
</div>
{/* Gradient fade indicator when more than 4 items */}
{sharedChats.length > 4 && (
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-5 bg-gradient-to-t from-sidebar/80 to-transparent" />
)}
</div>
) : (
<p className="px-2 py-1 text-sm text-muted-foreground/60">{t("no_shared_chats")}</p>
)}
</SidebarSection>
{/* Private Chats Section - fills remaining space */}
<SidebarSection
title={t("chats")}
defaultOpen={true}
fillHeight={true}
alwaysShowAction={!disableTooltips && isPrivateChatsPanelOpen}
alwaysShowAction={!disableTooltips && isChatsPanelOpen}
action={
onViewAllPrivateChats ? (
onViewAllChats ? (
<Button
type="button"
variant="ghost"
onClick={onViewAllPrivateChats}
onClick={onViewAllChats}
className="h-auto cursor-pointer whitespace-nowrap bg-transparent p-0 text-xs font-medium text-muted-foreground/60 transition-colors hover:bg-transparent hover:text-muted-foreground"
>
{!disableTooltips && isPrivateChatsPanelOpen ? t("hide") : t("show_all")}
{!disableTooltips && isChatsPanelOpen ? t("hide") : t("show_all")}
</Button>
) : undefined
}
@ -347,6 +288,7 @@ export function Sidebar({
key={chat.id}
name={chat.name}
isActive={chat.id === activeChatId}
isShared={chat.visibility === "SEARCH_SPACE"}
archived={chat.archived}
dropdownOpen={openDropdownChatId === chat.id}
onDropdownOpenChange={(open) => setOpenDropdownChatId(open ? chat.id : null)}

View file

@ -1,5 +1,4 @@
export { AllPrivateChatsSidebar, AllPrivateChatsSidebarContent } from "./AllPrivateChatsSidebar";
export { AllSharedChatsSidebar, AllSharedChatsSidebarContent } from "./AllSharedChatsSidebar";
export { AllChatsSidebar, AllChatsSidebarContent } from "./AllChatsSidebar";
export { ChatListItem } from "./ChatListItem";
export { DocumentsSidebar } from "./DocumentsSidebar";
export { InboxSidebar, InboxSidebarContent } from "./InboxSidebar";

View file

@ -650,13 +650,14 @@
"created": "Created"
},
"sidebar": {
"chats": "Private Chats",
"chats": "Chats",
"shared_chats": "Shared Chats",
"search_chats": "Search chats",
"no_chats_found": "No chats found",
"no_shared_chats": "No shared chats",
"shared_chat": "Shared chat",
"view_all_shared_chats": "View all shared chats",
"view_all_private_chats": "View all private chats",
"view_all_chats": "View all chats",
"show_all": "Show all",
"hide": "Hide",
"no_chats": "No chats",

View file

@ -650,13 +650,14 @@
"created": "Creado"
},
"sidebar": {
"chats": "Chats privados",
"chats": "Chats",
"shared_chats": "Chats compartidos",
"search_chats": "Buscar chats",
"no_chats_found": "No se encontraron chats",
"no_shared_chats": "No hay chats compartidos",
"shared_chat": "Chat compartido",
"view_all_shared_chats": "Ver todos los chats compartidos",
"view_all_private_chats": "Ver todos los chats privados",
"view_all_chats": "Ver todos los chats",
"show_all": "Ver todo",
"hide": "Ocultar",
"no_chats": "Sin chats",

View file

@ -650,13 +650,14 @@
"created": "बनाया गया"
},
"sidebar": {
"chats": "निजी चैट",
"chats": "चैट",
"shared_chats": "साझा चैट",
"search_chats": "चैट खोजें",
"no_chats_found": "कोई चैट नहीं मिला",
"no_shared_chats": "कोई साझा चैट नहीं",
"shared_chat": "साझा चैट",
"view_all_shared_chats": "सभी साझा चैट देखें",
"view_all_private_chats": "सभी निजी चैट देखें",
"view_all_chats": "सभी चैट देखें",
"show_all": "सभी देखें",
"hide": "छिपाएँ",
"no_chats": "कोई चैट नहीं",

View file

@ -650,13 +650,14 @@
"created": "Criado"
},
"sidebar": {
"chats": "Chats privados",
"chats": "Chats",
"shared_chats": "Chats compartilhados",
"search_chats": "Pesquisar chats",
"no_chats_found": "Nenhum chat encontrado",
"no_shared_chats": "Nenhum chat compartilhado",
"shared_chat": "Chat compartilhado",
"view_all_shared_chats": "Ver todos os chats compartilhados",
"view_all_private_chats": "Ver todos os chats privados",
"view_all_chats": "Ver todos os chats",
"show_all": "Ver tudo",
"hide": "Ocultar",
"no_chats": "Nenhum chat",

View file

@ -634,13 +634,14 @@
"created": "创建于"
},
"sidebar": {
"chats": "私人对话",
"chats": "对话",
"shared_chats": "共享对话",
"search_chats": "搜索对话...",
"no_chats_found": "未找到对话",
"no_shared_chats": "暂无共享对话",
"shared_chat": "共享对话",
"view_all_shared_chats": "查看所有共享对话",
"view_all_private_chats": "查看所有私人对话",
"view_all_chats": "查看所有对话",
"show_all": "查看全部",
"hide": "隐藏",
"no_chats": "无对话",