diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index 88a86ae7..175a66e0 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -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": { diff --git a/ui/desktop/package.json b/ui/desktop/package.json index 535f02db..d9125a67 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -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", diff --git a/ui/desktop/src/components/Input.tsx b/ui/desktop/src/components/Input.tsx index bae0eda1..bf8492a3 100644 --- a/ui/desktop/src/components/Input.tsx +++ b/ui/desktop/src/components/Input.tsx @@ -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) => { 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) => { 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' : '' }`} > diff --git a/ui/desktop/src/components/conversation/SearchBar.tsx b/ui/desktop/src/components/conversation/SearchBar.tsx index 4fd5bebb..26861c05 100644 --- a/ui/desktop/src/components/conversation/SearchBar.tsx +++ b/ui/desktop/src/components/conversation/SearchBar.tsx @@ -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 = ({ onSearch, @@ -35,18 +37,33 @@ export const SearchBar: React.FC = ({ 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(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) => { 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) => { @@ -67,13 +84,16 @@ export const SearchBar: React.FC = ({ }; 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 = ({ ref={inputRef} id="search-input" type="text" - value={searchTerm} + value={displayTerm} onChange={handleSearch} onKeyDown={handleKeyDown} placeholder="Search conversation..." diff --git a/ui/desktop/src/components/conversation/SearchView.tsx b/ui/desktop/src/components/conversation/SearchView.tsx index c75e61b1..a08e6562 100644 --- a/ui/desktop/src/components/conversation/SearchView.tsx +++ b/ui/desktop/src/components/conversation/SearchView.tsx @@ -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> = ({ className = '', @@ -27,14 +29,38 @@ export const SearchView: React.FC> = ({ const highlighterRef = React.useRef(null); const containerRef = React.useRef(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> = ({ /** * 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> = ({ 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> = ({ }; /** - * 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> = ({ if (highlighterRef.current) { highlighterRef.current.clearHighlights(); } + // Cancel any pending highlight operations + debouncedHighlight.cancel(); }; return (