Merge remote-tracking branch 'upstream/dev' into fix/drive-connector

This commit is contained in:
Anish Sarkar 2026-01-28 02:34:30 +05:30
commit 8a5f6ecce1
16 changed files with 171 additions and 218 deletions

View file

@ -93,7 +93,7 @@ function LoginContent() {
}, [searchParams, t, tCommon]);
// Use global loading screen for auth type determination - spinner animation won't reset
useGlobalLoadingEffect(isLoading, tCommon("loading"), "login");
useGlobalLoadingEffect(isLoading);
// Show nothing while loading - the GlobalLoadingProvider handles the loading UI
if (isLoading) {

View file

@ -1,13 +1,10 @@
"use client";
import { useTranslations } from "next-intl";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
export default function AuthCallbackLoading() {
const t = useTranslations("auth");
// Use global loading - spinner animation won't reset when page transitions
useGlobalLoadingEffect(true, t("processing_authentication"), "default");
useGlobalLoadingEffect(true);
// Return null - the GlobalLoadingProvider handles the loading UI
return null;

View file

@ -154,11 +154,7 @@ export function DashboardClientLayout({
isAutoConfiguring;
// Use global loading screen - spinner animation won't reset
useGlobalLoadingEffect(
shouldShowLoading,
isAutoConfiguring ? t("setting_up_ai") : t("checking_llm_prefs"),
"default"
);
useGlobalLoadingEffect(shouldShowLoading);
if (shouldShowLoading) {
return null;

View file

@ -1,6 +1,5 @@
"use client";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
@ -10,11 +9,10 @@ interface DashboardLayoutProps {
}
export default function DashboardLayout({ children }: DashboardLayoutProps) {
const t = useTranslations("dashboard");
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
// Use the global loading screen - spinner animation won't reset
useGlobalLoadingEffect(isCheckingAuth, t("checking_auth"), "default");
useGlobalLoadingEffect(isCheckingAuth);
useEffect(() => {
// Check if user is authenticated

View file

@ -1,13 +1,10 @@
"use client";
import { useTranslations } from "next-intl";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
export default function DashboardLoading() {
const t = useTranslations("common");
// Use global loading - spinner animation won't reset when page transitions
useGlobalLoadingEffect(true, t("loading"), "default");
useGlobalLoadingEffect(true);
// Return null - the GlobalLoadingProvider handles the loading UI
return null;

View file

@ -106,7 +106,7 @@ export default function DashboardPage() {
const shouldShowLoading = isLoading || searchSpaces.length > 0;
// Use global loading screen - spinner animation won't reset
useGlobalLoadingEffect(shouldShowLoading, t("fetching_spaces"), "default");
useGlobalLoadingEffect(shouldShowLoading);
if (error) return <ErrorScreen message={error?.message || "Failed to load search spaces"} />;

View file

@ -2,29 +2,18 @@ import { atom } from "jotai";
interface GlobalLoadingState {
isLoading: boolean;
message?: string;
variant: "login" | "default";
}
export const globalLoadingAtom = atom<GlobalLoadingState>({
isLoading: false,
message: undefined,
variant: "default",
});
// Helper atom for showing global loading
export const showGlobalLoadingAtom = atom(
null,
(
get,
set,
{ message, variant = "default" }: { message?: string; variant?: "login" | "default" }
) => {
set(globalLoadingAtom, { isLoading: true, message, variant });
}
);
export const showGlobalLoadingAtom = atom(null, (get, set) => {
set(globalLoadingAtom, { isLoading: true });
});
// Helper atom for hiding global loading
export const hideGlobalLoadingAtom = atom(null, (get, set) => {
set(globalLoadingAtom, { isLoading: false, message: undefined, variant: "default" });
set(globalLoadingAtom, { isLoading: false });
});

View file

@ -1,7 +1,6 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { getAndClearRedirectPath, setBearerToken } from "@/lib/auth-utils";
@ -27,11 +26,10 @@ const TokenHandler = ({
tokenParamName = "token",
storageKey = "surfsense_bearer_token",
}: TokenHandlerProps) => {
const t = useTranslations("auth");
const searchParams = useSearchParams();
// Always show loading for this component - spinner animation won't reset
useGlobalLoadingEffect(true, t("processing_authentication"), "default");
useGlobalLoadingEffect(true);
useEffect(() => {
// Only run on client-side

View file

@ -86,10 +86,10 @@ export function LayoutDataProvider({
enabled: !!searchSpaceId,
});
// Fetch threads
// Fetch threads (40 total to allow up to 20 per section - shared/private)
const { data: threadsData } = useQuery({
queryKey: ["threads", searchSpaceId, { limit: 4 }],
queryFn: () => fetchThreads(Number(searchSpaceId), 4),
queryKey: ["threads", searchSpaceId, { limit: 40 }],
queryFn: () => fetchThreads(Number(searchSpaceId), 40),
enabled: !!searchSpaceId,
});

View file

@ -3,7 +3,6 @@
import { FolderOpen, MessageSquare, PenSquare } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
@ -121,101 +120,113 @@ export function Sidebar({
)}
</div>
{/* Scrollable content */}
<ScrollArea className="flex-1">
{isCollapsed ? (
<div className="flex flex-col items-center gap-2 py-2 w-[60px]">
{(chats.length > 0 || sharedChats.length > 0) && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => onToggleCollapse?.()}
>
<MessageSquare className="h-4 w-4" />
<span className="sr-only">{t("chats")}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{t("chats")} ({chats.length + sharedChats.length})
</TooltipContent>
</Tooltip>
{/* Chat sections - fills available space */}
{isCollapsed ? (
<div className="flex-1 flex flex-col items-center gap-2 py-2 w-[60px]">
{(chats.length > 0 || sharedChats.length > 0) && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => onToggleCollapse?.()}
>
<MessageSquare className="h-4 w-4" />
<span className="sr-only">{t("chats")}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{t("chats")} ({chats.length + sharedChats.length})
</TooltipContent>
</Tooltip>
)}
</div>
) : (
<div className="flex-1 flex flex-col gap-1 py-2 w-[240px] min-h-0 overflow-hidden">
{/* Shared Chats Section - takes half the space */}
<SidebarSection
title={t("shared_chats")}
defaultOpen={true}
fillHeight={true}
action={
onViewAllSharedChats ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
onClick={onViewAllSharedChats}
>
<FolderOpen className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{t("view_all_shared_chats") || "View all shared chats"}
</TooltipContent>
</Tooltip>
) : undefined
}
>
{sharedChats.length > 0 ? (
<div className="relative flex-1 min-h-0">
<div
className={`flex flex-col gap-0.5 h-full overflow-y-auto scrollbar-thin scrollbar-thumb-muted-foreground/20 scrollbar-track-transparent ${sharedChats.length > 4 ? "pb-8" : ""}`}
>
{sharedChats.slice(0, 20).map((chat) => (
<ChatListItem
key={chat.id}
name={chat.name}
isActive={chat.id === activeChatId}
archived={chat.archived}
onClick={() => onChatSelect(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-8 bg-gradient-to-t from-sidebar via-sidebar/90 to-transparent" />
)}
</div>
) : (
<p className="px-2 py-1 text-xs text-muted-foreground">{t("no_shared_chats")}</p>
)}
</div>
) : (
<div className="flex flex-col gap-1 py-2 w-[240px]">
{/* Shared Chats Section */}
<SidebarSection
title={t("shared_chats")}
defaultOpen={true}
action={
onViewAllSharedChats ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
onClick={onViewAllSharedChats}
>
<FolderOpen className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{t("view_all_shared_chats") || "View all shared chats"}
</TooltipContent>
</Tooltip>
) : undefined
}
>
{sharedChats.length > 0 ? (
<div className="flex flex-col gap-0.5">
{sharedChats.map((chat) => (
<ChatListItem
key={chat.id}
name={chat.name}
isActive={chat.id === activeChatId}
archived={chat.archived}
onClick={() => onChatSelect(chat)}
onArchive={() => onChatArchive?.(chat)}
onDelete={() => onChatDelete?.(chat)}
/>
))}
</div>
) : (
<p className="px-2 py-1 text-xs text-muted-foreground">{t("no_shared_chats")}</p>
)}
</SidebarSection>
</SidebarSection>
{/* Private Chats Section */}
<SidebarSection
title={t("chats")}
defaultOpen={true}
action={
onViewAllPrivateChats ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
onClick={onViewAllPrivateChats}
>
<FolderOpen className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{t("view_all_private_chats") || "View all private chats"}
</TooltipContent>
</Tooltip>
) : undefined
}
>
{chats.length > 0 ? (
<div className="flex flex-col gap-0.5">
{chats.map((chat) => (
{/* Private Chats Section - takes half the space */}
<SidebarSection
title={t("chats")}
defaultOpen={true}
fillHeight={true}
action={
onViewAllPrivateChats ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
onClick={onViewAllPrivateChats}
>
<FolderOpen className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{t("view_all_private_chats") || "View all private chats"}
</TooltipContent>
</Tooltip>
) : undefined
}
>
{chats.length > 0 ? (
<div className="relative flex-1 min-h-0">
<div
className={`flex flex-col gap-0.5 h-full overflow-y-auto scrollbar-thin scrollbar-thumb-muted-foreground/20 scrollbar-track-transparent ${chats.length > 4 ? "pb-8" : ""}`}
>
{chats.slice(0, 20).map((chat) => (
<ChatListItem
key={chat.id}
name={chat.name}
@ -227,13 +238,17 @@ export function Sidebar({
/>
))}
</div>
) : (
<p className="px-2 py-1 text-xs text-muted-foreground">{t("no_chats")}</p>
)}
</SidebarSection>
</div>
)}
</ScrollArea>
{/* Gradient fade indicator when more than 4 items */}
{chats.length > 4 && (
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-sidebar via-sidebar/90 to-transparent" />
)}
</div>
) : (
<p className="px-2 py-1 text-xs text-muted-foreground">{t("no_chats")}</p>
)}
</SidebarSection>
</div>
)}
{/* Footer */}
<div className="mt-auto border-t">

View file

@ -11,6 +11,8 @@ interface SidebarSectionProps {
children: React.ReactNode;
action?: React.ReactNode;
persistentAction?: React.ReactNode;
className?: string;
fillHeight?: boolean;
}
export function SidebarSection({
@ -19,12 +21,18 @@ export function SidebarSection({
children,
action,
persistentAction,
className,
fillHeight = false,
}: SidebarSectionProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="overflow-hidden">
<div className="flex items-center group/section">
<Collapsible
open={isOpen}
onOpenChange={setIsOpen}
className={cn("overflow-hidden", fillHeight && "flex flex-col flex-1 min-h-0", className)}
>
<div className="flex items-center group/section shrink-0">
<CollapsibleTrigger className="flex flex-1 items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors min-w-0">
<ChevronRight
className={cn(
@ -48,8 +56,14 @@ export function SidebarSection({
)}
</div>
<CollapsibleContent className="overflow-hidden">
<div className="px-2 pb-2">{children}</div>
<CollapsibleContent
className={cn("overflow-hidden", fillHeight && "flex-1 flex flex-col min-h-0")}
>
<div
className={cn("px-2 pb-2", fillHeight && "flex-1 flex flex-col min-h-0 overflow-hidden")}
>
{children}
</div>
</CollapsibleContent>
</Collapsible>
);

View file

@ -1,7 +1,6 @@
"use client";
import { useAtomValue } from "jotai";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
@ -30,7 +29,6 @@ interface ElectricProviderProps {
* 5. Provides client via context - hooks should use useElectricClient()
*/
export function ElectricProvider({ children }: ElectricProviderProps) {
const t = useTranslations("common");
const [electricClient, setElectricClient] = useState<ElectricClient | null>(null);
const [error, setError] = useState<Error | null>(null);
const {
@ -117,7 +115,7 @@ export function ElectricProvider({ children }: ElectricProviderProps) {
const shouldShowLoading = hasToken && isUserLoaded && !!user?.id && !electricClient && !error;
// Use global loading hook with ownership tracking - prevents flash during transitions
useGlobalLoadingEffect(shouldShowLoading, t("initializing"), "default");
useGlobalLoadingEffect(shouldShowLoading);
// For non-authenticated pages (like landing page), render immediately with null context
// Also render immediately if user query failed (e.g., token expired)

View file

@ -3,9 +3,7 @@
import { useAtomValue } from "jotai";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { AmbientBackground } from "@/app/(home)/login/AmbientBackground";
import { globalLoadingAtom } from "@/atoms/ui/loading.atoms";
import { Logo } from "@/components/Logo";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
@ -18,7 +16,7 @@ import { cn } from "@/lib/utils";
*/
export function GlobalLoadingProvider({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
const { isLoading, message, variant } = useAtomValue(globalLoadingAtom);
const { isLoading } = useAtomValue(globalLoadingAtom);
useEffect(() => {
setMounted(true);
@ -36,35 +34,11 @@ export function GlobalLoadingProvider({ children }: { children: React.ReactNode
)}
aria-hidden={!isLoading}
>
{variant === "login" ? (
<div className="relative w-full h-full overflow-hidden bg-background">
<AmbientBackground />
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
<Logo className="rounded-md" />
<div className="mt-8 flex flex-col items-center space-y-4">
<div className="h-12 w-12 flex items-center justify-center">
{/* Spinner is always mounted, animation never resets */}
<Spinner size="lg" className="text-muted-foreground" />
</div>
<span className="text-muted-foreground text-sm min-h-[1.25rem] text-center max-w-xs">
{message}
</span>
</div>
</div>
<div className="flex min-h-screen flex-col items-center justify-center bg-background">
<div className="h-12 w-12 flex items-center justify-center">
<Spinner size="lg" className="text-muted-foreground" />
</div>
) : (
<div className="flex min-h-screen flex-col items-center justify-center bg-background">
<div className="flex flex-col items-center space-y-4">
<div className="h-12 w-12 flex items-center justify-center">
{/* Spinner is always mounted, animation never resets */}
<Spinner size="xl" className="text-primary" />
</div>
<span className="text-muted-foreground text-sm min-h-[1.25rem] text-center max-w-md px-4">
{message}
</span>
</div>
</div>
)}
</div>
</div>
);

View file

@ -20,21 +20,18 @@ let pendingHideTimeout: ReturnType<typeof setTimeout> | null = null;
export function useGlobalLoading() {
const [loading, setLoading] = useAtom(globalLoadingAtom);
const show = useCallback(
(message?: string, variant: "login" | "default" = "default") => {
// Cancel any pending hide - new loading request takes over
if (pendingHideTimeout) {
clearTimeout(pendingHideTimeout);
pendingHideTimeout = null;
}
const show = useCallback(() => {
// Cancel any pending hide - new loading request takes over
if (pendingHideTimeout) {
clearTimeout(pendingHideTimeout);
pendingHideTimeout = null;
}
const id = ++loadingIdCounter;
currentLoadingId = id;
setLoading({ isLoading: true, message, variant });
return id;
},
[setLoading]
);
const id = ++loadingIdCounter;
currentLoadingId = id;
setLoading({ isLoading: true });
return id;
}, [setLoading]);
const hide = useCallback(
(id?: number) => {
@ -50,7 +47,7 @@ export function useGlobalLoading() {
// Double-check we're still the current loading after the delay
if (id === undefined || id === currentLoadingId) {
currentLoadingId = null;
setLoading({ isLoading: false, message: undefined, variant: "default" });
setLoading({ isLoading: false });
}
pendingHideTimeout = null;
}, 50); // Small delay to allow next component to mount and show loading
@ -70,27 +67,21 @@ export function useGlobalLoading() {
* transition loading states (e.g., layout page).
*
* @param shouldShow - Whether the loading screen should be visible
* @param message - Optional message to display
* @param variant - Visual style variant ("login" or "default")
*/
export function useGlobalLoadingEffect(
shouldShow: boolean,
message?: string,
variant: "login" | "default" = "default"
) {
export function useGlobalLoadingEffect(shouldShow: boolean) {
const { show, hide } = useGlobalLoading();
const loadingIdRef = useRef<number | null>(null);
useEffect(() => {
if (shouldShow) {
// Show loading and store the ID
loadingIdRef.current = show(message, variant);
loadingIdRef.current = show();
} else if (loadingIdRef.current !== null) {
// Only hide if we were the ones showing loading
hide(loadingIdRef.current);
loadingIdRef.current = null;
}
}, [shouldShow, message, variant, show, hide]);
}, [shouldShow, show, hide]);
// Cleanup on unmount - only hide if we're still the active loading
useEffect(() => {

View file

@ -2,8 +2,6 @@
"common": {
"app_name": "SurfSense",
"welcome": "Welcome",
"loading": "Loading",
"initializing": "Initializing",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
@ -80,8 +78,7 @@
"passwords_no_match_desc": "The passwords you entered do not match",
"creating_account": "Creating your account",
"creating_account_btn": "Creating account",
"redirecting_login": "Redirecting to login page",
"processing_authentication": "Processing authentication"
"redirecting_login": "Redirecting to login page"
},
"searchSpace": {
"create_title": "Create Search Space",
@ -146,10 +143,7 @@
"api_keys": "API Keys",
"profile": "Profile",
"loading_dashboard": "Loading Dashboard",
"checking_auth": "Checking authentication",
"loading_config": "Loading Configuration",
"checking_llm_prefs": "Checking your LLM preferences",
"setting_up_ai": "Setting up AI",
"config_error": "Configuration Error",
"failed_load_llm_config": "Failed to load your LLM configuration",
"error_loading_chats": "Error loading chats",
@ -171,7 +165,6 @@
"create_search_space": "Create Search Space",
"add_new_search_space": "Add New Search Space",
"loading": "Loading",
"fetching_spaces": "Fetching your search spaces",
"may_take_moment": "This may take a moment",
"error": "Error",
"something_wrong": "Something went wrong",

View file

@ -2,8 +2,6 @@
"common": {
"app_name": "SurfSense",
"welcome": "欢迎",
"loading": "加载中...",
"initializing": "正在初始化",
"save": "保存",
"cancel": "取消",
"delete": "删除",
@ -80,8 +78,7 @@
"passwords_no_match_desc": "您输入的密码不一致",
"creating_account": "正在创建您的账户",
"creating_account_btn": "创建中",
"redirecting_login": "正在跳转到登录页面",
"processing_authentication": "正在处理身份验证"
"redirecting_login": "正在跳转到登录页面"
},
"searchSpace": {
"create_title": "创建搜索空间",
@ -131,10 +128,7 @@
"api_keys": "API 密钥",
"profile": "个人资料",
"loading_dashboard": "正在加载仪表盘",
"checking_auth": "正在检查身份验证",
"loading_config": "正在加载配置",
"checking_llm_prefs": "正在检查您的 LLM 偏好设置",
"setting_up_ai": "正在设置 AI",
"config_error": "配置错误",
"failed_load_llm_config": "无法加载您的 LLM 配置",
"error_loading_chats": "加载对话失败",
@ -156,7 +150,6 @@
"create_search_space": "创建搜索空间",
"add_new_search_space": "添加新的搜索空间",
"loading": "加载中",
"fetching_spaces": "正在获取您的搜索空间",
"may_take_moment": "这可能需要一些时间",
"error": "错误",
"something_wrong": "出现错误",