feat(ui): add search functionality to chat view (#1790)

Co-authored-by: Nahiyan Khan <nahiyan.khan@gmail.com>
This commit is contained in:
Joshua Swigut
2025-03-30 21:54:04 -04:00
committed by GitHub
parent 4eb5a64e6a
commit 326f087637
10 changed files with 534 additions and 6 deletions
+5 -4
View File
@@ -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}
/>
) : (
<ScrollArea ref={scrollRef} className="flex-1" autoScroll>
<div className="px-4">
<ScrollArea ref={scrollRef} className="flex-1 px-4" autoScroll>
<SearchView className="mt-[16px]" scrollAreaRef={scrollRef}>
{filteredMessages.map((message, index) => (
<div key={message.id || index} className="mt-[16px]">
<div key={message.id || index} className="mt-[16px] message-content">
{isUserMessage(message) ? (
<UserMessage message={message} />
) : (
@@ -407,7 +408,7 @@ export default function ChatView({
)}
</div>
))}
</div>
</SearchView>
{error && (
<div className="flex flex-col items-center justify-center p-4">
<div className="text-red-700 dark:text-red-300 bg-red-400/50 p-3 rounded-lg mb-2">
@@ -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<SearchBarProps> = ({
onSearch,
onClose,
onNavigate,
searchResults,
}) => {
const [searchTerm, setSearchTerm] = useState('');
const [caseSensitive, setCaseSensitive] = useState(false);
const [isExiting, setIsExiting] = useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
setSearchTerm(value);
onSearch(value, caseSensitive);
};
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
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 (
<div
className={`fixed top-[36px] left-0 right-0 bg-bgAppInverse text-textProminentInverse z-50 ${
isExiting ? 'search-bar-exit' : 'search-bar-enter'
}`}
>
<div className="flex w-full max-w-5xl mx-auto">
<div className="relative flex flex-1 items-center h-full">
<SearchIcon className="h-4 w-4 text-textSubtleInverse absolute left-3" />
<div className="w-full">
<input
ref={inputRef}
id="search-input"
type="text"
value={searchTerm}
onChange={handleSearch}
onKeyDown={handleKeyDown}
placeholder="Search conversation..."
className="w-full text-sm pl-9 pr-10 py-3 bg-bgAppInverse
placeholder:text-textSubtleInverse focus:outline-none
active:border-borderProminent"
/>
</div>
<div className="absolute right-3 flex h-full items-center justify-center text-sm text-textStandardInverse">
{searchResults && searchResults.count}
</div>
</div>
<div className="flex items-center justify-center h-auto px-4 gap-2">
<button
onClick={toggleCaseSensitive}
className={`flex items-center justify-center case-sensitive-btn px-2 ${
caseSensitive
? 'text-textStandardInverse bg-bgHover'
: 'text-textSubtleInverse hover:text-textStandardInverse'
}`}
title="Case Sensitive"
>
<span className="text-md font-medium">Aa</span>
</button>
<button
onClick={() => handleNavigate('prev')}
className={`p-1 text-textSubtleInverse ${!searchResults || searchResults.count === 0 ? '' : 'hover:text-textStandardInverse'}`}
title="Previous (↑)"
disabled={!searchResults || searchResults.count === 0}
>
<ArrowUp className="h-5 w-5" />
</button>
<button
onClick={() => handleNavigate('next')}
className={`p-1 text-textSubtleInverse ${!searchResults || searchResults.count === 0 ? '' : 'hover:text-textStandardInverse'}`}
title="Next (↓)"
disabled={!searchResults || searchResults.count === 0}
>
<ArrowDown className="h-5 w-5" />
</button>
<button
onClick={handleClose}
className="p-1 text-textSubtleInverse hover:text-textStandardInverse"
title="Close (Esc)"
>
<Close className="h-5 w-5" />
</button>
</div>
</div>
</div>
);
};
@@ -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<ScrollAreaHandle>;
}
/**
* 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<PropsWithChildren<SearchViewProps>> = ({
className = '',
children,
scrollAreaRef,
}) => {
const [isSearchVisible, setIsSearchVisible] = useState(false);
const [searchResults, setSearchResults] = useState<{
currentIndex: number;
count: number;
} | null>(null);
const highlighterRef = React.useRef<SearchHighlighter | null>(null);
const containerRef = React.useRef<HTMLDivElement | null>(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 (
<div ref={containerRef} className={`search-container ${className}`}>
{isSearchVisible && (
<SearchBar
onSearch={handleSearch}
onClose={handleCloseSearch}
onNavigate={navigateResults}
searchResults={searchResults}
/>
)}
<div
className={`transition-transform ${isSearchVisible ? 'translate-y-[52px] pb-[52px]' : 'translate-y-0'}`}
>
{children}
</div>
</div>
);
};
@@ -0,0 +1,22 @@
import React from 'react';
export default function ArrowDown({ className = '' }) {
return (
<svg
width="1.5rem"
height="1.5rem"
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
aria-hidden="true"
className={className}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 5.5a1 1 0 0 1 1 1v9.394l2.757-3.063a1 1 0 1 1 1.486 1.338l-4.5 5a1 1 0 0 1-1.486 0l-4.5-5a1 1 0 0 1 1.486-1.338L11 15.894V6.5a1 1 0 0 1 1-1Z"
fill="currentColor"
></path>
</svg>
);
}
@@ -0,0 +1,22 @@
import React from 'react';
export default function ArrowUp({ className = '' }) {
return (
<svg
width="1.5rem"
height="1.5rem"
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
aria-hidden="true"
className={className}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 19.5a1 1 0 0 0 1-1V9.106l2.757 3.063a1 1 0 1 0 1.486-1.338l-4.5-5a1 1 0 0 0-1.486 0l-4.5 5a1 1 0 0 0 1.486 1.338L11 9.106V18.5a1 1 0 0 0 1 1Z"
fill="currentColor"
></path>
</svg>
);
}
@@ -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,
+18 -2
View File
@@ -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<typeof ScrollAreaPrimitive.Root> {
@@ -30,13 +33,26 @@ const ScrollArea = React.forwardRef<ScrollAreaHandle, ScrollAreaProps>(
}
}, []);
// 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
+4
View File
@@ -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 */
+42
View File
@@ -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;
}
+108
View File
@@ -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();
}
}
}
}