diff --git a/surfsense_web/components/chat/AnimatedEmptyState.tsx b/surfsense_web/components/chat/AnimatedEmptyState.tsx index 2e1d7a8..7b23ddb 100644 --- a/surfsense_web/components/chat/AnimatedEmptyState.tsx +++ b/surfsense_web/components/chat/AnimatedEmptyState.tsx @@ -1,45 +1,137 @@ "use client"; + import { cn } from "@/lib/utils"; import { Manrope } from "next/font/google"; -import React, { useRef, useEffect, useState } from "react"; +import React, { + useRef, + useEffect, + useReducer, + useMemo +} from "react"; import { RoughNotation, RoughNotationGroup } from "react-rough-notation"; import { useInView } from "framer-motion"; import { useSidebar } from "@/components/ui/sidebar"; -const manrope = Manrope({ subsets: ["latin"], weight: ["400", "700"] }); +// Font configuration - could be moved to a global font config file +const manrope = Manrope({ + subsets: ["latin"], + weight: ["400", "700"], + display: "swap", // Optimize font loading + variable: "--font-manrope" +}); + +// Constants for timing - makes it easier to adjust and more maintainable +const TIMING = { + SIDEBAR_TRANSITION: 300, // Wait for sidebar transition + buffer + LAYOUT_SETTLE: 100, // Small delay to ensure layout is fully settled +} as const; + +// Animation configuration +const ANIMATION_CONFIG = { + HIGHLIGHT: { + type: "highlight" as const, + animationDuration: 2000, + iterations: 3, + color: "#3b82f680", + multiline: true, + }, + UNDERLINE: { + type: "underline" as const, + animationDuration: 2000, + iterations: 3, + color: "#10b981", + }, +} as const; + +// State management with useReducer for better organization +interface HighlightState { + shouldShowHighlight: boolean; + layoutStable: boolean; +} + +type HighlightAction = + | { type: "SIDEBAR_CHANGED" } + | { type: "LAYOUT_STABILIZED" } + | { type: "SHOW_HIGHLIGHT" } + | { type: "HIDE_HIGHLIGHT" }; + +const highlightReducer = ( + state: HighlightState, + action: HighlightAction +): HighlightState => { + switch (action.type) { + case "SIDEBAR_CHANGED": + return { + shouldShowHighlight: false, + layoutStable: false, + }; + case "LAYOUT_STABILIZED": + return { + ...state, + layoutStable: true, + }; + case "SHOW_HIGHLIGHT": + return { + ...state, + shouldShowHighlight: true, + }; + case "HIDE_HIGHLIGHT": + return { + ...state, + shouldShowHighlight: false, + }; + default: + return state; + } +}; + +const initialState: HighlightState = { + shouldShowHighlight: false, + layoutStable: true, +}; export function AnimatedEmptyState() { - const ref = useRef(null); + const ref = useRef(null); const isInView = useInView(ref); - const { state } = useSidebar(); - const [shouldShowHighlight, setShouldShowHighlight] = useState(false); - const [layoutStable, setLayoutStable] = useState(true); + const { state: sidebarState } = useSidebar(); + const [{ shouldShowHighlight, layoutStable }, dispatch] = useReducer( + highlightReducer, + initialState + ); - // Track sidebar state changes and manage highlight visibility + // Memoize class names to prevent unnecessary recalculations + const headingClassName = useMemo(() => cn( + "text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight text-neutral-900 dark:text-neutral-50 mb-6", + manrope.className, + ), []); + + const paragraphClassName = useMemo(() => + "text-lg sm:text-xl text-neutral-600 dark:text-neutral-300 mb-8 max-w-2xl mx-auto", + []); + + // Handle sidebar state changes useEffect(() => { - // Set layout as unstable when sidebar state changes - setLayoutStable(false); - setShouldShowHighlight(false); + dispatch({ type: "SIDEBAR_CHANGED" }); - // Wait for layout to stabilize after sidebar transition const stabilizeTimer = setTimeout(() => { - setLayoutStable(true); - }, 300); // Wait for sidebar transition (200ms) + buffer + dispatch({ type: "LAYOUT_STABILIZED" }); + }, TIMING.SIDEBAR_TRANSITION); return () => clearTimeout(stabilizeTimer); - }, [state]); + }, [sidebarState]); - // Re-enable highlights after layout stabilizes and component is in view + // Handle highlight visibility based on layout stability and viewport visibility useEffect(() => { - if (layoutStable && isInView) { - const showTimer = setTimeout(() => { - setShouldShowHighlight(true); - }, 100); // Small delay to ensure layout is fully settled - - return () => clearTimeout(showTimer); - } else { - setShouldShowHighlight(false); + if (!layoutStable || !isInView) { + dispatch({ type: "HIDE_HIGHLIGHT" }); + return; } + + const showTimer = setTimeout(() => { + dispatch({ type: "SHOW_HIGHLIGHT" }); + }, TIMING.LAYOUT_SETTLE); + + return () => clearTimeout(showTimer); }, [layoutStable, isInView]); return ( @@ -49,30 +141,14 @@ export function AnimatedEmptyState() { >
-

- +

+ SurfSense

-

- +

+ Let's Start Surfing {" "} through your knowledge base. diff --git a/surfsense_web/components/chat/ChatMessages.tsx b/surfsense_web/components/chat/ChatMessages.tsx index 473564d..20aa078 100644 --- a/surfsense_web/components/chat/ChatMessages.tsx +++ b/surfsense_web/components/chat/ChatMessages.tsx @@ -12,6 +12,9 @@ import ChatSourcesDisplay from "@/components/chat/ChatSources"; import { CitationDisplay } from "@/components/chat/ChatCitation"; import { ChatFurtherQuestions } from "@/components/chat/ChatFurtherQuestions"; import { AnimatedEmptyState } from "@/components/chat/AnimatedEmptyState"; +import { languageRenderers } from "@/components/chat/CodeBlock"; + + export function ChatMessagesUI() { const { messages } = useChatUI(); @@ -63,6 +66,7 @@ function ChatMessageUI({

@@ -73,7 +77,9 @@ function ChatMessageUI({
) : ( - + )} diff --git a/surfsense_web/components/chat/CodeBlock.tsx b/surfsense_web/components/chat/CodeBlock.tsx new file mode 100644 index 0000000..965b4b0 --- /dev/null +++ b/surfsense_web/components/chat/CodeBlock.tsx @@ -0,0 +1,194 @@ +"use client"; + +import React, { useState, useEffect, useMemo, useCallback } from "react"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { + oneLight, + oneDark, +} from "react-syntax-highlighter/dist/cjs/styles/prism"; +import { Check, Copy } from "lucide-react"; +import { useTheme } from "next-themes"; + +// Constants for styling and configuration +const COPY_TIMEOUT = 2000; + +const BASE_CUSTOM_STYLE = { + margin: 0, + borderRadius: "0.375rem", + fontSize: "0.75rem", + lineHeight: "1.5rem", + border: "none", +} as const; + +const LINE_PROPS_STYLE = { + wordBreak: "break-all" as const, + whiteSpace: "pre-wrap" as const, + border: "none", + borderBottom: "none", + paddingLeft: 0, + paddingRight: 0, + margin: "0.25rem 0", +} as const; + +const CODE_TAG_PROPS = { + className: "font-mono", + style: { + border: "none", + background: "var(--syntax-bg)", + }, +} as const; + +// TypeScript interfaces +interface CodeBlockProps { + children: string; + language: string; +} + +interface LanguageRenderer { + (props: { code: string }): React.JSX.Element; +} + +interface SyntaxStyle { + [key: string]: React.CSSProperties; +} + +// Memoized fallback component for SSR/hydration +const FallbackCodeBlock = React.memo(({ children }: { children: string }) => ( +
+
+      
+        {children}
+      
+    
+
+)); + +FallbackCodeBlock.displayName = "FallbackCodeBlock"; + +// Code block component with syntax highlighting and copy functionality +export const CodeBlock = React.memo(({ + children, + language, +}) => { + const [copied, setCopied] = useState(false); + const { resolvedTheme, theme } = useTheme(); + const [mounted, setMounted] = useState(false); + + // Prevent hydration issues + useEffect(() => { + setMounted(true); + }, []); + + // Memoize theme detection + const isDarkTheme = useMemo(() => + mounted && (resolvedTheme === "dark" || theme === "dark"), + [mounted, resolvedTheme, theme] + ); + + // Memoize syntax theme selection + const syntaxTheme = useMemo(() => + isDarkTheme ? oneDark : oneLight, + [isDarkTheme] + ); + + // Memoize enhanced style with theme-specific modifications + const enhancedStyle = useMemo(() => ({ + ...syntaxTheme, + 'pre[class*="language-"]': { + ...syntaxTheme['pre[class*="language-"]'], + margin: 0, + border: "none", + borderRadius: "0.375rem", + background: "var(--syntax-bg)", + }, + 'code[class*="language-"]': { + ...syntaxTheme['code[class*="language-"]'], + border: "none", + background: "var(--syntax-bg)", + }, + }), [syntaxTheme]); + + // Memoize custom style with background + const customStyle = useMemo(() => ({ + ...BASE_CUSTOM_STYLE, + backgroundColor: "var(--syntax-bg)", + }), []); + + // Memoized copy handler + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(children); + setCopied(true); + const timeoutId = setTimeout(() => setCopied(false), COPY_TIMEOUT); + return () => clearTimeout(timeoutId); + } catch (error) { + console.warn("Failed to copy code to clipboard:", error); + } + }, [children]); + + // Memoized line props with style + const lineProps = useMemo(() => ({ + style: LINE_PROPS_STYLE, + }), []); + + // Early return for non-mounted state + if (!mounted) { + return {children}; + } + + return ( +
+
+ +
+ + {children} + +
+ ); +}); + +CodeBlock.displayName = "CodeBlock"; + +// Optimized language renderer factory with memoization +const createLanguageRenderer = (lang: string): LanguageRenderer => { + const renderer = ({ code }: { code: string }) => ( + {code} + ); + renderer.displayName = `LanguageRenderer(${lang})`; + return renderer; +}; + +// Pre-defined supported languages for better maintainability +const SUPPORTED_LANGUAGES = [ + "javascript", "typescript", "python", "java", "csharp", "cpp", "c", + "php", "ruby", "go", "rust", "swift", "kotlin", "scala", "sql", + "json", "xml", "yaml", "bash", "shell", "powershell", "dockerfile", + "html", "css", "scss", "less", "markdown", "text" +] as const; + +// Generate language renderers efficiently +export const languageRenderers: Record = + Object.fromEntries( + SUPPORTED_LANGUAGES.map(lang => [lang, createLanguageRenderer(lang)]) + ); \ No newline at end of file diff --git a/surfsense_web/components/chat/index.ts b/surfsense_web/components/chat/index.ts index 55ab716..812bf80 100644 --- a/surfsense_web/components/chat/index.ts +++ b/surfsense_web/components/chat/index.ts @@ -4,4 +4,5 @@ export * from './ConnectorComponents'; export * from './Citation'; export * from './SourceUtils'; export * from './ScrollUtils'; +export * from './CodeBlock'; export * from './types'; \ No newline at end of file