From 0fb6caa01f9fdaac9180d82bb25a993ab6ad9fee Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Sat, 21 Feb 2026 20:05:17 -0800 Subject: [PATCH 1/4] fix: improve error handling for AI responding state clearance in chat streaming --- .../app/tasks/chat/stream_new_chat.py | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 12bdea455..4ba12c171 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -15,6 +15,8 @@ from dataclasses import dataclass from typing import Any from uuid import UUID +import logging + from langchain_core.messages import HumanMessage from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select @@ -28,7 +30,7 @@ from app.agents.new_chat.llm_config import ( load_agent_config, load_llm_config_from_yaml, ) -from app.db import ChatVisibility, Document, Report, SurfsenseDocsDocument +from app.db import ChatVisibility, Document, Report, SurfsenseDocsDocument, async_session_maker from app.prompts import TITLE_GENERATION_PROMPT_TEMPLATE from app.services.chat_session_state_service import ( clear_ai_responding, @@ -1276,8 +1278,21 @@ async def stream_new_chat( yield streaming_service.format_done() finally: - # Clear AI responding state for live collaboration - await clear_ai_responding(session, chat_id) + # Clear AI responding state for live collaboration. + # The original session may be broken (client disconnect / CancelledError + # can corrupt the underlying DB connection), so we try a rollback first + # and fall back to a fresh session if the original is unusable. + try: + await session.rollback() + await clear_ai_responding(session, chat_id) + except Exception: + try: + async with async_session_maker() as fresh_session: + await clear_ai_responding(fresh_session, chat_id) + except Exception: + logging.getLogger(__name__).warning( + "Failed to clear AI responding state for thread %s", chat_id + ) async def stream_resume_chat( @@ -1397,4 +1412,14 @@ async def stream_resume_chat( yield streaming_service.format_done() finally: - await clear_ai_responding(session, chat_id) + try: + await session.rollback() + await clear_ai_responding(session, chat_id) + except Exception: + try: + async with async_session_maker() as fresh_session: + await clear_ai_responding(fresh_session, chat_id) + except Exception: + logging.getLogger(__name__).warning( + "Failed to clear AI responding state for thread %s", chat_id + ) From e917ff1056a11ea959bc90d65cd0be783d6092cc Mon Sep 17 00:00:00 2001 From: Eric Lammertsma Date: Sun, 22 Feb 2026 23:38:19 -0500 Subject: [PATCH 2/4] refactor: changed hero to a carousel --- surfsense_web/app/(home)/page.tsx | 2 - .../components/ui/expanded-gif-overlay.tsx | 3 +- .../components/ui/walkthrough-scroll.tsx | 337 ++++++++++++++---- 3 files changed, 261 insertions(+), 81 deletions(-) diff --git a/surfsense_web/app/(home)/page.tsx b/surfsense_web/app/(home)/page.tsx index b5a726423..e0478fce3 100644 --- a/surfsense_web/app/(home)/page.tsx +++ b/surfsense_web/app/(home)/page.tsx @@ -5,13 +5,11 @@ import { FeaturesBentoGrid } from "@/components/homepage/features-bento-grid"; import { FeaturesCards } from "@/components/homepage/features-card"; import { HeroSection } from "@/components/homepage/hero-section"; import ExternalIntegrations from "@/components/homepage/integrations"; -import { UseCasesGrid } from "@/components/homepage/use-cases-grid"; export default function HomePage() { return (
- diff --git a/surfsense_web/components/ui/expanded-gif-overlay.tsx b/surfsense_web/components/ui/expanded-gif-overlay.tsx index 56f343ded..49b8190ff 100644 --- a/surfsense_web/components/ui/expanded-gif-overlay.tsx +++ b/surfsense_web/components/ui/expanded-gif-overlay.tsx @@ -36,8 +36,7 @@ function ExpandedGifOverlay({ transition={{ duration: 0.25, ease: "easeOut" }} src={src} alt={alt} - className="max-h-[90vh] max-w-[90vw] rounded-2xl shadow-2xl" - onClick={(e) => e.stopPropagation()} + className="max-h-[90vh] max-w-[90vw] cursor-pointer rounded-2xl shadow-2xl" /> ); diff --git a/surfsense_web/components/ui/walkthrough-scroll.tsx b/surfsense_web/components/ui/walkthrough-scroll.tsx index c14aa968f..560e4dde5 100644 --- a/surfsense_web/components/ui/walkthrough-scroll.tsx +++ b/surfsense_web/components/ui/walkthrough-scroll.tsx @@ -1,87 +1,155 @@ "use client"; -import { AnimatePresence, motion, useScroll, useTransform } from "motion/react"; -import { useRef } from "react"; +import { AnimatePresence, motion } from "motion/react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ExpandedGifOverlay, useExpandedGif } from "@/components/ui/expanded-gif-overlay"; -const walkthroughSteps = [ +const carouselItems = [ { - step: 1, - title: "Login", - description: "Login to get started.", - src: "/homepage/hero_tutorial/LoginFlowGif.gif", - }, - { - step: 2, title: "Connect & Sync", - description: "Connect your connectors and sync. Enable periodic syncing to keep them updated.", + description: + "Connect data sources like Notion, Drive and Gmail. Enable periodic syncing to keep them updated.", src: "/homepage/hero_tutorial/ConnectorFlowGif.gif", }, { - step: 3, title: "Upload Documents", - description: "While connectors index, upload your documents directly.", + description: "Or upload your documents directly, including iamges and massive PDFs.", src: "/homepage/hero_tutorial/DocUploadGif.gif", }, + { + title: "Search & Citation", + description: + "Ask questions and get Perplexity-style cited responses from your knowledge base.", + src: "/homepage/hero_tutorial/BSNCGif.gif", + }, + { + title: "Document Mention Q&A", + description: "Mention specific documents in your queries for targeted answers.", + src: "/homepage/hero_tutorial/BQnaGif_compressed.gif", + }, + { + title: "Report Generation", + description: "Generate and export reports in many formats.", + src: "/homepage/hero_tutorial/ReportGenGif_compressed.gif", + }, + { + title: "Podcast Generation", + description: "Turn your knowledge into podcasts in under 20 seconds.", + src: "/homepage/hero_tutorial/PodcastGenGif.gif", + }, + { + title: "Image Generation", + description: "Generate images directly from your conversations.", + src: "/homepage/hero_tutorial/ImageGenGif.gif", + }, + { + title: "Realtime Chat", + description: "Chat together in realtime with your team.", + src: "/homepage/hero_realtime/RealTimeChatGif.gif", + }, + { + title: "Realtime Comments", + description: "Add comments and tag teammates on any message.", + src: "/homepage/hero_realtime/RealTimeCommentsFlow.gif", + }, ]; function WalkthroughCard({ - i, - step, + index, title, description, src, - progress, - range, - targetScale, + isActive, + onExpandedChange, }: { - i: number; - step: number; + index: number; title: string; description: string; src: string; - progress: ReturnType["scrollYProgress"]; - range: [number, number]; - targetScale: number; + isActive: boolean; + onExpandedChange?: (expanded: boolean) => void; }) { - const container = useRef(null); - const scale = useTransform(progress, range, [1, targetScale]); const { expanded, open, close } = useExpandedGif(); + useEffect(() => { + onExpandedChange?.(expanded); + }, [expanded, onExpandedChange]); + const imgRef = useRef(null); + const [frozenFrame, setFrozenFrame] = useState(null); + const [playKey, setPlayKey] = useState(0); + + const captureFrame = useCallback((img: HTMLImageElement) => { + try { + const canvas = document.createElement("canvas"); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + canvas.getContext("2d")?.drawImage(img, 0, 0); + setFrozenFrame(canvas.toDataURL()); + } catch { + /* cross-origin or other issue */ + } + }, []); + + useEffect(() => { + if (isActive) { + setPlayKey((k) => k + 1); + setFrozenFrame(null); + } else { + const img = imgRef.current; + if (img && img.complete && img.naturalWidth > 0) { + captureFrame(img); + } + } + }, [isActive, captureFrame]); + + useEffect(() => { + if (!isActive && !frozenFrame) { + const img = new Image(); + img.onload = () => captureFrame(img); + img.src = src; + } + }, [isActive, frozenFrame, src, captureFrame]); + return ( <> -
- +
+ + {index + 1} + +
+

+ {title} +

+

+ {description} +

+
+
+
-
- - {step} - -
-

- {title} -

-

- {description} -

-
-
-
- {title} -
- + {isActive ? ( + {title} + ) : frozenFrame ? ( + {title} + ) : ( +
+ )} +
@@ -92,30 +160,145 @@ function WalkthroughCard({ } function WalkthroughScroll() { - const container = useRef(null); - const { scrollYProgress } = useScroll({ - target: container, - offset: ["start start", "end end"], - }); + const [activeIndex, setActiveIndex] = useState(0); + const [isPaused, setIsPaused] = useState(false); + const [isGifExpanded, setIsGifExpanded] = useState(false); + const [containerWidth, setContainerWidth] = useState(0); + const [cardHeight, setCardHeight] = useState(420); + const containerRef = useRef(null); + const activeCardRef = useRef(null); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const update = () => setContainerWidth(el.offsetWidth); + update(); + const observer = new ResizeObserver(update); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + const el = activeCardRef.current; + if (!el) return; + const update = () => setCardHeight(el.offsetHeight); + update(); + const observer = new ResizeObserver(update); + observer.observe(el); + return () => observer.disconnect(); + }, [activeIndex, containerWidth]); + + useEffect(() => { + if (isPaused || isGifExpanded) return; + const timer = setTimeout(() => { + setActiveIndex((prev) => (prev >= carouselItems.length - 1 ? 0 : prev + 1)); + }, 8000); + return () => clearTimeout(timer); + }, [activeIndex, isPaused, isGifExpanded]); + + const cardWidth = + containerWidth < 640 + ? containerWidth * 0.85 + : containerWidth < 1024 + ? Math.min(containerWidth * 0.7, 680) + : Math.min(containerWidth * 0.55, 900); + + const baseOffset = + containerWidth < 640 + ? containerWidth * 0.2 + : containerWidth < 1024 + ? containerWidth * 0.15 + : 150; + + const stackGap = containerWidth < 640 ? 35 : containerWidth < 1024 ? 45 : 55; + const perspective = containerWidth < 640 ? 800 : containerWidth < 1024 ? 1000 : 1200; + + const getCardStyle = useCallback( + (index: number) => { + const diff = index - activeIndex; + + if (diff === 0) { + return { x: -cardWidth / 2, rotateY: 0, zIndex: 20, originX: 1 }; + } + + const dist = Math.abs(diff); + const isLeft = diff < 0; + const offset = baseOffset + (dist - 1) * stackGap; + + return { + x: -cardWidth / 2 + (isLeft ? -offset : offset), + rotateY: isLeft ? 90 : -90, + zIndex: 20 - dist, + originX: isLeft ? 0 : 1, + }; + }, + [activeIndex, cardWidth, baseOffset, stackGap], + ); return ( -
- {walkthroughSteps.map((project, i) => { - const targetScale = Math.max(0.6, 1 - (walkthroughSteps.length - i - 1) * 0.05); - return ( - +
setIsPaused(true)} + onMouseLeave={() => setIsPaused(false)} + onTouchStart={() => setIsPaused(true)} + onTouchEnd={() => setIsPaused(false)} + > +
+ {containerWidth > 0 && + carouselItems.map((item, i) => { + const style = getCardStyle(i); + return ( + + + + ); + })} +
+
+ +
+ {carouselItems.map((_, i) => ( +
); } From 837fa0627f7683a71089af7b63f672480f518c91 Mon Sep 17 00:00:00 2001 From: Eric Lammertsma Date: Mon, 23 Feb 2026 09:17:33 -0500 Subject: [PATCH 3/4] refactor: improved hero-carousel visuals, performance and descriptions --- .../components/homepage/hero-section.tsx | 4 +- ...lkthrough-scroll.tsx => hero-carousel.tsx} | 70 +++++++++++++------ 2 files changed, 50 insertions(+), 24 deletions(-) rename surfsense_web/components/ui/{walkthrough-scroll.tsx => hero-carousel.tsx} (76%) diff --git a/surfsense_web/components/homepage/hero-section.tsx b/surfsense_web/components/homepage/hero-section.tsx index 9d0cd6f0c..09d05f9ea 100644 --- a/surfsense_web/components/homepage/hero-section.tsx +++ b/surfsense_web/components/homepage/hero-section.tsx @@ -5,7 +5,7 @@ import Link from "next/link"; import type React from "react"; import { useEffect, useRef, useState } from "react"; import Balancer from "react-wrap-balancer"; -import { WalkthroughScroll } from "@/components/ui/walkthrough-scroll"; +import { HeroCarousel } from "@/components/ui/hero-carousel"; import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config"; import { trackLoginAttempt } from "@/lib/posthog/events"; import { cn } from "@/lib/utils"; @@ -108,7 +108,7 @@ export function HeroSection() { {/* */}
- +
); diff --git a/surfsense_web/components/ui/walkthrough-scroll.tsx b/surfsense_web/components/ui/hero-carousel.tsx similarity index 76% rename from surfsense_web/components/ui/walkthrough-scroll.tsx rename to surfsense_web/components/ui/hero-carousel.tsx index 560e4dde5..4a7d800cf 100644 --- a/surfsense_web/components/ui/walkthrough-scroll.tsx +++ b/surfsense_web/components/ui/hero-carousel.tsx @@ -8,43 +8,43 @@ const carouselItems = [ { title: "Connect & Sync", description: - "Connect data sources like Notion, Drive and Gmail. Enable periodic syncing to keep them updated.", + "Connect data sources like Notion, Drive and Gmail. Automatically sync to keep them updated.", src: "/homepage/hero_tutorial/ConnectorFlowGif.gif", }, { title: "Upload Documents", - description: "Or upload your documents directly, including iamges and massive PDFs.", + description: "Upload documents directly, from images to massive PDFs.", src: "/homepage/hero_tutorial/DocUploadGif.gif", }, { title: "Search & Citation", description: - "Ask questions and get Perplexity-style cited responses from your knowledge base.", + "Ask questions and get cited responses from your knowledge base.", src: "/homepage/hero_tutorial/BSNCGif.gif", }, { - title: "Document Mention Q&A", - description: "Mention specific documents in your queries for targeted answers.", + title: "Targeted Document Q&A", + description: "Mention specific documents in chat for targeted answers.", src: "/homepage/hero_tutorial/BQnaGif_compressed.gif", }, { - title: "Report Generation", - description: "Generate and export reports in many formats.", + title: "Produce Reports Instantly", + description: "Generate reports from your sources in many formats.", src: "/homepage/hero_tutorial/ReportGenGif_compressed.gif", }, { - title: "Podcast Generation", - description: "Turn your knowledge into podcasts in under 20 seconds.", + title: "Create Podcasts", + description: "Turn anything into a podcast in under 20 seconds.", src: "/homepage/hero_tutorial/PodcastGenGif.gif", }, { title: "Image Generation", - description: "Generate images directly from your conversations.", + description: "Generate high-quality images easily from your conversations.", src: "/homepage/hero_tutorial/ImageGenGif.gif", }, { - title: "Realtime Chat", - description: "Chat together in realtime with your team.", + title: "Collaborative AI Chat", + description: "Collaborate on AI-powered conversations in realtime with your team.", src: "/homepage/hero_realtime/RealTimeChatGif.gif", }, { @@ -54,7 +54,7 @@ const carouselItems = [ }, ]; -function WalkthroughCard({ +function HeroCarouselCard({ index, title, description, @@ -114,14 +114,14 @@ function WalkthroughCard({ <>
- + {/* {index + 1} - + */}
-

+

{title}

-

+

{description}

@@ -159,7 +159,7 @@ function WalkthroughCard({ ); } -function WalkthroughScroll() { +function HeroCarousel() { const [activeIndex, setActiveIndex] = useState(0); const [isPaused, setIsPaused] = useState(false); const [isGifExpanded, setIsGifExpanded] = useState(false); @@ -167,6 +167,15 @@ function WalkthroughScroll() { const [cardHeight, setCardHeight] = useState(420); const containerRef = useRef(null); const activeCardRef = useRef(null); + const directionRef = useRef<"forward" | "backward">("forward"); + + const goTo = useCallback( + (newIndex: number) => { + directionRef.current = newIndex >= activeIndex ? "forward" : "backward"; + setActiveIndex(newIndex); + }, + [activeIndex], + ); useEffect(() => { const el = containerRef.current; @@ -191,6 +200,7 @@ function WalkthroughScroll() { useEffect(() => { if (isPaused || isGifExpanded) return; const timer = setTimeout(() => { + directionRef.current = "forward"; setActiveIndex((prev) => (prev >= carouselItems.length - 1 ? 0 : prev + 1)); }, 8000); return () => clearTimeout(timer); @@ -218,18 +228,22 @@ function WalkthroughScroll() { const diff = index - activeIndex; if (diff === 0) { - return { x: -cardWidth / 2, rotateY: 0, zIndex: 20, originX: 1 }; + const originX = directionRef.current === "forward" ? 1 : 0; + return { x: -cardWidth / 2, rotateY: 0, zIndex: 20, originX, overlayOpacity: 0, blur: 0 }; } const dist = Math.abs(diff); const isLeft = diff < 0; const offset = baseOffset + (dist - 1) * stackGap; + const t = Math.min(1, dist / 3); return { x: -cardWidth / 2 + (isLeft ? -offset : offset), rotateY: isLeft ? 90 : -90, zIndex: 20 - dist, originX: isLeft ? 0 : 1, + overlayOpacity: t, + blur: t * 6, }; }, [activeIndex, cardWidth, baseOffset, stackGap], @@ -263,14 +277,20 @@ function WalkthroughScroll() { transformStyle: "preserve-3d", zIndex: style.zIndex, transformOrigin: `${style.originX * 100}% 50%`, + cursor: i !== activeIndex ? "pointer" : undefined, }} + onClick={i !== activeIndex ? () => goTo(i) : undefined} animate={{ x: style.x, rotateY: style.rotateY, }} transition={{ duration: 0.7, ease: [0.32, 0.72, 0, 1] }} > - + + + ); })}
@@ -289,7 +315,7 @@ function WalkthroughScroll() {
-
+
{carouselItems.map((_, i) => (