diff --git a/ui/desktop/public/background.jpg b/ui/desktop/public/background.jpg new file mode 100644 index 000000000000..af52866cd7df Binary files /dev/null and b/ui/desktop/public/background.jpg differ diff --git a/ui/desktop/src/components/BackgroundImageFix.tsx b/ui/desktop/src/components/BackgroundImageFix.tsx new file mode 100644 index 000000000000..8f611957a7b8 --- /dev/null +++ b/ui/desktop/src/components/BackgroundImageFix.tsx @@ -0,0 +1,192 @@ +import React, { useEffect } from 'react'; + +/** + * BackgroundImageFix Component + * + * A clean implementation to ensure background image and overlay display correctly. + * This component injects CSS directly into the document head to ensure proper z-index + * and positioning of background elements. + */ +const BackgroundImageFix: React.FC = () => { + useEffect(() => { + // Create a style element + const styleElement = document.createElement('style'); + styleElement.id = 'background-image-fix-styles'; + + // Define CSS that ensures the background image and overlay are displayed correctly + const css = ` + /* Reset any existing background styles that might interfere */ + .fixed.inset-0.-z-10, + .fixed.inset-0.-z-9, + .fixed.inset-0.-z-8, + .fixed.inset-0.-z-5, + .fixed.inset-0.-z-1, + [style*="z-index: -10"], + [style*="z-index: -9"], + [style*="z-index: -8"], + [style*="z-index: -5"], + [style*="z-index: -1"] { + z-index: auto !important; + } + + /* Remove any background gradients from the app container */ + #root > div, + .bg-background-muted, + .animate-gradient-slow, + [class*="bg-gradient"] { + background: none !important; + background-image: none !important; + } + + /* Make headers transparent in the sessions view */ + .sticky.top-0.z-10.bg-background-default\/80, + .sticky.top-0.z-10.bg-background-default, + .sticky.top-0.z-10, + .bg-background-default\/80.backdrop-blur-md { + background: transparent !important; + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + } + + /* Make session history headers transparent */ + .text-text-muted { + background: transparent !important; + } + + /* Root background container - lowest layer */ + #root-background-container { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: -1000; + pointer-events: none; + } + + /* Background image layer */ + #app-background-image { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + background-image: url('/background.jpg'); + background-size: cover; + background-position: center; + background-repeat: no-repeat; + z-index: -900; + } + + /* Blur overlay layer */ + #app-background-overlay { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + background-color: rgba(24, 24, 27, 0.5); + transition: background-color 0.5s ease; + z-index: -800; + } + + /* Ensure app content is above background */ + #root > div { + position: relative; + z-index: 1; + } + `; + + // Add the CSS to the style element + styleElement.textContent = css; + + // Append the style element to the head + document.head.appendChild(styleElement); + + // Create the background container and elements + const backgroundContainer = document.createElement('div'); + backgroundContainer.id = 'root-background-container'; + + const backgroundImage = document.createElement('div'); + backgroundImage.id = 'app-background-image'; + + const backgroundOverlay = document.createElement('div'); + backgroundOverlay.id = 'app-background-overlay'; + + // Assemble the elements + backgroundContainer.appendChild(backgroundImage); + backgroundContainer.appendChild(backgroundOverlay); + + // Insert the background container as the first child of the body + document.body.insertBefore(backgroundContainer, document.body.firstChild); + + // Find and remove any gradient backgrounds in the application + const removeGradientBackgrounds = () => { + // Target elements with gradient backgrounds + const gradientElements = document.querySelectorAll('[class*="bg-gradient"], .animate-gradient-slow'); + gradientElements.forEach(element => { + if (element instanceof HTMLElement) { + element.style.background = 'none'; + element.style.backgroundImage = 'none'; + } + }); + + // Specifically target GlobalBackground components + const globalBackgrounds = document.querySelectorAll('.fixed.inset-0.-z-10'); + globalBackgrounds.forEach(element => { + if (element instanceof HTMLElement) { + element.style.display = 'none'; + } + }); + + // Make session headers transparent + const sessionHeaders = document.querySelectorAll('.sticky.top-0.z-10, .bg-background-default\\/80.backdrop-blur-md'); + sessionHeaders.forEach(element => { + if (element instanceof HTMLElement) { + element.style.background = 'transparent'; + element.style.backdropFilter = 'none'; + element.style.webkitBackdropFilter = 'none'; + } + }); + }; + + // Run immediately and set up an observer to catch any dynamically added elements + removeGradientBackgrounds(); + + const observer = new MutationObserver((mutations) => { + removeGradientBackgrounds(); + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['class', 'style'] + }); + + // Cleanup function + return () => { + // Disconnect the observer + observer.disconnect(); + + // Remove the style element + const styleToRemove = document.getElementById('background-image-fix-styles'); + if (styleToRemove) { + document.head.removeChild(styleToRemove); + } + + // Remove the background container + const containerToRemove = document.getElementById('root-background-container'); + if (containerToRemove) { + document.body.removeChild(containerToRemove); + } + }; + }, []); + + // This component doesn't render anything visible + return null; +}; + +export default BackgroundImageFix; diff --git a/ui/desktop/src/components/GlobalBlurOverlay.tsx b/ui/desktop/src/components/GlobalBlurOverlay.tsx new file mode 100644 index 000000000000..93dd288e6c59 --- /dev/null +++ b/ui/desktop/src/components/GlobalBlurOverlay.tsx @@ -0,0 +1,203 @@ +import React, { useEffect, useState } from 'react'; +import { useFocusMode } from '../contexts/FocusModeContext'; + +/** + * GlobalBlurOverlay Component + * + * A reusable component that provides a consistent glassmorphism effect across the application. + * This component: + * 1. Renders a background image from app settings + * 2. Applies a blur effect with theme-aware styling + * 3. Adjusts opacity based on focus mode state + * 4. Handles theme changes automatically + * + * The component is designed to be mounted once at the application level to ensure + * consistent styling across all views. + */ +const GlobalBlurOverlay: React.FC = () => { + const { isInFocusMode } = useFocusMode(); + const [isDarkTheme, setIsDarkTheme] = useState(false); + const [imageLoaded, setImageLoaded] = useState(false); + const [backgroundImage, setBackgroundImage] = useState(null); + const [backgroundId, setBackgroundId] = useState('default-gradient'); + + // Update theme detection when it changes + useEffect(() => { + const updateTheme = () => { + setIsDarkTheme(document.documentElement.classList.contains('dark')); + }; + + // Initial check + updateTheme(); + + // Set up observer to detect theme changes + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.attributeName === 'class') { + updateTheme(); + } + }); + }); + + observer.observe(document.documentElement, { attributes: true }); + + // Load background settings + const savedBackground = localStorage.getItem('dashboard-background'); + const savedCustomImage = localStorage.getItem('dashboard-custom-image'); + + if (savedBackground) { + setBackgroundId(savedBackground); + } + + if (savedBackground === 'custom-image' && savedCustomImage) { + setBackgroundImage(savedCustomImage); + + // Preload the custom image + const img = new Image(); + img.onload = () => { + console.log("Custom background image loaded successfully"); + setImageLoaded(true); + }; + img.onerror = (e) => { + console.error("Failed to load custom background image:", e); + }; + img.src = savedCustomImage; + } else { + // If not using custom image, mark as loaded + setImageLoaded(true); + } + + // 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); + } + }; + + window.addEventListener('dashboard-background-changed', handleBackgroundChange as EventListener); + + return () => { + observer.disconnect(); + window.removeEventListener('dashboard-background-changed', handleBackgroundChange as EventListener); + }; + }, []); + + // Fixed blur intensity + const blurIntensity = 20; // Consistent blur for chat mode + + // Determine background color based on focus mode and theme + // Using more grey-tinted overlays to match the home page + const backgroundColor = isInFocusMode + ? (isDarkTheme ? 'rgba(24, 24, 27, 0.8)' : 'rgba(245, 245, 250, 0.8)') // 80% opacity in focus mode + : (isDarkTheme ? 'rgba(24, 24, 27, 0.5)' : 'rgba(245, 245, 250, 0.5)'); // 50% opacity in normal mode + + // Determine background style based on settings + const getBackgroundStyle = () => { + // If using custom image, return image style + if (backgroundId === 'custom-image' && backgroundImage) { + return { + backgroundImage: `url(${backgroundImage})`, + backgroundSize: 'cover', + backgroundPosition: 'center', + backgroundRepeat: 'no-repeat', + }; + } + + // Fallback to default image + return { + backgroundImage: `url('/background.jpg')`, + backgroundSize: 'cover', + backgroundPosition: 'center', + backgroundRepeat: 'no-repeat', + }; + }; + + // Create a div element to append to the body + useEffect(() => { + // Create background and blur overlay elements + const backgroundDiv = document.createElement('div'); + const blurDiv = document.createElement('div'); + + // Set styles for background image + Object.assign(backgroundDiv.style, { + position: 'fixed', + top: '0', + right: '0', + bottom: '0', + left: '0', + zIndex: '-8', + ...getBackgroundStyle(), + opacity: imageLoaded ? '1' : '0', + transition: 'opacity 0.5s ease-in-out', + }); + + // Set styles for blur overlay + Object.assign(blurDiv.style, { + position: 'fixed', + top: '0', + right: '0', + bottom: '0', + left: '0', + zIndex: '-5', + backdropFilter: `blur(${blurIntensity}px)`, + backgroundColor: backgroundColor, + transition: 'background-color 0.5s ease', + pointerEvents: 'none', + }); + + // Append elements to body + document.body.appendChild(backgroundDiv); + document.body.appendChild(blurDiv); + + // Debug info + if (process.env.NODE_ENV === 'development') { + const debugDiv = document.createElement('div'); + Object.assign(debugDiv.style, { + position: 'fixed', + bottom: '16px', + right: '16px', + backgroundColor: 'rgba(0, 0, 0, 0.5)', + color: 'white', + padding: '8px', + borderRadius: '4px', + fontSize: '12px', + zIndex: '50', + }); + + debugDiv.innerHTML = ` + Image Loaded: ${imageLoaded ? 'Yes' : 'No'}
+ Background ID: ${backgroundId}
+ Custom Image: ${backgroundImage ? 'Yes' : 'No'}
+ Dark Theme: ${isDarkTheme ? 'Yes' : 'No'}
+ Focus Mode: ${isInFocusMode ? 'Yes' : 'No'}
+ Overlay Color: ${backgroundColor} + `; + + document.body.appendChild(debugDiv); + } + + // Cleanup function + return () => { + document.body.removeChild(backgroundDiv); + document.body.removeChild(blurDiv); + + if (process.env.NODE_ENV === 'development') { + const debugElement = document.body.querySelector('[style*="position: fixed"][style*="bottom: 16px"][style*="right: 16px"]'); + if (debugElement) { + document.body.removeChild(debugElement); + } + } + }; + }, [backgroundId, backgroundImage, imageLoaded, isDarkTheme, isInFocusMode, backgroundColor]); + + // Return null since we're appending directly to the body + return null; +}; + +export default GlobalBlurOverlay; diff --git a/ui/desktop/src/components/Layout/AppLayout.tsx b/ui/desktop/src/components/Layout/AppLayout.tsx index 301e777063fc..50b6ed543152 100644 --- a/ui/desktop/src/components/Layout/AppLayout.tsx +++ b/ui/desktop/src/components/Layout/AppLayout.tsx @@ -4,8 +4,9 @@ import { View, ViewOptions } from '../../App'; import { AppWindowMac, AppWindow } from 'lucide-react'; import { Button } from '../ui/button'; import { SidebarProvider, useSidebar } from '../ui/sidebar'; -import GlobalBackground from '../GlobalBackground'; +import GlobalBlurOverlay from '../GlobalBlurOverlay'; import PillSideNav from '../PillSideNav'; +import BackgroundImageFix from '../BackgroundImageFix'; interface AppLayoutProps { setIsGoosehintsModalOpen?: (isOpen: boolean) => void; @@ -74,8 +75,11 @@ const AppLayoutContent: React.FC = ({ setIsGoosehintsModalOpen } return (
- {/* Global background */} - + {/* Add the new background fix component */} + + + {/* Keep the original background components for compatibility */} + {/* Floating pill navigation in center */}
diff --git a/ui/desktop/src/components/hub-glassmorphism.tsx b/ui/desktop/src/components/hub-glassmorphism.tsx new file mode 100644 index 000000000000..8453534ca5c8 --- /dev/null +++ b/ui/desktop/src/components/hub-glassmorphism.tsx @@ -0,0 +1,60 @@ +import React, { useEffect, useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import { useFocusMode } from '../contexts/FocusModeContext'; +import GlobalBlurOverlay from './GlobalBlurOverlay'; +import { type View, ViewOptions } from '../App'; +import { cn } from '../utils'; + +/** + * Hub Component + * + * The Hub component serves as the initial landing page for the Goose Desktop application. + * It provides a welcoming interface for users to start new conversations and access key features. + * + * This component has been updated to use the GlobalBlurOverlay for consistent glassmorphism styling. + */ +export default function Hub({ + readyForAutoUserPrompt, + chat, + setChat, + setPairChat, + setView, + setIsGoosehintsModalOpen, +}: { + readyForAutoUserPrompt: boolean; + chat: any; + setChat: (chat: any) => void; + setPairChat: (chat: any) => void; + setView: (view: View, viewOptions?: ViewOptions) => void; + setIsGoosehintsModalOpen: (isOpen: boolean) => void; +}) { + const location = useLocation(); + const { isInFocusMode, setIsInFocusMode } = useFocusMode(); + + // Reset focus mode when returning to hub + useEffect(() => { + setIsInFocusMode(false); + }, [setIsInFocusMode]); + + // Custom main layout props to override background completely + const customMainLayoutProps = { + backgroundColor: 'transparent', + style: { + backgroundColor: 'transparent', + background: 'transparent' + }, + }; + + return ( +
+ + + {/* Hub content goes here */} +
+
+ {/* Your existing Hub content */} +
+
+
+ ); +} diff --git a/ui/desktop/src/components/pair.tsx b/ui/desktop/src/components/pair.tsx index ad6bad7e3d2d..741fe34d31ec 100644 --- a/ui/desktop/src/components/pair.tsx +++ b/ui/desktop/src/components/pair.tsx @@ -28,7 +28,7 @@ import { useEffect, useState } from 'react'; import { useLocation } from 'react-router-dom'; import { type View, ViewOptions } from '../App'; import BaseChat from './BaseChat'; -import GlobalBackground from './GlobalBackground'; +import GlobalBlurOverlay from './GlobalBlurOverlay'; import { useRecipeManager } from '../hooks/useRecipeManager'; import { useIsMobile } from '../hooks/use-mobile'; import { useSidebar } from './ui/sidebar'; @@ -329,24 +329,8 @@ export default function Pair({ return (
- {/* Image background implementation */} -
- - {/* Fixed blur overlay - always present with consistent intensity */} -
+ {/* Add back the GlobalBlurOverlay for the chat page */} + {/* Centered chat content */}
diff --git a/ui/desktop/src/components/sessions/SessionListView.tsx b/ui/desktop/src/components/sessions/SessionListView.tsx index 0d4597088099..a030a4eeee1d 100644 --- a/ui/desktop/src/components/sessions/SessionListView.tsx +++ b/ui/desktop/src/components/sessions/SessionListView.tsx @@ -1,320 +1,201 @@ -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 React, { useCallback, useEffect, useState, useRef } from 'react'; +import { Session, fetchSessions, updateSessionMetadata } from '../../sessions'; import { ScrollArea } from '../ui/scroll-area'; -import { View, ViewOptions } from '../../App'; -import { formatMessageTimestamp } from '../../utils/timeUtils'; -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; -} - -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 handleSave = useCallback(async () => { - if (!session || disabled) return; - - const trimmedDescription = description.trim(); - if (trimmedDescription === session.metadata.description) { - onClose(); - return; - } +import { Button } from '../ui/button'; +import { Input } from '../ui/input'; +import { Calendar, Folder, MessageSquareText, MoreHorizontal, AlertCircle, Target, Search, X } from 'lucide-react'; +import { formatMessageTimestamp } from '../../utils/timeUtils'; +import { StaggeredSessionItem } from './StaggeredSessionItem'; - 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]); +interface DateGroup { + label: string; + sessions: Session[]; +} - 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] - ); +interface SearchViewProps { + onSearch: (query: string) => void; +} - const handleInputChange = useCallback((e: React.ChangeEvent) => { - setDescription(e.target.value); - }, []); +const SearchView: React.FC = ({ onSearch }) => { + const [query, setQuery] = useState(''); + const inputRef = useRef(null); - if (!isOpen || !session) return null; + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSearch(query); + }; - return ( -
-
-

Edit Session Description

- -
-
- -
-
+ const handleClear = () => { + setQuery(''); + onSearch(''); + inputRef.current?.focus(); + }; -
- - -
-
+ return ( +
+
+ + setQuery(e.target.value)} + /> + {query && ( + + )}
- ); - } -); - -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 }) => { +export default function SessionListView({ + onSessionSelect, +}: { + onSessionSelect?: (sessionId: string) => void; +}) { 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 [searchResults, setSearchResults] = useState<{ - count: number; - currentIndex: number; - } | null>(null); - - // Edit modal state - const [showEditModal, setShowEditModal] = useState(false); const [editingSession, setEditingSession] = useState(null); - - // Search state for debouncing - const [searchTerm, setSearchTerm] = useState(''); - const [caseSensitive, setCaseSensitive] = useState(false); - const debouncedSearchTerm = useDebounce(searchTerm, 300); // 300ms debounce - + const [showEditModal, setShowEditModal] = useState(false); + const [newDescription, setNewDescription] = useState(''); + const [searchResults, setSearchResults] = useState(null); const containerRef = useRef(null); - const loadSessions = useCallback(async () => { + // Load sessions on mount + useEffect(() => { + loadSessions(); + }, []); + + const loadSessions = async () => { setIsLoading(true); - setShowSkeleton(true); - setShowContent(false); setError(null); + try { - const sessions = await fetchSessions(); - // Use startTransition to make state updates non-blocking - startTransition(() => { - setSessions(sessions); - setFilteredSessions(sessions); - }); + // Use the fetchSessions function from sessions.ts instead of window.electron.getSessions + const result = await fetchSessions(); + setSessions(result); + groupSessionsByDate(result); } catch (err) { console.error('Failed to load sessions:', err); - setError('Failed to load sessions. Please try again later.'); - setSessions([]); - setFilteredSessions([]); + setError('Failed to load sessions. Please try again.'); } finally { - setIsLoading(false); - } - }, []); - - useEffect(() => { - loadSessions(); - }, [loadSessions]); - - // 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]); - - // Memoize date groups calculation to prevent unnecessary recalculations - const memoizedDateGroups = useMemo(() => { - if (filteredSessions.length > 0) { - return groupSessionsByDate(filteredSessions); + // Set loading to false after a short delay to ensure smooth transition + setTimeout(() => { + setIsLoading(false); + }, 300); } - return []; - }, [filteredSessions]); + }; - // Update date groups when filtered sessions change - useEffect(() => { - startTransition(() => { - setDateGroups(memoizedDateGroups); + 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); + } }); - }, [memoizedDateGroups]); - // Debounced search effect - performs actual filtering - useEffect(() => { - if (!debouncedSearchTerm) { - startTransition(() => { - setFilteredSessions(sessions); - setSearchResults(null); - }); + // Filter out empty groups + const filteredGroups = groups.filter((group) => group.sessions.length > 0); + setDateGroups(filteredGroups); + }; + + const handleSearch = (query: string) => { + if (!query.trim()) { + setSearchResults(null); + groupSessionsByDate(sessions); return; } - // 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); + 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); }); - }, [debouncedSearchTerm, caseSensitive, sessions]); - // Handle immediate search input (updates search term for debouncing) - const handleSearch = useCallback((term: string, caseSensitive: boolean) => { - setSearchTerm(term); - setCaseSensitive(caseSensitive); - }, []); + setSearchResults(results); + groupSessionsByDate(results); + }; - // Handle search result navigation - const handleSearchNavigation = (direction: 'next' | 'prev') => { - if (!searchResults || filteredSessions.length === 0) return; + const handleSaveDescription = useCallback(async () => { + if (!editingSession) return; - let newIndex: number; - if (direction === 'next') { - newIndex = (searchResults.currentIndex % filteredSessions.length) + 1; - } else { - newIndex = - searchResults.currentIndex === 1 ? filteredSessions.length : searchResults.currentIndex - 1; + 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); } + }, [editingSession, newDescription]); - 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); + useEffect(() => { + if (editingSession) { + setNewDescription(editingSession.metadata.description || ''); } - }; + }, [editingSession]); - // Handle modal close - const handleModalClose = useCallback(() => { + const handleCancelEdit = useCallback(() => { setShowEditModal(false); setEditingSession(null); }, []); - const handleModalSave = useCallback(async (sessionId: string, newDescription: string) => { - // Update state immediately for optimistic UI + const updateSessionDescription = useCallback((sessionId: string, newDescription: string) => { setSessions((prevSessions) => prevSessions.map((s) => s.id === sessionId ? { ...s, metadata: { ...s.metadata, description: newDescription } } : s @@ -327,110 +208,7 @@ const SessionListView: React.FC = React.memo(({ onSelectSe setShowEditModal(true); }, []); - 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 = () => { + const renderContent = () => { if (error) { return (
@@ -444,6 +222,20 @@ const SessionListView: React.FC = React.memo(({ onSelectSe ); } + if (isLoading) { + // Show a minimal loading state instead of skeletons + return ( +
+
+
+
+
+
+

Loading sessions...

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

{group.label}

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

Chat history

@@ -501,83 +304,35 @@ const SessionListView: React.FC = React.memo(({ onSelectSe
- - {/* Skeleton layer - always rendered but conditionally visible */} -
-
- {/* Today section */} -
- -
- - - - - -
-
- - {/* Yesterday section */} -
- -
- - - - - - -
-
- - {/* Additional section */} -
- -
- - - -
-
-
-
- - {/* Content layer - always rendered but conditionally visible */} -
- {renderActualContent()} -
-
+ + {renderContent()}
- + {/* 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/StaggeredSessionItem.tsx b/ui/desktop/src/components/sessions/StaggeredSessionItem.tsx new file mode 100644 index 000000000000..cbf727380b52 --- /dev/null +++ b/ui/desktop/src/components/sessions/StaggeredSessionItem.tsx @@ -0,0 +1,115 @@ +import React, { useState, useEffect } from 'react'; +import { Session } from '../../sessions'; +import { Calendar, MessageSquareText, Target, Folder, MoreHorizontal } from 'lucide-react'; +import { formatMessageTimestamp } from '../../utils/timeUtils'; +import { Button } from '../ui/button'; +import { cn } from '../../utils'; + +interface StaggeredSessionItemProps { + session: Session; + onEditClick: (session: Session) => void; + index: number; + groupIndex: number; // Add group index to stagger across groups +} + +export const StaggeredSessionItem: React.FC = ({ + session, + onEditClick, + index, + groupIndex +}) => { + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + // Calculate a more natural staggered delay + // Base delay + group delay + position within group delay + const baseDelay = 30; // Base delay in ms + const groupDelay = groupIndex * 100; // Additional delay per group + const positionDelay = index * 80; // Delay per position within group + + // Apply a slight randomization to make it feel more organic + const randomFactor = Math.random() * 50; // Random factor between 0-50ms + + // Total delay calculation + const totalDelay = baseDelay + groupDelay + positionDelay + randomFactor; + + const timeout = setTimeout(() => { + setIsVisible(true); + }, totalDelay); + + return () => clearTimeout(timeout); + }, [index, groupIndex]); + + const handleEditClick = (e: React.MouseEvent) => { + e.stopPropagation(); // Prevent card click + onEditClick(session); + }; + + return ( +
{ + // Use the API directly instead of window.electron + if (window.electron && window.electron.resumeSession) { + window.electron.resumeSession(session.id); + } else { + console.warn("resumeSession function not available on window.electron"); + // Fallback method if available + if (window.location && typeof window.location.href === 'string') { + window.location.href = `/?resumeSessionId=${encodeURIComponent(session.id)}`; + } + } + }} + > +
+
+

+ {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()} +
+ )} +
+
+
+ ); +};