Add search to sessions list (#2439)

This commit is contained in:
Zane
2025-05-06 16:34:11 -07:00
committed by GitHub
parent 392a101078
commit 9417e54593
5 changed files with 565 additions and 385 deletions

View File

@@ -1,4 +1,4 @@
import React, { useEffect, KeyboardEvent, useState, useCallback } from 'react'; import React, { useEffect, useState, useRef, KeyboardEvent } from 'react';
import { Search as SearchIcon } from 'lucide-react'; import { Search as SearchIcon } from 'lucide-react';
import { ArrowDown, ArrowUp, Close } from '../icons'; import { ArrowDown, ArrowUp, Close } from '../icons';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
@@ -26,13 +26,6 @@ interface SearchBarProps {
/** /**
* SearchBar provides a search input with case-sensitive toggle and result navigation. * SearchBar provides a search input with case-sensitive toggle and result navigation.
* Features:
* - Case-sensitive search toggle
* - Result count display
* - Navigation between results with arrows
* - Keyboard shortcuts (↑/↓ for navigation, Esc to close)
* - Smooth animations for enter/exit
* - Debounced search for better performance
*/ */
export const SearchBar: React.FC<SearchBarProps> = ({ export const SearchBar: React.FC<SearchBarProps> = ({
onSearch, onSearch,
@@ -41,23 +34,26 @@ export const SearchBar: React.FC<SearchBarProps> = ({
searchResults, searchResults,
inputRef: externalInputRef, inputRef: externalInputRef,
initialSearchTerm = '', initialSearchTerm = '',
}) => { }: SearchBarProps) => {
const [searchTerm, setSearchTerm] = useState(initialSearchTerm); const [searchTerm, setSearchTerm] = useState(initialSearchTerm);
const [displayTerm, setDisplayTerm] = useState(initialSearchTerm); // For immediate visual feedback
const [caseSensitive, setCaseSensitive] = useState(false); const [caseSensitive, setCaseSensitive] = useState(false);
const [isExiting, setIsExiting] = useState(false); const [isExiting, setIsExiting] = useState(false);
const internalInputRef = React.useRef<HTMLInputElement>(null); const internalInputRef = React.useRef<HTMLInputElement>(null);
const inputRef = externalInputRef || internalInputRef; const inputRef = externalInputRef || internalInputRef;
const debouncedSearchRef = useRef<ReturnType<typeof debounce>>();
// Create debounced search function // Create debounced search function
const debouncedSearch = useCallback( useEffect(() => {
(term: string, isCaseSensitive: boolean) => { const debouncedFn = debounce((term: string, caseSensitive: boolean) => {
debounce((searchTerm: string, caseSensitive: boolean) => { onSearch(term, caseSensitive);
onSearch(searchTerm, caseSensitive); }, 200);
}, 150)(term, isCaseSensitive);
}, debouncedSearchRef.current = debouncedFn;
[onSearch]
); return () => {
debouncedFn.cancel();
};
}, [onSearch]);
useEffect(() => { useEffect(() => {
inputRef.current?.focus(); inputRef.current?.focus();
@@ -67,29 +63,48 @@ export const SearchBar: React.FC<SearchBarProps> = ({
useEffect(() => { useEffect(() => {
if (initialSearchTerm) { if (initialSearchTerm) {
setSearchTerm(initialSearchTerm); setSearchTerm(initialSearchTerm);
setDisplayTerm(initialSearchTerm); if (initialSearchTerm.length >= 2) {
debouncedSearch(initialSearchTerm, caseSensitive); debouncedSearchRef.current?.(initialSearchTerm, caseSensitive);
}
} }
}, [initialSearchTerm, caseSensitive, debouncedSearch]); }, [initialSearchTerm, caseSensitive, debouncedSearchRef]);
// Cleanup debounced function on unmount const [localSearchResults, setLocalSearchResults] = useState<typeof searchResults>(null);
// Sync external search results with local state
useEffect(() => { useEffect(() => {
return () => { // Only set results if we have a search term
debouncedSearch.cancel?.(); if (!searchTerm) {
}; setLocalSearchResults(null);
}, [debouncedSearch]); } else {
setLocalSearchResults(searchResults);
}
}, [searchResults, searchTerm]);
const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => { const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value; const value = event.target.value;
setDisplayTerm(value); // Update display immediately
setSearchTerm(value); // Update actual term // Always cancel pending searches first
debouncedSearch(value, caseSensitive); if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
// Update display term immediately for UI feedback
setSearchTerm(value);
// Only trigger search if we have 2 or more characters
if (value.length >= 2) {
debouncedSearchRef.current?.(value, caseSensitive);
} else {
// Clear results if less than 2 characters
onSearch('', caseSensitive);
}
}; };
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowUp') { if (event.key === 'ArrowUp') {
handleNavigate('prev', event); handleNavigate('prev', event);
} else if (event.key === 'ArrowDown') { } else if (event.key === 'ArrowDown' || event.key === 'Enter') {
handleNavigate('next', event); handleNavigate('next', event);
} else if (event.key === 'Escape') { } else if (event.key === 'Escape') {
event.preventDefault(); event.preventDefault();
@@ -99,26 +114,32 @@ export const SearchBar: React.FC<SearchBarProps> = ({
const handleNavigate = (direction: 'next' | 'prev', e?: React.MouseEvent | KeyboardEvent) => { const handleNavigate = (direction: 'next' | 'prev', e?: React.MouseEvent | KeyboardEvent) => {
e?.preventDefault(); e?.preventDefault();
onNavigate?.(direction); if (searchResults && searchResults.count > 0) {
inputRef.current?.focus(); inputRef.current?.focus();
onNavigate?.(direction);
}
}; };
const toggleCaseSensitive = () => { const toggleCaseSensitive = () => {
const newCaseSensitive = !caseSensitive; const newCaseSensitive = !caseSensitive;
setCaseSensitive(newCaseSensitive); setCaseSensitive(newCaseSensitive);
// Immediately trigger a new search with updated case sensitivity // Immediately trigger a new search with updated case sensitivity
debouncedSearch(searchTerm, newCaseSensitive); if (searchTerm) {
debouncedSearchRef.current?.(searchTerm, newCaseSensitive);
}
inputRef.current?.focus(); inputRef.current?.focus();
}; };
const handleClose = () => { const handleClose = () => {
setIsExiting(true); setIsExiting(true);
debouncedSearch.cancel?.(); // Cancel any pending searches debouncedSearchRef.current?.cancel(); // Cancel any pending searches
setTimeout(() => { setTimeout(() => {
onClose(); onClose();
}, 150); // Match animation duration }, 150); // Match animation duration
}; };
const hasResults = searchResults && searchResults.count > 0;
return ( return (
<div <div
className={`sticky top-0 bg-bgAppInverse text-textProminentInverse z-50 ${ className={`sticky top-0 bg-bgAppInverse text-textProminentInverse z-50 ${
@@ -133,60 +154,74 @@ export const SearchBar: React.FC<SearchBarProps> = ({
ref={inputRef} ref={inputRef}
id="search-input" id="search-input"
type="text" type="text"
value={displayTerm} value={searchTerm}
onChange={handleSearch} onChange={handleSearch}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Search conversation..." placeholder="Search conversation..."
className="w-full text-sm pl-9 pr-10 py-3 bg-bgAppInverse className="w-full text-sm pl-9 pr-24 py-3 bg-bgAppInverse
placeholder:text-textSubtleInverse focus:outline-none placeholder:text-textSubtleInverse focus:outline-none
active:border-borderProminent" active:border-borderProminent"
/> />
</div> </div>
<div className="absolute right-3 flex h-full items-center justify-center text-sm text-textStandardInverse"> <div className="absolute right-3 flex h-full items-center justify-end">
{searchResults && searchResults.count} <div className="flex items-center gap-1">
<div className="w-16 text-right text-sm text-textStandardInverse flex items-center justify-end">
{(() => {
return localSearchResults?.count > 0 && searchTerm
? `${localSearchResults.currentIndex}/${localSearchResults.count}`
: null;
})()}
</div>
</div>
</div> </div>
</div> </div>
<div className="flex items-center justify-center h-auto px-4 gap-2"> <div className="flex items-center justify-center h-auto px-4 gap-2">
<button <button
onClick={toggleCaseSensitive} onClick={toggleCaseSensitive}
className={`flex items-center justify-center case-sensitive-btn px-2 ${ className={`flex items-center justify-center min-w-[32px] h-[28px] rounded transition-all duration-150 ${
caseSensitive caseSensitive
? 'text-textStandardInverse bg-bgHover' ? 'bg-white/20 shadow-[inset_0_1px_2px_rgba(0,0,0,0.2)]'
: 'text-textSubtleInverse hover:text-textStandardInverse' : 'text-textSubtleInverse hover:text-textStandardInverse hover:bg-white/5'
}`} }`}
title="Case Sensitive" title="Case Sensitive"
> >
<span className="text-md font-medium">Aa</span> <span className="text-md font-normal">Aa</span>
</button> </button>
<button <div className="flex items-center gap-2">
onClick={(e) => handleNavigate('prev', e)} <button onClick={(e) => handleNavigate('prev', e)} className="p-1" title="Previous (↑)">
className={`p-1 text-textSubtleInverse ${!searchResults || searchResults.count === 0 ? '' : 'hover:text-textStandardInverse'}`} <ArrowUp
title="Previous (↑)" className={`h-5 w-5 transition-opacity ${
disabled={!searchResults || searchResults.count === 0} !hasResults
> ? 'opacity-30'
<ArrowUp className="h-5 w-5" /> : 'text-textSubtleInverse hover:text-textStandardInverse'
</button> }`}
<button />
onClick={(e) => handleNavigate('next', e)} </button>
className={`p-1 text-textSubtleInverse ${!searchResults || searchResults.count === 0 ? '' : 'hover:text-textStandardInverse'}`} <button
title="Next (↓)" onClick={(e) => handleNavigate('next', e)}
disabled={!searchResults || searchResults.count === 0} className="p-1"
> title="Next (↓ or Enter)"
<ArrowDown className="h-5 w-5" /> >
</button> <ArrowDown
className={`h-5 w-5 transition-opacity ${
!hasResults
? 'opacity-30'
: 'text-textSubtleInverse hover:text-textStandardInverse'
}`}
/>
</button>
</div>
<button <button onClick={handleClose} className="p-1" title="Close (Esc)">
onClick={handleClose} <Close className="h-5 w-5 text-textSubtleInverse hover:text-textStandardInverse" />
className="p-1 text-textSubtleInverse hover:text-textStandardInverse"
title="Close (Esc)"
>
<Close className="h-5 w-5" />
</button> </button>
</div> </div>
</div> </div>
</div> </div>
); );
}; };
export default SearchBar;

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, PropsWithChildren, useCallback } from 'react'; import React, { useState, useEffect, PropsWithChildren, useCallback } from 'react';
import { SearchBar } from './SearchBar'; import SearchBar from './SearchBar';
import { SearchHighlighter } from '../../utils/searchHighlighter'; import { SearchHighlighter } from '../../utils/searchHighlighter';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import '../../styles/search.css'; import '../../styles/search.css';
@@ -10,6 +10,19 @@ import '../../styles/search.css';
interface SearchViewProps { interface SearchViewProps {
/** Optional CSS class name */ /** Optional CSS class name */
className?: string; className?: string;
/** Optional callback for search term changes */
onSearch?: (term: string, caseSensitive: boolean) => void;
/** Optional callback for navigating between search results */
onNavigate?: (direction: 'next' | 'prev') => void;
/** Current search results state */
searchResults?: {
count: number;
currentIndex: number;
} | null;
}
interface SearchContainerElement extends HTMLDivElement {
_searchHighlighter: SearchHighlighter | null;
} }
/** /**
@@ -20,17 +33,20 @@ interface SearchViewProps {
export const SearchView: React.FC<PropsWithChildren<SearchViewProps>> = ({ export const SearchView: React.FC<PropsWithChildren<SearchViewProps>> = ({
className = '', className = '',
children, children,
onSearch,
onNavigate,
searchResults,
}) => { }) => {
const [isSearchVisible, setIsSearchVisible] = useState(false); const [isSearchVisible, setIsSearchVisible] = useState(false);
const [initialSearchTerm, setInitialSearchTerm] = useState(''); const [initialSearchTerm, setInitialSearchTerm] = useState('');
const [searchResults, setSearchResults] = useState<{ const [internalSearchResults, setInternalSearchResults] = useState<{
currentIndex: number; currentIndex: number;
count: number; count: number;
} | null>(null); } | null>(null);
const searchInputRef = React.useRef<HTMLInputElement>(null); const searchInputRef = React.useRef<HTMLInputElement>(null);
const highlighterRef = React.useRef<SearchHighlighter | null>(null); const highlighterRef = React.useRef<SearchHighlighter | null>(null);
const containerRef = React.useRef<HTMLDivElement | null>(null); const containerRef = React.useRef<SearchContainerElement | null>(null);
const lastSearchRef = React.useRef<{ term: string; caseSensitive: boolean }>({ const lastSearchRef = React.useRef<{ term: string; caseSensitive: boolean }>({
term: '', term: '',
caseSensitive: false, caseSensitive: false,
@@ -39,23 +55,37 @@ export const SearchView: React.FC<PropsWithChildren<SearchViewProps>> = ({
// Create debounced highlight function // Create debounced highlight function
const debouncedHighlight = useCallback( const debouncedHighlight = useCallback(
(term: string, caseSensitive: boolean, highlighter: SearchHighlighter) => { (term: string, caseSensitive: boolean, highlighter: SearchHighlighter) => {
debounce( const performHighlight = () => {
(searchTerm: string, isCaseSensitive: boolean, searchHighlighter: SearchHighlighter) => { const highlights = highlighter.highlight(term, caseSensitive);
const highlights = searchHighlighter.highlight(searchTerm, isCaseSensitive); const count = highlights.length;
const count = highlights.length;
if (count > 0) { if (count > 0) {
setSearchResults({ setInternalSearchResults({
currentIndex: 1, currentIndex: 1,
count, count,
}); });
searchHighlighter.setCurrentMatch(0, true); // Explicitly scroll when setting initial match highlighter.setCurrentMatch(0, true);
} else { } else {
setSearchResults(null); setInternalSearchResults(null);
} }
}, };
150
)(term, caseSensitive, highlighter); // If this is a case sensitivity change (same term, different case setting),
// execute immediately
if (
term === lastSearchRef.current.term &&
caseSensitive !== lastSearchRef.current.caseSensitive
) {
performHighlight();
return;
}
// Create a debounced version of performHighlight
const debouncedFn = debounce(performHighlight, 150);
debouncedFn();
// Store the debounced function for potential cancellation
return debouncedFn;
}, },
[] []
); );
@@ -69,10 +99,18 @@ export const SearchView: React.FC<PropsWithChildren<SearchViewProps>> = ({
const handleSearch = useCallback( const handleSearch = useCallback(
(term: string, caseSensitive: boolean) => { (term: string, caseSensitive: boolean) => {
// Store the latest search parameters // Store the latest search parameters
const isCaseChange =
term === lastSearchRef.current.term &&
caseSensitive !== lastSearchRef.current.caseSensitive;
lastSearchRef.current = { term, caseSensitive }; lastSearchRef.current = { term, caseSensitive };
// Call the onSearch callback if provided
onSearch?.(term, caseSensitive);
// If empty, clear everything and return
if (!term) { if (!term) {
setSearchResults(null); setInternalSearchResults(null);
if (highlighterRef.current) { if (highlighterRef.current) {
highlighterRef.current.clearHighlights(); highlighterRef.current.clearHighlights();
} }
@@ -82,62 +120,73 @@ export const SearchView: React.FC<PropsWithChildren<SearchViewProps>> = ({
const container = containerRef.current; const container = containerRef.current;
if (!container) return; if (!container) return;
if (!highlighterRef.current) { // For case sensitivity changes, reuse existing highlighter
highlighterRef.current = new SearchHighlighter(container, (count) => { if (isCaseChange && highlighterRef.current) {
// Only update if this is still the latest search debouncedHighlight(term, caseSensitive, highlighterRef.current);
if ( return;
lastSearchRef.current.term === term &&
lastSearchRef.current.caseSensitive === caseSensitive
) {
if (count > 0) {
setSearchResults((prev) => ({
currentIndex: prev?.currentIndex || 1,
count,
}));
} else {
setSearchResults(null);
}
}
});
} }
// Debounce the highlight operation // Otherwise create new highlighter
if (highlighterRef.current) {
highlighterRef.current.clearHighlights();
highlighterRef.current.destroy();
}
highlighterRef.current = new SearchHighlighter(container, (count) => {
// Only update if this is still the latest search
if (
lastSearchRef.current.term === term &&
lastSearchRef.current.caseSensitive === caseSensitive
) {
if (count > 0) {
setInternalSearchResults({
currentIndex: 1,
count,
});
} else {
setInternalSearchResults(null);
}
}
});
debouncedHighlight(term, caseSensitive, highlighterRef.current); debouncedHighlight(term, caseSensitive, highlighterRef.current);
}, },
[debouncedHighlight] [debouncedHighlight, onSearch]
); );
/** /**
* Navigates between search results in the specified direction. * Navigates between search results in the specified direction.
* @param direction - Direction to navigate ('next' or 'prev') * @param direction - Direction to navigate ('next' or 'prev')
*/ */
const navigateResults = useCallback( const handleNavigate = useCallback(
(direction: 'next' | 'prev') => { (direction: 'next' | 'prev') => {
if (!searchResults || searchResults.count === 0 || !highlighterRef.current) return; // If external navigation is provided, use that
if (onNavigate) {
let newIndex: number; onNavigate(direction);
const currentIdx = searchResults.currentIndex - 1; // Convert to 0-based return;
if (direction === 'next') {
newIndex = currentIdx + 1;
if (newIndex >= searchResults.count) {
newIndex = 0;
}
} else {
newIndex = currentIdx - 1;
if (newIndex < 0) {
newIndex = searchResults.count - 1;
}
} }
setSearchResults({ // Otherwise use internal navigation
...searchResults, if (!internalSearchResults || !highlighterRef.current) return;
currentIndex: newIndex + 1,
let newIndex: number;
if (direction === 'next') {
newIndex = (internalSearchResults.currentIndex % internalSearchResults.count) + 1;
} else {
newIndex =
internalSearchResults.currentIndex === 1
? internalSearchResults.count
: internalSearchResults.currentIndex - 1;
}
setInternalSearchResults({
...internalSearchResults,
currentIndex: newIndex,
}); });
highlighterRef.current.setCurrentMatch(newIndex, true); // Explicitly scroll when navigating highlighterRef.current.setCurrentMatch(newIndex - 1, true);
}, },
[searchResults] [internalSearchResults, onNavigate]
); );
const handleFindCommand = useCallback(() => { const handleFindCommand = useCallback(() => {
@@ -151,15 +200,15 @@ export const SearchView: React.FC<PropsWithChildren<SearchViewProps>> = ({
const handleFindNext = useCallback(() => { const handleFindNext = useCallback(() => {
if (isSearchVisible) { if (isSearchVisible) {
navigateResults('next'); handleNavigate('next');
} }
}, [isSearchVisible, navigateResults]); }, [isSearchVisible, handleNavigate]);
const handleFindPrevious = useCallback(() => { const handleFindPrevious = useCallback(() => {
if (isSearchVisible) { if (isSearchVisible) {
navigateResults('prev'); handleNavigate('prev');
} }
}, [isSearchVisible, navigateResults]); }, [isSearchVisible, handleNavigate]);
const handleUseSelectionFind = useCallback(() => { const handleUseSelectionFind = useCallback(() => {
const selection = window.getSelection()?.toString().trim(); const selection = window.getSelection()?.toString().trim();
@@ -173,13 +222,21 @@ export const SearchView: React.FC<PropsWithChildren<SearchViewProps>> = ({
*/ */
const handleCloseSearch = useCallback(() => { const handleCloseSearch = useCallback(() => {
setIsSearchVisible(false); setIsSearchVisible(false);
setSearchResults(null); setInternalSearchResults(null);
lastSearchRef.current = { term: '', caseSensitive: false };
if (highlighterRef.current) { if (highlighterRef.current) {
highlighterRef.current.clearHighlights(); highlighterRef.current.clearHighlights();
highlighterRef.current.destroy();
highlighterRef.current = null;
} }
// Cancel any pending highlight operations // Cancel any pending highlight operations
debouncedHighlight.cancel?.(); debouncedHighlight.cancel?.();
}, [debouncedHighlight]);
// Clear search when closing
onSearch?.('', false);
}, [debouncedHighlight, onSearch]);
// Clean up highlighter and debounced functions on unmount // Clean up highlighter and debounced functions on unmount
useEffect(() => { useEffect(() => {
@@ -228,10 +285,10 @@ export const SearchView: React.FC<PropsWithChildren<SearchViewProps>> = ({
e.preventDefault(); e.preventDefault();
if (e.shiftKey) { if (e.shiftKey) {
// ⇧⌘G - Find Previous // ⇧⌘G - Find Previous
navigateResults('prev'); handleNavigate('prev');
} else { } else {
// ⌘G - Find Next // ⌘G - Find Next
navigateResults('next'); handleNavigate('next');
} }
} }
}; };
@@ -240,7 +297,7 @@ export const SearchView: React.FC<PropsWithChildren<SearchViewProps>> = ({
return () => { return () => {
window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keydown', handleKeyDown);
}; };
}, [isSearchVisible, navigateResults, handleSearch, handleUseSelectionFind]); }, [isSearchVisible, handleNavigate, handleSearch, handleUseSelectionFind]);
// Listen for Find menu commands // Listen for Find menu commands
useEffect(() => { useEffect(() => {
@@ -258,13 +315,22 @@ export const SearchView: React.FC<PropsWithChildren<SearchViewProps>> = ({
}, [handleFindCommand, handleFindNext, handleFindPrevious, handleUseSelectionFind]); }, [handleFindCommand, handleFindNext, handleFindPrevious, handleUseSelectionFind]);
return ( return (
<div ref={containerRef} className={`search-container ${className}`}> <div
ref={(el) => {
if (el) {
containerRef.current = el;
// Expose the highlighter instance
containerRef.current._searchHighlighter = highlighterRef.current;
}
}}
className={`search-container ${className}`}
>
{isSearchVisible && ( {isSearchVisible && (
<SearchBar <SearchBar
onSearch={handleSearch} onSearch={handleSearch}
onClose={handleCloseSearch} onClose={handleCloseSearch}
onNavigate={navigateResults} onNavigate={handleNavigate}
searchResults={searchResults} searchResults={searchResults || internalSearchResults}
inputRef={searchInputRef} inputRef={searchInputRef}
initialSearchTerm={initialSearchTerm} initialSearchTerm={initialSearchTerm}
/> />

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import { import {
MessageSquareText, MessageSquareText,
Target, Target,
@@ -16,129 +16,288 @@ import { ScrollArea } from '../ui/scroll-area';
import { View, ViewOptions } from '../../App'; import { View, ViewOptions } from '../../App';
import { formatMessageTimestamp } from '../../utils/timeUtils'; import { formatMessageTimestamp } from '../../utils/timeUtils';
import MoreMenuLayout from '../more_menu/MoreMenuLayout'; import MoreMenuLayout from '../more_menu/MoreMenuLayout';
import { SearchView } from '../conversation/SearchView';
import { SearchHighlighter } from '../../utils/searchHighlighter';
interface SearchContainerElement extends HTMLDivElement {
_searchHighlighter: SearchHighlighter | null;
}
interface SessionListViewProps { interface SessionListViewProps {
setView: (view: View, viewOptions?: ViewOptions) => void; setView: (view: View, viewOptions?: ViewOptions) => void;
onSelectSession: (sessionId: string) => void; onSelectSession: (sessionId: string) => void;
} }
const ITEM_HEIGHT = 90; // Adjust based on your card height
const BUFFER_SIZE = 5; // Number of items to render above/below viewport
const SessionListView: React.FC<SessionListViewProps> = ({ setView, onSelectSession }) => { const SessionListView: React.FC<SessionListViewProps> = ({ setView, onSelectSession }) => {
const [sessions, setSessions] = useState<Session[]>([]); const [sessions, setSessions] = useState<Session[]>([]);
const [filteredSessions, setFilteredSessions] = useState<Session[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [searchResults, setSearchResults] = useState<{
count: number;
currentIndex: number;
} | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 20 });
useEffect(() => { useEffect(() => {
// Load sessions on component mount
loadSessions(); loadSessions();
}, []); }, []);
// Handle scroll events to update visible range
useEffect(() => {
const viewportEl = containerRef.current?.closest('[data-radix-scroll-area-viewport]');
if (!viewportEl) return;
const handleScroll = () => {
const scrollTop = viewportEl.scrollTop;
const viewportHeight = viewportEl.clientHeight;
const start = Math.max(0, Math.floor(scrollTop / ITEM_HEIGHT) - BUFFER_SIZE);
const end = Math.min(
filteredSessions.length,
Math.ceil((scrollTop + viewportHeight) / ITEM_HEIGHT) + BUFFER_SIZE
);
setVisibleRange({ start, end });
};
handleScroll(); // Initial calculation
viewportEl.addEventListener('scroll', handleScroll);
const resizeObserver = new ResizeObserver(handleScroll);
resizeObserver.observe(viewportEl);
return () => {
viewportEl.removeEventListener('scroll', handleScroll);
resizeObserver.disconnect();
};
}, [filteredSessions.length]);
// Filter sessions when search term or case sensitivity changes
const handleSearch = (term: string, caseSensitive: boolean) => {
if (!term) {
setFilteredSessions(sessions);
setSearchResults(null);
return;
}
const searchTerm = caseSensitive ? term : term.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);
// Reset scroll position when search changes
const viewportEl = containerRef.current?.closest('[data-radix-scroll-area-viewport]');
if (viewportEl) {
viewportEl.scrollTop = 0;
}
setVisibleRange({ start: 0, end: 20 });
};
const loadSessions = async () => { const loadSessions = async () => {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
const sessions = await fetchSessions(); const sessions = await fetchSessions();
setSessions(sessions); setSessions(sessions);
setFilteredSessions(sessions);
} catch (err) { } catch (err) {
console.error('Failed to load sessions:', err); console.error('Failed to load sessions:', err);
setError('Failed to load sessions. Please try again later.'); setError('Failed to load sessions. Please try again later.');
setSessions([]); setSessions([]);
setFilteredSessions([]);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
return ( // Handle search result navigation
<div className="h-screen w-full"> const handleSearchNavigation = (direction: 'next' | 'prev') => {
<MoreMenuLayout showMenu={false} /> if (!searchResults || filteredSessions.length === 0) return;
<ScrollArea className="h-full w-full"> let newIndex: number;
<div className="flex flex-col pb-24"> if (direction === 'next') {
<div className="px-8 pt-6 pb-4"> newIndex = (searchResults.currentIndex % filteredSessions.length) + 1;
<BackButton onClick={() => setView('chat')} /> } else {
</div> newIndex =
searchResults.currentIndex === 1 ? filteredSessions.length : searchResults.currentIndex - 1;
}
{/* Content Area */} setSearchResults({ ...searchResults, currentIndex: newIndex });
<div className="flex flex-col mb-6 px-8">
<h1 className="text-3xl font-medium text-textStandard">Previous goose sessions</h1> // Find the SearchView's container element
<h3 className="text-sm text-textSubtle mt-2"> const searchContainer =
View previous goose sessions and their contents to pick up where you left off. containerRef.current?.querySelector<SearchContainerElement>('.search-container');
if (searchContainer?._searchHighlighter) {
// Update the current match in the highlighter
searchContainer._searchHighlighter.setCurrentMatch(newIndex - 1, true);
}
};
// Render a session item
const SessionItem = React.memo(function SessionItem({ session }: { session: Session }) {
return (
<Card
onClick={() => onSelectSession(session.id)}
className="p-2 mx-4 mb-2 bg-bgSecondary hover:bg-bgSubtle cursor-pointer transition-all duration-150"
>
<div className="flex justify-between items-start gap-4">
<div className="min-w-0 flex-1">
<h3 className="text-base font-medium text-textStandard truncate max-w-[50vw]">
{session.metadata.description || session.id}
</h3> </h3>
<div className="flex gap-3 min-w-0">
<div className="flex items-center text-textSubtle text-sm shrink-0">
<Calendar className="w-3 h-3 mr-1 flex-shrink-0" />
<span>{formatMessageTimestamp(Date.parse(session.modified) / 1000)}</span>
</div>
<div className="flex items-center text-textSubtle text-sm min-w-0">
<Folder className="w-3 h-3 mr-1 flex-shrink-0" />
<span className="truncate">{session.metadata.working_dir}</span>
</div>
</div>
</div> </div>
<div className="flex-1 overflow-y-auto p-4">
{isLoading ? (
<div className="flex justify-center items-center h-full">
<LoaderCircle className="h-8 w-8 animate-spin text-textPrimary" />
</div>
) : error ? (
<div className="flex flex-col items-center justify-center h-full text-textSubtle">
<AlertCircle className="h-12 w-12 text-red-500 mb-4" />
<p className="text-lg mb-2">Error Loading Sessions</p>
<p className="text-sm text-center mb-4">{error}</p>
<Button onClick={loadSessions} variant="default">
Try Again
</Button>
</div>
) : sessions.length > 0 ? (
<div className="grid gap-2">
{sessions.map((session) => (
<Card
key={session.id}
onClick={() => onSelectSession(session.id)}
className="p-2 bg-bgSecondary hover:bg-bgSubtle cursor-pointer transition-all duration-150"
>
<div className="flex justify-between items-start gap-4">
<div className="min-w-0 flex-1">
<h3 className="text-base font-medium text-textStandard truncate max-w-[50vw]">
{session.metadata.description || session.id}
</h3>
<div className="flex gap-3 min-w-0">
<div className="flex items-center text-textSubtle text-sm shrink-0">
<Calendar className="w-3 h-3 mr-1 flex-shrink-0" />
<span>
{formatMessageTimestamp(Date.parse(session.modified) / 1000)}
</span>
</div>
<div className="flex items-center text-textSubtle text-sm min-w-0">
<Folder className="w-3 h-3 mr-1 flex-shrink-0" />
<span className="truncate">{session.metadata.working_dir}</span>
</div>
</div>
</div>
<div className="flex items-center gap-3 shrink-0"> <div className="flex items-center gap-3 shrink-0">
<div className="flex flex-col items-end"> <div className="flex flex-col items-end">
<div className="flex items-center text-sm text-textSubtle"> <div className="flex items-center text-sm text-textSubtle">
<span>{session.path.split('/').pop() || session.path}</span> <span>{session.path.split('/').pop() || session.path}</span>
</div>
<div className="flex items-center mt-1 space-x-3 text-sm text-textSubtle">
<div className="flex items-center">
<MessageSquareText className="w-3 h-3 mr-1" />
<span>{session.metadata.message_count}</span>
</div>
{session.metadata.total_tokens !== null && (
<div className="flex items-center">
<Target className="w-3 h-3 mr-1" />
<span>{session.metadata.total_tokens.toLocaleString()}</span>
</div>
)}
</div>
</div>
<ChevronRight className="w-8 h-5 text-textSubtle" />
</div>
</div>
</Card>
))}
</div> </div>
) : ( <div className="flex items-center mt-1 space-x-3 text-sm text-textSubtle">
<div className="flex flex-col items-center justify-center h-full text-textSubtle"> <div className="flex items-center">
<MessageSquareText className="h-12 w-12 mb-4" /> <MessageSquareText className="w-3 h-3 mr-1" />
<p className="text-lg mb-2">No chat sessions found</p> <span>{session.metadata.message_count}</span>
<p className="text-sm">Your chat history will appear here</p> </div>
{session.metadata.total_tokens !== null && (
<div className="flex items-center">
<Target className="w-3 h-3 mr-1" />
<span>{session.metadata.total_tokens.toLocaleString()}</span>
</div>
)}
</div> </div>
)} </div>
<ChevronRight className="w-8 h-5 text-textSubtle" />
</div> </div>
</div> </div>
</ScrollArea> </Card>
);
});
const renderContent = () => {
if (isLoading) {
return (
<div className="flex justify-center items-center h-full">
<LoaderCircle className="h-8 w-8 animate-spin text-textPrimary" />
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full text-textSubtle">
<AlertCircle className="h-12 w-12 text-red-500 mb-4" />
<p className="text-lg mb-2">Error Loading Sessions</p>
<p className="text-sm text-center mb-4">{error}</p>
<Button onClick={loadSessions} variant="default">
Try Again
</Button>
</div>
);
}
if (filteredSessions.length === 0) {
if (searchResults === null && sessions.length > 0) {
return (
<div className="flex flex-col items-center justify-center h-full text-textSubtle mt-4">
<MessageSquareText className="h-12 w-12 mb-4" />
<p className="text-lg mb-2">No matching sessions found</p>
<p className="text-sm">Try adjusting your search terms</p>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center h-full text-textSubtle">
<MessageSquareText className="h-12 w-12 mb-4" />
<p className="text-lg mb-2">No chat sessions found</p>
<p className="text-sm">Your chat history will appear here</p>
</div>
);
}
const visibleSessions = filteredSessions.slice(visibleRange.start, visibleRange.end);
return (
<div style={{ height: filteredSessions.length * ITEM_HEIGHT }} className="relative">
<div
style={{
position: 'absolute',
top: visibleRange.start * ITEM_HEIGHT,
width: '100%',
}}
>
{visibleSessions.map((session) => (
<SessionItem key={session.id} session={session} />
))}
</div>
</div>
);
};
return (
<div className="h-screen w-full flex flex-col">
<MoreMenuLayout showMenu={false} />
<div className="flex-1 flex flex-col min-h-0">
<div className="px-8 pt-6 pb-4">
<BackButton onClick={() => setView('chat')} />
</div>
{/* Content Area */}
<div className="flex flex-col mb-6 px-8">
<h1 className="text-3xl font-medium text-textStandard">Previous goose sessions</h1>
<h3 className="text-sm text-textSubtle mt-2">
View previous goose sessions and their contents to pick up where you left off.
</h3>
</div>
<div className="flex-1 min-h-0 relative">
<ScrollArea className="h-full" data-search-scroll-area>
<div ref={containerRef} className="h-full relative">
<SearchView
onSearch={handleSearch}
onNavigate={handleSearchNavigation}
searchResults={searchResults}
className="relative"
>
{renderContent()}
</SearchView>
</div>
</ScrollArea>
</div>
</div>
</div> </div>
); );
}; };

View File

@@ -21,7 +21,7 @@ const SharedSessionView: React.FC<SharedSessionViewProps> = ({
}) => { }) => {
return ( return (
<div className="h-screen w-full flex flex-col"> <div className="h-screen w-full flex flex-col">
<div className="relative flex items-center h-[36px] w-full"></div> <div className="relative flex items-center h-14 w-full"></div>
{/* Top Row - back, info (fixed) */} {/* Top Row - back, info (fixed) */}
<SessionHeaderCard onBack={onBack}> <SessionHeaderCard onBack={onBack}>

View File

@@ -11,18 +11,10 @@ export class SearchHighlighter {
private scrollContainer: HTMLElement | null = null; private scrollContainer: HTMLElement | null = null;
private currentTerm: string = ''; private currentTerm: string = '';
private caseSensitive: boolean = false; private caseSensitive: boolean = false;
private scrollHandler: (() => void) | null = null;
private onMatchesChange?: (count: number) => void; private onMatchesChange?: (count: number) => void;
private currentMatchIndex: number = -1; private currentMatchIndex: number = -1;
private isUpdatingPositions: boolean = false; private isScrollingProgrammatically: boolean = false;
private updatePending: boolean = false;
private shouldScrollToMatch: boolean = false;
/**
* Creates a new SearchHighlighter instance.
* @param container - The root HTML element to search within
* @param onMatchesChange - Optional callback that receives the count of matches when changed
*/
constructor(container: HTMLElement, onMatchesChange?: (count: number) => void) { constructor(container: HTMLElement, onMatchesChange?: (count: number) => void) {
this.container = container; this.container = container;
this.onMatchesChange = onMatchesChange; this.onMatchesChange = onMatchesChange;
@@ -40,55 +32,37 @@ export class SearchHighlighter {
z-index: 1; z-index: 1;
`; `;
// Find scroll container (usually the radix scroll area viewport) // Find scroll container (look for our custom data attribute first, then fallback to radix)
this.scrollContainer = container.closest('[data-radix-scroll-area-viewport]'); this.scrollContainer =
container
.closest('[data-search-scroll-area]')
?.querySelector('[data-radix-scroll-area-viewport]') ||
container.closest('[data-radix-scroll-area-viewport]');
if (this.scrollContainer) { if (this.scrollContainer) {
this.scrollContainer.style.position = 'relative'; this.scrollContainer.style.position = 'relative';
this.scrollContainer.appendChild(this.overlay); this.scrollContainer.appendChild(this.overlay);
// Add scroll handler with debouncing to prevent performance issues // Add scroll end detection
this.scrollHandler = () => { this.scrollContainer.addEventListener(
if (this.isUpdatingPositions) { 'scroll',
this.updatePending = true; () => {
return; if (!this.isScrollingProgrammatically) {
} // User is manually scrolling, update highlight positions
this.updateHighlightPositions();
this.isUpdatingPositions = true;
requestAnimationFrame(() => {
this.updateHighlightPositions();
this.isUpdatingPositions = false;
if (this.updatePending) {
this.updatePending = false;
this.scrollHandler?.();
} }
}); },
}; { passive: true }
this.scrollContainer.addEventListener('scroll', this.scrollHandler); );
} else { } else {
container.style.position = 'relative'; container.style.position = 'relative';
container.appendChild(this.overlay); container.appendChild(this.overlay);
} }
// Handle content changes with debouncing // Handle content changes
this.resizeObserver = new ResizeObserver(() => { this.resizeObserver = new ResizeObserver(() => {
if (this.highlights.length > 0) { if (this.highlights.length > 0) {
if (this.isUpdatingPositions) { this.updateHighlightPositions();
this.updatePending = true;
return;
}
this.isUpdatingPositions = true;
requestAnimationFrame(() => {
this.updateHighlightPositions();
this.isUpdatingPositions = false;
if (this.updatePending) {
this.updatePending = false;
// Re-run the update
requestAnimationFrame(() => this.updateHighlightPositions());
}
});
} }
}); });
this.resizeObserver.observe(container); this.resizeObserver.observe(container);
@@ -103,36 +77,16 @@ export class SearchHighlighter {
} }
} }
if (shouldUpdate && this.currentTerm) { if (shouldUpdate && this.currentTerm) {
if (this.isUpdatingPositions) { this.highlight(this.currentTerm, this.caseSensitive);
this.updatePending = true;
return;
}
this.isUpdatingPositions = true;
requestAnimationFrame(() => {
this.highlight(this.currentTerm, this.caseSensitive);
this.isUpdatingPositions = false;
if (this.updatePending) {
this.updatePending = false;
// Re-run the update
requestAnimationFrame(() => this.highlight(this.currentTerm, this.caseSensitive));
}
});
} }
}); });
this.mutationObserver.observe(container, { childList: true, subtree: true }); this.mutationObserver.observe(container, { childList: true, subtree: true });
} }
/**
* Highlights all occurrences of a search term within the container.
* @param term - The text to search for
* @param caseSensitive - Whether to perform a case-sensitive search
* @returns Array of highlight elements created
*/
highlight(term: string, caseSensitive = false) { highlight(term: string, caseSensitive = false) {
// Store the current match index before clearing // Store the current match index and count before clearing
const currentIndex = this.currentMatchIndex; const currentIndex = this.currentMatchIndex;
const oldHighlightCount = this.highlights.length;
this.clearHighlights(); this.clearHighlights();
this.currentTerm = term; this.currentTerm = term;
@@ -191,16 +145,20 @@ export class SearchHighlighter {
const highlightRect = document.createElement('div'); const highlightRect = document.createElement('div');
highlightRect.className = 'search-highlight'; highlightRect.className = 'search-highlight';
const scrollTop = this.scrollContainer ? this.scrollContainer.scrollTop : window.scrollY; // Get the scroll container's position
const scrollLeft = this.scrollContainer ? this.scrollContainer.scrollLeft : window.scrollX; const containerRect = this.scrollContainer?.getBoundingClientRect() || { top: 0, left: 0 };
const containerTop = this.scrollContainer?.getBoundingClientRect().top || 0; const scrollTop = this.scrollContainer?.scrollTop || 0;
const containerLeft = this.scrollContainer?.getBoundingClientRect().left || 0; const scrollLeft = this.scrollContainer?.scrollLeft || 0;
// Calculate the highlight position relative to the scroll container
const top = rect.top + scrollTop - containerRect.top;
const left = rect.left + scrollLeft - containerRect.left;
highlightRect.style.cssText = ` highlightRect.style.cssText = `
position: absolute; position: absolute;
pointer-events: none; pointer-events: none;
top: ${rect.top + scrollTop - containerTop}px; top: ${top}px;
left: ${rect.left + scrollLeft - containerLeft}px; left: ${left}px;
width: ${rect.width}px; width: ${rect.width}px;
height: ${rect.height}px; height: ${rect.height}px;
`; `;
@@ -211,135 +169,97 @@ export class SearchHighlighter {
return highlight; return highlight;
}); });
// Notify about updated match count // Only notify about count changes if the number of matches has actually changed
this.onMatchesChange?.(this.highlights.length); if (this.highlights.length !== oldHighlightCount) {
this.onMatchesChange?.(this.highlights.length);
}
// Restore current match if it was set // Restore current match if we have the same number of highlights
if (currentIndex >= 0 && this.highlights.length > 0) { if (currentIndex >= 0 && this.highlights.length === oldHighlightCount) {
// Use the stored index, not this.currentMatchIndex which was reset in clearHighlights this.setCurrentMatch(currentIndex, false);
this.setCurrentMatch(currentIndex, false); // Don't scroll when restoring highlight }
// Otherwise, if we have highlights but the count changed, start from the beginning
else if (this.highlights.length > 0) {
this.setCurrentMatch(0, false);
} }
return this.highlights; return this.highlights;
} }
/**
* Sets the current match and optionally scrolls to it.
* @param index - Zero-based index of the match to set as current
* @param shouldScroll - Whether to scroll to the match (true for explicit navigation)
*/
setCurrentMatch(index: number, shouldScroll = true) { setCurrentMatch(index: number, shouldScroll = true) {
// Store the current match index if (!this.highlights.length) return;
this.currentMatchIndex = index;
// Save the scroll flag // Ensure index wraps around
this.shouldScrollToMatch = shouldScroll; const wrappedIndex =
((index % this.highlights.length) + this.highlights.length) % this.highlights.length;
// Store the current match index
this.currentMatchIndex = wrappedIndex;
// Remove current class from all highlights // Remove current class from all highlights
this.overlay.querySelectorAll('.search-highlight').forEach((el) => { this.overlay.querySelectorAll('.search-highlight').forEach((el) => {
el.classList.remove('current'); el.classList.remove('current');
}); });
// Add current class to the matched highlight // Add current class to all parts of the highlight
if (this.highlights.length > 0) { const currentHighlight = this.highlights[wrappedIndex];
// Ensure index wraps around const highlightElements = currentHighlight.querySelectorAll('.search-highlight');
const wrappedIndex = highlightElements.forEach((el) => {
((index % this.highlights.length) + this.highlights.length) % this.highlights.length; el.classList.add('current');
});
// Find all highlight elements within the current highlight container // Only scroll if explicitly requested
const highlightElements = this.highlights[wrappedIndex].querySelectorAll('.search-highlight'); if (shouldScroll && this.scrollContainer) {
const firstHighlight = highlightElements[0] as HTMLElement;
if (firstHighlight) {
// Calculate the target scroll position
const containerRect = this.scrollContainer.getBoundingClientRect();
const highlightRect = firstHighlight.getBoundingClientRect();
// Add 'current' class to all parts of the highlight (for multi-line matches) // Calculate the target position that would center the highlight
highlightElements.forEach((el) => { const targetScrollTop =
el.classList.add('current'); this.scrollContainer.scrollTop +
}); (highlightRect.top - containerRect.top) -
(containerRect.height - highlightRect.height) / 2;
// Only scroll if explicitly requested (e.g., when navigating) // Set flag before scrolling
if (shouldScroll) { this.isScrollingProgrammatically = true;
// Ensure we call scrollToMatch with the correct index
setTimeout(() => this.scrollToMatch(wrappedIndex), 0); // Perform the scroll
this.scrollContainer.scrollTop = targetScrollTop;
// Clear flag after a short delay
setTimeout(() => {
this.isScrollingProgrammatically = false;
}, 100);
} }
} }
} }
/**
* Scrolls to center the specified match in the viewport.
* @param index - Zero-based index of the match to scroll to
*/
private scrollToMatch(index: number) {
if (!this.scrollContainer || !this.highlights[index]) return;
const currentHighlight = this.highlights[index].querySelector(
'.search-highlight'
) as HTMLElement;
if (!currentHighlight) return;
const rect = currentHighlight.getBoundingClientRect();
const containerRect = this.scrollContainer.getBoundingClientRect();
// Calculate how far the element is from the top of the viewport
const elementRelativeToViewport = rect.top - containerRect.top;
// Calculate the new scroll position that would center the element
const currentScrollTop = this.scrollContainer.scrollTop;
const targetPosition =
currentScrollTop + elementRelativeToViewport - (containerRect.height - rect.height) / 2;
// Ensure we don't scroll past the bottom
const maxScroll = this.scrollContainer.scrollHeight - this.scrollContainer.clientHeight;
const finalPosition = Math.max(0, Math.min(targetPosition, maxScroll));
this.scrollContainer.scrollTo({
top: finalPosition,
behavior: 'smooth',
});
}
/**
* Updates the positions of all highlights after content changes.
* This preserves the current match selection but doesn't scroll.
*/
private updateHighlightPositions() { private updateHighlightPositions() {
if (this.currentTerm) { if (this.currentTerm) {
// Store the current index for restoration
const currentIndex = this.currentMatchIndex; const currentIndex = this.currentMatchIndex;
const oldHighlights = this.highlights.length; // Store the current count
// Clear and recreate all highlights
this.overlay.innerHTML = '';
this.highlights = [];
// Re-highlight with the current term
this.highlight(this.currentTerm, this.caseSensitive); this.highlight(this.currentTerm, this.caseSensitive);
// Ensure the current match is still highlighted, but don't scroll // If we still have the same number of highlights, restore the current index
if (currentIndex >= 0 && this.highlights.length > 0) { if (this.highlights.length === oldHighlights && currentIndex >= 0) {
this.setCurrentMatch(currentIndex, false); this.setCurrentMatch(currentIndex, false);
} }
} }
} }
/**
* Removes all search highlights from the container.
*/
clearHighlights() { clearHighlights() {
this.highlights.forEach((h) => h.remove()); this.highlights.forEach((h) => h.remove());
this.highlights = []; this.highlights = [];
this.currentTerm = ''; this.currentTerm = '';
this.currentMatchIndex = -1; this.currentMatchIndex = -1;
this.shouldScrollToMatch = false; this.overlay.innerHTML = '';
this.overlay.innerHTML = ''; // Ensure all highlights are removed
} }
/**
* Cleans up all resources used by the highlighter.
* Should be called when the component using this highlighter unmounts.
*/
destroy() { destroy() {
this.resizeObserver.disconnect(); this.resizeObserver.disconnect();
this.mutationObserver.disconnect(); this.mutationObserver.disconnect();
if (this.scrollHandler && this.scrollContainer) {
this.scrollContainer.removeEventListener('scroll', this.scrollHandler);
}
this.overlay.remove(); this.overlay.remove();
} }
} }