mirror of
https://github.com/aljazceru/goose.git
synced 2026-01-07 16:34:23 +01:00
Added debounce for search and chat text inputs to help with typing speed responsiveness with large sessions (#2065)
This commit is contained in:
9
ui/desktop/package-lock.json
generated
9
ui/desktop/package-lock.json
generated
@@ -23,6 +23,7 @@
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/themes": "^3.1.5",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
@@ -35,6 +36,7 @@
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"express": "^4.21.1",
|
||||
"framer-motion": "^11.11.11",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.454.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -4486,6 +4488,12 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.16.tgz",
|
||||
"integrity": "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/mdast": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
|
||||
@@ -11151,7 +11159,6 @@
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.castarray": {
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/themes": "^3.1.5",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
@@ -96,6 +97,7 @@
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"express": "^4.21.1",
|
||||
"framer-motion": "^11.11.11",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.454.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import Stop from './ui/Stop';
|
||||
import { Attach, Send } from './icons';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
interface InputProps {
|
||||
handleSubmit: (e: React.FormEvent) => void;
|
||||
@@ -18,12 +19,14 @@ export default function Input({
|
||||
commandHistory = [],
|
||||
initialValue = '',
|
||||
}: InputProps) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const [_value, setValue] = useState(initialValue);
|
||||
const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback
|
||||
|
||||
// Update internal value when initialValue changes
|
||||
useEffect(() => {
|
||||
if (initialValue) {
|
||||
setValue(initialValue);
|
||||
setDisplayValue(initialValue);
|
||||
}
|
||||
}, [initialValue]);
|
||||
|
||||
@@ -39,12 +42,28 @@ export default function Input({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Debounced function to update actual value
|
||||
const debouncedSetValue = useCallback(
|
||||
debounce((val: string) => {
|
||||
setValue(val);
|
||||
}, 150),
|
||||
[]
|
||||
);
|
||||
|
||||
// Debounced autosize function
|
||||
const debouncedAutosize = useCallback(
|
||||
debounce((textArea: HTMLTextAreaElement, value: string) => {
|
||||
textArea.style.height = '0px'; // Reset height
|
||||
const scrollHeight = textArea.scrollHeight;
|
||||
textArea.style.height = Math.min(scrollHeight, maxHeight) + 'px';
|
||||
}, 150),
|
||||
[]
|
||||
);
|
||||
|
||||
const useAutosizeTextArea = (textAreaRef: HTMLTextAreaElement | null, value: string) => {
|
||||
useEffect(() => {
|
||||
if (textAreaRef) {
|
||||
textAreaRef.style.height = '0px'; // Reset height
|
||||
const scrollHeight = textAreaRef.scrollHeight;
|
||||
textAreaRef.style.height = Math.min(scrollHeight, maxHeight) + 'px';
|
||||
debouncedAutosize(textAreaRef, value);
|
||||
}
|
||||
}, [textAreaRef, value]);
|
||||
};
|
||||
@@ -52,13 +71,22 @@ export default function Input({
|
||||
const minHeight = '1rem';
|
||||
const maxHeight = 10 * 24;
|
||||
|
||||
useAutosizeTextArea(textAreaRef.current, value);
|
||||
useAutosizeTextArea(textAreaRef.current, displayValue);
|
||||
|
||||
const handleChange = (evt: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const val = evt.target.value;
|
||||
setValue(val);
|
||||
setDisplayValue(val); // Update display immediately
|
||||
debouncedSetValue(val); // Debounce the actual state update
|
||||
};
|
||||
|
||||
// Cleanup debounced functions on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedSetValue.cancel();
|
||||
debouncedAutosize.cancel();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handlers for composition events, which are crucial for proper IME behavior
|
||||
const handleCompositionStart = (evt: React.CompositionEvent<HTMLTextAreaElement>) => {
|
||||
setIsComposing(true);
|
||||
@@ -73,7 +101,7 @@ export default function Input({
|
||||
|
||||
// Save current input if we're just starting to navigate history
|
||||
if (historyIndex === -1) {
|
||||
setSavedInput(value);
|
||||
setSavedInput(displayValue);
|
||||
}
|
||||
|
||||
// Calculate new history index
|
||||
@@ -98,8 +126,10 @@ export default function Input({
|
||||
setHistoryIndex(newIndex);
|
||||
if (newIndex === -1) {
|
||||
// Restore saved input when going past the end of history
|
||||
setDisplayValue(savedInput);
|
||||
setValue(savedInput);
|
||||
} else {
|
||||
setDisplayValue(commandHistory[newIndex] || '');
|
||||
setValue(commandHistory[newIndex] || '');
|
||||
}
|
||||
};
|
||||
@@ -118,7 +148,9 @@ export default function Input({
|
||||
return;
|
||||
}
|
||||
if (evt.altKey) {
|
||||
setValue(value + '\n');
|
||||
const newValue = displayValue + '\n';
|
||||
setDisplayValue(newValue);
|
||||
setValue(newValue);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -127,8 +159,9 @@ export default function Input({
|
||||
evt.preventDefault();
|
||||
|
||||
// Only submit if not loading and has content
|
||||
if (!isLoading && value.trim()) {
|
||||
handleSubmit(new CustomEvent('submit', { detail: { value } }));
|
||||
if (!isLoading && displayValue.trim()) {
|
||||
handleSubmit(new CustomEvent('submit', { detail: { value: displayValue } }));
|
||||
setDisplayValue('');
|
||||
setValue('');
|
||||
setHistoryIndex(-1);
|
||||
setSavedInput('');
|
||||
@@ -138,8 +171,9 @@ export default function Input({
|
||||
|
||||
const onFormSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (value.trim() && !isLoading) {
|
||||
handleSubmit(new CustomEvent('submit', { detail: { value } }));
|
||||
if (displayValue.trim() && !isLoading) {
|
||||
handleSubmit(new CustomEvent('submit', { detail: { value: displayValue } }));
|
||||
setDisplayValue('');
|
||||
setValue('');
|
||||
setHistoryIndex(-1);
|
||||
setSavedInput('');
|
||||
@@ -150,10 +184,9 @@ export default function Input({
|
||||
const path = await window.electron.selectFileOrDirectory();
|
||||
if (path) {
|
||||
// Append the path to existing text, with a space if there's existing text
|
||||
setValue((prev) => {
|
||||
const currentText = prev.trim();
|
||||
return currentText ? `${currentText} ${path}` : path;
|
||||
});
|
||||
const newValue = displayValue.trim() ? `${displayValue.trim()} ${path}` : path;
|
||||
setDisplayValue(newValue);
|
||||
setValue(newValue);
|
||||
textAreaRef.current?.focus();
|
||||
}
|
||||
};
|
||||
@@ -167,7 +200,7 @@ export default function Input({
|
||||
autoFocus
|
||||
id="dynamic-textarea"
|
||||
placeholder="What can goose help with? ⌘↑/⌘↓"
|
||||
value={value}
|
||||
value={displayValue}
|
||||
onChange={handleChange}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
@@ -209,9 +242,9 @@ export default function Input({
|
||||
type="submit"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={!value.trim()}
|
||||
disabled={!displayValue.trim()}
|
||||
className={`absolute right-2 top-1/2 -translate-y-1/2 text-textSubtle hover:text-textStandard ${
|
||||
!value.trim() ? 'text-textSubtle cursor-not-allowed' : ''
|
||||
!displayValue.trim() ? 'text-textSubtle cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
<Send />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, KeyboardEvent, useState } from 'react';
|
||||
import React, { useEffect, KeyboardEvent, useState, useCallback } from 'react';
|
||||
import { Search as SearchIcon } from 'lucide-react';
|
||||
import { ArrowDown, ArrowUp, Close } from '../icons';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
/**
|
||||
* Props for the SearchBar component
|
||||
@@ -27,6 +28,7 @@ interface SearchBarProps {
|
||||
* - 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> = ({
|
||||
onSearch,
|
||||
@@ -35,18 +37,33 @@ export const SearchBar: React.FC<SearchBarProps> = ({
|
||||
searchResults,
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [displayTerm, setDisplayTerm] = useState(''); // For immediate visual feedback
|
||||
const [caseSensitive, setCaseSensitive] = useState(false);
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
// Create debounced search function
|
||||
const debouncedSearch = useCallback(
|
||||
debounce((term: string, caseSensitive: boolean) => {
|
||||
onSearch(term, caseSensitive);
|
||||
}, 150),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
|
||||
// Cleanup debounced function
|
||||
return () => {
|
||||
debouncedSearch.cancel();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value;
|
||||
setSearchTerm(value);
|
||||
onSearch(value, caseSensitive);
|
||||
setDisplayTerm(value); // Update display immediately
|
||||
setSearchTerm(value); // Update actual term
|
||||
debouncedSearch(value, caseSensitive);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
@@ -67,13 +84,16 @@ export const SearchBar: React.FC<SearchBarProps> = ({
|
||||
};
|
||||
|
||||
const toggleCaseSensitive = () => {
|
||||
setCaseSensitive(!caseSensitive);
|
||||
onSearch(searchTerm, !caseSensitive);
|
||||
const newCaseSensitive = !caseSensitive;
|
||||
setCaseSensitive(newCaseSensitive);
|
||||
// Immediately trigger a new search with updated case sensitivity
|
||||
debouncedSearch(searchTerm, newCaseSensitive);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsExiting(true);
|
||||
debouncedSearch.cancel(); // Cancel any pending searches
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 150); // Match animation duration
|
||||
@@ -93,7 +113,7 @@ export const SearchBar: React.FC<SearchBarProps> = ({
|
||||
ref={inputRef}
|
||||
id="search-input"
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
value={displayTerm}
|
||||
onChange={handleSearch}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search conversation..."
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, PropsWithChildren } from 'react';
|
||||
import React, { useState, useEffect, PropsWithChildren, useCallback } from 'react';
|
||||
import { SearchBar } from './SearchBar';
|
||||
import { SearchHighlighter } from '../../utils/searchHighlighter';
|
||||
import { debounce } from 'lodash';
|
||||
import '../../styles/search.css';
|
||||
|
||||
/**
|
||||
@@ -14,6 +15,7 @@ interface SearchViewProps {
|
||||
/**
|
||||
* SearchView wraps content in a searchable container with a search bar that appears
|
||||
* when Cmd/Ctrl+F is pressed. Supports case-sensitive search and result navigation.
|
||||
* Features debounced search for better performance with large content.
|
||||
*/
|
||||
export const SearchView: React.FC<PropsWithChildren<SearchViewProps>> = ({
|
||||
className = '',
|
||||
@@ -27,14 +29,38 @@ export const SearchView: React.FC<PropsWithChildren<SearchViewProps>> = ({
|
||||
|
||||
const highlighterRef = React.useRef<SearchHighlighter | null>(null);
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const lastSearchRef = React.useRef<{ term: string; caseSensitive: boolean }>({
|
||||
term: '',
|
||||
caseSensitive: false,
|
||||
});
|
||||
|
||||
// Clean up highlighter on unmount
|
||||
// Create debounced highlight function
|
||||
const debouncedHighlight = useCallback(
|
||||
debounce((term: string, caseSensitive: boolean, highlighter: SearchHighlighter) => {
|
||||
const highlights = highlighter.highlight(term, caseSensitive);
|
||||
const count = highlights.length;
|
||||
|
||||
if (count > 0) {
|
||||
setSearchResults({
|
||||
currentIndex: 1,
|
||||
count,
|
||||
});
|
||||
highlighter.setCurrentMatch(0, true); // Explicitly scroll when setting initial match
|
||||
} else {
|
||||
setSearchResults(null);
|
||||
}
|
||||
}, 150),
|
||||
[]
|
||||
);
|
||||
|
||||
// Clean up highlighter and debounced functions on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (highlighterRef.current) {
|
||||
highlighterRef.current.destroy();
|
||||
highlighterRef.current = null;
|
||||
}
|
||||
debouncedHighlight.cancel();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -54,10 +80,14 @@ export const SearchView: React.FC<PropsWithChildren<SearchViewProps>> = ({
|
||||
|
||||
/**
|
||||
* Handles the search operation when a user enters a search term.
|
||||
* Uses debouncing to prevent excessive highlighting operations.
|
||||
* @param term - The text to search for
|
||||
* @param caseSensitive - Whether to perform a case-sensitive search
|
||||
*/
|
||||
const handleSearch = (term: string, caseSensitive: boolean) => {
|
||||
// Store the latest search parameters
|
||||
lastSearchRef.current = { term, caseSensitive };
|
||||
|
||||
if (!term) {
|
||||
setSearchResults(null);
|
||||
if (highlighterRef.current) {
|
||||
@@ -71,29 +101,25 @@ export const SearchView: React.FC<PropsWithChildren<SearchViewProps>> = ({
|
||||
|
||||
if (!highlighterRef.current) {
|
||||
highlighterRef.current = new SearchHighlighter(container, (count) => {
|
||||
if (count > 0) {
|
||||
setSearchResults((prev) => ({
|
||||
currentIndex: prev?.currentIndex || 1,
|
||||
count,
|
||||
}));
|
||||
} else {
|
||||
setSearchResults(null);
|
||||
// Only update if this is still the latest search
|
||||
if (
|
||||
lastSearchRef.current.term === term &&
|
||||
lastSearchRef.current.caseSensitive === caseSensitive
|
||||
) {
|
||||
if (count > 0) {
|
||||
setSearchResults((prev) => ({
|
||||
currentIndex: prev?.currentIndex || 1,
|
||||
count,
|
||||
}));
|
||||
} else {
|
||||
setSearchResults(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const highlights = highlighterRef.current.highlight(term, caseSensitive);
|
||||
const count = highlights.length;
|
||||
|
||||
if (count > 0) {
|
||||
setSearchResults({
|
||||
currentIndex: 1,
|
||||
count,
|
||||
});
|
||||
highlighterRef.current.setCurrentMatch(0, true); // Explicitly scroll when setting initial match
|
||||
} else {
|
||||
setSearchResults(null);
|
||||
}
|
||||
// Debounce the highlight operation
|
||||
debouncedHighlight(term, caseSensitive, highlighterRef.current);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -127,7 +153,7 @@ export const SearchView: React.FC<PropsWithChildren<SearchViewProps>> = ({
|
||||
};
|
||||
|
||||
/**
|
||||
* Closes the search interface and clears all highlights.
|
||||
* Closes the search interface and cleans up highlights.
|
||||
*/
|
||||
const handleCloseSearch = () => {
|
||||
setIsSearchVisible(false);
|
||||
@@ -135,6 +161,8 @@ export const SearchView: React.FC<PropsWithChildren<SearchViewProps>> = ({
|
||||
if (highlighterRef.current) {
|
||||
highlighterRef.current.clearHighlights();
|
||||
}
|
||||
// Cancel any pending highlight operations
|
||||
debouncedHighlight.cancel();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user