From 99039f98e3875e63345a29063836ae77f0797d07 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 8 Mar 2026 16:41:00 +0530 Subject: [PATCH 01/28] feat: add GitHub stars badge component and integrate it into the navbar --- .../homepage/github-stars-badge.tsx | 481 ++++++++++++++++++ surfsense_web/components/homepage/navbar.tsx | 44 +- surfsense_web/hooks/use-github-stars.ts | 58 --- 3 files changed, 485 insertions(+), 98 deletions(-) create mode 100644 surfsense_web/components/homepage/github-stars-badge.tsx delete mode 100644 surfsense_web/hooks/use-github-stars.ts diff --git a/surfsense_web/components/homepage/github-stars-badge.tsx b/surfsense_web/components/homepage/github-stars-badge.tsx new file mode 100644 index 000000000..cb4b55247 --- /dev/null +++ b/surfsense_web/components/homepage/github-stars-badge.tsx @@ -0,0 +1,481 @@ +"use client"; + +import * as React from "react"; +import { + motion, + AnimatePresence, + useInView, + useMotionValue, + useSpring, + useTransform, +} from "motion/react"; +import type { HTMLMotionProps, UseInViewOptions } from "motion/react"; +import { StarIcon } from "lucide-react"; +import { IconBrandGithub } from "@tabler/icons-react"; +import { cn } from "@/lib/utils"; + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- +function getStrictContext(name?: string) { + const Context = React.createContext(undefined); + const Provider = ({ value, children }: { value: T; children?: React.ReactNode }) => ( + {children} + ); + const useSafeContext = () => { + const ctx = React.useContext(Context); + if (ctx === undefined) { + throw new Error(`useContext must be used within ${name ?? "a Provider"}`); + } + return ctx; + }; + return [Provider, useSafeContext] as const; +} + +interface UseIsInViewOptions { + inView?: boolean; + inViewOnce?: boolean; + inViewMargin?: UseInViewOptions["margin"]; +} + +function useIsInView( + ref: React.Ref, + options: UseIsInViewOptions = {} +) { + const { inView, inViewOnce = false, inViewMargin = "0px" } = options; + const localRef = React.useRef(null); + React.useImperativeHandle(ref, () => localRef.current as T); + const inViewResult = useInView(localRef, { + once: inViewOnce, + margin: inViewMargin, + }); + const isInView = !inView || inViewResult; + return { ref: localRef, isInView }; +} + +// --------------------------------------------------------------------------- +// Particles (for star burst effect on completion) +// --------------------------------------------------------------------------- +type ParticlesContextType = { animate: boolean; isInView: boolean }; +const [ParticlesProvider, useParticles] = + getStrictContext("ParticlesContext"); + +function Particles({ + ref, + animate = true, + inView = false, + inViewMargin = "0px", + inViewOnce = true, + children, + style, + ...props +}: Omit, "children"> & { + animate?: boolean; + children: React.ReactNode; +} & UseIsInViewOptions) { + const { ref: localRef, isInView } = useIsInView(ref as React.Ref, { + inView, + inViewOnce, + inViewMargin, + }); + return ( + + + {children} + + + ); +} + +function ParticlesEffect({ + side = "top", + align = "center", + count = 6, + radius = 30, + spread = 360, + duration = 0.8, + holdDelay = 0.05, + sideOffset = 0, + alignOffset = 0, + delay = 0, + transition, + style, + ...props +}: Omit, "children"> & { + side?: "top" | "bottom" | "left" | "right"; + align?: "start" | "center" | "end"; + count?: number; + radius?: number; + spread?: number; + duration?: number; + holdDelay?: number; + sideOffset?: number; + alignOffset?: number; + delay?: number; +}) { + const { animate, isInView } = useParticles(); + const isVertical = side === "top" || side === "bottom"; + const alignPct = align === "start" ? "0%" : align === "end" ? "100%" : "50%"; + + const top = isVertical + ? side === "top" + ? `calc(0% - ${sideOffset}px)` + : `calc(100% + ${sideOffset}px)` + : `calc(${alignPct} + ${alignOffset}px)`; + const left = isVertical + ? `calc(${alignPct} + ${alignOffset}px)` + : side === "left" + ? `calc(0% - ${sideOffset}px)` + : `calc(100% + ${sideOffset}px)`; + + const containerStyle: React.CSSProperties = { + position: "absolute", + top, + left, + transform: "translate(-50%, -50%)", + }; + const angleStep = (spread * (Math.PI / 180)) / Math.max(1, count - 1); + + return ( + + {animate && + isInView && + [...Array(count)].map((_, i) => { + const angle = i * angleStep; + const x = Math.cos(angle) * radius; + const y = Math.sin(angle) * radius; + return ( + + ); + })} + + ); +} + +// --------------------------------------------------------------------------- +// Per-digit scrolling wheel +// --------------------------------------------------------------------------- +const ROLLING_ITEM_COUNT = 200; + +function DigitWheel({ + digit, + itemSize = 22, + delay = 0, + cycles = 5, + isRolling = false, + reverse = false, + className, + onSettled, +}: { + digit: number; + itemSize?: number; + delay?: number; + cycles?: number; + isRolling?: boolean; + reverse?: boolean; + className?: string; + onSettled?: () => void; +}) { + const sequence = React.useMemo(() => { + if (isRolling) { + return Array.from({ length: ROLLING_ITEM_COUNT }, (_, i) => ({ + id: `r${i}`, + value: i % 10, + })); + } + + const seq = Array.from({ length: cycles * 10 }, (_, i) => ({ + id: `s${i}`, + value: Math.floor(Math.random() * 10), + })); + const target = { id: "target", value: digit }; + if (reverse) { + seq.unshift(target); + } else { + seq.push(target); + } + return seq; + }, [digit, cycles, isRolling, reverse]); + + const maxOffset = (sequence.length - 1) * itemSize; + const endY = reverse ? 0 : -maxOffset; + + const rollingStartItem = React.useRef(Math.floor(Math.random() * 10)); + const startOffset = rollingStartItem.current * itemSize; + + const y = useMotionValue( + isRolling ? (reverse ? -(maxOffset - startOffset) : -startOffset) : reverse ? -maxOffset : 0 + ); + const ySpring = useSpring( + y, + isRolling ? { stiffness: 10000, damping: 500 } : { stiffness: 70, damping: 20 } + ); + const settledRef = React.useRef(false); + const wasRollingRef = React.useRef(isRolling); + + // Jump y to settling start position when transitioning from rolling → settled + React.useLayoutEffect(() => { + if (wasRollingRef.current && !isRolling) { + y.jump(reverse ? -maxOffset : 0); + } + wasRollingRef.current = isRolling; + }, [isRolling, reverse, maxOffset, y]); + + // Rolling: drive y continuously via RAF (stiff spring tracks it transparently) + React.useEffect(() => { + if (!isRolling) return; + + const cycleHeight = 10 * itemSize; + const msPerCycle = 1000; + let startTime: number | null = null; + let rafId: number; + + const tick = (time: number) => { + if (startTime === null) startTime = time; + const elapsed = time - startTime; + const speed = cycleHeight / msPerCycle; + const travel = elapsed * speed + startOffset; + + if (reverse) { + y.set(Math.min(-maxOffset + travel, 0)); + } else { + y.set(Math.max(-travel, -maxOffset)); + } + + rafId = requestAnimationFrame(tick); + }; + + rafId = requestAnimationFrame(tick); + return () => cancelAnimationFrame(rafId); + }, [isRolling, itemSize, reverse, y, maxOffset, startOffset]); + + // Settling: spring to endY after delay + React.useEffect(() => { + if (isRolling) return; + settledRef.current = false; + const timer = setTimeout(() => y.set(endY), delay); + return () => clearTimeout(timer); + }, [endY, y, delay, isRolling]); + + // Detect settled + React.useEffect(() => { + if (isRolling) return; + const unsub = ySpring.on("change", (latest) => { + if (!settledRef.current && Math.abs(latest - endY) < 0.5) { + settledRef.current = true; + onSettled?.(); + } + }); + return unsub; + }, [ySpring, endY, onSettled, isRolling]); + + return ( +
+ + {sequence.map((item) => ( +
+ {item.value} +
+ ))} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Animated star count with per-digit alternating wheels +// --------------------------------------------------------------------------- +const numberFormatter = new Intl.NumberFormat("en-US"); + +function AnimatedStarCount({ + value, + itemSize = 22, + isRolling = false, + className, + onComplete, +}: { + value: number; + itemSize?: number; + isRolling?: boolean; + className?: string; + onComplete?: () => void; +}) { + const formatted = numberFormatter.format(value); + const chars = formatted.split(""); + + let totalDigits = 0; + for (const c of chars) { + if (c >= "0" && c <= "9") totalDigits++; + } + + const settledCount = React.useRef(0); + const completedRef = React.useRef(false); + + const handleDigitSettled = React.useCallback(() => { + settledCount.current++; + if (!completedRef.current && settledCount.current >= totalDigits) { + completedRef.current = true; + onComplete?.(); + } + }, [totalDigits, onComplete]); + + let digitIndex = 0; + let separatorIndex = 0; + + return ( +
+ {chars.map((char) => { + if (char < "0" || char > "9") { + const sepKey = `sep-${separatorIndex++}`; + return ( +
+ {char} +
+ ); + } + const digit = parseInt(char, 10); + const idx = digitIndex++; + return ( + + ); + })} +
+ ); +} + +// --------------------------------------------------------------------------- +// NavbarGitHubStars — the exported component +// --------------------------------------------------------------------------- +const ITEM_SIZE = 22; + +type NavbarGitHubStarsProps = { + username?: string; + repo?: string; + href?: string; + className?: string; +}; + +function NavbarGitHubStars({ + username = "MODSetter", + repo = "SurfSense", + href = "https://github.com/MODSetter/SurfSense", + className, +}: NavbarGitHubStarsProps) { + const [stars, setStars] = React.useState(0); + const [isLoading, setIsLoading] = React.useState(true); + const [isCompleted, setIsCompleted] = React.useState(false); + + const fillRaw = useMotionValue(0); + const fillSpring = useSpring(fillRaw, { stiffness: 12, damping: 14 }); + const clipPath = useTransform(fillSpring, (v) => `inset(${100 - v * 100}% 0 0 0)`); + + React.useEffect(() => { + const abortController = new AbortController(); + fetch(`https://api.github.com/repos/${username}/${repo}`, { + signal: abortController.signal, + }) + .then((res) => res.json()) + .then((data) => { + if (data && typeof data.stargazers_count === "number") { + setStars(data.stargazers_count); + fillRaw.set(1); + } + }) + .catch((err) => { + if (err instanceof Error && err.name !== "AbortError") { + console.error("Error fetching stars:", err); + } + }) + .finally(() => setIsLoading(false)); + return () => abortController.abort(); + }, [username, repo, fillRaw]); + + return ( + + +
+ setIsCompleted(true)} + /> + +
+
+ +
+
+
+ ); +} + +export { NavbarGitHubStars, type NavbarGitHubStarsProps }; diff --git a/surfsense_web/components/homepage/navbar.tsx b/surfsense_web/components/homepage/navbar.tsx index ddf43e7eb..f711c9103 100644 --- a/surfsense_web/components/homepage/navbar.tsx +++ b/surfsense_web/components/homepage/navbar.tsx @@ -1,18 +1,12 @@ "use client"; -import { - IconBrandDiscord, - IconBrandGithub, - IconBrandReddit, - IconMenu2, - IconX, -} from "@tabler/icons-react"; +import { IconBrandDiscord, IconBrandReddit, IconMenu2, IconX } from "@tabler/icons-react"; import { AnimatePresence, motion } from "motion/react"; import Link from "next/link"; import { useEffect, useState } from "react"; import { SignInButton } from "@/components/auth/sign-in-button"; import { Logo } from "@/components/Logo"; import { ThemeTogglerComponent } from "@/components/theme/theme-toggle"; -import { useGithubStars } from "@/hooks/use-github-stars"; +import { NavbarGitHubStars } from "@/components/homepage/github-stars-badge"; import { cn } from "@/lib/utils"; export const Navbar = () => { @@ -47,7 +41,6 @@ export const Navbar = () => { const DesktopNav = ({ navItems, isScrolled }: any) => { const [hovered, setHovered] = useState(null); - const { compactFormat: githubStars, loading: loadingGithubStars } = useGithubStars(); return ( { @@ -103,21 +96,7 @@ const DesktopNav = ({ navItems, isScrolled }: any) => { > - - - {loadingGithubStars ? ( -
- ) : ( - - {githubStars} - - )} - + @@ -127,7 +106,6 @@ const DesktopNav = ({ navItems, isScrolled }: any) => { const MobileNav = ({ navItems, isScrolled }: any) => { const [open, setOpen] = useState(false); - const { compactFormat: githubStars, loading: loadingGithubStars } = useGithubStars(); return ( { > - - - {loadingGithubStars ? ( -
- ) : ( - - {githubStars} - - )} - + diff --git a/surfsense_web/hooks/use-github-stars.ts b/surfsense_web/hooks/use-github-stars.ts deleted file mode 100644 index aa2bad1b9..000000000 --- a/surfsense_web/hooks/use-github-stars.ts +++ /dev/null @@ -1,58 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; - -export const useGithubStars = () => { - const [stars, setStars] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const abortController = new AbortController(); - const getStars = async () => { - try { - setError(null); - - const response = await fetch(`https://api.github.com/repos/MODSetter/SurfSense`, { - signal: abortController.signal, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch stars: ${response.statusText}`); - } - - const data = await response.json(); - - setStars(data?.stargazers_count); - } catch (err) { - // Ignore abort errors (expected on unmount) - if (err instanceof Error && err.name === "AbortError") { - return; - } - if (err instanceof Error) { - console.error("Error fetching stars:", err); - setError(err.message); - } - } finally { - setLoading(false); - } - }; - - getStars(); - - return () => { - abortController.abort("Component unmounted"); - }; - }, []); - - return { - stars, - loading, - error, - compactFormat: Intl.NumberFormat("en-US", { - notation: "compact", - maximumFractionDigits: 1, - minimumFractionDigits: 1, - }).format(stars || 0), - }; -}; From 0f7878a3a0ae391c534b9ca895bd837217ae04b8 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 8 Mar 2026 17:10:24 +0530 Subject: [PATCH 02/28] refactor: update UI component styles for improved visual consistency and user experience across GitHub stars badge, model selector, dialog, dropdown menu, and shortcut keyboard components --- surfsense_web/components/homepage/github-stars-badge.tsx | 8 ++++---- surfsense_web/components/new-chat/model-selector.tsx | 6 +++--- surfsense_web/components/ui/dialog.tsx | 2 +- surfsense_web/components/ui/dropdown-menu.tsx | 8 ++++---- surfsense_web/components/ui/shortcut-kbd.tsx | 4 ++-- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/surfsense_web/components/homepage/github-stars-badge.tsx b/surfsense_web/components/homepage/github-stars-badge.tsx index cb4b55247..8638a9293 100644 --- a/surfsense_web/components/homepage/github-stars-badge.tsx +++ b/surfsense_web/components/homepage/github-stars-badge.tsx @@ -446,25 +446,25 @@ function NavbarGitHubStars({ className )} > - +
setIsCompleted(true)} />
diff --git a/surfsense_web/components/new-chat/model-selector.tsx b/surfsense_web/components/new-chat/model-selector.tsx index a85658bd8..f23c3480f 100644 --- a/surfsense_web/components/new-chat/model-selector.tsx +++ b/surfsense_web/components/new-chat/model-selector.tsx @@ -291,7 +291,7 @@ export function ModelSelector({ @@ -304,14 +304,14 @@ export function ModelSelector({ LLM Image diff --git a/surfsense_web/components/ui/dialog.tsx b/surfsense_web/components/ui/dialog.tsx index d6a5f255b..f13da44a9 100644 --- a/surfsense_web/components/ui/dialog.tsx +++ b/surfsense_web/components/ui/dialog.tsx @@ -38,7 +38,7 @@ const DialogContent = React.forwardRef< + {keys.map((key) => ( {key} From 5ce37908473ce2c714459141e646725b1a6bb555 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 8 Mar 2026 17:43:19 +0530 Subject: [PATCH 03/28] refactor: enhance UI component styles and functionality in Documents filters, table shell, and editor for improved user experience and consistency --- .../(manage)/components/DocumentsFilters.tsx | 8 +++---- .../components/DocumentsTableShell.tsx | 18 +++++++++++---- .../editor/[documentId]/page.tsx | 23 ++++++++----------- .../layout/ui/sidebar/DocumentsSidebar.tsx | 2 +- .../new-chat/image-config-dialog.tsx | 4 ++-- .../new-chat/model-config-dialog.tsx | 4 ++-- surfsense_web/components/ui/alert-dialog.tsx | 2 +- surfsense_web/components/ui/fixed-toolbar.tsx | 2 +- .../components/ui/floating-toolbar.tsx | 2 +- .../components/ui/insert-toolbar-button.tsx | 2 +- surfsense_web/components/ui/slash-node.tsx | 3 +-- .../ui/turn-into-toolbar-button.tsx | 2 +- 12 files changed, 38 insertions(+), 34 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx index f75e1d727..d29db13ae 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx @@ -79,7 +79,7 @@ export function DocumentsFilters({
{/* Search input */} -
+
onToggleType(value, !activeTypes.includes(value))} > {/* Icon */} @@ -137,11 +137,11 @@ export function DocumentsFilters({ )}
{activeTypes.length > 0 && ( -
+
diff --git a/surfsense_web/components/new-chat/image-config-dialog.tsx b/surfsense_web/components/new-chat/image-config-dialog.tsx index bdb6670f5..12263bdb1 100644 --- a/surfsense_web/components/new-chat/image-config-dialog.tsx +++ b/surfsense_web/components/new-chat/image-config-dialog.tsx @@ -243,8 +243,8 @@ export function ImageConfigDialog({ aria-modal="true" className={cn( "relative w-full max-w-lg h-[85vh]", - "rounded-xl bg-background shadow-2xl ring-1 ring-border/50", - "dark:bg-neutral-900 dark:ring-white/5", + "rounded-xl bg-background shadow-2xl", + "dark:bg-neutral-900", "flex flex-col overflow-hidden" )} onClick={(e) => e.stopPropagation()} diff --git a/surfsense_web/components/new-chat/model-config-dialog.tsx b/surfsense_web/components/new-chat/model-config-dialog.tsx index 6ae023147..06ec3b9b5 100644 --- a/surfsense_web/components/new-chat/model-config-dialog.tsx +++ b/surfsense_web/components/new-chat/model-config-dialog.tsx @@ -195,8 +195,8 @@ export function ModelConfigDialog({ aria-modal="true" className={cn( "relative w-full max-w-lg h-[85vh]", - "rounded-xl bg-background shadow-2xl ring-1 ring-border/50", - "dark:bg-neutral-900 dark:ring-white/5", + "rounded-xl bg-background shadow-2xl", + "dark:bg-neutral-900", "flex flex-col overflow-hidden" )} onClick={(e) => e.stopPropagation()} diff --git a/surfsense_web/components/ui/alert-dialog.tsx b/surfsense_web/components/ui/alert-dialog.tsx index 97264a646..53fa986e6 100644 --- a/surfsense_web/components/ui/alert-dialog.tsx +++ b/surfsense_web/components/ui/alert-dialog.tsx @@ -45,7 +45,7 @@ function AlertDialogContent({ {groups.map(({ group, items }) => ( diff --git a/surfsense_web/components/ui/slash-node.tsx b/surfsense_web/components/ui/slash-node.tsx index 8869d8bf5..d5687efcc 100644 --- a/surfsense_web/components/ui/slash-node.tsx +++ b/surfsense_web/components/ui/slash-node.tsx @@ -1,6 +1,5 @@ "use client"; -import { SlashInputPlugin } from "@platejs/slash-command/react"; import { ChevronRightIcon, Code2Icon, @@ -177,7 +176,7 @@ export function SlashInputElement({ children, ...props }: PlateElementProps) { - + No results found. {slashCommandGroups.map(({ heading, items }) => ( diff --git a/surfsense_web/components/ui/turn-into-toolbar-button.tsx b/surfsense_web/components/ui/turn-into-toolbar-button.tsx index ef5d28324..64ae5e82e 100644 --- a/surfsense_web/components/ui/turn-into-toolbar-button.tsx +++ b/surfsense_web/components/ui/turn-into-toolbar-button.tsx @@ -150,7 +150,7 @@ export function TurnIntoToolbarButton({ { e.preventDefault(); editor.tf.focus(); From 2ac0e4f93132282e457a50ade61d6ee147264692 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 8 Mar 2026 17:52:53 +0530 Subject: [PATCH 04/28] refactor: improve skeleton loading UI and enhance dropdown menu styles for better user experience --- .../dashboard/[search_space_id]/team/page.tsx | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index f5553a580..d21e0387e 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -138,6 +138,7 @@ function getAvatarInitials(member: Membership): string { } const PAGE_SIZE = 5; +const SKELETON_KEYS = Array.from({ length: PAGE_SIZE }, (_, i) => `skeleton-${i}`); export default function TeamManagementPage() { const params = useParams(); @@ -290,9 +291,9 @@ export default function TeamManagementPage() { - {Array.from({ length: PAGE_SIZE }).map((_, i) => ( + {SKELETON_KEYS.map((id) => ( @@ -564,7 +565,7 @@ function MemberRow({ e.preventDefault()} - className="min-w-[120px]" + className="min-w-[120px] dark:bg-neutral-900 dark:border dark:border-white/5" > {canManageRoles && roles @@ -607,12 +608,12 @@ function MemberRow({ )} - - - router.push(`/dashboard/${searchSpaceId}/settings?section=team-roles`) - } - > + + + router.push(`/dashboard/${searchSpaceId}/settings?section=team-roles`) + } + > Manage Roles @@ -876,10 +877,10 @@ function AllInvitesDialog({ return ( - From a11c95e30fccfea2efda32dc92530031af980c2a Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 8 Mar 2026 18:24:29 +0530 Subject: [PATCH 05/28] feat: add last_login column to user table and update user login tracking --- .../versions/103_add_last_login_to_user.py | 39 +++++++++++++++++++ surfsense_backend/app/db.py | 4 ++ surfsense_backend/app/routes/rbac_routes.py | 2 + surfsense_backend/app/schemas/rbac_schemas.py | 1 + surfsense_backend/app/users.py | 19 +++++++++ .../dashboard/[search_space_id]/team/page.tsx | 6 +-- .../atoms/members/members-query.atoms.ts | 1 + .../contracts/types/members.types.ts | 1 + 8 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 surfsense_backend/alembic/versions/103_add_last_login_to_user.py diff --git a/surfsense_backend/alembic/versions/103_add_last_login_to_user.py b/surfsense_backend/alembic/versions/103_add_last_login_to_user.py new file mode 100644 index 000000000..20a061082 --- /dev/null +++ b/surfsense_backend/alembic/versions/103_add_last_login_to_user.py @@ -0,0 +1,39 @@ +"""103_add_last_login_to_user + +Revision ID: 103 +Revises: 102 +Create Date: 2026-03-08 + +Adds last_login timestamp column to the user table so we can track +when each user last authenticated. The column is nullable — existing +rows will have NULL until the user's next login. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "103" +down_revision: str | None = "102" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + conn = op.get_bind() + existing_columns = [col["name"] for col in sa.inspect(conn).get_columns("user")] + + if "last_login" not in existing_columns: + op.add_column( + "user", + sa.Column("last_login", sa.TIMESTAMP(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("user", "last_login") diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 510f64cc3..9f0af4fc5 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -1720,6 +1720,8 @@ if config.AUTH_TYPE == "GOOGLE": display_name = Column(String, nullable=True) avatar_url = Column(String, nullable=True) + last_login = Column(TIMESTAMP(timezone=True), nullable=True) + # Refresh tokens for this user refresh_tokens = relationship( "RefreshToken", @@ -1820,6 +1822,8 @@ else: display_name = Column(String, nullable=True) avatar_url = Column(String, nullable=True) + last_login = Column(TIMESTAMP(timezone=True), nullable=True) + # Refresh tokens for this user refresh_tokens = relationship( "RefreshToken", diff --git a/surfsense_backend/app/routes/rbac_routes.py b/surfsense_backend/app/routes/rbac_routes.py index 7d2cc5c77..38ae31269 100644 --- a/surfsense_backend/app/routes/rbac_routes.py +++ b/surfsense_backend/app/routes/rbac_routes.py @@ -510,6 +510,7 @@ async def list_members( "user_email": member_user.email if member_user else None, "user_display_name": member_user.display_name if member_user else None, "user_avatar_url": member_user.avatar_url if member_user else None, + "user_last_login": member_user.last_login if member_user else None, } response.append(membership_dict) @@ -602,6 +603,7 @@ async def update_member_role( "created_at": db_membership.created_at, "role": db_membership.role, "user_email": member_user.email if member_user else None, + "user_last_login": member_user.last_login if member_user else None, } except HTTPException: diff --git a/surfsense_backend/app/schemas/rbac_schemas.py b/surfsense_backend/app/schemas/rbac_schemas.py index 031eef3d2..8de8426c3 100644 --- a/surfsense_backend/app/schemas/rbac_schemas.py +++ b/surfsense_backend/app/schemas/rbac_schemas.py @@ -77,6 +77,7 @@ class MembershipRead(BaseModel): user_email: str | None = None user_display_name: str | None = None user_avatar_url: str | None = None + user_last_login: datetime | None = None class Config: from_attributes = True diff --git a/surfsense_backend/app/users.py b/surfsense_backend/app/users.py index 7ec657781..d24a6faf1 100644 --- a/surfsense_backend/app/users.py +++ b/surfsense_backend/app/users.py @@ -1,5 +1,6 @@ import logging import uuid +from datetime import UTC, datetime import httpx from fastapi import Depends, Request, Response @@ -12,6 +13,7 @@ from fastapi_users.authentication import ( ) from fastapi_users.db import SQLAlchemyUserDatabase from pydantic import BaseModel +from sqlalchemy import update from app.config import config from app.db import ( @@ -123,6 +125,23 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): return user + async def on_after_login( + self, + user: User, + request: Request | None = None, + response: Response | None = None, + ) -> None: + try: + async with async_session_maker() as session: + await session.execute( + update(User) + .where(User.id == user.id) + .values(last_login=datetime.now(UTC)) + ) + await session.commit() + except Exception as e: + logger.warning(f"Failed to update last_login for user {user.id}: {e}") + async def on_after_register(self, user: User, request: Request | None = None): """ Called after a user registers. Creates a default search space for the user diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index d21e0387e..1d91d32f0 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -546,9 +546,9 @@ function MemberRow({
- - {formatRelativeDate(member.joined_at)} - + + {member.user_last_login ? formatRelativeDate(member.user_last_login) : "Never"} + {showActions ? ( diff --git a/surfsense_web/atoms/members/members-query.atoms.ts b/surfsense_web/atoms/members/members-query.atoms.ts index f486dc02b..c08a7a337 100644 --- a/surfsense_web/atoms/members/members-query.atoms.ts +++ b/surfsense_web/atoms/members/members-query.atoms.ts @@ -10,6 +10,7 @@ export const membersAtom = atomWithQuery((get) => { queryKey: cacheKeys.members.all(searchSpaceId?.toString() ?? ""), enabled: !!searchSpaceId, staleTime: 3 * 1000, // 3 seconds - short staleness for live collaboration + refetchInterval: 2 * 60 * 1000, // 2 minutes queryFn: async () => { if (!searchSpaceId) { return []; diff --git a/surfsense_web/contracts/types/members.types.ts b/surfsense_web/contracts/types/members.types.ts index 9e0665c65..e458807af 100644 --- a/surfsense_web/contracts/types/members.types.ts +++ b/surfsense_web/contracts/types/members.types.ts @@ -13,6 +13,7 @@ export const membership = z.object({ user_email: z.string().nullable().optional(), user_display_name: z.string().nullable().optional(), user_avatar_url: z.string().nullable().optional(), + user_last_login: z.string().nullable().optional(), user_is_active: z.boolean().nullable().optional(), }); From c4d332232365907a6c43f3754d836ad03b9532f8 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 8 Mar 2026 18:30:33 +0530 Subject: [PATCH 06/28] refactor: update icons in user message component and adjust dropdown menu alignment for improved UI consistency --- surfsense_web/components/assistant-ui/user-message.tsx | 4 ++-- surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/surfsense_web/components/assistant-ui/user-message.tsx b/surfsense_web/components/assistant-ui/user-message.tsx index 7ba5b9462..1c0525277 100644 --- a/surfsense_web/components/assistant-ui/user-message.tsx +++ b/surfsense_web/components/assistant-ui/user-message.tsx @@ -1,6 +1,6 @@ import { ActionBarPrimitive, MessagePrimitive, useAssistantState } from "@assistant-ui/react"; import { useAtomValue } from "jotai"; -import { FileText, PencilIcon } from "lucide-react"; +import { FileText, Pen } from "lucide-react"; import { type FC, useState } from "react"; import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; @@ -125,7 +125,7 @@ const UserActionBar: FC = () => { {canEdit && ( - + )} diff --git a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx index 514bf7c60..f73e48cdf 100644 --- a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx +++ b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx @@ -86,7 +86,7 @@ export function ChatListItem({ {t("more_options")} - + {onRename && ( { From 855c334f7ef277590f2e34cf27a5ab39e7264e26 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 8 Mar 2026 18:32:25 +0530 Subject: [PATCH 07/28] refactor: replace PencilIcon with Pen in various components for consistent icon usage --- surfsense_web/components/tool-ui/google-drive/create-file.tsx | 4 ++-- .../components/tool-ui/linear/create-linear-issue.tsx | 4 ++-- .../components/tool-ui/linear/update-linear-issue.tsx | 4 ++-- .../components/tool-ui/notion/create-notion-page.tsx | 4 ++-- .../components/tool-ui/notion/update-notion-page.tsx | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/surfsense_web/components/tool-ui/google-drive/create-file.tsx b/surfsense_web/components/tool-ui/google-drive/create-file.tsx index f2cc97dcf..fb3fb65a0 100644 --- a/surfsense_web/components/tool-ui/google-drive/create-file.tsx +++ b/surfsense_web/components/tool-ui/google-drive/create-file.tsx @@ -6,7 +6,7 @@ import { CheckIcon, FileIcon, Loader2Icon, - PencilIcon, + Pen, RefreshCwIcon, XIcon, } from "lucide-react"; @@ -400,7 +400,7 @@ function ApprovalCard({ )} {canEdit && ( )} diff --git a/surfsense_web/components/tool-ui/linear/create-linear-issue.tsx b/surfsense_web/components/tool-ui/linear/create-linear-issue.tsx index b6eea7a28..ec45ff2aa 100644 --- a/surfsense_web/components/tool-ui/linear/create-linear-issue.tsx +++ b/surfsense_web/components/tool-ui/linear/create-linear-issue.tsx @@ -1,7 +1,7 @@ "use client"; import { makeAssistantToolUI } from "@assistant-ui/react"; -import { AlertTriangleIcon, CheckIcon, Loader2Icon, PencilIcon, XIcon } from "lucide-react"; +import { AlertTriangleIcon, CheckIcon, Loader2Icon, Pen, XIcon } from "lucide-react"; import { useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -495,7 +495,7 @@ function ApprovalCard({ )} {canEdit && ( )} diff --git a/surfsense_web/components/tool-ui/linear/update-linear-issue.tsx b/surfsense_web/components/tool-ui/linear/update-linear-issue.tsx index fa1c2c687..076896549 100644 --- a/surfsense_web/components/tool-ui/linear/update-linear-issue.tsx +++ b/surfsense_web/components/tool-ui/linear/update-linear-issue.tsx @@ -6,7 +6,7 @@ import { CheckIcon, InfoIcon, Loader2Icon, - PencilIcon, + Pen, XIcon, } from "lucide-react"; import { useState } from "react"; @@ -618,7 +618,7 @@ function ApprovalCard({ )} {canEdit && ( )} diff --git a/surfsense_web/components/tool-ui/notion/create-notion-page.tsx b/surfsense_web/components/tool-ui/notion/create-notion-page.tsx index e148c71ba..46e092649 100644 --- a/surfsense_web/components/tool-ui/notion/create-notion-page.tsx +++ b/surfsense_web/components/tool-ui/notion/create-notion-page.tsx @@ -1,7 +1,7 @@ "use client"; import { makeAssistantToolUI } from "@assistant-ui/react"; -import { AlertTriangleIcon, CheckIcon, Loader2Icon, PencilIcon, XIcon } from "lucide-react"; +import { AlertTriangleIcon, CheckIcon, Loader2Icon, Pen, XIcon } from "lucide-react"; import { useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -373,7 +373,7 @@ function ApprovalCard({ )} {canEdit && ( )} diff --git a/surfsense_web/components/tool-ui/notion/update-notion-page.tsx b/surfsense_web/components/tool-ui/notion/update-notion-page.tsx index de8fdd359..885c4325f 100644 --- a/surfsense_web/components/tool-ui/notion/update-notion-page.tsx +++ b/surfsense_web/components/tool-ui/notion/update-notion-page.tsx @@ -8,7 +8,7 @@ import { Loader2Icon, MaximizeIcon, MinimizeIcon, - PencilIcon, + Pen, XIcon, } from "lucide-react"; import { useMemo, useState } from "react"; @@ -336,7 +336,7 @@ function ApprovalCard({ )} {canEdit && ( )} From 78e66477cf88f90608f317c52c3c4da5bbaece51 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 8 Mar 2026 18:40:32 +0530 Subject: [PATCH 08/28] feat: implement document metadata viewer with Ctrl+Click functionality in DocumentsTableShell --- .../components/DocumentsTableShell.tsx | 55 ++++++++++++++++++- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx index 17569fd7a..aa712da5b 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx @@ -19,6 +19,7 @@ import { useTranslations } from "next-intl"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup"; +import { JsonMetadataViewer } from "@/components/json-metadata-viewer"; import { MarkdownViewer } from "@/components/markdown-viewer"; import { AlertDialog, @@ -351,6 +352,10 @@ export function DocumentsTableShell({ const [viewingDoc, setViewingDoc] = useState(null); const [viewingContent, setViewingContent] = useState(""); const [viewingLoading, setViewingLoading] = useState(false); + + const [metadataDoc, setMetadataDoc] = useState(null); + const [metadataJson, setMetadataJson] = useState | null>(null); + const [metadataLoading, setMetadataLoading] = useState(false); const [previewScrollPos, setPreviewScrollPos] = useState<"top" | "middle" | "bottom">("top"); const handlePreviewScroll = useCallback((e: React.UIEvent) => { const el = e.currentTarget; @@ -418,6 +423,20 @@ export function DocumentsTableShell({ setViewingLoading(false); }, []); + const handleViewMetadata = useCallback(async (doc: Document) => { + setMetadataDoc(doc); + setMetadataLoading(true); + try { + const fullDoc = await documentsApiService.getDocument({ id: doc.id }); + setMetadataJson(fullDoc.document_metadata ?? {}); + } catch (err) { + console.error("[DocumentsTableShell] Failed to fetch document metadata:", err); + setMetadataJson({ error: "Failed to load document metadata" }); + } finally { + setMetadataLoading(false); + } + }, []); + const handleDeleteFromMenu = useCallback(async () => { if (!deleteDoc) return; setIsDeleting(true); @@ -573,11 +592,20 @@ export function DocumentsTableShell({ {sorted.map((doc) => { const isMentioned = mentionedDocIds?.has(doc.id) ?? false; const canInteract = isSelectable(doc); - const handleRowClick = () => { + const handleRowToggle = () => { if (canInteract && onToggleChatMention) { onToggleChatMention(doc, isMentioned); } }; + const handleRowClick = (e: React.MouseEvent) => { + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + e.stopPropagation(); + handleViewMetadata(doc); + return; + } + handleRowToggle(); + }; return ( handleRowClick()} + onCheckedChange={() => handleRowToggle()} disabled={!canInteract} aria-label={isMentioned ? "Remove from chat" : "Add to chat"} className={`border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary ${!canInteract ? "opacity-40 cursor-not-allowed" : ""}`} @@ -690,7 +718,13 @@ export function DocumentsTableShell({ {sorted.map((doc) => { const isMentioned = mentionedDocIds?.has(doc.id) ?? false; const canInteract = isSelectable(doc); - const handleCardClick = () => { + const handleCardClick = (e?: React.MouseEvent) => { + if (e && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + e.stopPropagation(); + handleViewMetadata(doc); + return; + } if (canInteract && onToggleChatMention) { onToggleChatMention(doc, isMentioned); } @@ -776,6 +810,21 @@ export function DocumentsTableShell({ + {/* Document Metadata Viewer (Ctrl+Click) */} + { + if (!open) { + setMetadataDoc(null); + setMetadataJson(null); + setMetadataLoading(false); + } + }} + /> + {/* Delete Confirmation Dialog */} !open && setDeleteDoc(null)}> From 97fbb70672137d4ef215198c44bee5f26747dc4e Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 8 Mar 2026 18:45:05 +0530 Subject: [PATCH 09/28] refactor: update DocumentMentionPicker styles for improved dark mode support and UI consistency --- .../components/new-chat/document-mention-picker.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/surfsense_web/components/new-chat/document-mention-picker.tsx b/surfsense_web/components/new-chat/document-mention-picker.tsx index cd639a95a..ee053a9f5 100644 --- a/surfsense_web/components/new-chat/document-mention-picker.tsx +++ b/surfsense_web/components/new-chat/document-mention-picker.tsx @@ -396,7 +396,7 @@ export const DocumentMentionPicker = forwardRef< return (
0 && ( <> + {surfsenseDocsList.length > 0 && ( +
+ )}
Your Documents
From 77dc6b7c91f91399ad5996d45da8c8a671c68861 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 8 Mar 2026 19:36:12 +0530 Subject: [PATCH 10/28] feat: implement user settings page with profile and API key management components --- .../components/ApiKeyContent.tsx | 71 +++ .../components/ProfileContent.tsx | 130 +++++ .../[search_space_id]/user-settings/page.tsx | 41 ++ .../settings/components/ApiKeyContent.tsx | 122 ---- .../settings/components/ProfileContent.tsx | 182 ------ .../components/UserSettingsSidebar.tsx | 160 ------ .../app/dashboard/user/settings/page.tsx | 64 --- .../layout/providers/LayoutDataProvider.tsx | 4 +- .../components/sources/DocumentUploadTab.tsx | 2 +- surfsense_web/components/ui/animated-tabs.tsx | 540 ++++++++++++++++++ 10 files changed, 785 insertions(+), 531 deletions(-) create mode 100644 surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent.tsx create mode 100644 surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ProfileContent.tsx create mode 100644 surfsense_web/app/dashboard/[search_space_id]/user-settings/page.tsx delete mode 100644 surfsense_web/app/dashboard/user/settings/components/ApiKeyContent.tsx delete mode 100644 surfsense_web/app/dashboard/user/settings/components/ProfileContent.tsx delete mode 100644 surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx delete mode 100644 surfsense_web/app/dashboard/user/settings/page.tsx create mode 100644 surfsense_web/components/ui/animated-tabs.tsx diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent.tsx new file mode 100644 index 000000000..c45eca45c --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { Check, Copy, Shield } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useTranslations } from "next-intl"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { useApiKey } from "@/hooks/use-api-key"; + +export function ApiKeyContent() { + const t = useTranslations("userSettings"); + const { apiKey, isLoading, copied, copyToClipboard } = useApiKey(); + + return ( + + + + + {t("api_key_warning_title")} + {t("api_key_warning_description")} + + +
+

{t("your_api_key")}

+ {isLoading ? ( +
+ ) : apiKey ? ( +
+
+ {apiKey} +
+ + + + + + {copied ? t("copied") : t("copy")} + + +
+ ) : ( +

{t("no_api_key")}

+ )} +
+ +
+

{t("usage_title")}

+

{t("usage_description")}

+
+						Authorization: Bearer {apiKey || "YOUR_API_KEY"}
+					
+
+ + + ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ProfileContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ProfileContent.tsx new file mode 100644 index 000000000..a6cb11ecf --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ProfileContent.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useAtomValue } from "jotai"; +import { AnimatePresence, motion } from "motion/react"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { updateUserMutationAtom } from "@/atoms/user/user-mutation.atoms"; +import { currentUserAtom } from "@/atoms/user/user-query.atoms"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Spinner } from "@/components/ui/spinner"; + +function AvatarDisplay({ url, fallback }: { url?: string; fallback: string }) { + const [hasError, setHasError] = useState(false); + + useEffect(() => { + setHasError(false); + }, [url]); + + if (url && !hasError) { + return ( + Avatar setHasError(true)} + /> + ); + } + + return ( +
+ {fallback} +
+ ); +} + +export function ProfileContent() { + const t = useTranslations("userSettings"); + const { data: user, isLoading: isUserLoading } = useAtomValue(currentUserAtom); + const { mutateAsync: updateUser, isPending } = useAtomValue(updateUserMutationAtom); + + const [displayName, setDisplayName] = useState(""); + + useEffect(() => { + if (user) { + setDisplayName(user.display_name || ""); + } + }, [user]); + + const getInitials = (email: string) => { + const name = email.split("@")[0]; + return name.slice(0, 2).toUpperCase(); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + try { + await updateUser({ + display_name: displayName || null, + }); + toast.success(t("profile_saved")); + } catch { + toast.error(t("profile_save_error")); + } + }; + + const hasChanges = displayName !== (user?.display_name || ""); + + return ( + + + {isUserLoading ? ( +
+ +
+ ) : ( +
+
+
+
+ + +
+ +
+ + setDisplayName(e.target.value)} + /> +

+ {t("profile_display_name_hint")} +

+
+ +
+ + +
+
+
+ +
+ +
+
+ )} +
+
+ ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/page.tsx new file mode 100644 index 000000000..019684a95 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/page.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { UserKey, User } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/animated-tabs"; +import { ApiKeyContent } from "./components/ApiKeyContent"; +import { ProfileContent } from "./components/ProfileContent"; + +export default function UserSettingsPage() { + const t = useTranslations("userSettings"); + + return ( +
+
+ + + + + {t("profile_nav_label")} + + + + {t("api_key_nav_label")} + + + + + + + + + +
+
+ ); +} diff --git a/surfsense_web/app/dashboard/user/settings/components/ApiKeyContent.tsx b/surfsense_web/app/dashboard/user/settings/components/ApiKeyContent.tsx deleted file mode 100644 index 6bf10a78f..000000000 --- a/surfsense_web/app/dashboard/user/settings/components/ApiKeyContent.tsx +++ /dev/null @@ -1,122 +0,0 @@ -"use client"; - -import { Check, Copy, Key, Menu, Shield } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; -import { useTranslations } from "next-intl"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { useApiKey } from "@/hooks/use-api-key"; - -interface ApiKeyContentProps { - onMenuClick: () => void; -} - -export function ApiKeyContent({ onMenuClick }: ApiKeyContentProps) { - const t = useTranslations("userSettings"); - const { apiKey, isLoading, copied, copyToClipboard } = useApiKey(); - - return ( - -
-
- - -
- - - - -
-

- {t("api_key_title")} -

-

{t("api_key_description")}

-
-
-
-
- - - - - - {t("api_key_warning_title")} - {t("api_key_warning_description")} - - -
-

{t("your_api_key")}

- {isLoading ? ( -
- ) : apiKey ? ( -
-
- {apiKey} -
- - - - - - {copied ? t("copied") : t("copy")} - - -
- ) : ( -

{t("no_api_key")}

- )} -
- -
-

{t("usage_title")}

-

{t("usage_description")}

-
-									Authorization: Bearer {apiKey || "YOUR_API_KEY"}
-								
-
- - -
-
- - ); -} diff --git a/surfsense_web/app/dashboard/user/settings/components/ProfileContent.tsx b/surfsense_web/app/dashboard/user/settings/components/ProfileContent.tsx deleted file mode 100644 index a1ff4d781..000000000 --- a/surfsense_web/app/dashboard/user/settings/components/ProfileContent.tsx +++ /dev/null @@ -1,182 +0,0 @@ -"use client"; - -import { useAtomValue } from "jotai"; -import { Menu, User } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; -import { useTranslations } from "next-intl"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { updateUserMutationAtom } from "@/atoms/user/user-mutation.atoms"; -import { currentUserAtom } from "@/atoms/user/user-query.atoms"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Spinner } from "@/components/ui/spinner"; - -interface ProfileContentProps { - onMenuClick: () => void; -} - -function AvatarDisplay({ url, fallback }: { url?: string; fallback: string }) { - const [hasError, setHasError] = useState(false); - - useEffect(() => { - setHasError(false); - }, [url]); - - if (url && !hasError) { - return ( - Avatar setHasError(true)} - /> - ); - } - - return ( -
- {fallback} -
- ); -} - -export function ProfileContent({ onMenuClick }: ProfileContentProps) { - const t = useTranslations("userSettings"); - const { data: user, isLoading: isUserLoading } = useAtomValue(currentUserAtom); - const { mutateAsync: updateUser, isPending } = useAtomValue(updateUserMutationAtom); - - const [displayName, setDisplayName] = useState(""); - - useEffect(() => { - if (user) { - setDisplayName(user.display_name || ""); - } - }, [user]); - - const getInitials = (email: string) => { - const name = email.split("@")[0]; - return name.slice(0, 2).toUpperCase(); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - try { - await updateUser({ - display_name: displayName || null, - }); - toast.success(t("profile_saved")); - } catch { - toast.error(t("profile_save_error")); - } - }; - - const hasChanges = displayName !== (user?.display_name || ""); - - return ( - -
-
- - -
- - - - -
-

- {t("profile_title")} -

-

{t("profile_description")}

-
-
-
-
- - - - {isUserLoading ? ( -
- -
- ) : ( -
-
-
-
- - -
- -
- - setDisplayName(e.target.value)} - /> -

- {t("profile_display_name_hint")} -

-
- -
- - -
-
-
- -
- -
-
- )} -
-
-
-
-
- ); -} diff --git a/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx b/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx deleted file mode 100644 index 3424113a9..000000000 --- a/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx +++ /dev/null @@ -1,160 +0,0 @@ -"use client"; - -import type { LucideIcon } from "lucide-react"; -import { ArrowLeft, ChevronRight, X } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; -import { useTranslations } from "next-intl"; -import { Button } from "@/components/ui/button"; -import { APP_VERSION } from "@/lib/env-config"; -import { cn } from "@/lib/utils"; - -export interface SettingsNavItem { - id: string; - label: string; - description: string; - icon: LucideIcon; -} - -interface UserSettingsSidebarProps { - activeSection: string; - onSectionChange: (section: string) => void; - onBackToApp: () => void; - isOpen: boolean; - onClose: () => void; - navItems: SettingsNavItem[]; -} - -export function UserSettingsSidebar({ - activeSection, - onSectionChange, - onBackToApp, - isOpen, - onClose, - navItems, -}: UserSettingsSidebarProps) { - const t = useTranslations("userSettings"); - - const handleNavClick = (sectionId: string) => { - onSectionChange(sectionId); - onClose(); - }; - - return ( - <> - - {isOpen && ( - - )} - - - - - ); -} diff --git a/surfsense_web/app/dashboard/user/settings/page.tsx b/surfsense_web/app/dashboard/user/settings/page.tsx deleted file mode 100644 index 8e04ce37a..000000000 --- a/surfsense_web/app/dashboard/user/settings/page.tsx +++ /dev/null @@ -1,64 +0,0 @@ -"use client"; - -import { Key, User } from "lucide-react"; -import { motion } from "motion/react"; -import { useRouter } from "next/navigation"; -import { useTranslations } from "next-intl"; -import { useCallback, useState } from "react"; -import { ApiKeyContent } from "./components/ApiKeyContent"; -import { ProfileContent } from "./components/ProfileContent"; -import { type SettingsNavItem, UserSettingsSidebar } from "./components/UserSettingsSidebar"; - -export default function UserSettingsPage() { - const t = useTranslations("userSettings"); - const router = useRouter(); - const [activeSection, setActiveSection] = useState("profile"); - const [isSidebarOpen, setIsSidebarOpen] = useState(false); - - const navItems: SettingsNavItem[] = [ - { - id: "profile", - label: t("profile_nav_label"), - description: t("profile_nav_description"), - icon: User, - }, - { - id: "api-key", - label: t("api_key_nav_label"), - description: t("api_key_nav_description"), - icon: Key, - }, - ]; - - const handleBackToApp = useCallback(() => { - router.back(); - }, [router]); - - return ( - -
-
- setIsSidebarOpen(false)} - navItems={navItems} - /> - {activeSection === "profile" && ( - setIsSidebarOpen(true)} /> - )} - {activeSection === "api-key" && ( - setIsSidebarOpen(true)} /> - )} -
-
-
- ); -} diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index c288aacb3..b0ff496f6 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -304,8 +304,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid }, []); const handleUserSettings = useCallback(() => { - router.push("/dashboard/user/settings"); - }, [router]); + router.push(`/dashboard/${searchSpaceId}/user-settings`); + }, [router, searchSpaceId]); const handleSearchSpaceSettings = useCallback( (space: SearchSpace) => { diff --git a/surfsense_web/components/sources/DocumentUploadTab.tsx b/surfsense_web/components/sources/DocumentUploadTab.tsx index 29925a555..4c3615086 100644 --- a/surfsense_web/components/sources/DocumentUploadTab.tsx +++ b/surfsense_web/components/sources/DocumentUploadTab.tsx @@ -303,7 +303,7 @@ export function DocumentUploadTab({ {!isFileCountLimitReached && (
- - - {displayName} - + + + + From 0f2d3bba3c551340da30303b4840be81e6c7890d Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:19:15 +0530 Subject: [PATCH 14/28] feat: add "Learn More" section to SidebarUserProfile with links to documentation and GitHub, and update translations for multiple languages --- .../layout/ui/sidebar/SidebarUserProfile.tsx | 50 ++++++++++++++++++- surfsense_web/messages/en.json | 3 ++ surfsense_web/messages/es.json | 3 ++ surfsense_web/messages/hi.json | 3 ++ surfsense_web/messages/pt.json | 3 ++ surfsense_web/messages/zh.json | 3 ++ 6 files changed, 64 insertions(+), 1 deletion(-) diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx index a314690c1..17856c6fb 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx @@ -1,6 +1,6 @@ "use client"; -import { Check, ChevronUp, Languages, Laptop, LogOut, Moon, Settings, Sun } from "lucide-react"; +import { Check, ChevronUp, ExternalLink, Info, Languages, Laptop, LogOut, Moon, Settings, Sun } from "lucide-react"; import Image from "next/image"; import { useTranslations } from "next-intl"; import { useState } from "react"; @@ -18,6 +18,7 @@ import { } from "@/components/ui/dropdown-menu"; import { Spinner } from "@/components/ui/spinner"; import { useLocaleContext } from "@/contexts/LocaleContext"; +import { APP_VERSION } from "@/lib/env-config"; import { cn } from "@/lib/utils"; import type { User } from "../../types/layout.types"; @@ -37,6 +38,11 @@ const THEMES = [ { value: "system" as const, name: "System", icon: Laptop }, ]; +const LEARN_MORE_LINKS = [ + { key: "documentation" as const, href: "https://surfsense.com/docs" }, + { key: "github" as const, href: "https://github.com/MODSetter/SurfSense" }, +]; + interface SidebarUserProfileProps { user: User; onUserSettings?: () => void; @@ -254,6 +260,27 @@ export function SidebarUserProfile({ + + + + {t("learn_more")} + + + + {LEARN_MORE_LINKS.map((link) => ( + + + {t(link.key)} + + + + ))} + +

v{APP_VERSION}

+
+
+
+ @@ -376,6 +403,27 @@ export function SidebarUserProfile({ + + + + {t("learn_more")} + + + + {LEARN_MORE_LINKS.map((link) => ( + + + {t(link.key)} + + + + ))} + +

v{APP_VERSION}

+
+
+
+ diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index ddd0682a0..09e3c6107 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -685,6 +685,9 @@ "system": "System", "logout": "Logout", "loggingOut": "Logging out...", + "learn_more": "Learn more", + "documentation": "Documentation", + "github": "GitHub", "inbox": "Inbox", "search_inbox": "Search inbox", "mark_all_read": "Mark all as read", diff --git a/surfsense_web/messages/es.json b/surfsense_web/messages/es.json index 1f046ed47..6ba856b26 100644 --- a/surfsense_web/messages/es.json +++ b/surfsense_web/messages/es.json @@ -685,6 +685,9 @@ "system": "Sistema", "logout": "Cerrar sesión", "loggingOut": "Cerrando sesión...", + "learn_more": "Más información", + "documentation": "Documentación", + "github": "GitHub", "inbox": "Bandeja de entrada", "search_inbox": "Buscar en bandeja de entrada", "mark_all_read": "Marcar todo como leído", diff --git a/surfsense_web/messages/hi.json b/surfsense_web/messages/hi.json index 215924bdf..e0d00c750 100644 --- a/surfsense_web/messages/hi.json +++ b/surfsense_web/messages/hi.json @@ -685,6 +685,9 @@ "system": "सिस्टम", "logout": "लॉगआउट", "loggingOut": "लॉगआउट हो रहा है...", + "learn_more": "और जानें", + "documentation": "दस्तावेज़ीकरण", + "github": "GitHub", "inbox": "इनबॉक्स", "search_inbox": "इनबॉक्स में खोजें", "mark_all_read": "सभी पढ़ा हुआ चिह्नित करें", diff --git a/surfsense_web/messages/pt.json b/surfsense_web/messages/pt.json index 2d958b602..4b833bd33 100644 --- a/surfsense_web/messages/pt.json +++ b/surfsense_web/messages/pt.json @@ -685,6 +685,9 @@ "system": "Sistema", "logout": "Sair", "loggingOut": "Saindo...", + "learn_more": "Saiba mais", + "documentation": "Documentação", + "github": "GitHub", "inbox": "Caixa de entrada", "search_inbox": "Pesquisar caixa de entrada", "mark_all_read": "Marcar tudo como lido", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index 24de6f685..b97f1d595 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -669,6 +669,9 @@ "system": "系统", "logout": "退出登录", "loggingOut": "正在退出...", + "learn_more": "了解更多", + "documentation": "文档", + "github": "GitHub", "inbox": "收件箱", "search_inbox": "搜索收件箱", "mark_all_read": "全部标记为已读", From 863ba6865cc018bd20f1206d9de3b9277dec209b Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:26:48 +0530 Subject: [PATCH 15/28] fix: update routing in settings and onboarding components to use 'tab' query parameter instead of 'section' for improved navigation consistency --- .../[search_space_id]/onboard/page.tsx | 2 +- .../[search_space_id]/settings/layout.tsx | 6 +- .../[search_space_id]/settings/page.tsx | 418 ++++-------------- .../dashboard/[search_space_id]/team/page.tsx | 2 +- .../assistant-ui/connector-popup.tsx | 2 +- .../assistant-ui/document-upload-popup.tsx | 2 +- .../layout/providers/LayoutDataProvider.tsx | 4 +- .../components/new-chat/chat-share-button.tsx | 2 +- surfsense_web/components/ui/animated-tabs.tsx | 52 ++- 9 files changed, 138 insertions(+), 352 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx index 17db12ab7..8dbc6b919 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx @@ -259,7 +259,7 @@ export default function OnboardPage() { You can add more configurations and customize settings anytime in{" "} - {/* Mobile close button */} - -
- {/* Settings Title */} -
-

{t("title")}

-
-
- - {/* Navigation Items */} - - - - ); -} - -function SettingsContent({ - activeSection, - searchSpaceId, - onMenuClick, -}: { - activeSection: string; - searchSpaceId: number; - onMenuClick: () => void; -}) { - const t = useTranslations("searchSpaceSettings"); - const activeItem = settingsNavItems.find((item) => item.id === activeSection); - const Icon = activeItem?.icon || Settings; - - return ( - -
-
- {/* Section Header */} - - -
- {/* Mobile menu button */} - - - - -
-

- {activeItem ? t(activeItem.labelKey) : ""} -

-
-
-
-
- - {/* Section Content */} - - - {activeSection === "general" && ( - - )} - {activeSection === "models" && } - {activeSection === "roles" && } - {activeSection === "image-models" && ( - - )} - {activeSection === "prompts" && } - {activeSection === "public-links" && ( - - )} - {activeSection === "team-roles" && } - - -
-
-
- ); -} - -const VALID_SECTIONS = new Set(settingsNavItems.map((item) => item.id)); -const DEFAULT_SECTION = "general"; +const DEFAULT_TAB = "general"; export default function SettingsPage() { + const t = useTranslations("searchSpaceSettings"); const router = useRouter(); const params = useParams(); const searchParams = useSearchParams(); const searchSpaceId = Number(params.search_space_id); - const [isSidebarOpen, setIsSidebarOpen] = useState(false); - const sectionParam = searchParams.get("section"); - const activeSection = - sectionParam && VALID_SECTIONS.has(sectionParam) ? sectionParam : DEFAULT_SECTION; + const tabParam = searchParams.get("tab") ?? ""; + const activeTab = VALID_TABS.includes(tabParam as (typeof VALID_TABS)[number]) + ? tabParam + : DEFAULT_TAB; - const handleSectionChange = useCallback( - (section: string) => { - router.replace(`/dashboard/${searchSpaceId}/settings?section=${section}`, { scroll: false }); + const handleTabChange = useCallback( + (value: string) => { + const p = new URLSearchParams(searchParams.toString()); + p.set("tab", value); + router.replace(`?${p.toString()}`, { scroll: false }); }, - [router, searchSpaceId] + [router, searchParams] ); useEffect(() => { - trackSettingsViewed(searchSpaceId, activeSection); - }, [searchSpaceId, activeSection]); - - const handleBackToApp = useCallback(() => { - router.push(`/dashboard/${searchSpaceId}/new-chat`); - }, [router, searchSpaceId]); + trackSettingsViewed(searchSpaceId, activeTab); + }, [searchSpaceId, activeTab]); return ( - -
-
- setIsSidebarOpen(false)} - /> - setIsSidebarOpen(true)} - /> -
+
+
+ + + + + {t("nav_general")} + + + + {t("nav_agent_configs")} + + + + {t("nav_role_assignments")} + + + + {t("nav_image_models")} + + + + {t("nav_team_roles")} + + + + {t("nav_system_instructions")} + + + + {t("nav_public_links")} + + + + + + + + + + + + + + + + + + + + + + + +
- +
); } diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index 1d91d32f0..4b4a702c8 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -611,7 +611,7 @@ function MemberRow({ - router.push(`/dashboard/${searchSpaceId}/settings?section=team-roles`) + router.push(`/dashboard/${searchSpaceId}/settings?tab=team-roles`) } > Manage Roles diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx index 5104efc7b..f234f46eb 100644 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -379,7 +379,7 @@ export const ConnectorIndicator: FC = () => { : "You need to configure a Document Summary LLM before adding connectors. This LLM is used to process and summarize documents from your connected sources."}

- - {copied ? t("copied") : t("copy")} - - +

- ) : ( -

{t("no_api_key")}

- )} -
+ + + + + + {copied ? t("copied") : t("copy")} + + +
+ ) : ( +

{t("no_api_key")}

+ )} +
-
-

{t("usage_title")}

-

{t("usage_description")}

-
+			
+

{t("usage_title")}

+

{t("usage_description")}

+
+
+
 						Authorization: Bearer {apiKey || "YOUR_API_KEY"}
 					
+ + + + + + {copiedUsage ? t("copied") : t("copy")} + + +
+
); diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx index f5909ef85..18dafd5d8 100644 --- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -166,9 +166,30 @@ export function MobileSidebar({ : undefined } user={user} - onSettings={onSettings} - onManageMembers={onManageMembers} - onUserSettings={onUserSettings} + onSettings={ + onSettings + ? () => { + onOpenChange(false); + onSettings(); + } + : undefined + } + onManageMembers={ + onManageMembers + ? () => { + onOpenChange(false); + onManageMembers(); + } + : undefined + } + onUserSettings={ + onUserSettings + ? () => { + onOpenChange(false); + onUserSettings(); + } + : undefined + } onLogout={onLogout} pageUsage={pageUsage} theme={theme} From 33a822432d894f1fbb3084031229713173e19b5c Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:48:21 +0530 Subject: [PATCH 17/28] refactor: improve styling of CreateInviteDialog footer for better layout consistency --- .../dashboard/[search_space_id]/team/page.tsx | 30 +++++++++---------- surfsense_web/app/globals.css | 12 ++++++++ 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index 4b4a702c8..cd413f57e 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -833,21 +833,21 @@ function CreateInviteDialog({
- - - - + + + + )} diff --git a/surfsense_web/app/globals.css b/surfsense_web/app/globals.css index 9dffa8a03..1f44ead92 100644 --- a/surfsense_web/app/globals.css +++ b/surfsense_web/app/globals.css @@ -195,6 +195,18 @@ button { background-color: hsl(var(--muted-foreground) / 0.4); } +/* Hide scrollbar on mobile, only visible while scrolling */ +@media (max-width: 767px) { + .scrollbar-thin { + scrollbar-width: none; + } + + .scrollbar-thin::-webkit-scrollbar { + width: 0; + display: none; + } +} + /* Human-in-the-loop approval card animations */ @keyframes pulse-subtle { 0%, From af77c26572afdbe9a610274b4205359338d99a0f Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:56:01 +0530 Subject: [PATCH 18/28] refactor: update Navbar component to improve styling and add select-none class for better user interaction --- surfsense_web/components/homepage/navbar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/surfsense_web/components/homepage/navbar.tsx b/surfsense_web/components/homepage/navbar.tsx index f711c9103..c751c5e6f 100644 --- a/surfsense_web/components/homepage/navbar.tsx +++ b/surfsense_web/components/homepage/navbar.tsx @@ -4,9 +4,9 @@ import { AnimatePresence, motion } from "motion/react"; import Link from "next/link"; import { useEffect, useState } from "react"; import { SignInButton } from "@/components/auth/sign-in-button"; +import { NavbarGitHubStars } from "@/components/homepage/github-stars-badge"; import { Logo } from "@/components/Logo"; import { ThemeTogglerComponent } from "@/components/theme/theme-toggle"; -import { NavbarGitHubStars } from "@/components/homepage/github-stars-badge"; import { cn } from "@/lib/utils"; export const Navbar = () => { @@ -32,7 +32,7 @@ export const Navbar = () => { }, []); return ( -
+
From 06e74d5357e6672106c555b9a052e88e1e09ddcb Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:56:57 +0530 Subject: [PATCH 19/28] refactor: enhance MobileNav component with click outside detection for improved user interaction --- surfsense_web/components/homepage/navbar.tsx | 21 +++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/surfsense_web/components/homepage/navbar.tsx b/surfsense_web/components/homepage/navbar.tsx index c751c5e6f..255134353 100644 --- a/surfsense_web/components/homepage/navbar.tsx +++ b/surfsense_web/components/homepage/navbar.tsx @@ -2,7 +2,7 @@ import { IconBrandDiscord, IconBrandReddit, IconMenu2, IconX } from "@tabler/icons-react"; import { AnimatePresence, motion } from "motion/react"; import Link from "next/link"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { SignInButton } from "@/components/auth/sign-in-button"; import { NavbarGitHubStars } from "@/components/homepage/github-stars-badge"; import { Logo } from "@/components/Logo"; @@ -106,9 +106,28 @@ const DesktopNav = ({ navItems, isScrolled }: any) => { const MobileNav = ({ navItems, isScrolled }: any) => { const [open, setOpen] = useState(false); + const navRef = useRef(null); + + useEffect(() => { + if (!open) return; + + const handleClickOutside = (e: MouseEvent | TouchEvent) => { + if (navRef.current && !navRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("touchstart", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("touchstart", handleClickOutside); + }; + }, [open]); return ( Date: Sun, 8 Mar 2026 20:57:29 +0530 Subject: [PATCH 20/28] chore: ran linting --- .../[search_space_id]/settings/page.tsx | 17 +- .../dashboard/[search_space_id]/team/page.tsx | 55 +- .../components/ApiKeyContent.tsx | 104 +- .../components/ProfileContent.tsx | 4 +- .../[search_space_id]/user-settings/page.tsx | 11 +- .../homepage/github-stars-badge.tsx | 10 +- .../layout/ui/sidebar/SidebarUserProfile.tsx | 51 +- .../components/sources/DocumentUploadTab.tsx | 6 +- .../tool-ui/linear/update-linear-issue.tsx | 9 +- surfsense_web/components/ui/animated-tabs.tsx | 949 +++++++++--------- 10 files changed, 594 insertions(+), 622 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx index d999aacfb..e385e3983 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx @@ -1,14 +1,6 @@ "use client"; -import { - Bot, - Brain, - FileText, - Globe, - ImageIcon, - MessageSquare, - Shield, -} from "lucide-react"; +import { Bot, Brain, FileText, Globe, ImageIcon, MessageSquare, Shield } from "lucide-react"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect } from "react"; @@ -19,12 +11,7 @@ import { LLMRoleManager } from "@/components/settings/llm-role-manager"; import { ModelConfigManager } from "@/components/settings/model-config-manager"; import { PromptConfigManager } from "@/components/settings/prompt-config-manager"; import { RolesManager } from "@/components/settings/roles-manager"; -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "@/components/ui/animated-tabs"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/animated-tabs"; import { trackSettingsViewed } from "@/lib/posthog/events"; const VALID_TABS = [ diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index cd413f57e..831a48ed2 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -292,10 +292,7 @@ export default function TeamManagementPage() { {SKELETON_KEYS.map((id) => ( - +
@@ -546,9 +543,9 @@ function MemberRow({
- - {member.user_last_login ? formatRelativeDate(member.user_last_login) : "Never"} - + + {member.user_last_login ? formatRelativeDate(member.user_last_login) : "Never"} + {showActions ? ( @@ -608,12 +605,10 @@ function MemberRow({ )} - - - router.push(`/dashboard/${searchSpaceId}/settings?tab=team-roles`) - } - > + + router.push(`/dashboard/${searchSpaceId}/settings?tab=team-roles`)} + > Manage Roles @@ -833,21 +828,21 @@ function CreateInviteDialog({
- - - - + + + + )} @@ -877,8 +872,8 @@ function AllInvitesDialog({ return ( - + + {copied ? t("copied") : t("copy")} + + + + ) : ( +

{t("no_api_key")}

+ )} + + +
+

{t("usage_title")}

+

{t("usage_description")}

-

- {apiKey} -

+
+								Authorization: Bearer {apiKey || "YOUR_API_KEY"}
+							
@@ -59,47 +95,21 @@ export function ApiKeyContent() { - {copied ? t("copied") : t("copy")} + {copiedUsage ? t("copied") : t("copy")}
- ) : ( -

{t("no_api_key")}

- )} -
- -
-

{t("usage_title")}

-

{t("usage_description")}

-
-
-
-						Authorization: Bearer {apiKey || "YOUR_API_KEY"}
-					
- - - - - - {copiedUsage ? t("copied") : t("copy")} - - -
-
); diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ProfileContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ProfileContent.tsx index c2736f208..49f23e85a 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ProfileContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ProfileContent.tsx @@ -105,9 +105,7 @@ export function ProfileContent() { value={displayName} onChange={(e) => setDisplayName(e.target.value)} /> -

- {t("profile_display_name_hint")} -

+

{t("profile_display_name_hint")}

diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/page.tsx index 53f1182eb..8cdfdc178 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/page.tsx @@ -1,15 +1,10 @@ "use client"; -import { UserKey, User } from "lucide-react"; -import { useTranslations } from "next-intl"; +import { User, UserKey } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; import { useCallback } from "react"; -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "@/components/ui/animated-tabs"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/animated-tabs"; import { ApiKeyContent } from "./components/ApiKeyContent"; import { ProfileContent } from "./components/ProfileContent"; diff --git a/surfsense_web/components/homepage/github-stars-badge.tsx b/surfsense_web/components/homepage/github-stars-badge.tsx index 8638a9293..56abdc464 100644 --- a/surfsense_web/components/homepage/github-stars-badge.tsx +++ b/surfsense_web/components/homepage/github-stars-badge.tsx @@ -1,17 +1,17 @@ "use client"; -import * as React from "react"; +import { IconBrandGithub } from "@tabler/icons-react"; +import { StarIcon } from "lucide-react"; +import type { HTMLMotionProps, UseInViewOptions } from "motion/react"; import { - motion, AnimatePresence, + motion, useInView, useMotionValue, useSpring, useTransform, } from "motion/react"; -import type { HTMLMotionProps, UseInViewOptions } from "motion/react"; -import { StarIcon } from "lucide-react"; -import { IconBrandGithub } from "@tabler/icons-react"; +import * as React from "react"; import { cn } from "@/lib/utils"; // --------------------------------------------------------------------------- diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx index 17856c6fb..0c7181ee0 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx @@ -1,6 +1,17 @@ "use client"; -import { Check, ChevronUp, ExternalLink, Info, Languages, Laptop, LogOut, Moon, Settings, Sun } from "lucide-react"; +import { + Check, + ChevronUp, + ExternalLink, + Info, + Languages, + Laptop, + LogOut, + Moon, + Settings, + Sun, +} from "lucide-react"; import Image from "next/image"; import { useTranslations } from "next-intl"; import { useState } from "react"; @@ -165,21 +176,21 @@ export function SidebarUserProfile({ if (isCollapsed) { return (
- - - - + + + + @@ -276,7 +287,9 @@ export function SidebarUserProfile({ ))} -

v{APP_VERSION}

+

+ v{APP_VERSION} +

@@ -419,7 +432,9 @@ export function SidebarUserProfile({ ))} -

v{APP_VERSION}

+

+ v{APP_VERSION} +

diff --git a/surfsense_web/components/sources/DocumentUploadTab.tsx b/surfsense_web/components/sources/DocumentUploadTab.tsx index 7d8f44c71..f1c81bbfb 100644 --- a/surfsense_web/components/sources/DocumentUploadTab.tsx +++ b/surfsense_web/components/sources/DocumentUploadTab.tsx @@ -223,7 +223,11 @@ export function DocumentUploadTab({ const rawFiles = files.map((entry) => entry.file); uploadDocuments( - { files: rawFiles, search_space_id: Number(searchSpaceId), should_summarize: shouldSummarize }, + { + files: rawFiles, + search_space_id: Number(searchSpaceId), + should_summarize: shouldSummarize, + }, { onSuccess: () => { clearInterval(progressInterval); diff --git a/surfsense_web/components/tool-ui/linear/update-linear-issue.tsx b/surfsense_web/components/tool-ui/linear/update-linear-issue.tsx index 076896549..8a68a1176 100644 --- a/surfsense_web/components/tool-ui/linear/update-linear-issue.tsx +++ b/surfsense_web/components/tool-ui/linear/update-linear-issue.tsx @@ -1,14 +1,7 @@ "use client"; import { makeAssistantToolUI } from "@assistant-ui/react"; -import { - AlertTriangleIcon, - CheckIcon, - InfoIcon, - Loader2Icon, - Pen, - XIcon, -} from "lucide-react"; +import { AlertTriangleIcon, CheckIcon, InfoIcon, Loader2Icon, Pen, XIcon } from "lucide-react"; import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; diff --git a/surfsense_web/components/ui/animated-tabs.tsx b/surfsense_web/components/ui/animated-tabs.tsx index ea45759c4..7d39ce1dd 100644 --- a/surfsense_web/components/ui/animated-tabs.tsx +++ b/surfsense_web/components/ui/animated-tabs.tsx @@ -1,37 +1,35 @@ -"use client" +"use client"; import React, { - createContext, - forwardRef, - useCallback, - useContext, - useEffect, - useRef, - useState, - type ReactNode, -} from "react" + createContext, + forwardRef, + type ReactNode, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; /* ─────────────────────────── Context (replaces cloneElement) ─────────────────────────── */ interface TabsContextValue { - activeValue: string - onValueChange: (value: string) => void + activeValue: string; + onValueChange: (value: string) => void; } -const TabsContext = createContext(null) +const TabsContext = createContext(null); function useTabsContext() { - const ctx = useContext(TabsContext) - if (!ctx) { - throw new Error( - "AnimatedTabs compound components must be rendered inside " - ) - } - return ctx + const ctx = useContext(TabsContext); + if (!ctx) { + throw new Error("AnimatedTabs compound components must be rendered inside "); + } + return ctx; } /* ─────────────────────────── @@ -39,538 +37,515 @@ function useTabsContext() { ─────────────────────────── */ const SIZE_CLASSES = { - sm: "h-[32px] text-sm", - md: "h-[40px] text-base", - lg: "h-[48px] text-lg", -} as const + sm: "h-[32px] text-sm", + md: "h-[40px] text-base", + lg: "h-[48px] text-lg", +} as const; const VARIANT_CLASSES = { - default: "", - pills: "rounded-full", - underlined: "", -} as const + default: "", + pills: "rounded-full", + underlined: "", +} as const; const ACTIVE_INDICATOR_CLASSES = { - default: "h-[4px] bg-primary dark:bg-primary", - pills: "hidden", - underlined: "h-[4px] bg-primary dark:bg-primary", -} as const + default: "h-[4px] bg-primary dark:bg-primary", + pills: "hidden", + underlined: "h-[4px] bg-primary dark:bg-primary", +} as const; const HOVER_INDICATOR_CLASSES = { - default: "bg-muted dark:bg-muted rounded-[6px]", - pills: "bg-muted dark:bg-muted rounded-full", - underlined: "bg-muted dark:bg-muted rounded-[6px]", -} as const + default: "bg-muted dark:bg-muted rounded-[6px]", + pills: "bg-muted dark:bg-muted rounded-full", + underlined: "bg-muted dark:bg-muted rounded-[6px]", +} as const; /* ─────────────────────────── XScrollable (internal) ─────────────────────────── */ const XScrollable = forwardRef< - HTMLDivElement, - { - className?: string - children?: ReactNode - showScrollbar?: boolean - contentClassName?: string - } & React.HTMLAttributes + HTMLDivElement, + { + className?: string; + children?: ReactNode; + showScrollbar?: boolean; + contentClassName?: string; + } & React.HTMLAttributes >(({ className, children, showScrollbar = true, contentClassName, ...props }, ref) => { - const scrollRef = useRef(null) - const dragging = useRef(false) - const startX = useRef(0) - const startScrollLeft = useRef(0) - const [scrollPos, setScrollPos] = useState<"start" | "middle" | "end">("start") + const scrollRef = useRef(null); + const dragging = useRef(false); + const startX = useRef(0); + const startScrollLeft = useRef(0); + const [scrollPos, setScrollPos] = useState<"start" | "middle" | "end">("start"); - const updateScrollPos = useCallback(() => { - const el = scrollRef.current - if (!el) return - const canScroll = el.scrollWidth > el.clientWidth + 1 - if (!canScroll) { - setScrollPos("start") - return - } - const atStart = el.scrollLeft <= 2 - const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2 - setScrollPos(atStart ? "start" : atEnd ? "end" : "middle") - }, []) + const updateScrollPos = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + const canScroll = el.scrollWidth > el.clientWidth + 1; + if (!canScroll) { + setScrollPos("start"); + return; + } + const atStart = el.scrollLeft <= 2; + const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2; + setScrollPos(atStart ? "start" : atEnd ? "end" : "middle"); + }, []); - useEffect(() => { - updateScrollPos() - const el = scrollRef.current - if (!el) return - const ro = new ResizeObserver(updateScrollPos) - ro.observe(el) - return () => ro.disconnect() - }, [updateScrollPos]) + useEffect(() => { + updateScrollPos(); + const el = scrollRef.current; + if (!el) return; + const ro = new ResizeObserver(updateScrollPos); + ro.observe(el); + return () => ro.disconnect(); + }, [updateScrollPos]); - const onMouseDown = (e: React.MouseEvent) => { - if (!scrollRef.current) return - dragging.current = true - startX.current = e.clientX - startScrollLeft.current = scrollRef.current.scrollLeft - } - const endDrag = () => { - dragging.current = false - } - const onMouseMove = (e: React.MouseEvent) => { - if (!dragging.current || !scrollRef.current) return - e.preventDefault() - const dx = e.clientX - startX.current - scrollRef.current.scrollLeft = startScrollLeft.current - dx - } + const onMouseDown = (e: React.MouseEvent) => { + if (!scrollRef.current) return; + dragging.current = true; + startX.current = e.clientX; + startScrollLeft.current = scrollRef.current.scrollLeft; + }; + const endDrag = () => { + dragging.current = false; + }; + const onMouseMove = (e: React.MouseEvent) => { + if (!dragging.current || !scrollRef.current) return; + e.preventDefault(); + const dx = e.clientX - startX.current; + scrollRef.current.scrollLeft = startScrollLeft.current - dx; + }; - const onWheel = (e: React.WheelEvent) => { - if (!scrollRef.current) return - const delta = - Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX - if (delta !== 0) { - e.preventDefault() - scrollRef.current.scrollLeft += delta - } - } + const onWheel = (e: React.WheelEvent) => { + if (!scrollRef.current) return; + const delta = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX; + if (delta !== 0) { + e.preventDefault(); + scrollRef.current.scrollLeft += delta; + } + }; - const handleScroll = useCallback(() => { - updateScrollPos() - }, [updateScrollPos]) + const handleScroll = useCallback(() => { + updateScrollPos(); + }, [updateScrollPos]); - const maskStart = scrollPos === "start" ? "black" : "transparent" - const maskEnd = scrollPos === "end" ? "black" : "transparent" - const maskImage = `linear-gradient(to right, ${maskStart}, black 24px, black calc(100% - 24px), ${maskEnd})` + const maskStart = scrollPos === "start" ? "black" : "transparent"; + const maskEnd = scrollPos === "end" ? "black" : "transparent"; + const maskImage = `linear-gradient(to right, ${maskStart}, black 24px, black calc(100% - 24px), ${maskEnd})`; - return ( - // biome-ignore lint/a11y/noStaticElementInteractions: drag-scroll container needs mouse events -
- {/* biome-ignore lint/a11y/noStaticElementInteractions: drag-scroll requires onMouseDown */} -
- {children} -
-
- ) -}) -XScrollable.displayName = "XScrollable" + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: drag-scroll container needs mouse events +
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: drag-scroll requires onMouseDown */} +
+ {children} +
+
+ ); +}); +XScrollable.displayName = "XScrollable"; /* ─────────────────────────── Tabs (root) ─────────────────────────── */ const Tabs = forwardRef< - HTMLDivElement, - { - defaultValue?: string - value?: string - onValueChange?: (value: string) => void - className?: string - children?: ReactNode - } ->( - ( - { defaultValue, value, onValueChange, className, children, ...props }, - ref - ) => { - const [activeValue, setActiveValue] = useState(value || defaultValue || "") + HTMLDivElement, + { + defaultValue?: string; + value?: string; + onValueChange?: (value: string) => void; + className?: string; + children?: ReactNode; + } +>(({ defaultValue, value, onValueChange, className, children, ...props }, ref) => { + const [activeValue, setActiveValue] = useState(value || defaultValue || ""); - useEffect(() => { - if (value !== undefined) { - setActiveValue(value) - } - }, [value]) + useEffect(() => { + if (value !== undefined) { + setActiveValue(value); + } + }, [value]); - const handleValueChange = useCallback( - (newValue: string) => { - if (value === undefined) { - setActiveValue(newValue) - } - onValueChange?.(newValue) - }, - [onValueChange, value] - ) + const handleValueChange = useCallback( + (newValue: string) => { + if (value === undefined) { + setActiveValue(newValue); + } + onValueChange?.(newValue); + }, + [onValueChange, value] + ); - return ( - -
- {children} -
-
- ) - } -) -Tabs.displayName = "Tabs" + return ( + +
+ {children} +
+
+ ); +}); +Tabs.displayName = "Tabs"; /* ─────────────────────────── TabsList ─────────────────────────── */ -type TabsListVariant = "default" | "pills" | "underlined" -type TabsListSize = "sm" | "md" | "lg" +type TabsListVariant = "default" | "pills" | "underlined"; +type TabsListSize = "sm" | "md" | "lg"; const TabsList = forwardRef< - HTMLDivElement, - { - className?: string - children?: ReactNode - showHoverEffect?: boolean - showActiveIndicator?: boolean - activeIndicatorPosition?: "top" | "bottom" - activeIndicatorOffset?: number - size?: TabsListSize - variant?: TabsListVariant - stretch?: boolean - ariaLabel?: string - showBottomBorder?: boolean - bottomBorderClassName?: string - activeIndicatorClassName?: string - hoverIndicatorClassName?: string - } + HTMLDivElement, + { + className?: string; + children?: ReactNode; + showHoverEffect?: boolean; + showActiveIndicator?: boolean; + activeIndicatorPosition?: "top" | "bottom"; + activeIndicatorOffset?: number; + size?: TabsListSize; + variant?: TabsListVariant; + stretch?: boolean; + ariaLabel?: string; + showBottomBorder?: boolean; + bottomBorderClassName?: string; + activeIndicatorClassName?: string; + hoverIndicatorClassName?: string; + } >( - ( - { - className, - children, - showHoverEffect = true, - showActiveIndicator = true, - activeIndicatorPosition = "bottom", - activeIndicatorOffset = 0, - size = "sm", - variant = "default", - stretch = false, - ariaLabel = "Tabs", - showBottomBorder = false, - bottomBorderClassName, - activeIndicatorClassName, - hoverIndicatorClassName, - ...props - }, - ref - ) => { - const { activeValue, onValueChange } = useTabsContext() + ( + { + className, + children, + showHoverEffect = true, + showActiveIndicator = true, + activeIndicatorPosition = "bottom", + activeIndicatorOffset = 0, + size = "sm", + variant = "default", + stretch = false, + ariaLabel = "Tabs", + showBottomBorder = false, + bottomBorderClassName, + activeIndicatorClassName, + hoverIndicatorClassName, + ...props + }, + ref + ) => { + const { activeValue, onValueChange } = useTabsContext(); - const [hoveredIndex, setHoveredIndex] = useState(null) - const [hoverStyle, setHoverStyle] = useState({}) - const [activeStyle, setActiveStyle] = useState({ - left: "0px", - width: "0px", - }) - const tabRefs = useRef<(HTMLDivElement | null)[]>([]) - const scrollContainerRef = useRef(null) + const [hoveredIndex, setHoveredIndex] = useState(null); + const [hoverStyle, setHoverStyle] = useState({}); + const [activeStyle, setActiveStyle] = useState({ + left: "0px", + width: "0px", + }); + const tabRefs = useRef<(HTMLDivElement | null)[]>([]); + const scrollContainerRef = useRef(null); - const activeIndex = React.Children.toArray(children).findIndex( - (child) => - React.isValidElement(child) && - (child as React.ReactElement<{ value: string }>).props.value === - activeValue - ) + const activeIndex = React.Children.toArray(children).findIndex( + (child) => + React.isValidElement(child) && + (child as React.ReactElement<{ value: string }>).props.value === activeValue + ); - useEffect(() => { - if (hoveredIndex !== null && showHoverEffect) { - const hoveredElement = tabRefs.current[hoveredIndex] - if (hoveredElement) { - const { offsetLeft, offsetWidth } = hoveredElement - setHoverStyle({ - left: `${offsetLeft}px`, - width: `${offsetWidth}px`, - }) - } - } - }, [hoveredIndex, showHoverEffect]) + useEffect(() => { + if (hoveredIndex !== null && showHoverEffect) { + const hoveredElement = tabRefs.current[hoveredIndex]; + if (hoveredElement) { + const { offsetLeft, offsetWidth } = hoveredElement; + setHoverStyle({ + left: `${offsetLeft}px`, + width: `${offsetWidth}px`, + }); + } + } + }, [hoveredIndex, showHoverEffect]); - const updateActiveIndicator = useCallback(() => { - if (showActiveIndicator && activeIndex >= 0) { - const activeElement = tabRefs.current[activeIndex] - if (activeElement) { - const { offsetLeft, offsetWidth } = activeElement - setActiveStyle({ - left: `${offsetLeft}px`, - width: `${offsetWidth}px`, - }) - } - } - }, [showActiveIndicator, activeIndex]) + const updateActiveIndicator = useCallback(() => { + if (showActiveIndicator && activeIndex >= 0) { + const activeElement = tabRefs.current[activeIndex]; + if (activeElement) { + const { offsetLeft, offsetWidth } = activeElement; + setActiveStyle({ + left: `${offsetLeft}px`, + width: `${offsetWidth}px`, + }); + } + } + }, [showActiveIndicator, activeIndex]); - useEffect(() => { - updateActiveIndicator() - }, [updateActiveIndicator]) + useEffect(() => { + updateActiveIndicator(); + }, [updateActiveIndicator]); - useEffect(() => { - requestAnimationFrame(updateActiveIndicator) - }, [updateActiveIndicator]) + useEffect(() => { + requestAnimationFrame(updateActiveIndicator); + }, [updateActiveIndicator]); - const scrollTabToCenter = useCallback((index: number) => { - const tabElement = tabRefs.current[index] - const scrollContainer = scrollContainerRef.current + const scrollTabToCenter = useCallback((index: number) => { + const tabElement = tabRefs.current[index]; + const scrollContainer = scrollContainerRef.current; - if (tabElement && scrollContainer) { - const containerWidth = scrollContainer.offsetWidth - const tabWidth = tabElement.offsetWidth - const tabLeft = tabElement.offsetLeft - const scrollTarget = tabLeft - containerWidth / 2 + tabWidth / 2 - scrollContainer.scrollTo({ left: scrollTarget, behavior: "smooth" }) - } - }, []) + if (tabElement && scrollContainer) { + const containerWidth = scrollContainer.offsetWidth; + const tabWidth = tabElement.offsetWidth; + const tabLeft = tabElement.offsetLeft; + const scrollTarget = tabLeft - containerWidth / 2 + tabWidth / 2; + scrollContainer.scrollTo({ left: scrollTarget, behavior: "smooth" }); + } + }, []); - const setTabRef = useCallback( - (el: HTMLDivElement | null, index: number) => { - tabRefs.current[index] = el - }, - [] - ) + const setTabRef = useCallback((el: HTMLDivElement | null, index: number) => { + tabRefs.current[index] = el; + }, []); - const handleScrollableRef = useCallback((node: HTMLDivElement | null) => { - if (node) { - const scrollableDiv = node.querySelector( - 'div[class*="overflow-x-auto"]' - ) - if (scrollableDiv) { - scrollContainerRef.current = scrollableDiv as HTMLDivElement - } - } - }, []) + const handleScrollableRef = useCallback((node: HTMLDivElement | null) => { + if (node) { + const scrollableDiv = node.querySelector('div[class*="overflow-x-auto"]'); + if (scrollableDiv) { + scrollContainerRef.current = scrollableDiv as HTMLDivElement; + } + } + }, []); - useEffect(() => { - if (activeIndex >= 0) { - const timer = setTimeout(() => { - scrollTabToCenter(activeIndex) - }, 100) - return () => clearTimeout(timer) - } - }, [activeIndex, scrollTabToCenter]) + useEffect(() => { + if (activeIndex >= 0) { + const timer = setTimeout(() => { + scrollTabToCenter(activeIndex); + }, 100); + return () => clearTimeout(timer); + } + }, [activeIndex, scrollTabToCenter]); - return ( -
- {showBottomBorder && ( -
- )} - -
+ return ( +
+ {showBottomBorder && ( +
+ )} + +
+ {showHoverEffect && ( + + ); + } +); +TabsList.displayName = "TabsList"; /* ─────────────────────────── TabsTrigger ─────────────────────────── */ const TabsTrigger = forwardRef< - HTMLDivElement, - { - value: string - disabled?: boolean - label?: string - className?: string - activeClassName?: string - inactiveClassName?: string - disabledClassName?: string - children?: ReactNode - } + HTMLDivElement, + { + value: string; + disabled?: boolean; + label?: string; + className?: string; + activeClassName?: string; + inactiveClassName?: string; + disabledClassName?: string; + children?: ReactNode; + } >( - ( - { - value, - disabled = false, - label, - className, - activeClassName, - inactiveClassName, - disabledClassName, - children, - ...props - }, - ref - ) => { - return ( -
- {label || children} -
- ) - } -) -TabsTrigger.displayName = "TabsTrigger" + ( + { + value, + disabled = false, + label, + className, + activeClassName, + inactiveClassName, + disabledClassName, + children, + ...props + }, + ref + ) => { + return ( +
+ {label || children} +
+ ); + } +); +TabsTrigger.displayName = "TabsTrigger"; /* ─────────────────────────── TabsContent ─────────────────────────── */ const TabsContent = forwardRef< - HTMLDivElement, - { - value: string - className?: string - children: ReactNode - } ->( - ( - { value, className, children, ...props }, - ref - ) => { - const { activeValue } = useTabsContext() + HTMLDivElement, + { + value: string; + className?: string; + children: ReactNode; + } +>(({ value, className, children, ...props }, ref) => { + const { activeValue } = useTabsContext(); - if (value !== activeValue) return null - return ( -
- {children} -
- ) - } -) -TabsContent.displayName = "TabsContent" + if (value !== activeValue) return null; + return ( +
+ {children} +
+ ); +}); +TabsContent.displayName = "TabsContent"; -export { Tabs, TabsList, TabsTrigger, TabsContent } +export { Tabs, TabsList, TabsTrigger, TabsContent }; From 0a1d0035e6842caed422e48283bff24e05cfb1ab Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 8 Mar 2026 21:11:54 +0530 Subject: [PATCH 21/28] refactor: update DocumentUploadTab to ensure unique file IDs are generated using a fallback method for compatibility --- surfsense_web/components/sources/DocumentUploadTab.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/surfsense_web/components/sources/DocumentUploadTab.tsx b/surfsense_web/components/sources/DocumentUploadTab.tsx index f1c81bbfb..30db47801 100644 --- a/surfsense_web/components/sources/DocumentUploadTab.tsx +++ b/surfsense_web/components/sources/DocumentUploadTab.tsx @@ -148,7 +148,10 @@ export function DocumentUploadTab({ const onDrop = useCallback( (acceptedFiles: File[]) => { setFiles((prev) => { - const newEntries = acceptedFiles.map((f) => ({ id: crypto.randomUUID(), file: f })); + const newEntries = acceptedFiles.map((f) => ({ + id: crypto.randomUUID?.() ?? `file-${Date.now()}-${Math.random().toString(36)}`, + file: f, + })); const newFiles = [...prev, ...newEntries]; if (newFiles.length > MAX_FILES) { From 2adf5750df8a7bd4ff72540ea04b9b3296b98598 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 8 Mar 2026 21:16:52 +0530 Subject: [PATCH 22/28] refactor: update LayoutDataProvider and NavSection components to use DocumentsProcessingStatus for improved document processing status handling --- .../layout/providers/LayoutDataProvider.tsx | 8 +- .../components/layout/types/layout.types.ts | 3 +- .../layout/ui/sidebar/NavSection.tsx | 61 +++++++++++--- .../hooks/use-documents-processing.ts | 84 ++++++++++++++----- 4 files changed, 122 insertions(+), 34 deletions(-) diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 65143669e..22bbf6015 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -132,8 +132,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid const totalUnreadCount = commentsInbox.unreadCount + statusInbox.unreadCount; - // Whether any documents are currently being uploaded/indexed — drives sidebar spinner - const isDocumentsProcessing = useDocumentsProcessing(numericSpaceId); + // Document processing status — drives sidebar status indicator (spinner / check / error) + const documentsProcessingStatus = useDocumentsProcessing(numericSpaceId); // Track seen notification IDs to detect new page_limit_exceeded notifications const seenPageLimitNotifications = useRef>(new Set()); @@ -271,7 +271,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid url: "#documents", icon: SquareLibrary, isActive: isDocumentsSidebarOpen, - showSpinner: isDocumentsProcessing, + statusIndicator: documentsProcessingStatus, }, { title: "Announcements", @@ -287,7 +287,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid totalUnreadCount, isAnnouncementsSidebarOpen, announcementUnreadCount, - isDocumentsProcessing, + documentsProcessingStatus, ] ); diff --git a/surfsense_web/components/layout/types/layout.types.ts b/surfsense_web/components/layout/types/layout.types.ts index 063a2d38f..720aaecf1 100644 --- a/surfsense_web/components/layout/types/layout.types.ts +++ b/surfsense_web/components/layout/types/layout.types.ts @@ -1,4 +1,5 @@ import type { LucideIcon } from "lucide-react"; +import type { DocumentsProcessingStatus } from "@/hooks/use-documents-processing"; export interface SearchSpace { id: number; @@ -21,7 +22,7 @@ export interface NavItem { icon: LucideIcon; isActive?: boolean; badge?: string | number; - showSpinner?: boolean; + statusIndicator?: DocumentsProcessingStatus; } export interface ChatItem { diff --git a/surfsense_web/components/layout/ui/sidebar/NavSection.tsx b/surfsense_web/components/layout/ui/sidebar/NavSection.tsx index fa35b16f7..2ab1e6148 100644 --- a/surfsense_web/components/layout/ui/sidebar/NavSection.tsx +++ b/surfsense_web/components/layout/ui/sidebar/NavSection.tsx @@ -1,5 +1,6 @@ "use client"; +import { CheckCircle2, CircleAlert } from "lucide-react"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; @@ -11,13 +12,55 @@ interface NavSectionProps { isCollapsed?: boolean; } +function StatusBadge({ status }: { status: NavItem["statusIndicator"] }) { + if (status === "processing") { + return ( + + + + ); + } + if (status === "success") { + return ( + + + + ); + } + if (status === "error") { + return ( + + + + ); + } + return null; +} + +function StatusIcon({ status, FallbackIcon, className }: { + status: NavItem["statusIndicator"]; + FallbackIcon: NavItem["icon"]; + className?: string; +}) { + if (status === "processing") { + return ; + } + if (status === "success") { + return ; + } + if (status === "error") { + return ; + } + return ; +} + export function NavSection({ items, onItemClick, isCollapsed = false }: NavSectionProps) { return (
{items.map((item) => { const Icon = item.icon; + const indicator = item.statusIndicator; - // Add data-joyride for onboarding tour const joyrideAttr = item.title === "Documents" || item.title.toLowerCase().includes("documents") ? { "data-joyride": "documents-sidebar" } @@ -40,10 +83,8 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti {...joyrideAttr} > - {item.showSpinner ? ( - - - + {indicator && indicator !== "idle" ? ( + ) : item.badge ? ( {item.badge} @@ -72,11 +113,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti )} {...joyrideAttr} > - {item.showSpinner ? ( - - ) : ( - - )} + {item.title} {item.badge && ( diff --git a/surfsense_web/hooks/use-documents-processing.ts b/surfsense_web/hooks/use-documents-processing.ts index d1788a1b5..f7a09c36a 100644 --- a/surfsense_web/hooks/use-documents-processing.ts +++ b/surfsense_web/hooks/use-documents-processing.ts @@ -3,20 +3,23 @@ import { useEffect, useRef, useState } from "react"; import { useElectricClient } from "@/lib/electric/context"; +export type DocumentsProcessingStatus = "idle" | "processing" | "success" | "error"; + +const SUCCESS_LINGER_MS = 5000; + /** - * Returns whether any documents in the search space are currently being - * uploaded or indexed (status = "pending" | "processing"). - * - * Covers both manual file uploads (2-phase pattern) and all connector indexers, - * since both create documents with status = pending before processing. - * - * The sync shape uses the same columns as useDocuments so Electric can share - * the subscription when both hooks are active simultaneously. + * Returns the processing status of documents in the search space: + * - "processing" — at least one doc is pending/processing (show spinner) + * - "error" — nothing processing, but failed docs exist (show red icon) + * - "success" — just transitioned from processing → all clear (green check, auto-dismisses) + * - "idle" — nothing noteworthy (show normal icon) */ -export function useDocumentsProcessing(searchSpaceId: number | null): boolean { +export function useDocumentsProcessing(searchSpaceId: number | null): DocumentsProcessingStatus { const electricClient = useElectricClient(); - const [isProcessing, setIsProcessing] = useState(false); + const [status, setStatus] = useState("idle"); const liveQueryRef = useRef<{ unsubscribe?: () => void } | null>(null); + const wasProcessingRef = useRef(false); + const successTimerRef = useRef | null>(null); useEffect(() => { if (!searchSpaceId || !electricClient) return; @@ -76,10 +79,15 @@ export function useDocumentsProcessing(searchSpaceId: number | null): boolean { if (!db.live?.query) return; - const liveQuery = await db.live.query<{ count: number | string }>( - `SELECT COUNT(*) as count FROM documents - WHERE search_space_id = $1 - AND (status->>'state' = 'pending' OR status->>'state' = 'processing')`, + const liveQuery = await db.live.query<{ + processing_count: number | string; + failed_count: number | string; + }>( + `SELECT + SUM(CASE WHEN status->>'state' IN ('pending', 'processing') THEN 1 ELSE 0 END) AS processing_count, + SUM(CASE WHEN status->>'state' = 'failed' THEN 1 ELSE 0 END) AS failed_count + FROM documents + WHERE search_space_id = $1`, [spaceId] ); @@ -88,10 +96,44 @@ export function useDocumentsProcessing(searchSpaceId: number | null): boolean { return; } - liveQuery.subscribe((result: { rows: Array<{ count: number | string }> }) => { - if (!mounted || !result.rows?.[0]) return; - setIsProcessing((Number(result.rows[0].count) || 0) > 0); - }); + liveQuery.subscribe( + (result: { rows: Array<{ processing_count: number | string; failed_count: number | string }> }) => { + if (!mounted || !result.rows?.[0]) return; + + const processingCount = Number(result.rows[0].processing_count) || 0; + const failedCount = Number(result.rows[0].failed_count) || 0; + + if (processingCount > 0) { + wasProcessingRef.current = true; + if (successTimerRef.current) { + clearTimeout(successTimerRef.current); + successTimerRef.current = null; + } + setStatus("processing"); + } else if (failedCount > 0) { + wasProcessingRef.current = false; + if (successTimerRef.current) { + clearTimeout(successTimerRef.current); + successTimerRef.current = null; + } + setStatus("error"); + } else if (wasProcessingRef.current) { + wasProcessingRef.current = false; + setStatus("success"); + if (successTimerRef.current) { + clearTimeout(successTimerRef.current); + } + successTimerRef.current = setTimeout(() => { + if (mounted) { + setStatus("idle"); + successTimerRef.current = null; + } + }, SUCCESS_LINGER_MS); + } else { + setStatus("idle"); + } + } + ); liveQueryRef.current = liveQuery; } catch (err) { @@ -103,6 +145,10 @@ export function useDocumentsProcessing(searchSpaceId: number | null): boolean { return () => { mounted = false; + if (successTimerRef.current) { + clearTimeout(successTimerRef.current); + successTimerRef.current = null; + } if (liveQueryRef.current) { try { liveQueryRef.current.unsubscribe?.(); @@ -114,5 +160,5 @@ export function useDocumentsProcessing(searchSpaceId: number | null): boolean { }; }, [searchSpaceId, electricClient]); - return isProcessing; + return status; } From 195dbc5fc0589728d9c538ca8bb9db71fd531017 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 8 Mar 2026 21:17:45 +0530 Subject: [PATCH 23/28] chore: ran linting --- .../layout/ui/sidebar/NavSection.tsx | 24 ++++++++++++------- .../hooks/use-documents-processing.ts | 4 +++- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/NavSection.tsx b/surfsense_web/components/layout/ui/sidebar/NavSection.tsx index 2ab1e6148..0e3decd82 100644 --- a/surfsense_web/components/layout/ui/sidebar/NavSection.tsx +++ b/surfsense_web/components/layout/ui/sidebar/NavSection.tsx @@ -37,7 +37,11 @@ function StatusBadge({ status }: { status: NavItem["statusIndicator"] }) { return null; } -function StatusIcon({ status, FallbackIcon, className }: { +function StatusIcon({ + status, + FallbackIcon, + className, +}: { status: NavItem["statusIndicator"]; FallbackIcon: NavItem["icon"]; className?: string; @@ -46,10 +50,18 @@ function StatusIcon({ status, FallbackIcon, className }: { return ; } if (status === "success") { - return ; + return ( + + ); } if (status === "error") { - return ; + return ( + + ); } return ; } @@ -113,11 +125,7 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti )} {...joyrideAttr} > - + {item.title} {item.badge && ( diff --git a/surfsense_web/hooks/use-documents-processing.ts b/surfsense_web/hooks/use-documents-processing.ts index f7a09c36a..bb9901e64 100644 --- a/surfsense_web/hooks/use-documents-processing.ts +++ b/surfsense_web/hooks/use-documents-processing.ts @@ -97,7 +97,9 @@ export function useDocumentsProcessing(searchSpaceId: number | null): DocumentsP } liveQuery.subscribe( - (result: { rows: Array<{ processing_count: number | string; failed_count: number | string }> }) => { + (result: { + rows: Array<{ processing_count: number | string; failed_count: number | string }>; + }) => { if (!mounted || !result.rows?.[0]) return; const processingCount = Number(result.rows[0].processing_count) || 0; From ed6ff33933bd00fe0d350242dd4fd289e13215e3 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 8 Mar 2026 22:33:07 +0530 Subject: [PATCH 24/28] refactor: update ShortcutKbd component styling to adjust key width based on key length --- surfsense_web/components/ui/shortcut-kbd.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/surfsense_web/components/ui/shortcut-kbd.tsx b/surfsense_web/components/ui/shortcut-kbd.tsx index bf2d3e307..e65c4b586 100644 --- a/surfsense_web/components/ui/shortcut-kbd.tsx +++ b/surfsense_web/components/ui/shortcut-kbd.tsx @@ -13,7 +13,10 @@ export function ShortcutKbd({ keys, className }: ShortcutKbdProps) { {keys.map((key) => ( 1 ? "px-1" : "w-[18px]" + )} > {key} From 662c99c3f5b7ace38cb2404053a80b97ee0f7fca Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 8 Mar 2026 22:42:13 +0530 Subject: [PATCH 25/28] refactor: enhance DocumentsTableShell to support search mode with updated UI for no matching documents --- .../components/DocumentsTableShell.tsx | 81 ++++++++++++------- .../layout/ui/sidebar/DocumentsSidebar.tsx | 1 + 2 files changed, 55 insertions(+), 27 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx index aa712da5b..cddb3e79a 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx @@ -11,7 +11,7 @@ import { FileX, Network, PenLine, - Plus, + SearchX, Trash2, } from "lucide-react"; import { useRouter } from "next/navigation"; @@ -327,6 +327,7 @@ export function DocumentsTableShell({ mentionedDocIds, onToggleChatMention, onEditNavigate, + isSearchMode = false, }: { documents: Document[]; loading: boolean; @@ -345,6 +346,8 @@ export function DocumentsTableShell({ onToggleChatMention?: (doc: Document, mentioned: boolean) => void; /** Called when user navigates to the editor via Edit — use to close containing sidebar/panel */ onEditNavigate?: () => void; + /** Whether results are filtered by a search query or type filters */ + isSearchMode?: boolean; }) { const t = useTranslations("documents"); const { openDialog } = useDocumentUploadDialog(); @@ -569,21 +572,34 @@ export function DocumentsTableShell({
) : sorted.length === 0 ? (
-
-
- + {isSearchMode ? ( +
+ +
+

+ No matching documents +

+

+ Try a different search term or adjust your filters. +

+
-
-

{t("no_documents")}

-

- Get started by uploading your first document. -

+ ) : ( +
+
+ +
+
+

{t("no_documents")}

+

+ Get started by uploading your first document. +

+
+
- -
+ )}
) : (
@@ -694,21 +710,32 @@ export function DocumentsTableShell({
) : sorted.length === 0 ? (
-
-
- + {isSearchMode ? ( +
+ +
+

No matching documents

+

+ Try a different search term or adjust your filters. +

+
-
-

{t("no_documents")}

-

- Get started by uploading your first document. -

+ ) : ( +
+
+ +
+
+

{t("no_documents")}

+

+ Get started by uploading your first document. +

+
+
- -
+ )}
) : (
onOpenChange(false)} + isSearchMode={isSearchMode || activeTypes.length > 0} />
From bd91b0bef2c2f8988c055eaf447c3e5b7edfc69c Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 8 Mar 2026 22:59:32 +0530 Subject: [PATCH 26/28] refactor: remove unnecessary border from Header component --- surfsense_web/components/layout/ui/header/Header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/surfsense_web/components/layout/ui/header/Header.tsx b/surfsense_web/components/layout/ui/header/Header.tsx index 4063ffa63..166bf6ed0 100644 --- a/surfsense_web/components/layout/ui/header/Header.tsx +++ b/surfsense_web/components/layout/ui/header/Header.tsx @@ -39,7 +39,7 @@ export function Header({ mobileMenuTrigger }: HeaderProps) { const handleVisibilityChange = (_visibility: ChatVisibility) => {}; return ( -
+
{/* Left side - Mobile menu trigger + Model selector */}
{mobileMenuTrigger} From e8cf677b259ee322ba7e4155bbf71d16eba72c38 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 9 Mar 2026 01:28:53 +0530 Subject: [PATCH 27/28] refactor: update title generation logic to improve user experience by generating titles in parallel with assistant responses --- surfsense_backend/app/prompts/__init__.py | 10 +- .../app/tasks/chat/stream_new_chat.py | 113 ++++++++++-------- .../new-chat/[[...chat_id]]/page.tsx | 5 +- .../layout/ui/sidebar/ChatListItem.tsx | 4 +- surfsense_web/hooks/use-typewriter.ts | 49 ++++++++ 5 files changed, 120 insertions(+), 61 deletions(-) create mode 100644 surfsense_web/hooks/use-typewriter.ts diff --git a/surfsense_backend/app/prompts/__init__.py b/surfsense_backend/app/prompts/__init__.py index efa31d612..98909a906 100644 --- a/surfsense_backend/app/prompts/__init__.py +++ b/surfsense_backend/app/prompts/__init__.py @@ -109,12 +109,12 @@ SUMMARY_PROMPT_TEMPLATE = PromptTemplate( # Chat Title Generation Prompt # ============================================================================= -TITLE_GENERATION_PROMPT = """Generate a concise, descriptive title for the following conversation. +TITLE_GENERATION_PROMPT = """Generate a concise, descriptive title for the following user query. - The title MUST be between 1 and 6 words - The title MUST be on a single line -- Capture the main topic or intent of the conversation +- Capture the main topic or intent of the query - Do NOT use quotes, punctuation, or formatting - Do NOT include words like "Chat about" or "Discussion of" - Return ONLY the title, nothing else @@ -124,13 +124,9 @@ TITLE_GENERATION_PROMPT = """Generate a concise, descriptive title for the follo {user_query} - -{assistant_response} - - Title:""" TITLE_GENERATION_PROMPT_TEMPLATE = PromptTemplate( - input_variables=["user_query", "assistant_response"], + input_variables=["user_query"], template=TITLE_GENERATION_PROMPT, ) diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 8d09ff387..3f0cee145 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -1366,6 +1366,38 @@ async def stream_new_chat( del mentioned_documents, mentioned_surfsense_docs, recent_reports del langchain_messages, final_query + # Check if this is the first assistant response so we can generate + # a title in parallel with the agent stream (better UX than waiting + # until after the full response). + assistant_count_result = await session.execute( + select(func.count(NewChatMessage.id)).filter( + NewChatMessage.thread_id == chat_id, + NewChatMessage.role == "assistant", + ) + ) + is_first_response = (assistant_count_result.scalar() or 0) == 0 + + title_task: asyncio.Task[str | None] | None = None + if is_first_response: + + async def _generate_title() -> str | None: + try: + title_chain = TITLE_GENERATION_PROMPT_TEMPLATE | llm + title_result = await title_chain.ainvoke( + {"user_query": user_query[:500]} + ) + if title_result and hasattr(title_result, "content"): + raw_title = title_result.content.strip() + if raw_title and len(raw_title) <= 100: + return raw_title.strip("\"'") + except Exception: + pass + return None + + title_task = asyncio.create_task(_generate_title()) + + title_emitted = False + _t_stream_start = time.perf_counter() _first_event_logged = False async for sse in _stream_agent_events( @@ -1390,6 +1422,23 @@ async def stream_new_chat( _first_event_logged = True yield sse + # Inject title update mid-stream as soon as the background task finishes + if title_task is not None and title_task.done() and not title_emitted: + generated_title = title_task.result() + if generated_title: + async with shielded_async_session() as title_session: + title_thread_result = await title_session.execute( + select(NewChatThread).filter(NewChatThread.id == chat_id) + ) + title_thread = title_thread_result.scalars().first() + if title_thread: + title_thread.title = generated_title + await title_session.commit() + yield streaming_service.format_thread_title_update( + chat_id, generated_title + ) + title_emitted = True + _perf_log.info( "[stream_new_chat] Agent stream completed in %.3fs (chat_id=%s)", time.perf_counter() - _t_stream_start, @@ -1398,62 +1447,28 @@ async def stream_new_chat( log_system_snapshot("stream_new_chat_END") if stream_result.is_interrupted: + if title_task is not None and not title_task.done(): + title_task.cancel() yield streaming_service.format_finish_step() yield streaming_service.format_finish() yield streaming_service.format_done() return - accumulated_text = stream_result.accumulated_text - - assistant_count_result = await session.execute( - select(func.count(NewChatMessage.id)).filter( - NewChatMessage.thread_id == chat_id, - NewChatMessage.role == "assistant", - ) - ) - assistant_message_count = assistant_count_result.scalar() or 0 - - # Only generate title on the first response (no prior assistant messages) - if assistant_message_count == 0: - generated_title = None - try: - # Generate title using the same LLM - title_chain = TITLE_GENERATION_PROMPT_TEMPLATE | llm - # Truncate inputs to avoid context length issues - truncated_query = user_query[:500] - truncated_response = accumulated_text[:1000] - title_result = await title_chain.ainvoke( - { - "user_query": truncated_query, - "assistant_response": truncated_response, - } - ) - - # Extract and clean the title - if title_result and hasattr(title_result, "content"): - raw_title = title_result.content.strip() - # Validate the title (reasonable length) - if raw_title and len(raw_title) <= 100: - # Remove any quotes or extra formatting - generated_title = raw_title.strip("\"'") - except Exception: - generated_title = None - - # Only update if LLM succeeded (keep truncated prompt title as fallback) + # If the title task didn't finish during streaming, await it now + if title_task is not None and not title_emitted: + generated_title = await title_task if generated_title: - # Fetch thread and update title - thread_result = await session.execute( - select(NewChatThread).filter(NewChatThread.id == chat_id) - ) - thread = thread_result.scalars().first() - if thread: - thread.title = generated_title - await session.commit() - - # Notify frontend of the title update - yield streaming_service.format_thread_title_update( - chat_id, generated_title + async with shielded_async_session() as title_session: + title_thread_result = await title_session.execute( + select(NewChatThread).filter(NewChatThread.id == chat_id) ) + title_thread = title_thread_result.scalars().first() + if title_thread: + title_thread.title = generated_title + await title_session.commit() + yield streaming_service.format_thread_title_update( + chat_id, generated_title + ) # Finish the step and message yield streaming_service.format_finish_step() diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 9adf886a4..2fb2527c1 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -465,10 +465,7 @@ export default function NewChatPage() { let isNewThread = false; if (!currentThreadId) { try { - // Create thread with truncated prompt as initial title - const initialTitle = - userQuery.trim().slice(0, 100) + (userQuery.trim().length > 100 ? "..." : ""); - const newThread = await createThread(searchSpaceId, initialTitle); + const newThread = await createThread(searchSpaceId, "New Chat"); currentThreadId = newThread.id; setThreadId(currentThreadId); // Set currentThread so share button in header appears immediately diff --git a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx index f73e48cdf..078cea34e 100644 --- a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx +++ b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx @@ -20,6 +20,7 @@ import { } from "@/components/ui/dropdown-menu"; import { useLongPress } from "@/hooks/use-long-press"; import { useIsMobile } from "@/hooks/use-mobile"; +import { useTypewriter } from "@/hooks/use-typewriter"; import { cn } from "@/lib/utils"; interface ChatListItemProps { @@ -44,6 +45,7 @@ export function ChatListItem({ const t = useTranslations("sidebar"); const isMobile = useIsMobile(); const [dropdownOpen, setDropdownOpen] = useState(false); + const animatedName = useTypewriter(name); const { handlers: longPressHandlers, wasLongPress } = useLongPress( useCallback(() => setDropdownOpen(true), []) @@ -69,7 +71,7 @@ export function ChatListItem({ )} > - {name} + {animatedName} {/* Actions dropdown - trigger hidden on mobile, long-press opens it instead */} diff --git a/surfsense_web/hooks/use-typewriter.ts b/surfsense_web/hooks/use-typewriter.ts new file mode 100644 index 000000000..1e1ce8b83 --- /dev/null +++ b/surfsense_web/hooks/use-typewriter.ts @@ -0,0 +1,49 @@ +import { useEffect, useRef, useState } from "react"; + +/** + * Animates text changes with a typewriter reveal effect, but only when + * transitioning away from the `skipFor` placeholder (default "New Chat"). + * All other text values are shown instantly without animation. + */ +export function useTypewriter(text: string, speed = 35, skipFor = "New Chat"): string { + const [displayed, setDisplayed] = useState(text); + const prevTextRef = useRef(text); + const intervalRef = useRef | null>(null); + + useEffect(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + + const prevText = prevTextRef.current; + prevTextRef.current = text; + + const shouldAnimate = prevText === skipFor && text !== skipFor && !!text; + + if (!shouldAnimate) { + setDisplayed(text); + return; + } + + let i = 0; + setDisplayed(""); + intervalRef.current = setInterval(() => { + i++; + setDisplayed(text.slice(0, i)); + if (i >= text.length) { + if (intervalRef.current) clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, speed); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [text, speed, skipFor]); + + return displayed; +} From 514dcc1493cff4344abe34d5b7db52742de6e69d Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 9 Mar 2026 02:22:01 +0530 Subject: [PATCH 28/28] refactor: enhance LayoutDataProvider to manage chat navigation and state synchronization more effectively --- .../layout/providers/LayoutDataProvider.tsx | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 22bbf6015..322e136c0 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -6,7 +6,7 @@ import { AlertTriangle, Inbox, Megaphone, SquareLibrary } from "lucide-react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useTheme } from "next-themes"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +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"; @@ -86,6 +86,10 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid // State for handling new chat navigation when router is out of sync const [pendingNewChat, setPendingNewChat] = useState(false); + // 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) @@ -535,7 +539,14 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid await deleteThread(chatToDelete.id); queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); if (currentChatId === chatToDelete.id) { - router.push(`/dashboard/${searchSpaceId}/new-chat`); + resetCurrentThread(); + 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); @@ -544,7 +555,16 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid setShowDeleteChatDialog(false); setChatToDelete(null); } - }, [chatToDelete, queryClient, searchSpaceId, router, currentChatId]); + }, [ + chatToDelete, + queryClient, + searchSpaceId, + resetCurrentThread, + currentChatId, + currentThreadState.id, + params?.chat_id, + router, + ]); // Rename handler const confirmRenameChat = useCallback(async () => { @@ -660,7 +680,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid onOpenChange: setIsDocumentsSidebarOpen, }} > - {children} + {children} {/* Delete Chat Dialog */}