diff --git a/ui/desktop/src/components/GlobalBlurOverlay.tsx b/ui/desktop/src/components/GlobalBlurOverlay.tsx index 93dd288e6c59..906b34666c1a 100644 --- a/ui/desktop/src/components/GlobalBlurOverlay.tsx +++ b/ui/desktop/src/components/GlobalBlurOverlay.tsx @@ -20,6 +20,7 @@ const GlobalBlurOverlay: React.FC = () => { const [imageLoaded, setImageLoaded] = useState(false); const [backgroundImage, setBackgroundImage] = useState(null); const [backgroundId, setBackgroundId] = useState('default-gradient'); + const [isProcessingChange, setIsProcessingChange] = useState(false); // Update theme detection when it changes useEffect(() => { @@ -70,14 +71,49 @@ const GlobalBlurOverlay: React.FC = () => { // Listen for background changes const handleBackgroundChange = (e: CustomEvent) => { console.log("Background changed:", e.detail); - setBackgroundId(e.detail.backgroundId); - if (e.detail.backgroundId === 'custom-image' && e.detail.customImage) { - setBackgroundImage(e.detail.customImage); - setImageLoaded(true); - } else { - setBackgroundImage(null); - } + // Set processing flag to prevent UI freezes + setIsProcessingChange(true); + + // Use setTimeout to defer processing to next tick + setTimeout(() => { + try { + setBackgroundId(e.detail.backgroundId); + + if (e.detail.backgroundId === 'custom-image' && e.detail.customImage) { + setBackgroundImage(e.detail.customImage); + + // For custom images, we need to wait for them to load + if (e.detail.customImage !== backgroundImage) { + setImageLoaded(false); + const img = new Image(); + img.onload = () => { + console.log("New custom background image loaded successfully"); + setImageLoaded(true); + setIsProcessingChange(false); + }; + img.onerror = (e) => { + console.error("Failed to load new custom background image:", e); + setImageLoaded(true); // Still mark as loaded to prevent UI freeze + setIsProcessingChange(false); + }; + img.src = e.detail.customImage; + } else { + // If it's the same image, no need to reload + setImageLoaded(true); + setIsProcessingChange(false); + } + } else { + setBackgroundImage(null); + setImageLoaded(true); + setIsProcessingChange(false); + } + } catch (error) { + console.error("Error handling background change:", error); + setImageLoaded(true); // Ensure we don't get stuck in loading state + setIsProcessingChange(false); + } + }, 0); }; window.addEventListener('dashboard-background-changed', handleBackgroundChange as EventListener); @@ -86,7 +122,7 @@ const GlobalBlurOverlay: React.FC = () => { observer.disconnect(); window.removeEventListener('dashboard-background-changed', handleBackgroundChange as EventListener); }; - }, []); + }, [backgroundImage]); // Fixed blur intensity const blurIntensity = 20; // Consistent blur for chat mode @@ -176,7 +212,8 @@ const GlobalBlurOverlay: React.FC = () => { Custom Image: ${backgroundImage ? 'Yes' : 'No'}
Dark Theme: ${isDarkTheme ? 'Yes' : 'No'}
Focus Mode: ${isInFocusMode ? 'Yes' : 'No'}
- Overlay Color: ${backgroundColor} + Overlay Color: ${backgroundColor}
+ Processing Change: ${isProcessingChange ? 'Yes' : 'No'} `; document.body.appendChild(debugDiv); @@ -194,7 +231,19 @@ const GlobalBlurOverlay: React.FC = () => { } } }; - }, [backgroundId, backgroundImage, imageLoaded, isDarkTheme, isInFocusMode, backgroundColor]); + }, [backgroundId, backgroundImage, imageLoaded, isDarkTheme, isInFocusMode, backgroundColor, isProcessingChange]); + + // Loading overlay to prevent interaction during background changes + if (isProcessingChange) { + return ( +
+
+
+

Updating background...

+
+
+ ); + } // Return null since we're appending directly to the body return null; diff --git a/ui/desktop/src/components/sessions/SessionListView.tsx b/ui/desktop/src/components/sessions/SessionListView.tsx index a030a4eeee1d..b86aae593427 100644 --- a/ui/desktop/src/components/sessions/SessionListView.tsx +++ b/ui/desktop/src/components/sessions/SessionListView.tsx @@ -1,201 +1,320 @@ -import React, { useCallback, useEffect, useState, useRef } from 'react'; -import { Session, fetchSessions, updateSessionMetadata } from '../../sessions'; -import { ScrollArea } from '../ui/scroll-area'; -import { MainPanelLayout } from '../Layout/MainPanelLayout'; +import React, { useEffect, useState, useRef, useCallback, useMemo, startTransition } from 'react'; +import { MessageSquareText, Target, AlertCircle, Calendar, Folder, Edit2 } from 'lucide-react'; +import { fetchSessions, updateSessionMetadata, type Session } from '../../sessions'; +import { Card } from '../ui/card'; import { Button } from '../ui/button'; -import { Input } from '../ui/input'; -import { Calendar, Folder, MessageSquareText, MoreHorizontal, AlertCircle, Target, Search, X } from 'lucide-react'; +import { ScrollArea } from '../ui/scroll-area'; +import { View, ViewOptions } from '../../utils/navigationUtils'; import { formatMessageTimestamp } from '../../utils/timeUtils'; -import { StaggeredSessionItem } from './StaggeredSessionItem'; - -interface DateGroup { - label: string; - sessions: Session[]; +import { SearchView } from '../conversation/SearchView'; +import { SearchHighlighter } from '../../utils/searchHighlighter'; +import { MainPanelLayout } from '../Layout/MainPanelLayout'; +import { groupSessionsByDate, type DateGroup } from '../../utils/dateUtils'; +import { Skeleton } from '../ui/skeleton'; +import { toast } from 'react-toastify'; + +interface EditSessionModalProps { + session: Session | null; + isOpen: boolean; + onClose: () => void; + onSave: (sessionId: string, newDescription: string) => Promise; + disabled?: boolean; } -interface SearchViewProps { - onSearch: (query: string) => void; -} +const EditSessionModal = React.memo( + ({ session, isOpen, onClose, onSave, disabled = false }) => { + const [description, setDescription] = useState(''); + const [isUpdating, setIsUpdating] = useState(false); + + useEffect(() => { + if (session && isOpen) { + setDescription(session.metadata.description || session.id); + } else if (!isOpen) { + // Reset state when modal closes + setDescription(''); + setIsUpdating(false); + } + }, [session, isOpen]); -const SearchView: React.FC = ({ onSearch }) => { - const [query, setQuery] = useState(''); - const inputRef = useRef(null); + const handleSave = useCallback(async () => { + if (!session || disabled) return; - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - onSearch(query); - }; + const trimmedDescription = description.trim(); + if (trimmedDescription === session.metadata.description) { + onClose(); + return; + } - const handleClear = () => { - setQuery(''); - onSearch(''); - inputRef.current?.focus(); - }; + setIsUpdating(true); + try { + await updateSessionMetadata(session.id, trimmedDescription); + await onSave(session.id, trimmedDescription); + + // Close modal, then show success toast on a timeout to let the UI update complete. + onClose(); + setTimeout(() => { + toast.success('Session description updated successfully'); + }, 300); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + console.error('Failed to update session description:', errorMessage); + toast.error(`Failed to update session description: ${errorMessage}`); + // Reset to original description on error + setDescription(session.metadata.description || session.id); + } finally { + setIsUpdating(false); + } + }, [session, description, onSave, onClose, disabled]); - return ( -
-
- - setQuery(e.target.value)} - /> - {query && ( - - )} + const handleCancel = useCallback(() => { + if (!isUpdating) { + onClose(); + } + }, [onClose, isUpdating]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !isUpdating) { + handleSave(); + } else if (e.key === 'Escape' && !isUpdating) { + handleCancel(); + } + }, + [handleSave, handleCancel, isUpdating] + ); + + const handleInputChange = useCallback((e: React.ChangeEvent) => { + setDescription(e.target.value); + }, []); + + if (!isOpen || !session) return null; + + return ( +
+
+

Edit Session Description

+ +
+
+ +
+
+ +
+ + +
+
- - - ); -}; + ); + } +); -export default function SessionListView({ - onSessionSelect, -}: { - onSessionSelect?: (sessionId: string) => void; -}) { +EditSessionModal.displayName = 'EditSessionModal'; + +// Debounce hook for search +function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + window.clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} + +interface SearchContainerElement extends HTMLDivElement { + _searchHighlighter: SearchHighlighter | null; +} + +interface SessionListViewProps { + setView: (view: View, viewOptions?: ViewOptions) => void; + onSelectSession: (sessionId: string) => void; +} + +const SessionListView: React.FC = React.memo(({ onSelectSession }) => { const [sessions, setSessions] = useState([]); + const [filteredSessions, setFilteredSessions] = useState([]); const [dateGroups, setDateGroups] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [showSkeleton, setShowSkeleton] = useState(true); + const [showContent, setShowContent] = useState(false); + const [isInitialLoad, setIsInitialLoad] = useState(true); const [error, setError] = useState(null); - const [editingSession, setEditingSession] = useState(null); + const [searchResults, setSearchResults] = useState<{ + count: number; + currentIndex: number; + } | null>(null); + + // Edit modal state const [showEditModal, setShowEditModal] = useState(false); - const [newDescription, setNewDescription] = useState(''); - const [searchResults, setSearchResults] = useState(null); - const containerRef = useRef(null); + const [editingSession, setEditingSession] = useState(null); - // Load sessions on mount - useEffect(() => { - loadSessions(); - }, []); + // Search state for debouncing + const [searchTerm, setSearchTerm] = useState(''); + const [caseSensitive, setCaseSensitive] = useState(false); + const debouncedSearchTerm = useDebounce(searchTerm, 300); // 300ms debounce + + const containerRef = useRef(null); - const loadSessions = async () => { + const loadSessions = useCallback(async () => { setIsLoading(true); + setShowSkeleton(true); + setShowContent(false); setError(null); - try { - // Use the fetchSessions function from sessions.ts instead of window.electron.getSessions - const result = await fetchSessions(); - setSessions(result); - groupSessionsByDate(result); + const sessions = await fetchSessions(); + // Use startTransition to make state updates non-blocking + startTransition(() => { + setSessions(sessions); + setFilteredSessions(sessions); + }); } catch (err) { console.error('Failed to load sessions:', err); - setError('Failed to load sessions. Please try again.'); + setError('Failed to load sessions. Please try again later.'); + setSessions([]); + setFilteredSessions([]); } finally { - // Set loading to false after a short delay to ensure smooth transition - setTimeout(() => { - setIsLoading(false); - }, 300); + setIsLoading(false); } - }; + }, []); - const groupSessionsByDate = (sessionsToGroup: Session[]) => { - const today = new Date(); - today.setHours(0, 0, 0, 0); - - const yesterday = new Date(today); - yesterday.setDate(yesterday.getDate() - 1); - - const lastWeek = new Date(today); - lastWeek.setDate(lastWeek.getDate() - 7); - - const lastMonth = new Date(today); - lastMonth.setMonth(lastMonth.getMonth() - 1); - - const groups: DateGroup[] = [ - { label: 'Today', sessions: [] }, - { label: 'Yesterday', sessions: [] }, - { label: 'This Week', sessions: [] }, - { label: 'This Month', sessions: [] }, - { label: 'Earlier', sessions: [] }, - ]; - - sessionsToGroup.forEach((session) => { - const sessionDate = new Date(session.modified); - sessionDate.setHours(0, 0, 0, 0); - - if (sessionDate.getTime() === today.getTime()) { - groups[0].sessions.push(session); - } else if (sessionDate.getTime() === yesterday.getTime()) { - groups[1].sessions.push(session); - } else if (sessionDate > lastWeek) { - groups[2].sessions.push(session); - } else if (sessionDate > lastMonth) { - groups[3].sessions.push(session); - } else { - groups[4].sessions.push(session); - } - }); + useEffect(() => { + loadSessions(); + }, [loadSessions]); - // Filter out empty groups - const filteredGroups = groups.filter((group) => group.sessions.length > 0); - setDateGroups(filteredGroups); - }; + // Timing logic to prevent flicker between skeleton and content on initial load + useEffect(() => { + if (!isLoading && showSkeleton) { + setShowSkeleton(false); + // Use startTransition for non-blocking content show + startTransition(() => { + setTimeout(() => { + setShowContent(true); + if (isInitialLoad) { + setIsInitialLoad(false); + } + }, 10); + }); + } + return () => void 0; + }, [isLoading, showSkeleton, isInitialLoad]); - const handleSearch = (query: string) => { - if (!query.trim()) { - setSearchResults(null); - groupSessionsByDate(sessions); + // Memoize date groups calculation to prevent unnecessary recalculations + const memoizedDateGroups = useMemo(() => { + if (filteredSessions.length > 0) { + return groupSessionsByDate(filteredSessions); + } + return []; + }, [filteredSessions]); + + // Update date groups when filtered sessions change + useEffect(() => { + startTransition(() => { + setDateGroups(memoizedDateGroups); + }); + }, [memoizedDateGroups]); + + // Debounced search effect - performs actual filtering + useEffect(() => { + if (!debouncedSearchTerm) { + startTransition(() => { + setFilteredSessions(sessions); + setSearchResults(null); + }); return; } - const lowerQuery = query.toLowerCase(); - const results = sessions.filter((session) => { - const description = (session.metadata.description || session.id).toLowerCase(); - const workingDir = (session.metadata.working_dir || '').toLowerCase(); - - return description.includes(lowerQuery) || workingDir.includes(lowerQuery); + // Use startTransition to make search non-blocking + startTransition(() => { + const searchTerm = caseSensitive ? debouncedSearchTerm : debouncedSearchTerm.toLowerCase(); + const filtered = sessions.filter((session) => { + const description = session.metadata.description || session.id; + const path = session.path; + const workingDir = session.metadata.working_dir; + + if (caseSensitive) { + return ( + description.includes(searchTerm) || + path.includes(searchTerm) || + workingDir.includes(searchTerm) + ); + } else { + return ( + description.toLowerCase().includes(searchTerm) || + path.toLowerCase().includes(searchTerm) || + workingDir.toLowerCase().includes(searchTerm) + ); + } + }); + + setFilteredSessions(filtered); + setSearchResults(filtered.length > 0 ? { count: filtered.length, currentIndex: 1 } : null); }); + }, [debouncedSearchTerm, caseSensitive, sessions]); - setSearchResults(results); - groupSessionsByDate(results); - }; + // Handle immediate search input (updates search term for debouncing) + const handleSearch = useCallback((term: string, caseSensitive: boolean) => { + setSearchTerm(term); + setCaseSensitive(caseSensitive); + }, []); - const handleSaveDescription = useCallback(async () => { - if (!editingSession) return; + // Handle search result navigation + const handleSearchNavigation = (direction: 'next' | 'prev') => { + if (!searchResults || filteredSessions.length === 0) return; - const sessionId = editingSession.id; - try { - // Use the updateSessionMetadata function from sessions.ts - await updateSessionMetadata(sessionId, newDescription); - setShowEditModal(false); - setEditingSession(null); - - // Update session in state - setSessions((prevSessions) => - prevSessions.map((s) => - s.id === sessionId ? { ...s, metadata: { ...s.metadata, description: newDescription } } : s - ) - ); - } catch (error) { - console.error('Failed to update session description:', error); + let newIndex: number; + if (direction === 'next') { + newIndex = (searchResults.currentIndex % filteredSessions.length) + 1; + } else { + newIndex = + searchResults.currentIndex === 1 ? filteredSessions.length : searchResults.currentIndex - 1; } - }, [editingSession, newDescription]); - useEffect(() => { - if (editingSession) { - setNewDescription(editingSession.metadata.description || ''); + setSearchResults({ ...searchResults, currentIndex: newIndex }); + + // Find the SearchView's container element + const searchContainer = + containerRef.current?.querySelector('.search-container'); + if (searchContainer?._searchHighlighter) { + // Update the current match in the highlighter + searchContainer._searchHighlighter.setCurrentMatch(newIndex - 1, true); } - }, [editingSession]); + }; - const handleCancelEdit = useCallback(() => { + // Handle modal close + const handleModalClose = useCallback(() => { setShowEditModal(false); setEditingSession(null); }, []); - const updateSessionDescription = useCallback((sessionId: string, newDescription: string) => { + const handleModalSave = useCallback(async (sessionId: string, newDescription: string) => { + // Update state immediately for optimistic UI setSessions((prevSessions) => prevSessions.map((s) => s.id === sessionId ? { ...s, metadata: { ...s.metadata, description: newDescription } } : s @@ -208,7 +327,110 @@ export default function SessionListView({ setShowEditModal(true); }, []); - const renderContent = () => { + const SessionItem = React.memo(function SessionItem({ + session, + onEditClick, + }: { + session: Session; + onEditClick: (session: Session) => void; + }) { + const handleEditClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); // Prevent card click + onEditClick(session); + }, + [onEditClick, session] + ); + + const handleCardClick = useCallback(() => { + onSelectSession(session.id); + }, [session.id]); + + return ( + + + +
+

+ {session.metadata.description || session.id} +

+ +
+ + {formatMessageTimestamp(Date.parse(session.modified) / 1000)} +
+
+ + {session.metadata.working_dir} +
+
+ +
+
+
+ + {session.metadata.message_count} +
+ {session.metadata.total_tokens !== null && ( +
+ + {session.metadata.total_tokens.toLocaleString()} +
+ )} +
+
+
+ ); + }); + + // Render skeleton loader for session items with variations + const SessionSkeleton = React.memo(({ variant = 0 }: { variant?: number }) => { + const titleWidths = ['w-3/4', 'w-2/3', 'w-4/5', 'w-1/2']; + const pathWidths = ['w-32', 'w-28', 'w-36', 'w-24']; + const tokenWidths = ['w-12', 'w-10', 'w-14', 'w-8']; + + return ( + +
+ +
+ + +
+
+ + +
+
+ +
+
+
+ + +
+
+ + +
+
+
+
+ ); + }); + + SessionSkeleton.displayName = 'SessionSkeleton'; + + const renderActualContent = () => { if (error) { return (
@@ -222,20 +444,6 @@ export default function SessionListView({ ); } - if (isLoading) { - // Show a minimal loading state instead of skeletons - return ( -
-
-
-
-
-
-

Loading sessions...

-
- ); - } - if (sessions.length === 0) { return (
@@ -256,23 +464,17 @@ export default function SessionListView({ ); } - // For regular rendering in grid layout with staggered animation + // For regular rendering in grid layout return (
- {dateGroups.map((group, groupIndex) => ( + {dateGroups.map((group) => (
-
+

{group.label}

- {group.sessions.map((session, index) => ( - + {group.sessions.map((session) => ( + ))}
@@ -281,16 +483,11 @@ export default function SessionListView({ ); }; - // Custom main layout props to override background completely - const customMainLayoutProps = { - backgroundColor: 'transparent', // Force transparent background with inline style - }; - return ( <> - +
-
+

Chat history

@@ -304,35 +501,83 @@ export default function SessionListView({
- - {renderContent()} + + {/* Skeleton layer - always rendered but conditionally visible */} +
+
+ {/* Today section */} +
+ +
+ + + + + +
+
+ + {/* Yesterday section */} +
+ +
+ + + + + + +
+
+ + {/* Additional section */} +
+ +
+ + + +
+
+
+
+ + {/* Content layer - always rendered but conditionally visible */} +
+ {renderActualContent()} +
+
- {/* Edit Session Modal */} - {showEditModal && editingSession && ( -
-
-

Edit Session Description

- setNewDescription(e.target.value)} - placeholder="Enter a description for this session" - className="mb-6" - autoFocus - /> -
- - -
-
-
- )} + ); -} +}); + +SessionListView.displayName = 'SessionListView'; + +export default SessionListView; diff --git a/ui/desktop/src/components/sessions/SessionsView.tsx b/ui/desktop/src/components/sessions/SessionsView.tsx index 9fa3eb1848e6..603a330e5b90 100644 --- a/ui/desktop/src/components/sessions/SessionsView.tsx +++ b/ui/desktop/src/components/sessions/SessionsView.tsx @@ -1,10 +1,11 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { View, ViewOptions } from '../../App'; +import { View, ViewOptions } from '../../utils/navigationUtils'; import { fetchSessionDetails, type SessionDetails } from '../../sessions'; import SessionListView from './SessionListView'; import SessionHistoryView from './SessionHistoryView'; import { toastError } from '../../toasts'; import { useLocation } from 'react-router-dom'; +import BackgroundImageFix from '../BackgroundImageFix'; interface SessionsViewProps { setView: (view: View, viewOptions?: ViewOptions) => void; @@ -17,6 +18,56 @@ const SessionsView: React.FC = ({ setView }) => { const [initialSessionId, setInitialSessionId] = useState(null); const location = useLocation(); + // Use BackgroundImageFix for consistent background across the app + useEffect(() => { + // Override any background colors that might be covering our background + const mainPanels = document.querySelectorAll('.bg-background-default, .bg-background-muted') as NodeListOf; + mainPanels.forEach(panel => { + if (panel) { + panel.style.background = 'transparent'; + panel.style.backgroundColor = 'transparent'; + } + }); + + // Make session list transparent + const sessionList = document.querySelector('.sessions-list-container') as HTMLElement; + if (sessionList) { + sessionList.style.background = 'transparent'; + sessionList.style.backgroundColor = 'transparent'; + } + + // Make session items transparent but with glass effect + const sessionItems = document.querySelectorAll('.session-item') as NodeListOf; + sessionItems.forEach(item => { + if (item) { + item.style.backgroundColor = 'rgba(255, 255, 255, 0.1)'; + item.style.backdropFilter = 'blur(10px)'; + } + }); + + return () => { + // Cleanup styles + mainPanels.forEach(panel => { + if (panel) { + panel.style.background = ''; + panel.style.backgroundColor = ''; + } + }); + + if (sessionList) { + sessionList.style.background = ''; + sessionList.style.backgroundColor = ''; + } + + sessionItems.forEach(item => { + if (item) { + item.style.backgroundColor = ''; + item.style.backdropFilter = ''; + } + }); + }; + }, []); + const loadSessionDetails = async (sessionId: string) => { setIsLoadingSession(true); setError(null); @@ -69,29 +120,37 @@ const SessionsView: React.FC = ({ setView }) => { } }; - // If we're loading an initial session or have a selected session, show the session history view - // Otherwise, show the sessions list view - return selectedSession || (isLoadingSession && initialSessionId) ? ( - - ) : ( - + return ( +
+ {/* Use the global BackgroundImageFix component */} + + + {/* Content layer with transparent background */} +
+ {selectedSession || (isLoadingSession && initialSessionId) ? ( + + ) : ( + + )} +
+
); }; diff --git a/ui/desktop/src/components/settings/app/BackgroundSelector.tsx b/ui/desktop/src/components/settings/app/BackgroundSelector.tsx index 654e206b278e..81c941b7e733 100644 --- a/ui/desktop/src/components/settings/app/BackgroundSelector.tsx +++ b/ui/desktop/src/components/settings/app/BackgroundSelector.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { Button } from '../../ui/button'; import { Card } from '../../ui/card'; import { Upload, X, RotateCcw } from 'lucide-react'; @@ -98,73 +98,177 @@ const DEFAULT_BACKGROUNDS: BackgroundOption[] = [ const STORAGE_KEY = 'dashboard-background'; const CUSTOM_IMAGE_KEY = 'dashboard-custom-image'; const DOT_OVERLAY_KEY = 'dashboard-dot-overlay'; +const MAX_IMAGE_SIZE_MB = 5; // 5MB limit for images export default function BackgroundSelector() { const [selectedBackground, setSelectedBackground] = useState('default-gradient'); const [customImage, setCustomImage] = useState(null); const [showDotOverlay, setShowDotOverlay] = useState(true); const [isDragging, setIsDragging] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [error, setError] = useState(null); - // Load saved settings - useEffect(() => { - const savedBackground = localStorage.getItem(STORAGE_KEY); - const savedCustomImage = localStorage.getItem(CUSTOM_IMAGE_KEY); - const savedDotOverlay = localStorage.getItem(DOT_OVERLAY_KEY); - - if (savedBackground) { - setSelectedBackground(savedBackground); - } - if (savedCustomImage) { - setCustomImage(savedCustomImage); - } - if (savedDotOverlay !== null) { - setShowDotOverlay(savedDotOverlay === 'true'); + // Safe event dispatch function + const safeDispatchEvent = useCallback((eventName: string, detail: any) => { + try { + // Use requestAnimationFrame to ensure UI updates before dispatching event + requestAnimationFrame(() => { + window.dispatchEvent(new CustomEvent(eventName, { detail })); + }); + return true; + } catch (error) { + console.error(`Error dispatching ${eventName} event:`, error); + setError(`Failed to update background. Please try again.`); + return false; } }, []); - // Save settings and trigger update - const saveAndApplyBackground = (backgroundId: string, imageData?: string | null) => { - setSelectedBackground(backgroundId); - localStorage.setItem(STORAGE_KEY, backgroundId); + // Load saved settings with error handling + useEffect(() => { + try { + const savedBackground = localStorage.getItem(STORAGE_KEY); + const savedCustomImage = localStorage.getItem(CUSTOM_IMAGE_KEY); + const savedDotOverlay = localStorage.getItem(DOT_OVERLAY_KEY); - if (imageData !== undefined) { - if (imageData) { - localStorage.setItem(CUSTOM_IMAGE_KEY, imageData); - } else { - localStorage.removeItem(CUSTOM_IMAGE_KEY); + if (savedBackground) { + setSelectedBackground(savedBackground); } - setCustomImage(imageData); + if (savedCustomImage) { + setCustomImage(savedCustomImage); + } + if (savedDotOverlay !== null) { + setShowDotOverlay(savedDotOverlay === 'true'); + } + } catch (error) { + console.error('Error loading background settings:', error); + // Continue with defaults if loading fails } + }, []); + + // Save settings and trigger update with improved async handling + const saveAndApplyBackground = useCallback((backgroundId: string, imageData?: string | null) => { + // Clear any previous errors + setError(null); + + // Set updating state to show loading indicator + setIsUpdating(true); + + // Use requestAnimationFrame to ensure UI updates before potentially intensive operations + requestAnimationFrame(() => { + setTimeout(() => { + try { + // Update state first + setSelectedBackground(backgroundId); + + // Then update storage + localStorage.setItem(STORAGE_KEY, backgroundId); - // Trigger a custom event to notify the dashboard to update - window.dispatchEvent(new CustomEvent('dashboard-background-changed', { - detail: { backgroundId, customImage: imageData !== undefined ? imageData : customImage } - })); - }; + if (imageData !== undefined) { + if (imageData) { + localStorage.setItem(CUSTOM_IMAGE_KEY, imageData); + } else { + localStorage.removeItem(CUSTOM_IMAGE_KEY); + } + setCustomImage(imageData); + } - const handleDotOverlayToggle = (enabled: boolean) => { - setShowDotOverlay(enabled); - localStorage.setItem(DOT_OVERLAY_KEY, String(enabled)); - window.dispatchEvent(new CustomEvent('dashboard-dot-overlay-changed', { - detail: { enabled } - })); - }; + // Dispatch event after state and storage are updated + safeDispatchEvent('dashboard-background-changed', { + backgroundId, + customImage: imageData !== undefined ? imageData : customImage + }); + } catch (error) { + console.error('Error saving background settings:', error); + setError('Failed to save background settings. Please try again.'); + } finally { + // Clear updating state after a short delay + setTimeout(() => { + setIsUpdating(false); + }, 300); + } + }, 0); + }); + }, [customImage, safeDispatchEvent]); - const handleFileUpload = (file: File) => { + const handleDotOverlayToggle = useCallback((enabled: boolean) => { + setIsUpdating(true); + setError(null); + + requestAnimationFrame(() => { + setTimeout(() => { + try { + setShowDotOverlay(enabled); + localStorage.setItem(DOT_OVERLAY_KEY, String(enabled)); + + safeDispatchEvent('dashboard-dot-overlay-changed', { enabled }); + } catch (error) { + console.error('Error toggling dot overlay:', error); + setError('Failed to update overlay settings. Please try again.'); + } finally { + setTimeout(() => { + setIsUpdating(false); + }, 300); + } + }, 0); + }); + }, [safeDispatchEvent]); + + // Validate and process image with size limits + const handleFileUpload = useCallback((file: File) => { if (!file.type.startsWith('image/')) { - alert('Please select an image file'); + setError('Please select a valid image file'); return; } + // Check file size (limit to 5MB) + if (file.size > MAX_IMAGE_SIZE_MB * 1024 * 1024) { + setError(`Image size exceeds ${MAX_IMAGE_SIZE_MB}MB limit. Please choose a smaller file.`); + return; + } + + setIsUpdating(true); + setError(null); + + // Process image in a non-blocking way const reader = new FileReader(); + reader.onload = (e) => { - const imageData = e.target?.result as string; - saveAndApplyBackground('custom-image', imageData); + try { + const imageData = e.target?.result as string; + + // Validate image data + if (!imageData || !imageData.startsWith('data:image/')) { + throw new Error('Invalid image data'); + } + + // Create an image to verify it loads correctly + const img = new Image(); + img.onload = () => { + // Image loaded successfully, now safe to use + saveAndApplyBackground('custom-image', imageData); + }; + img.onerror = () => { + setError('Failed to process image. The file may be corrupted.'); + setIsUpdating(false); + }; + img.src = imageData; + } catch (error) { + console.error('Error processing image:', error); + setError('Failed to process image. Please try another file.'); + setIsUpdating(false); + } + }; + + reader.onerror = () => { + console.error('Error reading file'); + setError('Failed to read image file. Please try again.'); + setIsUpdating(false); }; + reader.readAsDataURL(file); - }; + }, [saveAndApplyBackground]); - const handleDrop = (e: React.DragEvent) => { + const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); @@ -173,41 +277,88 @@ export default function BackgroundSelector() { if (imageFile) { handleFileUpload(imageFile); + } else if (files.length > 0) { + setError('Please drop an image file (JPG, PNG, GIF, etc.)'); } - }; + }, [handleFileUpload]); - const handleDragOver = (e: React.DragEvent) => { + const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); - }; + }, []); - const handleDragLeave = (e: React.DragEvent) => { + const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); - }; + }, []); - const handleFileInputChange = (e: React.ChangeEvent) => { + const handleFileInputChange = useCallback((e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { handleFileUpload(file); } - }; + }, [handleFileUpload]); - const removeCustomImage = () => { + const removeCustomImage = useCallback(() => { saveAndApplyBackground('default-gradient', null); - }; + }, [saveAndApplyBackground]); - const resetToDefault = () => { - saveAndApplyBackground('default-gradient', null); - setShowDotOverlay(true); - localStorage.setItem(DOT_OVERLAY_KEY, 'true'); - window.dispatchEvent(new CustomEvent('dashboard-dot-overlay-changed', { - detail: { enabled: true } - })); - }; + const resetToDefault = useCallback(() => { + setIsUpdating(true); + setError(null); + + requestAnimationFrame(() => { + setTimeout(() => { + try { + // Update background + saveAndApplyBackground('default-gradient', null); + + // Update overlay + setShowDotOverlay(true); + localStorage.setItem(DOT_OVERLAY_KEY, 'true'); + + safeDispatchEvent('dashboard-dot-overlay-changed', { enabled: true }); + } catch (error) { + console.error('Error resetting to default:', error); + setError('Failed to reset settings. Please try again.'); + } finally { + setTimeout(() => { + setIsUpdating(false); + }, 300); + } + }, 0); + }); + }, [saveAndApplyBackground, safeDispatchEvent]); + + // Loading overlay that blocks interaction during updates + const LoadingOverlay = () => ( + isUpdating ? ( +
+
+
+

Updating background...

+
+
+ ) : null + ); + + // Error message display + const ErrorMessage = () => ( + error ? ( +
+

{error}

+
+ ) : null + ); return (
+ {/* Loading overlay */} + + + {/* Error message */} + + {/* Dot Overlay Toggle */}
@@ -220,6 +371,7 @@ export default function BackgroundSelector() { checked={showDotOverlay} onCheckedChange={handleDotOverlayToggle} variant="mono" + disabled={isUpdating} />
@@ -234,8 +386,8 @@ export default function BackgroundSelector() { selectedBackground === bg.id ? 'ring-2 ring-blue-500 shadow-lg' : 'hover:shadow-md' - }`} - onClick={() => saveAndApplyBackground(bg.id)} + } ${isUpdating ? 'pointer-events-none opacity-50' : ''}`} + onClick={() => !isUpdating && saveAndApplyBackground(bg.id)} >
saveAndApplyBackground('custom-image')} + } ${isUpdating ? 'pointer-events-none opacity-50' : ''}`} + onClick={() => !isUpdating && saveAndApplyBackground('custom-image')} >
Remove -
@@ -343,7 +498,7 @@ export default function BackgroundSelector() { isDragging ? 'border-blue-500 bg-blue-50 dark:bg-blue-950/20' : 'border-borderSubtle hover:border-borderStandard' - }`} + } ${isUpdating ? 'pointer-events-none opacity-50' : ''}`} onDrop={handleDrop} onDragOver={handleDragOver} onDragLeave={handleDragLeave} @@ -353,10 +508,16 @@ export default function BackgroundSelector() { Drop an image here or click to upload

- Supports JPG, PNG, GIF, WebP + Supports JPG, PNG, GIF, WebP (max {MAX_IMAGE_SIZE_MB}MB)

-
@@ -377,6 +539,7 @@ export default function BackgroundSelector() { size="sm" onClick={resetToDefault} className="flex items-center gap-2" + disabled={isUpdating} > Reset to Default