Added debounce for search and chat text inputs to help with typing speed responsiveness with large sessions (#2065)

This commit is contained in:
Zane
2025-04-07 14:53:57 -07:00
committed by GitHub
parent a986b2622c
commit e0baf577fc
5 changed files with 139 additions and 49 deletions

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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 />

View File

@@ -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..."

View File

@@ -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 (