mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-08 22:22:17 +02:00
- Added the ensure_publication function to create and verify the zero_publication if it is missing, ensuring idempotency during database initialization. - Integrated ensure_publication into the create_db_and_tables function to prevent zero-cache crash loops on startup. - Introduced a self-check script to validate the ensure_publication functionality on a create_all-bootstrapped database. - Updated various components to reflect the transition from search space to workspace, including adjustments in imports and routing paths.
949 lines
32 KiB
TypeScript
949 lines
32 KiB
TypeScript
"use client";
|
|
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
|
import { AlarmClock, AlertTriangle, Boxes, Inbox, LibraryBig } from "lucide-react";
|
|
import { useParams, usePathname, useRouter } from "next/navigation";
|
|
import { useTranslations } from "next-intl";
|
|
import { useTheme } from "next-themes";
|
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
|
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
|
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
|
|
import { announcementsDialogAtom } from "@/atoms/layout/dialogs.atom";
|
|
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
|
|
import { removeChatTabAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
|
|
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
|
import { deleteSearchSpaceMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms";
|
|
import { searchSpacesAtom } from "@/atoms/workspaces/workspace-query.atoms";
|
|
import { ActionLogDialog } from "@/components/agent-action-log/action-log-dialog";
|
|
import { AnnouncementSpotlight } from "@/components/announcements/AnnouncementSpotlight";
|
|
import { AnnouncementsDialog } from "@/components/announcements/AnnouncementsDialog";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Spinner } from "@/components/ui/spinner";
|
|
import { useActivateChatThread } from "@/hooks/use-activate-chat-thread";
|
|
import { useAnnouncements } from "@/hooks/use-announcements";
|
|
import { useInbox } from "@/hooks/use-inbox";
|
|
import { useIsMobile } from "@/hooks/use-mobile";
|
|
import { useArchiveThread, useDeleteThread, useRenameThread } from "@/hooks/use-thread-mutations";
|
|
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
|
|
import { searchSpacesApiService } from "@/lib/apis/workspaces-api.service";
|
|
import { getLoginPath, logout } from "@/lib/auth-utils";
|
|
import { fetchThreads } from "@/lib/chat/thread-persistence";
|
|
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
|
import type { ChatItem, NavItem, SearchSpace } from "../types/layout.types";
|
|
import { CreateSearchSpaceDialog } from "../ui/dialogs";
|
|
import { LayoutShell } from "../ui/shell";
|
|
|
|
interface LayoutDataProviderProps {
|
|
searchSpaceId: string;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
/**
|
|
* Format count for display: shows numbers up to 999, then "1k+", "2k+", etc.
|
|
*/
|
|
function formatInboxCount(count: number): string {
|
|
if (count <= 999) {
|
|
return count.toString();
|
|
}
|
|
const thousands = Math.floor(count / 1000);
|
|
return `${thousands}k+`;
|
|
}
|
|
|
|
export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProviderProps) {
|
|
const t = useTranslations("dashboard");
|
|
const tCommon = useTranslations("common");
|
|
const tSidebar = useTranslations("sidebar");
|
|
const router = useRouter();
|
|
const params = useParams();
|
|
const pathname = usePathname();
|
|
const { theme, setTheme } = useTheme();
|
|
const isMobile = useIsMobile();
|
|
|
|
// Announcements
|
|
const { unreadCount: announcementUnreadCount } = useAnnouncements();
|
|
|
|
// Atoms
|
|
const { data: user } = useAtomValue(currentUserAtom);
|
|
const {
|
|
data: searchSpacesData,
|
|
refetch: refetchSearchSpaces,
|
|
isSuccess: searchSpacesLoaded,
|
|
} = useAtomValue(searchSpacesAtom);
|
|
const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom);
|
|
const currentThreadState = useAtomValue(currentThreadAtom);
|
|
const resetCurrentThread = useSetAtom(resetCurrentThreadAtom);
|
|
const syncChatTab = useSetAtom(syncChatTabAtom);
|
|
const removeChatTab = useSetAtom(removeChatTabAtom);
|
|
const { activateChatThread, prefetchChatThread } = useActivateChatThread();
|
|
const { mutateAsync: archiveThread } = useArchiveThread(searchSpaceId);
|
|
const { mutateAsync: deleteThread } = useDeleteThread(searchSpaceId);
|
|
const { mutateAsync: renameThread } = useRenameThread(searchSpaceId);
|
|
|
|
// Key used to force-remount the page component (e.g. after deleting the active chat
|
|
// when the router is out of sync due to replaceState)
|
|
const [chatResetKey, setChatResetKey] = useState(0);
|
|
|
|
// Current IDs from URL, with fallback to atom for replaceState updates
|
|
const currentChatId = params?.chat_id
|
|
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
|
|
: currentThreadState.id;
|
|
|
|
// Fetch current search space (for caching purposes)
|
|
useQuery({
|
|
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId),
|
|
queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }),
|
|
enabled: !!searchSpaceId,
|
|
});
|
|
|
|
// Fetch threads (40 total to allow up to 20 per section - shared/private)
|
|
const { data: threadsData, isPending: isLoadingThreads } = useQuery({
|
|
queryKey: ["threads", searchSpaceId, { limit: 40 }],
|
|
queryFn: () => fetchThreads(Number(searchSpaceId), 40),
|
|
enabled: !!searchSpaceId,
|
|
});
|
|
|
|
// Unified slide-out panel state (only one can be open at a time)
|
|
type SlideoutPanel = "inbox" | "chats" | null;
|
|
const [activeSlideoutPanel, setActiveSlideoutPanel] = useState<SlideoutPanel>(null);
|
|
|
|
const isInboxSidebarOpen = activeSlideoutPanel === "inbox";
|
|
|
|
// Documents sidebar state (shared atom so Composer can toggle it)
|
|
const [isDocumentsSidebarOpen, setIsDocumentsSidebarOpen] = useAtom(documentsSidebarOpenAtom);
|
|
const setIsRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom);
|
|
|
|
// Open documents sidebar by default on desktop (docked mode)
|
|
const documentsInitialized = useRef(false);
|
|
useEffect(() => {
|
|
if (!documentsInitialized.current) {
|
|
documentsInitialized.current = true;
|
|
const isDesktop = typeof window !== "undefined" && window.innerWidth >= 768;
|
|
if (isDesktop) {
|
|
setIsDocumentsSidebarOpen(true);
|
|
}
|
|
}
|
|
}, [setIsDocumentsSidebarOpen]);
|
|
|
|
// Search space dialog state
|
|
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
|
|
|
|
const userId = user?.id ? String(user.id) : null;
|
|
const numericSpaceId = Number(searchSpaceId) || null;
|
|
|
|
// Batch-fetch unread counts for all categories in a single request
|
|
// instead of 2 separate /unread-count calls.
|
|
const { data: batchUnread, isLoading: isBatchUnreadLoading } = useQuery({
|
|
queryKey: cacheKeys.notifications.batchUnreadCounts(numericSpaceId),
|
|
queryFn: () => notificationsApiService.getBatchUnreadCounts(numericSpaceId ?? undefined),
|
|
enabled: !!userId && !!numericSpaceId,
|
|
staleTime: 30_000,
|
|
});
|
|
|
|
const commentsInbox = useInbox(
|
|
userId,
|
|
numericSpaceId,
|
|
"comments",
|
|
batchUnread?.comments,
|
|
!isBatchUnreadLoading
|
|
);
|
|
const statusInbox = useInbox(
|
|
userId,
|
|
numericSpaceId,
|
|
"status",
|
|
batchUnread?.status,
|
|
!isBatchUnreadLoading
|
|
);
|
|
|
|
const totalUnreadCount = commentsInbox.unreadCount + statusInbox.unreadCount;
|
|
|
|
// Sync status inbox items to a shared atom so child components
|
|
// (e.g. ConnectorPopup) can read them without creating duplicate useInbox hooks.
|
|
const setStatusInboxItems = useSetAtom(statusInboxItemsAtom);
|
|
useEffect(() => {
|
|
setStatusInboxItems(statusInbox.inboxItems);
|
|
}, [statusInbox.inboxItems, setStatusInboxItems]);
|
|
|
|
// Track seen notification IDs to detect new insufficient_credits notifications
|
|
const seenCreditNotifications = useRef<Set<number>>(new Set());
|
|
const isInitialLoad = useRef(true);
|
|
|
|
// Effect to show toast for new insufficient_credits notifications
|
|
useEffect(() => {
|
|
if (statusInbox.loading) return;
|
|
|
|
const creditNotifications = statusInbox.inboxItems.filter(
|
|
(item) => item.type === "insufficient_credits"
|
|
);
|
|
|
|
if (isInitialLoad.current) {
|
|
for (const notification of creditNotifications) {
|
|
seenCreditNotifications.current.add(notification.id);
|
|
}
|
|
isInitialLoad.current = false;
|
|
return;
|
|
}
|
|
|
|
const newNotifications = creditNotifications.filter(
|
|
(notification) => !seenCreditNotifications.current.has(notification.id)
|
|
);
|
|
|
|
for (const notification of newNotifications) {
|
|
seenCreditNotifications.current.add(notification.id);
|
|
|
|
toast.error(notification.title, {
|
|
description: notification.message,
|
|
duration: 8000,
|
|
icon: <AlertTriangle className="h-5 w-5 text-amber-500" />,
|
|
action: {
|
|
label: "Buy credits",
|
|
onClick: () => router.push(`/dashboard/${searchSpaceId}/buy-more`),
|
|
},
|
|
});
|
|
}
|
|
}, [statusInbox.inboxItems, statusInbox.loading, searchSpaceId, router]);
|
|
|
|
// Delete dialogs state
|
|
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
|
|
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);
|
|
const [searchSpaceToDelete, setSearchSpaceToDelete] = useState<SearchSpace | null>(null);
|
|
const [searchSpaceToLeave, setSearchSpaceToLeave] = useState<SearchSpace | null>(null);
|
|
const [isDeletingSearchSpace, setIsDeletingSearchSpace] = useState(false);
|
|
const [isLeavingSearchSpace, setIsLeavingSearchSpace] = useState(false);
|
|
|
|
// Reset transient slide-out panels when switching search spaces.
|
|
// Tabs intentionally persist across spaces — opening tabs from multiple
|
|
// search spaces is a supported flow (browser-tab semantics).
|
|
const prevSearchSpaceIdRef = useRef(searchSpaceId);
|
|
useEffect(() => {
|
|
if (prevSearchSpaceIdRef.current !== searchSpaceId) {
|
|
prevSearchSpaceIdRef.current = searchSpaceId;
|
|
setActiveSlideoutPanel(null);
|
|
}
|
|
}, [searchSpaceId]);
|
|
|
|
const searchSpaces: SearchSpace[] = useMemo(() => {
|
|
if (!searchSpacesData || !Array.isArray(searchSpacesData)) return [];
|
|
return searchSpacesData.map((space) => ({
|
|
id: space.id,
|
|
name: space.name,
|
|
description: space.description,
|
|
isOwner: space.is_owner,
|
|
memberCount: space.member_count || 0,
|
|
createdAt: space.created_at,
|
|
}));
|
|
}, [searchSpacesData]);
|
|
|
|
// Find active search space from list (has is_owner and member_count)
|
|
const activeSearchSpace: SearchSpace | null = useMemo(() => {
|
|
if (!searchSpaceId || !searchSpaces.length) return null;
|
|
return searchSpaces.find((s) => s.id === Number(searchSpaceId)) ?? null;
|
|
}, [searchSpaceId, searchSpaces]);
|
|
|
|
// Safety redirect: if the current search space is no longer in the user's list
|
|
// (e.g. deleted by background task, membership revoked), redirect to a valid space.
|
|
useEffect(() => {
|
|
if (!searchSpacesLoaded || !searchSpaceId || isDeletingSearchSpace || isLeavingSearchSpace)
|
|
return;
|
|
if (searchSpaces.length > 0 && !activeSearchSpace) {
|
|
router.replace(`/dashboard/${searchSpaces[0].id}/new-chat`);
|
|
} else if (searchSpaces.length === 0 && searchSpacesLoaded) {
|
|
router.replace("/dashboard");
|
|
}
|
|
}, [
|
|
searchSpacesLoaded,
|
|
searchSpaceId,
|
|
searchSpaces,
|
|
activeSearchSpace,
|
|
isDeletingSearchSpace,
|
|
isLeavingSearchSpace,
|
|
router,
|
|
]);
|
|
|
|
// Sync current chat route with tab state
|
|
useEffect(() => {
|
|
const chatId = currentChatId ?? null;
|
|
const chatUrl = chatId
|
|
? `/dashboard/${searchSpaceId}/new-chat/${chatId}`
|
|
: `/dashboard/${searchSpaceId}/new-chat`;
|
|
const thread = threadsData?.threads?.find((t) => t.id === chatId);
|
|
syncChatTab({
|
|
chatId,
|
|
// Avoid overwriting live SSE-updated tab titles with fallback values.
|
|
title: chatId ? (thread?.title ?? undefined) : "New Chat",
|
|
chatUrl,
|
|
searchSpaceId: Number(searchSpaceId),
|
|
...(thread?.visibility !== undefined ? { visibility: thread.visibility } : {}),
|
|
});
|
|
}, [currentChatId, searchSpaceId, threadsData?.threads, syncChatTab]);
|
|
|
|
const chats = useMemo(() => {
|
|
if (!threadsData?.threads) return [];
|
|
|
|
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
|
|
// Inbox, Automations, and Documents are rendered explicitly below "New chat"
|
|
// in the sidebar (also surfaced in the icon rail's collapsed mode via this
|
|
// list). Announcements has been moved to the avatar dropdown.
|
|
const isAutomationsActive = pathname?.includes("/automations") === true;
|
|
const isArtifactsActive = pathname?.endsWith("/artifacts") === true;
|
|
const navItems: NavItem[] = useMemo(
|
|
() =>
|
|
(
|
|
[
|
|
{
|
|
title: "Inbox",
|
|
url: "#inbox",
|
|
icon: Inbox,
|
|
isActive: isInboxSidebarOpen,
|
|
badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined,
|
|
},
|
|
{
|
|
title: "Automations",
|
|
url: `/dashboard/${searchSpaceId}/automations`,
|
|
icon: AlarmClock,
|
|
isActive: isAutomationsActive,
|
|
},
|
|
{
|
|
title: "Artifacts",
|
|
url: `/dashboard/${searchSpaceId}/artifacts`,
|
|
icon: Boxes,
|
|
isActive: isArtifactsActive,
|
|
},
|
|
isMobile
|
|
? {
|
|
title: "Documents",
|
|
url: "#documents",
|
|
icon: LibraryBig,
|
|
isActive: isDocumentsSidebarOpen,
|
|
}
|
|
: null,
|
|
] as (NavItem | null)[]
|
|
).filter((item): item is NavItem => item !== null),
|
|
[
|
|
isMobile,
|
|
isInboxSidebarOpen,
|
|
isDocumentsSidebarOpen,
|
|
totalUnreadCount,
|
|
searchSpaceId,
|
|
isAutomationsActive,
|
|
isArtifactsActive,
|
|
]
|
|
);
|
|
|
|
// Handlers
|
|
const handleSearchSpaceSelect = useCallback(
|
|
(id: number) => {
|
|
router.push(`/dashboard/${id}/new-chat`);
|
|
},
|
|
[router]
|
|
);
|
|
|
|
const handleAddSearchSpace = useCallback(() => {
|
|
setIsCreateSearchSpaceDialogOpen(true);
|
|
}, []);
|
|
|
|
const setAnnouncementsDialog = useSetAtom(announcementsDialogAtom);
|
|
|
|
const handleUserSettings = useCallback(() => {
|
|
router.push(`/dashboard/${searchSpaceId}/user-settings`);
|
|
}, [router, searchSpaceId]);
|
|
|
|
const handleAnnouncements = useCallback(() => {
|
|
setAnnouncementsDialog(true);
|
|
}, [setAnnouncementsDialog]);
|
|
|
|
const handleSearchSpaceSettings = useCallback(
|
|
(space: SearchSpace) => {
|
|
router.push(`/dashboard/${space.id}/workspace-settings`);
|
|
},
|
|
[router]
|
|
);
|
|
|
|
const handleSearchSpaceDeleteClick = useCallback((space: SearchSpace) => {
|
|
// If user is owner, show delete dialog; otherwise show leave dialog
|
|
if (space.isOwner) {
|
|
setSearchSpaceToDelete(space);
|
|
setShowDeleteSearchSpaceDialog(true);
|
|
} else {
|
|
setSearchSpaceToLeave(space);
|
|
setShowLeaveSearchSpaceDialog(true);
|
|
}
|
|
}, []);
|
|
|
|
const confirmDeleteSearchSpace = useCallback(async () => {
|
|
if (!searchSpaceToDelete) return;
|
|
setIsDeletingSearchSpace(true);
|
|
try {
|
|
await deleteSearchSpace({ id: searchSpaceToDelete.id });
|
|
|
|
const isCurrentSpace = Number(searchSpaceId) === searchSpaceToDelete.id;
|
|
|
|
// Await refetch so we have the freshest list (backend now hides [DELETING] spaces)
|
|
const result = await refetchSearchSpaces();
|
|
const updatedSpaces = (result.data ?? []).filter((s) => s.id !== searchSpaceToDelete.id);
|
|
|
|
if (isCurrentSpace) {
|
|
if (updatedSpaces.length > 0) {
|
|
router.push(`/dashboard/${updatedSpaces[0].id}/new-chat`);
|
|
} else {
|
|
router.push("/dashboard");
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Error deleting search space:", error);
|
|
toast.error(
|
|
t.has("delete_space_error") ? t("delete_space_error") : "Failed to delete search space"
|
|
);
|
|
} finally {
|
|
setIsDeletingSearchSpace(false);
|
|
setShowDeleteSearchSpaceDialog(false);
|
|
setSearchSpaceToDelete(null);
|
|
}
|
|
}, [searchSpaceToDelete, deleteSearchSpace, refetchSearchSpaces, searchSpaceId, router, t]);
|
|
|
|
const confirmLeaveSearchSpace = useCallback(async () => {
|
|
if (!searchSpaceToLeave) return;
|
|
setIsLeavingSearchSpace(true);
|
|
try {
|
|
await searchSpacesApiService.leaveSearchSpace(searchSpaceToLeave.id);
|
|
|
|
const isCurrentSpace = Number(searchSpaceId) === searchSpaceToLeave.id;
|
|
|
|
const result = await refetchSearchSpaces();
|
|
const updatedSpaces = (result.data ?? []).filter((s) => s.id !== searchSpaceToLeave.id);
|
|
|
|
if (isCurrentSpace) {
|
|
if (updatedSpaces.length > 0) {
|
|
router.push(`/dashboard/${updatedSpaces[0].id}/new-chat`);
|
|
} else {
|
|
router.push("/dashboard");
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Error leaving search space:", error);
|
|
toast.error(t.has("leave_error") ? t("leave_error") : "Failed to leave search space");
|
|
} finally {
|
|
setIsLeavingSearchSpace(false);
|
|
setShowLeaveSearchSpaceDialog(false);
|
|
setSearchSpaceToLeave(null);
|
|
}
|
|
}, [searchSpaceToLeave, refetchSearchSpaces, searchSpaceId, router, t]);
|
|
|
|
const handleTabSwitch = useCallback(
|
|
(tab: Tab) => {
|
|
if (tab.type === "chat") {
|
|
activateChatThread({
|
|
id: tab.chatId ?? null,
|
|
title: tab.title,
|
|
url: tab.chatUrl,
|
|
searchSpaceId: tab.searchSpaceId ?? searchSpaceId,
|
|
...(tab.visibility !== undefined ? { visibility: tab.visibility } : {}),
|
|
...(tab.hasComments !== undefined ? { hasComments: tab.hasComments } : {}),
|
|
});
|
|
}
|
|
// Document tabs are handled in-place by LayoutShell — no navigation needed
|
|
},
|
|
[activateChatThread, searchSpaceId]
|
|
);
|
|
|
|
const handleTabPrefetch = useCallback(
|
|
(tab: Tab) => {
|
|
if (tab.type === "chat") {
|
|
prefetchChatThread(tab.chatId);
|
|
}
|
|
},
|
|
[prefetchChatThread]
|
|
);
|
|
|
|
const handleChatPrefetch = useCallback(
|
|
(chat: ChatItem) => {
|
|
prefetchChatThread(chat.id);
|
|
},
|
|
[prefetchChatThread]
|
|
);
|
|
|
|
const handleNavItemClick = useCallback(
|
|
(item: NavItem) => {
|
|
if (item.url === "#inbox") {
|
|
setActiveSlideoutPanel((prev) => (prev === "inbox" ? null : "inbox"));
|
|
return;
|
|
}
|
|
if (item.url === "#documents") {
|
|
if (!isMobile) {
|
|
if (!isDocumentsSidebarOpen) {
|
|
setIsDocumentsSidebarOpen(true);
|
|
setIsRightPanelCollapsed(false);
|
|
setActiveSlideoutPanel(null);
|
|
} else {
|
|
setIsRightPanelCollapsed((prev) => !prev);
|
|
}
|
|
} else {
|
|
setIsDocumentsSidebarOpen((prev) => {
|
|
if (!prev) {
|
|
setActiveSlideoutPanel(null);
|
|
}
|
|
return !prev;
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
router.push(item.url);
|
|
},
|
|
[router, isMobile, isDocumentsSidebarOpen, setIsDocumentsSidebarOpen, setIsRightPanelCollapsed]
|
|
);
|
|
|
|
const handleNewChat = useCallback(() => {
|
|
// Check if router is out of sync (thread created via replaceState but params don't have chat_id)
|
|
const isOutOfSync = currentThreadState.id !== null && !params?.chat_id;
|
|
|
|
if (isOutOfSync) {
|
|
resetCurrentThread();
|
|
// Immediately set the browser URL so the page remounts with a clean /new-chat path
|
|
window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`);
|
|
// Force-remount the page component to reset all React state synchronously
|
|
setChatResetKey((k) => k + 1);
|
|
// Sync Next.js router internals so useParams/usePathname stay correct going forward
|
|
router.replace(`/dashboard/${searchSpaceId}/new-chat`);
|
|
} else {
|
|
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
|
}
|
|
}, [router, searchSpaceId, currentThreadState.id, params?.chat_id, resetCurrentThread]);
|
|
|
|
const handleChatSelect = useCallback(
|
|
(chat: ChatItem) => {
|
|
activateChatThread({
|
|
id: chat.id,
|
|
title: chat.name,
|
|
url: chat.url,
|
|
searchSpaceId,
|
|
...(chat.visibility !== undefined ? { visibility: chat.visibility } : {}),
|
|
});
|
|
},
|
|
[activateChatThread, searchSpaceId]
|
|
);
|
|
|
|
const handleChatDelete = useCallback((chat: ChatItem) => {
|
|
setChatToDelete({ id: chat.id, name: chat.name });
|
|
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;
|
|
const successMessage = newArchivedState
|
|
? tSidebar("chat_archived") || "Chat archived"
|
|
: tSidebar("chat_unarchived") || "Chat restored";
|
|
|
|
try {
|
|
await archiveThread({ threadId: chat.id, archived: newArchivedState });
|
|
toast.success(successMessage);
|
|
} catch (error) {
|
|
console.error("Error archiving thread:", error);
|
|
toast.error(tSidebar("error_archiving_chat") || "Failed to archive chat");
|
|
}
|
|
},
|
|
[archiveThread, tSidebar]
|
|
);
|
|
|
|
const handleSettings = useCallback(() => {
|
|
router.push(`/dashboard/${searchSpaceId}/workspace-settings`);
|
|
}, [router, searchSpaceId]);
|
|
|
|
const handleManageMembers = useCallback(() => {
|
|
router.push(`/dashboard/${searchSpaceId}/team`);
|
|
}, [router, searchSpaceId]);
|
|
|
|
const handleLogout = useCallback(async () => {
|
|
try {
|
|
trackLogout();
|
|
resetUser();
|
|
|
|
// Revoke refresh token on server and clear all tokens from localStorage
|
|
await logout();
|
|
|
|
if (typeof window !== "undefined") {
|
|
router.push(getLoginPath());
|
|
}
|
|
} catch (error) {
|
|
console.error("Error during logout:", error);
|
|
await logout();
|
|
router.push(getLoginPath());
|
|
}
|
|
}, [router]);
|
|
|
|
const handleViewAllChats = useCallback(() => {
|
|
setActiveSlideoutPanel((prev) => (prev === "chats" ? null : "chats"));
|
|
}, []);
|
|
|
|
// Delete handlers
|
|
const confirmDeleteChat = useCallback(async () => {
|
|
if (!chatToDelete) return;
|
|
setIsDeletingChat(true);
|
|
try {
|
|
await deleteThread({ threadId: chatToDelete.id });
|
|
const fallbackTab = removeChatTab(chatToDelete.id);
|
|
if (currentChatId === chatToDelete.id) {
|
|
resetCurrentThread();
|
|
if (fallbackTab?.type === "chat" && fallbackTab.chatUrl) {
|
|
activateChatThread({
|
|
id: fallbackTab.chatId ?? null,
|
|
title: fallbackTab.title,
|
|
url: fallbackTab.chatUrl,
|
|
searchSpaceId: fallbackTab.searchSpaceId ?? searchSpaceId,
|
|
...(fallbackTab.visibility !== undefined ? { visibility: fallbackTab.visibility } : {}),
|
|
...(fallbackTab.hasComments !== undefined
|
|
? { hasComments: fallbackTab.hasComments }
|
|
: {}),
|
|
});
|
|
} else {
|
|
const isOutOfSync = currentThreadState.id !== null && !params?.chat_id;
|
|
if (isOutOfSync) {
|
|
window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`);
|
|
setChatResetKey((k) => k + 1);
|
|
} else {
|
|
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Error deleting thread:", error);
|
|
} finally {
|
|
setIsDeletingChat(false);
|
|
setShowDeleteChatDialog(false);
|
|
setChatToDelete(null);
|
|
}
|
|
}, [
|
|
chatToDelete,
|
|
deleteThread,
|
|
searchSpaceId,
|
|
resetCurrentThread,
|
|
currentChatId,
|
|
currentThreadState.id,
|
|
params?.chat_id,
|
|
router,
|
|
removeChatTab,
|
|
activateChatThread,
|
|
]);
|
|
|
|
// Rename handler
|
|
const confirmRenameChat = useCallback(async () => {
|
|
if (!chatToRename || !newChatTitle.trim()) return;
|
|
setIsRenamingChat(true);
|
|
try {
|
|
await renameThread({
|
|
threadId: chatToRename.id,
|
|
title: newChatTitle.trim(),
|
|
previousTitle: chatToRename.name,
|
|
});
|
|
toast.success(tSidebar("chat_renamed") || "Chat renamed");
|
|
} 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, renameThread, tSidebar]);
|
|
|
|
// Detect if we're on the chat page (needs overflow-hidden for chat's own scroll)
|
|
const isChatPage = pathname?.includes("/new-chat") ?? false;
|
|
const isUserSettingsPage = pathname?.includes("/user-settings") === true;
|
|
const isSearchSpaceSettingsPage = pathname?.includes("/workspace-settings") === true;
|
|
const isTeamPage = pathname?.endsWith("/team") === true;
|
|
const isAutomationsPage = pathname?.includes("/automations") === true;
|
|
const useWorkspacePanel =
|
|
pathname?.endsWith("/buy-more") === true ||
|
|
pathname?.endsWith("/earn-credits") === true ||
|
|
pathname?.endsWith("/more-pages") === true ||
|
|
isUserSettingsPage ||
|
|
isSearchSpaceSettingsPage ||
|
|
isTeamPage ||
|
|
isAutomationsPage;
|
|
|
|
return (
|
|
<>
|
|
<LayoutShell
|
|
searchSpaces={searchSpaces}
|
|
activeSearchSpaceId={Number(searchSpaceId)}
|
|
onSearchSpaceSelect={handleSearchSpaceSelect}
|
|
onSearchSpaceDelete={handleSearchSpaceDeleteClick}
|
|
onSearchSpaceSettings={handleSearchSpaceSettings}
|
|
onAddSearchSpace={handleAddSearchSpace}
|
|
searchSpace={activeSearchSpace}
|
|
navItems={navItems}
|
|
onNavItemClick={handleNavItemClick}
|
|
chats={chats}
|
|
activeChatId={currentChatId}
|
|
onNewChat={handleNewChat}
|
|
onChatSelect={handleChatSelect}
|
|
onChatPrefetch={handleChatPrefetch}
|
|
onChatRename={handleChatRename}
|
|
onChatDelete={handleChatDelete}
|
|
onChatArchive={handleChatArchive}
|
|
onViewAllChats={handleViewAllChats}
|
|
user={{
|
|
email: user?.email || "",
|
|
name: user?.display_name || user?.email?.split("@")[0],
|
|
avatarUrl: user?.avatar_url || undefined,
|
|
}}
|
|
onSettings={handleSettings}
|
|
onManageMembers={handleManageMembers}
|
|
onUserSettings={handleUserSettings}
|
|
onAnnouncements={handleAnnouncements}
|
|
announcementUnreadCount={announcementUnreadCount}
|
|
onLogout={handleLogout}
|
|
theme={theme}
|
|
setTheme={setTheme}
|
|
isChatPage={isChatPage}
|
|
useWorkspacePanel={useWorkspacePanel}
|
|
workspacePanelViewportClassName={
|
|
isUserSettingsPage || isSearchSpaceSettingsPage || isTeamPage || isAutomationsPage
|
|
? "items-start justify-center px-6 py-8 md:px-10 md:py-10"
|
|
: undefined
|
|
}
|
|
workspacePanelContentClassName={
|
|
isAutomationsPage
|
|
? "max-w-none select-none"
|
|
: isUserSettingsPage || isSearchSpaceSettingsPage || isTeamPage
|
|
? "max-w-5xl"
|
|
: undefined
|
|
}
|
|
isLoadingChats={isLoadingThreads}
|
|
activeSlideoutPanel={activeSlideoutPanel}
|
|
onSlideoutPanelChange={setActiveSlideoutPanel}
|
|
inbox={{
|
|
isOpen: isInboxSidebarOpen,
|
|
totalUnreadCount,
|
|
comments: {
|
|
items: commentsInbox.inboxItems,
|
|
unreadCount: commentsInbox.unreadCount,
|
|
loading: commentsInbox.loading,
|
|
loadingMore: commentsInbox.loadingMore,
|
|
hasMore: commentsInbox.hasMore,
|
|
loadMore: commentsInbox.loadMore,
|
|
markAsRead: commentsInbox.markAsRead,
|
|
markAllAsRead: commentsInbox.markAllAsRead,
|
|
},
|
|
status: {
|
|
items: statusInbox.inboxItems,
|
|
unreadCount: statusInbox.unreadCount,
|
|
loading: statusInbox.loading,
|
|
loadingMore: statusInbox.loadingMore,
|
|
hasMore: statusInbox.hasMore,
|
|
loadMore: statusInbox.loadMore,
|
|
markAsRead: statusInbox.markAsRead,
|
|
markAllAsRead: statusInbox.markAllAsRead,
|
|
},
|
|
}}
|
|
allChatsPanel={{
|
|
searchSpaceId,
|
|
}}
|
|
documentsPanel={{
|
|
open: isDocumentsSidebarOpen,
|
|
onOpenChange: setIsDocumentsSidebarOpen,
|
|
}}
|
|
onTabSwitch={handleTabSwitch}
|
|
onTabPrefetch={handleTabPrefetch}
|
|
>
|
|
<Fragment key={chatResetKey}>{children}</Fragment>
|
|
</LayoutShell>
|
|
|
|
{/* Delete Chat Dialog */}
|
|
<AlertDialog open={showDeleteChatDialog} onOpenChange={setShowDeleteChatDialog}>
|
|
<AlertDialogContent className="sm:max-w-md">
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{t("delete_chat")}</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t("delete_chat_confirm")}{" "}
|
|
<span className="font-medium break-all">{chatToDelete?.name}</span>?{" "}
|
|
{t("action_cannot_undone")}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={isDeletingChat}>{tCommon("cancel")}</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
confirmDeleteChat();
|
|
}}
|
|
disabled={isDeletingChat}
|
|
className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90 items-center justify-center"
|
|
>
|
|
<span className={isDeletingChat ? "opacity-0" : ""}>{tCommon("delete")}</span>
|
|
{isDeletingChat && <Spinner size="sm" className="absolute" />}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
{/* Rename Chat Dialog */}
|
|
<Dialog open={showRenameChatDialog} onOpenChange={setShowRenameChatDialog}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<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 sm:justify-end">
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => setShowRenameChatDialog(false)}
|
|
disabled={isRenamingChat}
|
|
>
|
|
{tCommon("cancel")}
|
|
</Button>
|
|
<Button
|
|
onClick={confirmRenameChat}
|
|
disabled={
|
|
isRenamingChat || !newChatTitle.trim() || newChatTitle.trim() === chatToRename?.name
|
|
}
|
|
className="relative"
|
|
>
|
|
<span className={isRenamingChat ? "opacity-0" : ""}>
|
|
{tSidebar("rename") || "Rename"}
|
|
</span>
|
|
{isRenamingChat && (
|
|
<Spinner
|
|
size="sm"
|
|
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
|
|
/>
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Delete Search Space Dialog */}
|
|
<AlertDialog open={showDeleteSearchSpaceDialog} onOpenChange={setShowDeleteSearchSpaceDialog}>
|
|
<AlertDialogContent className="sm:max-w-md">
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{t("delete_search_space")}</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t("delete_space_confirm", { name: searchSpaceToDelete?.name || "" })}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={isDeletingSearchSpace}>
|
|
{tCommon("cancel")}
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
confirmDeleteSearchSpace();
|
|
}}
|
|
disabled={isDeletingSearchSpace}
|
|
className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
<span className={isDeletingSearchSpace ? "opacity-0" : ""}>{tCommon("delete")}</span>
|
|
{isDeletingSearchSpace && <Spinner size="sm" className="absolute" />}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
{/* Leave Search Space Dialog */}
|
|
<AlertDialog open={showLeaveSearchSpaceDialog} onOpenChange={setShowLeaveSearchSpaceDialog}>
|
|
<AlertDialogContent className="sm:max-w-md">
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{t("leave_title")}</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t("leave_confirm", { name: searchSpaceToLeave?.name || "" })}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={isLeavingSearchSpace}>
|
|
{tCommon("cancel")}
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
confirmLeaveSearchSpace();
|
|
}}
|
|
disabled={isLeavingSearchSpace}
|
|
className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
<span className={isLeavingSearchSpace ? "opacity-0" : ""}>{t("leave")}</span>
|
|
{isLeavingSearchSpace && <Spinner size="sm" className="absolute" />}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
{/* Create Search Space Dialog */}
|
|
<CreateSearchSpaceDialog
|
|
open={isCreateSearchSpaceDialogOpen}
|
|
onOpenChange={setIsCreateSearchSpaceDialogOpen}
|
|
/>
|
|
|
|
<AnnouncementsDialog />
|
|
<AnnouncementSpotlight />
|
|
|
|
{/* Agent action log + revert dialog */}
|
|
<ActionLogDialog />
|
|
</>
|
|
);
|
|
}
|