Merge remote-tracking branch 'upstream/dev' into electon-desktop

This commit is contained in:
CREDO23 2026-03-29 03:10:51 +02:00
commit b8a1d1f594
165 changed files with 17921 additions and 8767 deletions

View file

@ -1,8 +1,14 @@
import { loader } from "fumadocs-core/source";
import type { Metadata } from "next";
import { changelog } from "@/.source/server";
import { formatDate } from "@/lib/utils";
import { getMDXComponents } from "@/mdx-components";
export const metadata: Metadata = {
title: "Changelog | SurfSense",
description: "See what's new in SurfSense.",
};
const source = loader({
baseUrl: "/changelog",
source: changelog.toFumadocsSource(),

View file

@ -1,6 +1,11 @@
import React from "react";
import type { Metadata } from "next";
import { ContactFormGridWithDetails } from "@/components/contact/contact-form";
export const metadata: Metadata = {
title: "Contact | SurfSense",
description: "Get in touch with the SurfSense team.",
};
const page = () => {
return (
<div>

View file

@ -5,7 +5,7 @@ import { AnimatePresence, motion } from "motion/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { useState } from "react";
import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
import { Spinner } from "@/components/ui/spinner";
import { getAuthErrorDetails, isNetworkError } from "@/lib/auth-errors";
@ -25,15 +25,10 @@ export function LocalLoginForm() {
title: null,
message: null,
});
const [authType, setAuthType] = useState<string | null>(null);
const authType = AUTH_TYPE;
const router = useRouter();
const [{ mutateAsync: login, isPending: isLoggingIn }] = useAtom(loginMutationAtom);
useEffect(() => {
// Get the auth type from centralized config
setAuthType(AUTH_TYPE);
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError({ title: null, message: null }); // Clear any previous errors

View file

@ -1,6 +1,11 @@
import React from "react";
import type { Metadata } from "next";
import PricingBasic from "@/components/pricing/pricing-section";
export const metadata: Metadata = {
title: "Pricing | SurfSense",
description: "Explore SurfSense plans and pricing options.",
};
const page = () => {
return (
<div>

View file

@ -43,9 +43,12 @@ export default function RegisterPage() {
}
}, [router]);
const handleSubmit = async (e: React.FormEvent) => {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
submitForm();
};
const submitForm = async () => {
// Form validation
if (password !== confirmPassword) {
setError({ title: t("password_mismatch"), message: t("passwords_no_match_desc") });
@ -140,7 +143,7 @@ export default function RegisterPage() {
if (shouldRetry(errorCode)) {
toastOptions.action = {
label: tCommon("retry"),
onClick: () => handleSubmit(e),
onClick: () => submitForm(),
};
}

View file

@ -63,7 +63,7 @@ export function DocumentTypeChip({ type, className }: { type: string; className?
checkTruncation();
window.addEventListener("resize", checkTruncation);
return () => window.removeEventListener("resize", checkTruncation);
}, []);
}, [type]);
const chip = (
<span

View file

@ -1,6 +1,6 @@
"use client";
import { ListFilter, Search, Upload, X } from "lucide-react";
import { FolderPlus, ListFilter, Search, Upload, X } from "lucide-react";
import { useTranslations } from "next-intl";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { getDocumentTypeIcon, getDocumentTypeLabel } from "./DocumentTypeIcon";
@ -17,12 +18,14 @@ export function DocumentsFilters({
searchValue,
onToggleType,
activeTypes,
onCreateFolder,
}: {
typeCounts: Partial<Record<DocumentTypeEnum, number>>;
onSearch: (v: string) => void;
searchValue: string;
onToggleType: (type: DocumentTypeEnum, checked: boolean) => void;
activeTypes: DocumentTypeEnum[];
onCreateFolder?: () => void;
}) {
const t = useTranslations("documents");
const id = React.useId();
@ -194,6 +197,23 @@ export function DocumentsFilters({
)}
</div>
{/* New Folder Button */}
{onCreateFolder && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-9 w-9 shrink-0 border-dashed border-sidebar-border text-sidebar-foreground/60 hover:text-sidebar-foreground hover:border-sidebar-border bg-sidebar"
onClick={onCreateFolder}
>
<FolderPlus size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>New folder</TooltipContent>
</Tooltip>
)}
{/* Upload Button */}
<Button
data-joyride="upload-button"

View file

@ -473,14 +473,14 @@ export function DocumentsTableShell({
}, [deletableSelectedIds, bulkDeleteDocuments, deleteDocument]);
const bulkDeleteBar = hasDeletableSelection ? (
<div className="flex items-center justify-center py-1.5 border-b border-border/50 bg-destructive/5 shrink-0 animate-in fade-in slide-in-from-top-1 duration-150">
<div className="absolute inset-x-0 top-0 z-10 flex items-center justify-center py-1 pointer-events-none animate-in fade-in duration-150">
<button
type="button"
onClick={() => setBulkDeleteConfirmOpen(true)}
className="flex items-center gap-1.5 px-3 py-1 rounded-md bg-destructive text-destructive-foreground shadow-sm text-xs font-medium hover:bg-destructive/90 transition-colors"
className="pointer-events-auto flex items-center gap-1.5 px-3 py-1 rounded-md bg-destructive text-destructive-foreground shadow-lg text-xs font-medium hover:bg-destructive/90 transition-colors"
>
<Trash2 size={12} />
Delete ({deletableSelectedIds.length} selected)
Delete {deletableSelectedIds.length} {deletableSelectedIds.length === 1 ? "item" : "items"}
</button>
</div>
) : null;
@ -526,7 +526,6 @@ export function DocumentsTableShell({
</TableRow>
</TableHeader>
</Table>
{bulkDeleteBar}
{loading ? (
<div className="flex-1 overflow-auto">
<Table className="table-fixed w-full">
@ -594,7 +593,8 @@ export function DocumentsTableShell({
)}
</div>
) : (
<div ref={desktopScrollRef} className="flex-1 overflow-auto">
<div ref={desktopScrollRef} className="flex-1 overflow-auto relative">
{bulkDeleteBar}
<Table className="table-fixed w-full">
<TableBody>
{sorted.map((doc) => {
@ -788,9 +788,6 @@ export function DocumentsTableShell({
)}
</div>
{/* Mobile bulk delete bar */}
<div className="md:hidden">{bulkDeleteBar}</div>
{/* Mobile Card View */}
{loading ? (
<div className="md:hidden divide-y divide-border/50 flex-1 overflow-auto">
@ -846,8 +843,9 @@ export function DocumentsTableShell({
) : (
<div
ref={mobileScrollRef}
className="md:hidden divide-y divide-border/50 flex-1 overflow-auto"
className="md:hidden divide-y divide-border/50 flex-1 overflow-auto relative"
>
{bulkDeleteBar}
{sorted.map((doc) => {
const isMentioned = mentionedDocIds?.has(doc.id) ?? false;
const statusState = doc.status?.state ?? "ready";

View file

@ -24,8 +24,7 @@ import {
} from "@/components/ui/dropdown-menu";
import type { Document } from "./types";
// Only FILE and NOTE document types can be edited
const EDITABLE_DOCUMENT_TYPES = ["FILE", "NOTE"] as const;
const EDITABLE_DOCUMENT_TYPES = ["NOTE"] as const;
// SURFSENSE_DOCS are system-managed and cannot be deleted
const NON_DELETABLE_DOCUMENT_TYPES = ["SURFSENSE_DOCS"] as const;
@ -47,20 +46,14 @@ export function RowActions({
document.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number]
);
// Documents in "pending" or "processing" state should show disabled delete
const isBeingProcessed =
document.status?.state === "pending" || document.status?.state === "processing";
// FILE documents that failed processing cannot be edited
const isFileFailed = document.document_type === "FILE" && document.status?.state === "failed";
// SURFSENSE_DOCS are system-managed and should not show delete at all
const shouldShowDelete = !NON_DELETABLE_DOCUMENT_TYPES.includes(
document.document_type as (typeof NON_DELETABLE_DOCUMENT_TYPES)[number]
);
// Edit is disabled while processing OR for failed FILE documents
const isEditDisabled = isBeingProcessed || isFileFailed;
const isEditDisabled = isBeingProcessed;
const isDeleteDisabled = isBeingProcessed;
const handleDelete = async () => {

View file

@ -0,0 +1,133 @@
"use client";
import { motion } from "motion/react";
import { Skeleton } from "@/components/ui/skeleton";
export default function Loading() {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="w-full px-6 py-4 space-y-6 min-h-[calc(100vh-64px)]"
>
{/* Summary Dashboard Skeleton */}
<motion.div
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
{[...Array(4)].map((_, i) => (
<div key={i} className="rounded-lg border p-4">
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-4 rounded-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-8 w-16" />
<Skeleton className="h-3 w-32" />
</div>
</div>
))}
</motion.div>
{/* Header Section Skeleton */}
<motion.div
className="flex items-center justify-between"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<Skeleton className="h-9 w-24" />
</motion.div>
{/* Filters Skeleton */}
<motion.div
className="flex flex-wrap items-center justify-start gap-3 w-full"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<div className="flex items-center gap-3 flex-wrap w-full sm:w-auto">
<Skeleton className="h-9 w-full sm:w-60" />
<Skeleton className="h-9 w-24" />
<Skeleton className="h-9 w-24" />
<Skeleton className="h-9 w-20" />
</div>
</motion.div>
{/* Table Skeleton */}
<motion.div
className="rounded-md border overflow-hidden"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
{/* Table Header */}
<div className="border-b bg-muted/50 px-4 py-3 flex items-center gap-4">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 flex-1" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-8" />
</div>
{/* Table Rows */}
{[...Array(6)].map((_, i) => (
<div key={i} className="border-b px-4 py-3 flex items-center gap-4 hover:bg-muted/50">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-6 w-12 rounded-full" />
<Skeleton className="h-6 w-16 rounded-full" />
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-20" />
</div>
<div className="flex-1 space-y-1">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48" />
</div>
<div className="space-y-1">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-3 w-20" />
</div>
<Skeleton className="h-8 w-8" />
</div>
))}
</motion.div>
{/* Pagination Skeleton */}
<div className="flex items-center justify-between gap-8 mt-4">
<motion.div
className="flex items-center gap-3"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
>
<Skeleton className="h-4 w-20 max-sm:sr-only" />
<Skeleton className="h-9 w-16" />
</motion.div>
<motion.div
className="flex grow justify-end"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
>
<Skeleton className="h-4 w-40" />
</motion.div>
<div className="flex items-center gap-2">
<Skeleton className="h-9 w-9" />
<Skeleton className="h-9 w-9" />
<Skeleton className="h-9 w-9" />
<Skeleton className="h-9 w-9" />
</div>
</div>
</motion.div>
);
}

View file

@ -0,0 +1,10 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function Loading() {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-4">
<Skeleton className="h-4 w-64" />
<Skeleton className="h-32 w-full max-w-2xl rounded-xl" />
</div>
);
}

View file

@ -30,8 +30,10 @@ import {
// extractWriteTodosFromContent,
} from "@/atoms/chat/plan-state.atom";
import { closeReportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { type AgentCreatedDocument, agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
import { closeEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { membersAtom } from "@/atoms/members/members-query.atoms";
import { updateChatTabTitleAtom } from "@/atoms/tabs/tabs.atom";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps";
import { Thread } from "@/components/assistant-ui/thread";
@ -74,6 +76,7 @@ import {
trackChatMessageSent,
trackChatResponseReceived,
} from "@/lib/posthog/events";
import Loading from "../loading";
/**
* After a tool produces output, mark any previously-decided interrupt tool
@ -188,6 +191,8 @@ export default function NewChatPage() {
const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom);
const closeReportPanel = useSetAtom(closeReportPanelAtom);
const closeEditorPanel = useSetAtom(closeEditorPanelAtom);
const updateChatTabTitle = useSetAtom(updateChatTabTitleAtom);
const setAgentCreatedDocuments = useSetAtom(agentCreatedDocumentsAtom);
// Get current user for author info in shared chats
const { data: currentUser } = useAtomValue(currentUserAtom);
@ -726,12 +731,10 @@ export default function NewChatPage() {
}
case "data-thread-title-update": {
// Handle thread title update from LLM-generated title
const titleData = parsed.data as { threadId: number; title: string };
if (titleData?.title && titleData?.threadId === currentThreadId) {
// Update current thread state with new title
setCurrentThread((prev) => (prev ? { ...prev, title: titleData.title } : prev));
// Invalidate thread list to refresh sidebar
updateChatTabTitle({ chatId: currentThreadId, title: titleData.title });
queryClient.invalidateQueries({
queryKey: ["threads", String(searchSpaceId)],
});
@ -739,6 +742,20 @@ export default function NewChatPage() {
break;
}
case "data-documents-updated": {
const docEvent = parsed.data as {
action: string;
document: AgentCreatedDocument;
};
if (docEvent?.document?.id) {
setAgentCreatedDocuments((prev) => {
if (prev.some((d) => d.id === docEvent.document.id)) return prev;
return [...prev, docEvent.document];
});
}
break;
}
case "data-interrupt-request": {
wasInterrupted = true;
const interruptData = parsed.data as Record<string, unknown>;
@ -1526,49 +1543,14 @@ export default function NewChatPage() {
// Show loading state only when loading an existing thread
if (isInitializing) {
return (
<div className="flex h-[calc(100dvh-64px)] flex-col bg-main-panel px-4">
<div className="mx-auto w-full max-w-[44rem] flex flex-1 flex-col gap-6 py-8">
{/* User message */}
<div className="flex justify-end">
<Skeleton className="h-12 w-56 rounded-2xl" />
</div>
{/* Assistant message */}
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-[85%]" />
<Skeleton className="h-4 w-[70%]" />
</div>
{/* User message */}
<div className="flex justify-end">
<Skeleton className="h-12 w-40 rounded-2xl" />
</div>
{/* Assistant message */}
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-[90%]" />
<Skeleton className="h-4 w-[60%]" />
</div>
</div>
{/* Input bar */}
<div className="sticky bottom-0 pb-6 bg-main-panel">
<div className="mx-auto w-full max-w-[44rem]">
<Skeleton className="h-24 w-full rounded-2xl" />
</div>
</div>
</div>
);
return <Loading />;
}
// Show error state only if we tried to load an existing thread but failed
// For new chats (urlChatId === 0), threadId being null is expected (lazy creation)
if (!threadId && urlChatId > 0) {
return (
<div className="flex h-[calc(100dvh-64px)] flex-col items-center justify-center gap-4">
<div className="flex h-full flex-col items-center justify-center gap-4">
<div className="text-destructive">Failed to load chat</div>
<button
type="button"
@ -1587,7 +1569,7 @@ export default function NewChatPage() {
return (
<AssistantRuntimeProvider runtime={runtime}>
<ThinkingStepsDataUI />
<div key={searchSpaceId} className="flex h-[calc(100dvh-64px)] overflow-hidden">
<div key={searchSpaceId} className="flex h-full overflow-hidden">
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<Thread />
</div>

View file

@ -0,0 +1,45 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function Loading() {
return (
<div className="flex h-full flex-col bg-main-panel px-4">
<div className="mx-auto w-full max-w-[44rem] flex flex-1 flex-col gap-6 py-8">
{/* User message */}
<div className="flex justify-end">
<Skeleton className="h-12 w-56 rounded-2xl" />
</div>
{/* Assistant message */}
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-[85%]" />
<Skeleton className="h-18 w-[40%]" />
</div>
{/* User message */}
<div className="flex gap-2 justify-end">
<Skeleton className="h-12 w-72 rounded-2xl" />
</div>
{/* Assistant message */}
<div className="flex flex-col gap-2">
<Skeleton className="h-10 w-[30%]" />
<Skeleton className="h-4 w-[90%]" />
<Skeleton className="h-6 w-[60%]" />
</div>
{/* User message */}
<div className="flex gap-2 justify-end">
<Skeleton className="h-12 w-96 rounded-2xl" />
</div>
</div>
{/* Input bar */}
<div className="sticky bottom-0 pb-6 bg-main-panel">
<div className="mx-auto w-full max-w-[44rem]">
<Skeleton className="h-24 w-full rounded-2xl" />
</div>
</div>
</div>
);
}

View file

@ -1,15 +1,10 @@
"use client";
import { redirect } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import { useEffect } from "react";
export default function SearchSpaceDashboardPage() {
const router = useRouter();
const { search_space_id } = useParams();
useEffect(() => {
router.push(`/dashboard/${search_space_id}/new-chat`);
}, [router, search_space_id]);
return <></>;
export default async function SearchSpaceDashboardPage({
params,
}: {
params: Promise<{ search_space_id: string }>;
}) {
const { search_space_id } = await params;
redirect(`/dashboard/${search_space_id}/new-chat`);
}

View file

@ -188,13 +188,13 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
[deleteMember, searchSpaceId]
);
const { data: roles = [] } = useQuery({
const { data: roles = [], isLoading: rolesLoading } = useQuery({
queryKey: cacheKeys.roles.all(searchSpaceId.toString()),
queryFn: () => rolesApiService.getRoles({ search_space_id: searchSpaceId }),
enabled: !!searchSpaceId,
});
const { data: invites = [] } = useQuery({
const { data: invites = [], isLoading: invitesLoading } = useQuery({
queryKey: cacheKeys.invites.all(searchSpaceId.toString()),
queryFn: () => invitesApiService.getInvites({ search_space_id: searchSpaceId }),
staleTime: 5 * 60 * 1000,
@ -294,15 +294,23 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
return (
<div className="space-y-4 md:space-y-6">
<div className="flex items-center gap-2 flex-wrap">
{canInvite && (
<CreateInviteDialog
roles={roles}
onCreateInvite={handleCreateInvite}
searchSpaceId={searchSpaceId}
/>
{rolesLoading ? (
<Skeleton className="h-9 w-32 rounded-md" />
) : (
canInvite && (
<CreateInviteDialog
roles={roles}
onCreateInvite={handleCreateInvite}
searchSpaceId={searchSpaceId}
/>
)
)}
{canInvite && activeInvites.length > 0 && (
<AllInvitesDialog invites={activeInvites} onRevokeInvite={handleRevokeInvite} />
{invitesLoading ? (
<Skeleton className="h-9 w-32 rounded-md" />
) : (
canInvite && activeInvites.length > 0 && (
<AllInvitesDialog invites={activeInvites} onRevokeInvite={handleRevokeInvite} />
)
)}
<p className="text-xs md:text-sm text-muted-foreground whitespace-nowrap">
{members.length} {members.length === 1 ? "member" : "members"}
@ -595,6 +603,7 @@ function CreateInviteDialog({
});
} catch (error) {
console.error("Failed to create invite:", error);
toast.error("Failed to create invite. Please try again.");
} finally {
setCreating(false);
}

View file

@ -3,11 +3,11 @@
import posthog from "posthog-js";
import { useEffect } from "react";
export default function Error({
export default function ErrorPage({
error,
reset,
}: {
error: Error & { digest?: string };
error: globalThis.Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {

View file

@ -1,8 +1,9 @@
"use client";
import NextError from "next/error";
import "./globals.css";
import posthog from "posthog-js";
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
export default function GlobalError({
error,
@ -18,10 +19,11 @@ export default function GlobalError({
return (
<html lang="en">
<body>
<NextError statusCode={0} />
<button type="button" onClick={reset}>
Try again
</button>
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-4">
<h2 className="text-xl font-semibold">Something went wrong</h2>
<p className="text-sm text-muted-foreground">An unexpected error occurred.</p>
<Button onClick={reset}>Try again</Button>
</div>
</body>
</html>
);

View file

@ -1,11 +1,7 @@
"use client";
import { useParams } from "next/navigation";
import { PublicChatView } from "@/components/public-chat/public-chat-view";
export default function PublicChatPage() {
const params = useParams();
const token = params.token as string;
export default async function PublicChatPage({ params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
return <PublicChatView shareToken={token} />;
}