From 326f087637cbdb47d2533d1f855100363152be1b Mon Sep 17 00:00:00 2001 From: Joshua Swigut <62301576+JJSwigut@users.noreply.github.com> Date: Sun, 30 Mar 2025 21:54:04 -0400 Subject: [PATCH] feat(ui): add search functionality to chat view (#1790) Co-authored-by: Nahiyan Khan --- ui/desktop/src/components/ChatView.tsx | 9 +- .../src/components/conversation/SearchBar.tsx | 153 +++++++++++++++++ .../components/conversation/SearchView.tsx | 156 ++++++++++++++++++ ui/desktop/src/components/icons/ArrowDown.tsx | 22 +++ ui/desktop/src/components/icons/ArrowUp.tsx | 22 +++ ui/desktop/src/components/icons/index.tsx | 4 + ui/desktop/src/components/ui/scroll-area.tsx | 20 ++- ui/desktop/src/styles/main.css | 4 + ui/desktop/src/styles/search.css | 42 +++++ ui/desktop/src/utils/searchHighlighter.ts | 108 ++++++++++++ 10 files changed, 534 insertions(+), 6 deletions(-) create mode 100644 ui/desktop/src/components/conversation/SearchBar.tsx create mode 100644 ui/desktop/src/components/conversation/SearchView.tsx create mode 100644 ui/desktop/src/components/icons/ArrowDown.tsx create mode 100644 ui/desktop/src/components/icons/ArrowUp.tsx create mode 100644 ui/desktop/src/styles/search.css create mode 100644 ui/desktop/src/utils/searchHighlighter.ts diff --git a/ui/desktop/src/components/ChatView.tsx b/ui/desktop/src/components/ChatView.tsx index 3c7694cd..3815a43d 100644 --- a/ui/desktop/src/components/ChatView.tsx +++ b/ui/desktop/src/components/ChatView.tsx @@ -11,6 +11,7 @@ import { Card } from './ui/card'; import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area'; import UserMessage from './UserMessage'; import Splash from './Splash'; +import { SearchView } from './conversation/SearchView'; import { DeepLinkModal } from './ui/DeepLinkModal'; import 'react-toastify/dist/ReactToastify.css'; import { useMessageStream } from '../hooks/useMessageStream'; @@ -386,10 +387,10 @@ export default function ChatView({ activities={botConfig?.activities || null} /> ) : ( - -
+ + {filteredMessages.map((message, index) => ( -
+
{isUserMessage(message) ? ( ) : ( @@ -407,7 +408,7 @@ export default function ChatView({ )}
))} -
+
{error && (
diff --git a/ui/desktop/src/components/conversation/SearchBar.tsx b/ui/desktop/src/components/conversation/SearchBar.tsx new file mode 100644 index 00000000..81b8129a --- /dev/null +++ b/ui/desktop/src/components/conversation/SearchBar.tsx @@ -0,0 +1,153 @@ +import React, { useEffect, KeyboardEvent, useState } from 'react'; +import { Search as SearchIcon } from 'lucide-react'; +import { ArrowDown, ArrowUp, Close } from '../icons'; + +/** + * Props for the SearchBar component + */ +interface SearchBarProps { + /** Callback fired when search term or case sensitivity changes */ + onSearch: (term: string, caseSensitive: boolean) => void; + /** Callback fired when the search bar is closed */ + onClose: () => void; + /** Optional callback for navigating between search results */ + onNavigate?: (direction: 'next' | 'prev') => void; + /** Current search results state */ + searchResults?: { + count: number; + currentIndex: number; + }; +} + +/** + * 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 + */ +export const SearchBar: React.FC = ({ + onSearch, + onClose, + onNavigate, + searchResults, +}) => { + const [searchTerm, setSearchTerm] = useState(''); + const [caseSensitive, setCaseSensitive] = useState(false); + const [isExiting, setIsExiting] = useState(false); + const inputRef = React.useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const handleSearch = (event: React.ChangeEvent) => { + const value = event.target.value; + setSearchTerm(value); + onSearch(value, caseSensitive); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'ArrowUp') { + event.preventDefault(); + onNavigate?.('prev'); + } else if (event.key === 'ArrowDown') { + event.preventDefault(); + onNavigate?.('next'); + } else if (event.key === 'Escape') { + event.preventDefault(); + handleClose(); + } + }; + + const handleNavigate = (direction: 'next' | 'prev') => { + onNavigate?.(direction); + inputRef.current?.focus(); + }; + + const toggleCaseSensitive = () => { + setCaseSensitive(!caseSensitive); + onSearch(searchTerm, !caseSensitive); + inputRef.current?.focus(); + }; + + const handleClose = () => { + setIsExiting(true); + setTimeout(() => { + onClose(); + }, 150); // Match animation duration + }; + + return ( +
+
+
+ +
+ +
+ +
+ {searchResults && searchResults.count} +
+
+ +
+ + + + + + +
+
+
+ ); +}; diff --git a/ui/desktop/src/components/conversation/SearchView.tsx b/ui/desktop/src/components/conversation/SearchView.tsx new file mode 100644 index 00000000..221bff51 --- /dev/null +++ b/ui/desktop/src/components/conversation/SearchView.tsx @@ -0,0 +1,156 @@ +import React, { useState, useEffect, PropsWithChildren } from 'react'; +import { SearchBar } from './SearchBar'; +import { SearchHighlighter } from '../../utils/searchHighlighter'; +import { ScrollAreaHandle } from '../ui/scroll-area'; +import '../../styles/search.css'; + +/** + * Props for the SearchView component + */ +interface SearchViewProps { + /** Optional CSS class name */ + className?: string; + /** Reference to the scroll area for navigation */ + scrollAreaRef?: React.RefObject; +} + +/** + * 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. + */ +export const SearchView: React.FC> = ({ + className = '', + children, + scrollAreaRef, +}) => { + const [isSearchVisible, setIsSearchVisible] = useState(false); + const [searchResults, setSearchResults] = useState<{ + currentIndex: number; + count: number; + } | null>(null); + + const highlighterRef = React.useRef(null); + const containerRef = React.useRef(null); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'f') { + e.preventDefault(); + setIsSearchVisible(true); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, []); + + const handleSearch = (term: string, caseSensitive: boolean) => { + if (!term) { + setSearchResults(null); + clearHighlights(); + return; + } + + const container = containerRef.current; + if (!container) return; + + if (!highlighterRef.current) { + highlighterRef.current = new SearchHighlighter(container); + } + + highlighterRef.current.clearHighlights(); + highlighterRef.current.highlight(term, caseSensitive); + + const marks = container.querySelectorAll('mark'); + const count = marks.length; + + if (count > 0) { + setSearchResults({ + currentIndex: 1, + count: count, + }); + scrollToMatch(0); + } else { + setSearchResults(null); + } + }; + + const navigateResults = (direction: 'next' | 'prev') => { + if (!searchResults || searchResults.count === 0) return; + + let newIndex: number; + const currentIdx = searchResults.currentIndex - 1; // Convert to 0-based + + if (direction === 'next') { + newIndex = (currentIdx + 1) % searchResults.count; + } else { + newIndex = (currentIdx - 1 + searchResults.count) % searchResults.count; + } + + setSearchResults({ + ...searchResults, + currentIndex: newIndex + 1, + }); + + scrollToMatch(newIndex); + }; + + const scrollToMatch = (index: number) => { + if (!containerRef.current || !scrollAreaRef?.current) return; + + const marks = containerRef.current.querySelectorAll('mark'); + const mark = marks[index] as HTMLElement; + + if (mark) { + // Update highlight + marks.forEach((m) => m.classList.remove('current')); + mark.classList.add('current'); + + // Calculate position to center the mark in the viewport + const markRect = mark.getBoundingClientRect(); + const viewportRect = mark + .closest('[data-radix-scroll-area-viewport]') + ?.getBoundingClientRect(); + + if (viewportRect) { + const targetPosition = mark.offsetTop - viewportRect.height / 2 + markRect.height / 2; + scrollAreaRef.current.scrollToPosition({ + top: targetPosition, + behavior: 'smooth', + }); + } + } + }; + + const clearHighlights = () => { + if (highlighterRef.current) { + highlighterRef.current.clearHighlights(); + } + }; + + const handleCloseSearch = () => { + setIsSearchVisible(false); + setSearchResults(null); + clearHighlights(); + }; + + return ( +
+ {isSearchVisible && ( + + )} +
+ {children} +
+
+ ); +}; diff --git a/ui/desktop/src/components/icons/ArrowDown.tsx b/ui/desktop/src/components/icons/ArrowDown.tsx new file mode 100644 index 00000000..9a72d8c4 --- /dev/null +++ b/ui/desktop/src/components/icons/ArrowDown.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +export default function ArrowDown({ className = '' }) { + return ( + + ); +} diff --git a/ui/desktop/src/components/icons/ArrowUp.tsx b/ui/desktop/src/components/icons/ArrowUp.tsx new file mode 100644 index 00000000..0bef76a8 --- /dev/null +++ b/ui/desktop/src/components/icons/ArrowUp.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +export default function ArrowUp({ className = '' }) { + return ( + + ); +} diff --git a/ui/desktop/src/components/icons/index.tsx b/ui/desktop/src/components/icons/index.tsx index d62d48de..e256c496 100644 --- a/ui/desktop/src/components/icons/index.tsx +++ b/ui/desktop/src/components/icons/index.tsx @@ -1,3 +1,5 @@ +import ArrowDown from './ArrowDown'; +import ArrowUp from './ArrowUp'; import Attach from './Attach'; import Back from './Back'; import ChatSmart from './ChatSmart'; @@ -19,6 +21,8 @@ import Time from './Time'; import { Gear } from './Gear'; export { + ArrowDown, + ArrowUp, Attach, Back, ChatSmart, diff --git a/ui/desktop/src/components/ui/scroll-area.tsx b/ui/desktop/src/components/ui/scroll-area.tsx index ed618280..a994f8f3 100644 --- a/ui/desktop/src/components/ui/scroll-area.tsx +++ b/ui/desktop/src/components/ui/scroll-area.tsx @@ -1,10 +1,13 @@ import * as React from 'react'; import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'; +type ScrollBehavior = 'auto' | 'smooth'; + import { cn } from '../../utils'; export interface ScrollAreaHandle { scrollToBottom: () => void; + scrollToPosition: (options: { top: number; behavior?: ScrollBehavior }) => void; } interface ScrollAreaProps extends React.ComponentPropsWithoutRef { @@ -30,13 +33,26 @@ const ScrollArea = React.forwardRef( } }, []); - // Expose the scrollToBottom method to parent components + const scrollToPosition = React.useCallback( + ({ top, behavior = 'smooth' }: { top: number; behavior?: ScrollBehavior }) => { + if (viewportRef.current) { + viewportRef.current.scrollTo({ + top, + behavior, + }); + } + }, + [] + ); + + // Expose the scroll methods to parent components React.useImperativeHandle( ref, () => ({ scrollToBottom, + scrollToPosition, }), - [scrollToBottom] + [scrollToBottom, scrollToPosition] ); // Handle scroll events to update isFollowing state diff --git a/ui/desktop/src/styles/main.css b/ui/desktop/src/styles/main.css index 2fcf0167..7fd5a2ef 100644 --- a/ui/desktop/src/styles/main.css +++ b/ui/desktop/src/styles/main.css @@ -117,7 +117,9 @@ --text-placeholder: var(--grey-60); --text-prominent: var(--grey-10); --text-standard: var(--grey-20); + --text-standard-inverse: var(--dark-grey-90); --text-subtle: var(--grey-50); + --text-subtle-inverse: var(--dark-grey-60); --text-prominent-inverse: var(--constant-white); &.dark { @@ -144,7 +146,9 @@ --text-placeholder: var(--dark-grey-45); --text-prominent: var(--constant-white); --text-standard: var(--dark-grey-90); + --text-standard-inverse: var(--grey-20); --text-subtle: var(--dark-grey-60); + --text-subtle-inverse: var(--grey-50); --text-prominent-inverse: var(--grey-20); } /* end arcade colors */ diff --git a/ui/desktop/src/styles/search.css b/ui/desktop/src/styles/search.css new file mode 100644 index 00000000..2531c6a4 --- /dev/null +++ b/ui/desktop/src/styles/search.css @@ -0,0 +1,42 @@ +/* Search highlight styles */ +mark { + background-color: rgba(255, 213, 0, 0.5); + color: var(--text-standard); + padding: 0; + border-radius: 2px; +} + +mark.current { + background-color: rgba(252, 213, 3, 0.6); + box-shadow: inset 0 0 0 1px var(--text-prominent); +} + +@keyframes expandDown { + from { + max-height: 0; + } + to { + max-height: var(--search-bar-height); + } +} + +@keyframes collapseUp { + from { + max-height: var(--search-bar-height); + } + to { + max-height: 0; + } +} + +.search-bar-enter { + --search-bar-height: 72px; + animation: expandDown 0.15s ease-out forwards; + overflow: hidden; +} + +.search-bar-exit { + --search-bar-height: 72px; + animation: collapseUp 0.15s ease-out forwards; + overflow: hidden; +} diff --git a/ui/desktop/src/utils/searchHighlighter.ts b/ui/desktop/src/utils/searchHighlighter.ts new file mode 100644 index 00000000..aa3184cd --- /dev/null +++ b/ui/desktop/src/utils/searchHighlighter.ts @@ -0,0 +1,108 @@ +/* eslint-disable no-cond-assign */ +/** + * Utility class for highlighting search matches in text content. + * Supports case-sensitive search and maintains a "current" highlight state. + */ +export class SearchHighlighter { + private readonly container: HTMLElement; + + /** + * Creates a new SearchHighlighter instance. + * @param container - The root HTML element to search within + */ + constructor(container: HTMLElement) { + this.container = container; + } + + /** + * 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 + */ + highlight(term: string, caseSensitive = false): void { + this.clearHighlights(); + + if (!term.trim()) return; + + const walker = document.createTreeWalker(this.container, window.NodeFilter.SHOW_TEXT, { + acceptNode: (node: Node): number => { + // Check if this node or any of its ancestors have the excluded classes + let element = node.parentElement; + while (element) { + if ( + element.classList.contains('search-input') || + element.classList.contains('search-results') || + element.classList.contains('case-sensitive-btn') + ) { + return window.NodeFilter.FILTER_REJECT; + } + element = element.parentElement; + } + return window.NodeFilter.FILTER_ACCEPT; + }, + }); + + const matches: Node[] = []; + let node: Node | null; + + // Find all text nodes containing the search term + while ((node = walker.nextNode())) { + const nodeText = node.textContent || ''; + const searchText = caseSensitive ? nodeText : nodeText.toLowerCase(); + const searchTerm = caseSensitive ? term : term.toLowerCase(); + + if (searchText.includes(searchTerm)) { + matches.push(node); + } + } + + // Highlight matches + matches.forEach((textNode) => { + const text = textNode.textContent || ''; + const searchTerm = caseSensitive ? term : term.toLowerCase(); + const regex = new RegExp( + searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), + caseSensitive ? 'g' : 'gi' + ); + const fragment = document.createDocumentFragment(); + let lastIndex = 0; + let match; + + while ((match = regex.exec(text)) !== null) { + // Add text before the match + if (match.index > lastIndex) { + fragment.appendChild(document.createTextNode(text.slice(lastIndex, match.index))); + } + + // Add highlighted match + const mark = document.createElement('mark'); + mark.textContent = match[0]; // Use the actual matched text to preserve case + fragment.appendChild(mark); + + lastIndex = regex.lastIndex; + } + + // Add remaining text + if (lastIndex < text.length) { + fragment.appendChild(document.createTextNode(text.slice(lastIndex))); + } + + textNode.parentNode?.replaceChild(fragment, textNode); + }); + } + + /** + * Removes all search highlights from the container + */ + clearHighlights(): void { + const marks = this.container.getElementsByTagName('mark'); + while (marks.length > 0) { + const mark = marks[0]; + const parent = mark.parentNode; + if (parent) { + parent.replaceChild(document.createTextNode(mark.textContent || ''), mark); + parent.normalize(); + } + } + } +}