mirror of
https://github.com/aljazceru/goose.git
synced 2025-12-18 22:54:24 +01:00
Add search to sessions list (#2439)
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user