From aab127e18d1201665decd1080eaaf2cc7f2aa5a7 Mon Sep 17 00:00:00 2001 From: Zane <75694352+zanesq@users.noreply.github.com> Date: Tue, 21 Oct 2025 12:54:56 -0700 Subject: [PATCH] Added extension search (#5283) --- ui/desktop/src/components/BaseChat.tsx | 2 +- .../src/components/conversation/SearchBar.tsx | 5 ++- .../components/conversation/SearchView.tsx | 4 +++ .../components/extensions/ExtensionsView.tsx | 33 ++++++++++++------- .../sessions/SessionHistoryView.tsx | 2 +- .../components/sessions/SessionListView.tsx | 3 +- .../settings/extensions/ExtensionsSection.tsx | 3 ++ .../subcomponents/ExtensionList.tsx | 23 +++++++++++-- ui/desktop/src/utils/searchHighlighter.ts | 32 +++++++++++++++--- 9 files changed, 84 insertions(+), 23 deletions(-) diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index c14ce2d0..b02adaa7 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -366,7 +366,7 @@ function BaseChatContent({ /> ) : ( // Render messages with SearchView wrapper when search is enabled - + ; /** Initial search term */ initialSearchTerm?: string; + /** Placeholder text for the search input */ + placeholder?: string; } /** @@ -35,6 +37,7 @@ export const SearchBar: React.FC = ({ searchResults, inputRef: externalInputRef, initialSearchTerm = '', + placeholder = 'Search conversation...', }: SearchBarProps) => { const [searchTerm, setSearchTerm] = useState(initialSearchTerm); const [caseSensitive, setCaseSensitive] = useState(false); @@ -158,7 +161,7 @@ export const SearchBar: React.FC = ({ value={searchTerm} onChange={handleSearch} onKeyDown={handleKeyDown} - placeholder="Search conversation..." + placeholder={placeholder} className="no-drag w-full text-sm pl-9 pr-24 py-3 bg-background-inverse text-text-inverse placeholder:text-text-inverse/50 focus:outline-none active:border-border-strong" diff --git a/ui/desktop/src/components/conversation/SearchView.tsx b/ui/desktop/src/components/conversation/SearchView.tsx index 051ee0a5..ba6a5702 100644 --- a/ui/desktop/src/components/conversation/SearchView.tsx +++ b/ui/desktop/src/components/conversation/SearchView.tsx @@ -19,6 +19,8 @@ interface SearchViewProps { count: number; currentIndex: number; } | null; + /** Placeholder text for the search input */ + placeholder?: string; } interface SearchContainerElement extends HTMLDivElement { @@ -36,6 +38,7 @@ export const SearchView: React.FC> = ({ onSearch, onNavigate, searchResults, + placeholder, }) => { const [isSearchVisible, setIsSearchVisible] = useState(false); const [initialSearchTerm, setInitialSearchTerm] = useState(''); @@ -376,6 +379,7 @@ export const SearchView: React.FC> = ({ searchResults={searchResults || internalSearchResults || undefined} inputRef={searchInputRef} initialSearchTerm={initialSearchTerm} + placeholder={placeholder} /> )} {children} diff --git a/ui/desktop/src/components/extensions/ExtensionsView.tsx b/ui/desktop/src/components/extensions/ExtensionsView.tsx index b4af1adc..8177441a 100644 --- a/ui/desktop/src/components/extensions/ExtensionsView.tsx +++ b/ui/desktop/src/components/extensions/ExtensionsView.tsx @@ -16,6 +16,7 @@ import { } from '../settings/extensions/utils'; import { activateExtension } from '../settings/extensions'; import { useConfig } from '../ConfigContext'; +import { SearchView } from '../conversation/SearchView'; export type ExtensionsViewOptions = { deepLinkConfig?: ExtensionConfig; @@ -31,6 +32,7 @@ export default function ExtensionsView({ }) { const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [refreshKey, setRefreshKey] = useState(0); + const [searchTerm, setSearchTerm] = useState(''); const { addExtension } = useConfig(); const chatContext = useChatContext(); const sessionId = chatContext?.chat.sessionId || ''; @@ -102,7 +104,10 @@ export default function ExtensionsView({ return ( -
+
@@ -110,7 +115,8 @@ export default function ExtensionsView({

These extensions use the Model Context Protocol (MCP). They can expand Goose's - capabilities using three main components: Prompts, Resources, and Tools. + capabilities using three main components: Prompts, Resources, and Tools. ⌘F/Ctrl+F to + search.

{/* Action Buttons */} @@ -138,16 +144,19 @@ export default function ExtensionsView({
- { - scrollToExtension(extensionName); - }} - /> + setSearchTerm(term)} placeholder="Search extensions..."> + { + scrollToExtension(extensionName); + }} + /> +
{/* Bottom padding space - same as in hub.tsx */} diff --git a/ui/desktop/src/components/sessions/SessionHistoryView.tsx b/ui/desktop/src/components/sessions/SessionHistoryView.tsx index 4d297ae9..1f2a9f1f 100644 --- a/ui/desktop/src/components/sessions/SessionHistoryView.tsx +++ b/ui/desktop/src/components/sessions/SessionHistoryView.tsx @@ -103,7 +103,7 @@ const SessionMessages: React.FC<{ ) : filteredMessages?.length > 0 ? (
- + = React.memo(

- View and search your past conversations with Goose. + View and search your past conversations with Goose. ⌘F/Ctrl+F to search.

@@ -726,6 +726,7 @@ const SessionListView: React.FC = React.memo( onNavigate={handleSearchNavigation} searchResults={searchResults} className="relative" + placeholder="Search history..." > {/* Skeleton layer - always rendered but conditionally visible */}
Promise; selectedExtensions?: string[]; // Add controlled state onModalClose?: (extensionName: string) => void; + searchTerm?: string; } export default function ExtensionsSection({ @@ -35,6 +36,7 @@ export default function ExtensionsSection({ customToggle, selectedExtensions = [], onModalClose, + searchTerm = '', }: ExtensionSectionProps) { const { getExtensions, addExtension, removeExtension, extensionsList } = useConfig(); const [selectedExtension, setSelectedExtension] = useState(null); @@ -199,6 +201,7 @@ export default function ExtensionsSection({ onToggle={handleExtensionToggle} onConfigure={handleConfigureClick} disableConfiguration={disableConfiguration} + searchTerm={searchTerm} /> {!hideButtons && ( diff --git a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx index 203fb746..1c1b3752 100644 --- a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx +++ b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx @@ -10,6 +10,7 @@ interface ExtensionListProps { onConfigure?: (extension: FixedExtensionEntry) => void; isStatic?: boolean; disableConfiguration?: boolean; + searchTerm?: string; } export default function ExtensionList({ @@ -18,10 +19,26 @@ export default function ExtensionList({ onConfigure, isStatic, disableConfiguration: _disableConfiguration, + searchTerm = '', }: ExtensionListProps) { - // Separate enabled and disabled extensions - const enabledExtensions = extensions.filter((ext) => ext.enabled); - const disabledExtensions = extensions.filter((ext) => !ext.enabled); + const matchesSearch = (extension: FixedExtensionEntry): boolean => { + if (!searchTerm) return true; + + const searchLower = searchTerm.toLowerCase(); + const title = getFriendlyTitle(extension).toLowerCase(); + const name = extension.name.toLowerCase(); + const subtitle = getSubtitle(extension); + const description = subtitle.description?.toLowerCase() || ''; + + return ( + title.includes(searchLower) || name.includes(searchLower) || description.includes(searchLower) + ); + }; + + // Separate enabled and disabled extensions, then filter by search term + const enabledExtensions = extensions.filter((ext) => ext.enabled && matchesSearch(ext)); + const disabledExtensions = extensions.filter((ext) => !ext.enabled && matchesSearch(ext)); + // Sort each group alphabetically by their friendly title const sortedEnabledExtensions = [...enabledExtensions].sort((a, b) => getFriendlyTitle(a).localeCompare(getFriendlyTitle(b)) diff --git a/ui/desktop/src/utils/searchHighlighter.ts b/ui/desktop/src/utils/searchHighlighter.ts index ad3fa45e..610971c5 100644 --- a/ui/desktop/src/utils/searchHighlighter.ts +++ b/ui/desktop/src/utils/searchHighlighter.ts @@ -14,6 +14,7 @@ export class SearchHighlighter { private onMatchesChange?: (count: number) => void; private currentMatchIndex: number = -1; private isScrollingProgrammatically: boolean = false; + private highlightTimeout?: ReturnType; constructor(container: HTMLElement, onMatchesChange?: (count: number) => void) { this.container = container; @@ -33,10 +34,10 @@ export class SearchHighlighter { `; // Find scroll container (look for our custom data attribute first, then fallback to radix) + const searchScrollArea = container.closest('[data-search-scroll-area]'); this.scrollContainer = - container - .closest('[data-search-scroll-area]') - ?.querySelector('[data-radix-scroll-area-viewport]') || + searchScrollArea?.querySelector('[data-radix-scroll-area-viewport]') || + (searchScrollArea as HTMLElement) || container.closest('[data-radix-scroll-area-viewport]'); if (this.scrollContainer) { @@ -72,12 +73,32 @@ export class SearchHighlighter { let shouldUpdate = false; for (const mutation of mutations) { if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { + // Ignore mutations from our own overlay + if (mutation.target === this.overlay || this.overlay.contains(mutation.target as Node)) { + continue; + } + // Ignore mutations that only add/remove our highlight elements + const isOnlyHighlights = Array.from(mutation.addedNodes).every( + (node) => + node instanceof HTMLElement && + (node.classList.contains('search-highlight') || + node.classList.contains('search-highlight-container')) + ); + if (isOnlyHighlights) { + continue; + } shouldUpdate = true; break; } } if (shouldUpdate && this.currentTerm) { - this.highlight(this.currentTerm, this.caseSensitive); + // Debounce the highlight update to avoid rapid re-highlighting + if (this.highlightTimeout) { + clearTimeout(this.highlightTimeout); + } + this.highlightTimeout = setTimeout(() => { + this.highlight(this.currentTerm, this.caseSensitive); + }, 100); } }); this.mutationObserver.observe(container, { childList: true, subtree: true }); @@ -259,6 +280,9 @@ export class SearchHighlighter { } destroy() { + if (this.highlightTimeout) { + clearTimeout(this.highlightTimeout); + } this.resizeObserver.disconnect(); this.mutationObserver.disconnect(); this.overlay.remove();