diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_overview/components/controls/components/logs-search/index.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_overview/components/controls/components/logs-search/index.tsx index 6027e12cb8..33be9d80e9 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/_overview/components/controls/components/logs-search/index.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/_overview/components/controls/components/logs-search/index.tsx @@ -1,6 +1,6 @@ -import { LogsLLMSearch } from "@/components/logs/llm-search"; import { toast } from "@/components/ui/toaster"; import { trpc } from "@/lib/trpc/client"; +import { LLMSearch } from "@unkey/ui"; import { transformStructuredOutputToFilters } from "@unkey/ui"; import { useFilters } from "../../../../hooks/use-filters"; @@ -41,7 +41,7 @@ export const LogsSearch = ({ apiId }: { apiId: string }) => { }); return ( - { }); return ( - { }); return ( - { }; return ( - { }); return ( - { }); return ( - { }); return ( - { @@ -41,7 +41,7 @@ export const LogsSearch = () => { }); return ( - { }); return ( - { }); return ( - { @@ -41,7 +41,7 @@ export const RootKeysSearch = () => { }); return ( - void; + onClear?: () => void; +} + +// Custom hooks +function useSearchState({ delay = 800, onSearch, onClear }: UseSearchStateOptions = {}) { + const [isLoading, setIsLoading] = useState(false); + + const handleSearch = useCallback( + (query: string) => { + setIsLoading(true); + onSearch?.(query); + setTimeout(() => setIsLoading(false), delay); + }, + [delay, onSearch], + ); + + const handleClear = useCallback(() => { + onClear?.(); + }, [onClear]); + + return { isLoading, handleSearch, handleClear }; +} + +function useSearchWithResults() { + const [searchResults, setSearchResults] = useState([]); + + const handleSearch = useCallback((query: string) => { + setSearchResults([`Results for: "${query}"`]); + }, []); + + const handleClear = useCallback(() => { + setSearchResults([]); + }, []); + + return { searchResults, handleSearch, handleClear }; +} + +// Reusable components +function SearchExampleWrapper({ children, className = "w-full max-w-md" }: SearchExampleProps) { + return ( + +
{children}
+
+ ); +} + +function SearchResults({ results }: { results: string[] }) { + if (results.length === 0) { + return null; + } + + return ( +
+

Search Results:

+
    + {results.map((result) => ( +
  • {result}
  • + ))} +
+
+ ); +} + +// Example configurations +const EXAMPLE_QUERIES = { + default: [ + "Show me errors from the last hour", + "Find requests from user ID 12345", + "Display API calls with status 500", + ], + logs: [ + "What's causing the high latency?", + "Show me all authentication failures", + "Find requests from mobile devices", + ], +}; + +// Example components +export function DefaultLLMSearch() { + const { searchResults, handleSearch, handleClear } = useSearchWithResults(); + const { isLoading, handleSearch: handleSearchWithLoading } = useSearchState({ + delay: 1000, + onSearch: handleSearch, + }); + + return ( + + + + + ); +} + +export function LLMSearchWithCustomPlaceholder() { + const { isLoading, handleSearch } = useSearchState({ + delay: 800, + onSearch: (query) => console.log("Searching for:", query), + }); + + return ( + + + + ); +} + +export function LLMSearchWithDebouncedMode() { + const [lastQuery, setLastQuery] = useState(""); + const { isLoading, handleSearch } = useSearchState({ + delay: 500, + onSearch: setLastQuery, + }); + + return ( + + + {lastQuery &&
Last search: "{lastQuery}"
} +
+ ); +} + +export function LLMSearchWithThrottledMode() { + const [searchCount, setSearchCount] = useState(0); + const { isLoading, handleSearch } = useSearchState({ + delay: 400, + onSearch: () => setSearchCount((prev) => prev + 1), + }); + + return ( + + +
Search count: {searchCount}
+
+ ); +} + +export function LLMSearchWithCustomTexts() { + const { isLoading, handleSearch } = useSearchState({ delay: 1200 }); + + return ( + + + + ); +} + +export function LLMSearchWithoutExplainer() { + const { isLoading, handleSearch } = useSearchState({ delay: 800 }); + + return ( + + + + ); +} + +export function LLMSearchWithoutClear() { + const { isLoading, handleSearch } = useSearchState({ delay: 800 }); + + return ( + + + + ); +} + +export function LLMSearchWithKeyboardShortcuts() { + const [lastAction, setLastAction] = useState(""); + const { isLoading, handleSearch, handleClear } = useSearchState({ + delay: 600, + onSearch: (query) => setLastAction(`Searched: "${query}"`), + onClear: () => setLastAction("Cleared search"), + }); + + return ( + + + {lastAction &&
Last action: {lastAction}
} +
+
Keyboard shortcuts:
+
• Press 'S' to focus the search
+
• Press 'Enter' to search
+
• Press 'Esc' to clear
+
+
+ ); +} diff --git a/apps/engineering/content/design/components/search/llm-search.mdx b/apps/engineering/content/design/components/search/llm-search.mdx new file mode 100644 index 0000000000..33890322e3 --- /dev/null +++ b/apps/engineering/content/design/components/search/llm-search.mdx @@ -0,0 +1,181 @@ +--- +title: LLM Search +description: An intelligent search component with AI-powered query suggestions, multiple search modes, and keyboard shortcuts for enhanced user experience. +--- + +import { + DefaultLLMSearch, + LLMSearchWithCustomPlaceholder, + LLMSearchWithDebouncedMode, + LLMSearchWithThrottledMode, + LLMSearchWithCustomTexts, + LLMSearchWithoutExplainer, + LLMSearchWithoutClear, + LLMSearchWithKeyboardShortcuts +} from "./llm-search.examples"; + +## Features + +- **AI-powered search** with intelligent query suggestions +- **Multiple search modes**: manual, debounced, and throttled +- **Keyboard shortcuts** for power users +- **Example queries** to guide users +- **Loading states** with customizable text +- **Accessible design** with proper ARIA attributes +- **Responsive layout** that adapts to different screen sizes + +## Usage + +### With Log Analysis + +```tsx + 1000ms", + "Display authentication failures by user", + ]} + searchMode="debounced" + debounceTime={300} +/> +``` + +### With Analytics Dashboard + +```tsx + +``` + +### With Real-time Monitoring + +```tsx + +``` + +## Basic Usage + +The default LLMSearch includes example queries and standard search functionality. + + + +## Customization + +### Custom Placeholder Text + +Customize the placeholder text to match your application's context. + + + +### Search Modes + +The component supports three different search modes to optimize performance and user experience. + +#### Debounced Mode + +Searches are triggered after the user stops typing for a specified duration. + + + +#### Throttled Mode + +Searches are triggered while the user is typing, with rate limiting to prevent excessive API calls. + + + +### Custom Loading and Clearing Text + +Customize the text displayed during loading and clearing operations. + + + +### Visibility Controls + +Control which UI elements are displayed. + +#### Without Explainer + +Hide the explainer text to save space. + + + +#### Without Clear Button + +Hide the clear button for read-only or controlled search scenarios. + + + +### Keyboard Shortcuts + +The component includes comprehensive keyboard shortcuts for enhanced usability. + + + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `onSearch` | `(query: string) => void` | **Required** | Callback function called when a search is triggered | +| `onClear` | `() => void` | `undefined` | Optional callback function called when search is cleared | +| `isLoading` | `boolean` | **Required** | Whether the search is currently in progress | +| `exampleQueries` | `string[]` | `undefined` | Array of example queries to display as suggestions | +| `placeholder` | `string` | `"Search and filter with AI…"` | Placeholder text for the search input | +| `loadingText` | `string` | `"AI consults the Palantír..."` | Text displayed during loading state | +| `clearingText` | `string` | `"Clearing search..."` | Text displayed during clearing state | +| `searchMode` | `"manual" \| "debounced" \| "allowTypeDuringSearch"` | `"manual"` | The search mode to use | +| `debounceTime` | `number` | `500` | Debounce time in milliseconds (for debounced mode) | +| `hideExplainer` | `boolean` | `false` | Whether to hide the explainer text | +| `hideClear` | `boolean` | `false` | Whether to hide the clear button | + +## Search Modes + +### Manual Mode (`"manual"`) +- Search is triggered only on Enter key press or example query selection +- Best for precise searches where users want full control +- Reduces API calls and provides explicit user intent + +### Debounced Mode (`"debounced"`) +- Search is triggered after the user stops typing for the specified debounce time. +- Balances responsiveness with API efficiency +- Good for real-time search with reasonable rate limiting + +### Throttled Mode (`"allowTypeDuringSearch"`) +- Search is triggered while the user is typing with throttling +- Provides immediate feedback but with controlled API call frequency +- Best for highly responsive search experiences + +## Keyboard Shortcuts + +The component includes several keyboard shortcuts for enhanced usability: + +- **`S` key**: Focus the search input (global shortcut) +- **`Enter`**: Trigger search with current input +- **`Escape`**: Clear search and blur input + +## Accessibility + +The LLMSearch component is built with accessibility in mind: + +- **Keyboard navigation**: Full keyboard support with logical tab order +- **Screen reader support**: Proper ARIA labels and descriptions +- **Focus management**: Clear focus indicators and logical focus flow +- **Loading states**: Accessible loading indicators with descriptive text +- **Error handling**: Clear error messages and recovery options + + diff --git a/apps/engineering/content/design/components/tooltips/timestamp-info.mdx b/apps/engineering/content/design/components/tooltips/timestamp-info.mdx index 251edba0a0..6d5c1d6546 100644 --- a/apps/engineering/content/design/components/tooltips/timestamp-info.mdx +++ b/apps/engineering/content/design/components/tooltips/timestamp-info.mdx @@ -2,7 +2,6 @@ title: TimestampInfo description: A component that renders a timestamp with a tooltip that displays additional information. --- - import { TimestampExampleLocalTime, TimestampExampleUTC, TimestampExampleRelative } from "./timestamp-example" ## TimestampInfo diff --git a/internal/ui/package.json b/internal/ui/package.json index c308fece9b..03d331a9c4 100644 --- a/internal/ui/package.json +++ b/internal/ui/package.json @@ -9,6 +9,7 @@ "license": "AGPL-3.0", "devDependencies": { "@testing-library/react": "^16.2.0", + "@testing-library/react-hooks": "^8.0.1", "@types/node": "^20.14.9", "@types/react": "^18.3.11", "@unkey/tsconfig": "workspace:^", diff --git a/internal/ui/src/components/llm-search/components/search-actions.tsx b/internal/ui/src/components/llm-search/components/search-actions.tsx new file mode 100644 index 0000000000..67baf9ae1d --- /dev/null +++ b/internal/ui/src/components/llm-search/components/search-actions.tsx @@ -0,0 +1,55 @@ +import { XMark } from "@unkey/icons"; +import type React from "react"; +import { SearchExampleTooltip } from "./search-example-tooltip"; + +type SearchActionsProps = { + exampleQueries?: string[]; + searchText: string; + hideClear: boolean; + hideExplainer: boolean; + isProcessing: boolean; + searchMode: "allowTypeDuringSearch" | "debounced" | "manual"; + onClear: () => void; + onSelectExample: (query: string) => void; +}; + +/** + * SearchActions component renders the right-side actions (clear button or examples tooltip) + */ +export const SearchActions: React.FC = ({ + exampleQueries, + searchText, + hideClear, + hideExplainer, + isProcessing, + searchMode, + onClear, + onSelectExample, +}) => { + // Don't render anything if processing (unless in allowTypeDuringSearch mode) + if (isProcessing && searchMode !== "allowTypeDuringSearch") { + return null; + } + + // Render clear button when there's text + if (searchText.length > 0 && !hideClear) { + return ( + + ); + } + + if (searchText.length === 0 && !hideExplainer) { + return ( + + ); + } + + return null; +}; diff --git a/internal/ui/src/components/llm-search/components/search-example-tooltip.tsx b/internal/ui/src/components/llm-search/components/search-example-tooltip.tsx new file mode 100644 index 0000000000..cd429f3361 --- /dev/null +++ b/internal/ui/src/components/llm-search/components/search-example-tooltip.tsx @@ -0,0 +1,52 @@ +import { CaretRightOutline, CircleInfoSparkle } from "@unkey/icons"; +import type React from "react"; +import { InfoTooltip } from "../../info-tooltip"; + +type SearchExampleTooltipProps = { + onSelectExample: (query: string) => void; + exampleQueries?: string[]; +}; + +export const SearchExampleTooltip: React.FC = ({ + onSelectExample, + exampleQueries, +}) => { + const examples = exampleQueries ?? [ + "Show failed requests today", + "auth errors in the last 3h", + "API calls from a path that includes /api/v1/oz", + ]; + + return ( + +
+ Try queries like: + (click to use) +
+
    + {examples.map((example) => ( +
  • + + +
  • + ))} +
+ + } + delayDuration={150} + > +
+ +
+
+ ); +}; diff --git a/internal/ui/src/components/llm-search/components/search-icon.tsx b/internal/ui/src/components/llm-search/components/search-icon.tsx new file mode 100644 index 0000000000..3bd93ec221 --- /dev/null +++ b/internal/ui/src/components/llm-search/components/search-icon.tsx @@ -0,0 +1,13 @@ +import { Magnifier, Refresh3 } from "@unkey/icons"; + +type SearchIconProps = { + isProcessing: boolean; +}; + +export const SearchIcon = ({ isProcessing }: SearchIconProps) => { + if (isProcessing) { + return ; + } + + return ; +}; diff --git a/internal/ui/src/components/llm-search/components/search-input.tsx b/internal/ui/src/components/llm-search/components/search-input.tsx new file mode 100644 index 0000000000..14d7c02e1c --- /dev/null +++ b/internal/ui/src/components/llm-search/components/search-input.tsx @@ -0,0 +1,52 @@ +import type React from "react"; + +type SearchInputProps = { + value: string; + placeholder: string; + isProcessing: boolean; + isLoading: boolean; + loadingText: string; + clearingText: string; + searchMode: "allowTypeDuringSearch" | "debounced" | "manual"; + onChange: (e: React.ChangeEvent) => void; + onKeyDown: (e: React.KeyboardEvent) => void; + inputRef: React.RefObject; +}; + +const LLM_LIMITS_MAX_QUERY_LENGTH = 120; +export const SearchInput = ({ + value, + placeholder, + isProcessing, + isLoading, + loadingText, + clearingText, + searchMode, + onChange, + onKeyDown, + inputRef, +}: SearchInputProps) => { + // Show loading state unless we're in allowTypeDuringSearch mode + if (isProcessing && searchMode !== "allowTypeDuringSearch") { + return ( +
+ {isLoading ? loadingText : clearingText} +
+ ); + } + + return ( + + ); +}; diff --git a/internal/ui/src/components/llm-search/hooks/use-search-strategy.test.tsx b/internal/ui/src/components/llm-search/hooks/use-search-strategy.test.tsx new file mode 100644 index 0000000000..07501d8098 --- /dev/null +++ b/internal/ui/src/components/llm-search/hooks/use-search-strategy.test.tsx @@ -0,0 +1,195 @@ +import { act, renderHook } from "@testing-library/react-hooks"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useSearchStrategy } from "./use-search-strategy"; + +describe("useSearchStrategy", () => { + // Mock timers for debounce/throttle testing + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const onSearchMock = vi.fn(); + + it("should execute search immediately with executeSearch", () => { + const { result } = renderHook(() => useSearchStrategy(onSearchMock, 500)); + + act(() => { + result.current.executeSearch("test query"); + }); + + expect(onSearchMock).toHaveBeenCalledTimes(1); + expect(onSearchMock).toHaveBeenCalledWith("test query"); + }); + + it("should not execute search with empty query", () => { + const { result } = renderHook(() => useSearchStrategy(onSearchMock, 500)); + + act(() => { + result.current.executeSearch(" "); + }); + + expect(onSearchMock).not.toHaveBeenCalled(); + }); + + it("should debounce search calls with debouncedSearch", () => { + const { result } = renderHook(() => useSearchStrategy(onSearchMock, 500)); + + act(() => { + result.current.debouncedSearch("test query"); + }); + + expect(onSearchMock).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(499); + }); + + expect(onSearchMock).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(1); + }); + + expect(onSearchMock).toHaveBeenCalledTimes(1); + expect(onSearchMock).toHaveBeenCalledWith("test query"); + }); + + it("should cancel previous debounce if debouncedSearch is called again", () => { + const { result } = renderHook(() => useSearchStrategy(onSearchMock, 500)); + + act(() => { + result.current.debouncedSearch("first query"); + }); + + act(() => { + vi.advanceTimersByTime(300); + }); + + act(() => { + result.current.debouncedSearch("second query"); + }); + + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(onSearchMock).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(200); + }); + + expect(onSearchMock).toHaveBeenCalledTimes(1); + expect(onSearchMock).toHaveBeenCalledWith("second query"); + expect(onSearchMock).not.toHaveBeenCalledWith("first query"); + }); + + it("should use debounce for initial query with throttledSearch", () => { + const { result } = renderHook(() => useSearchStrategy(onSearchMock, 500)); + + act(() => { + result.current.throttledSearch("initial query"); + }); + + expect(onSearchMock).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(onSearchMock).toHaveBeenCalledTimes(1); + expect(onSearchMock).toHaveBeenCalledWith("initial query"); + }); + + it("should throttle subsequent searches", () => { + const { result } = renderHook(() => useSearchStrategy(onSearchMock, 500)); + + // First search - should be debounced + act(() => { + result.current.throttledSearch("initial query"); + vi.advanceTimersByTime(500); + }); + + expect(onSearchMock).toHaveBeenCalledTimes(1); + + // Reset mock to track subsequent calls + onSearchMock.mockReset(); + + // Second search immediately after - should be throttled + act(() => { + result.current.throttledSearch("second query"); + }); + + // Should not execute immediately due to throttling + expect(onSearchMock).not.toHaveBeenCalled(); + + // Advance time to just before throttle interval ends + act(() => { + vi.advanceTimersByTime(999); + }); + + expect(onSearchMock).not.toHaveBeenCalled(); + + // Complete the throttle interval + act(() => { + vi.advanceTimersByTime(1); + }); + + expect(onSearchMock).toHaveBeenCalledTimes(1); + expect(onSearchMock).toHaveBeenCalledWith("second query"); + }); + + it("should clean up timers with clearDebounceTimer", () => { + const { result } = renderHook(() => useSearchStrategy(onSearchMock, 500)); + + act(() => { + result.current.debouncedSearch("test query"); + }); + + act(() => { + result.current.clearDebounceTimer(); + }); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(onSearchMock).not.toHaveBeenCalled(); + }); + + it("should reset search state with resetSearchState", () => { + const { result } = renderHook(() => useSearchStrategy(onSearchMock, 500)); + + // First search to set initial state + act(() => { + result.current.throttledSearch("initial query"); + vi.advanceTimersByTime(500); + }); + + onSearchMock.mockReset(); + + // Reset search state + act(() => { + result.current.resetSearchState(); + }); + + // Next search should be debounced again, not throttled + act(() => { + result.current.throttledSearch("new query after reset"); + }); + + // Should not execute immediately (debounced, not throttled) + expect(onSearchMock).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(onSearchMock).toHaveBeenCalledTimes(1); + expect(onSearchMock).toHaveBeenCalledWith("new query after reset"); + }); +}); diff --git a/internal/ui/src/components/llm-search/hooks/use-search-strategy.ts b/internal/ui/src/components/llm-search/hooks/use-search-strategy.ts new file mode 100644 index 0000000000..c44ff130a4 --- /dev/null +++ b/internal/ui/src/components/llm-search/hooks/use-search-strategy.ts @@ -0,0 +1,102 @@ +import { useCallback, useRef } from "react"; + +/** + * Custom hook that provides different search strategies + * @param onSearch Function to execute the search + * @param debounceTime Delay for debounce in ms + */ +export const useSearchStrategy = (onSearch: (query: string) => void, debounceTime = 500) => { + const debounceTimerRef = useRef(null); + const lastSearchTimeRef = useRef(0); + const THROTTLE_INTERVAL = 1000; + + /** + * Clears the debounce timer + */ + const clearDebounceTimer = useCallback(() => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + }, []); + + /** + * Executes the search with the given query + */ + const executeSearch = useCallback( + (query: string) => { + if (query.trim()) { + try { + lastSearchTimeRef.current = Date.now(); + onSearch(query.trim()); + } catch (error) { + console.error("Search failed:", error); + } + } + }, + [onSearch], + ); + + /** + * Debounced search - waits for user to stop typing before executing search + */ + const debouncedSearch = useCallback( + (search: string) => { + clearDebounceTimer(); + + debounceTimerRef.current = setTimeout(() => { + executeSearch(search); + }, debounceTime); + }, + [clearDebounceTimer, executeSearch, debounceTime], + ); + + /** + * Throttled search with initial debounce - debounce first query, throttle subsequent searches + */ + + const throttledSearch = useCallback( + (search: string) => { + const now = Date.now(); + const timeElapsed = now - lastSearchTimeRef.current; + const query = search.trim(); + + // If this is the first search, use debounced search + if (lastSearchTimeRef.current === 0 && query) { + debouncedSearch(search); + return; + } + + // For subsequent searches, use throttling + if (timeElapsed >= THROTTLE_INTERVAL) { + // Enough time has passed, execute immediately + executeSearch(search); + } else if (query) { + // Not enough time has passed, schedule for later + clearDebounceTimer(); + + // Schedule execution after remaining throttle time + const remainingTime = THROTTLE_INTERVAL - timeElapsed; + debounceTimerRef.current = setTimeout(() => { + throttledSearch(search); + }, remainingTime); + } + }, + [clearDebounceTimer, debouncedSearch, executeSearch], + ); + + /** + * Resets search state for new search sequences + */ + const resetSearchState = useCallback(() => { + lastSearchTimeRef.current = 0; + }, []); + + return { + debouncedSearch, + throttledSearch, + executeSearch, + clearDebounceTimer, + resetSearchState, + }; +}; diff --git a/internal/ui/src/components/llm-search/index.tsx b/internal/ui/src/components/llm-search/index.tsx new file mode 100644 index 0000000000..e3511503f4 --- /dev/null +++ b/internal/ui/src/components/llm-search/index.tsx @@ -0,0 +1,176 @@ +"use client"; +import type React from "react"; +import { useEffect, useRef, useState } from "react"; +import { useKeyboardShortcut } from "../../hooks/use-keyboard-shortcut"; +import { cn } from "../../lib/utils"; +import { SearchActions } from "./components/search-actions"; +import { SearchIcon } from "./components/search-icon"; +import { SearchInput } from "./components/search-input"; +import { useSearchStrategy } from "./hooks/use-search-strategy"; + +type SearchMode = "allowTypeDuringSearch" | "debounced" | "manual"; + +type Props = { + exampleQueries?: string[]; + onSearch: (query: string) => void; + onClear?: () => void; + placeholder?: string; + isLoading: boolean; + hideExplainer?: boolean; + hideClear?: boolean; + loadingText?: string; + clearingText?: string; + searchMode?: SearchMode; + debounceTime?: number; +}; + +const LLMSearch = ({ + exampleQueries, + onSearch, + isLoading, + onClear, + hideExplainer = false, + hideClear = false, + placeholder = "Search and filter with AI…", + loadingText = "AI consults the Palantír...", + clearingText = "Clearing search...", + searchMode = "manual", + debounceTime = 500, +}: Props) => { + const [searchText, setSearchText] = useState(""); + const [isClearingState, setIsClearingState] = useState(false); + + const inputRef = useRef(null); + + const isProcessing = isLoading || isClearingState; + + const { debouncedSearch, throttledSearch, executeSearch, clearDebounceTimer, resetSearchState } = + useSearchStrategy(onSearch, debounceTime); + useKeyboardShortcut("s", () => { + inputRef.current?.click(); + inputRef.current?.focus(); + }); + + const handleClear = () => { + clearDebounceTimer(); + setIsClearingState(true); + + // Defer to next tick to ensure state updates are batched properly + setTimeout(() => { + onClear?.(); + setSearchText(""); + setIsClearingState(false); + }, 0); + + resetSearchState(); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value; + const wasFilled = searchText !== ""; + + setSearchText(value); + + // Handle clearing + if (wasFilled && value === "") { + handleClear(); + return; + } + + // Skip if empty + if (value === "") { + return; + } + + // Apply appropriate search strategy based on mode + switch (searchMode) { + case "allowTypeDuringSearch": + throttledSearch(value); + break; + case "debounced": + debouncedSearch(value); + break; + case "manual": + // Do nothing - search triggered on Enter key or preset click + break; + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + setSearchText(""); + handleClear(); + inputRef.current?.blur(); + } + + if (e.key === "Enter") { + e.preventDefault(); + if (searchText !== "") { + executeSearch(searchText); + } else { + handleClear(); + } + } + }; + + const handlePresetQuery = (query: string) => { + setSearchText(query); + executeSearch(query); + }; + + // Clean up timers on unmount + // biome-ignore lint/correctness/useExhaustiveDependencies: clearDebounceTimer is stable and doesn't need to be in dependencies + useEffect(() => { + return clearDebounceTimer(); + }, []); + + return ( +
+
0 ? "bg-gray-4" : "", + isProcessing ? "bg-gray-4" : "", + )} + > +
+
+ +
+ +
+ +
+
+ + +
+
+ ); +}; + +LLMSearch.displayName = "LLMSearch"; +export { LLMSearch }; diff --git a/internal/ui/src/index.ts b/internal/ui/src/index.ts index 34d159ee50..28b574ed2b 100644 --- a/internal/ui/src/index.ts +++ b/internal/ui/src/index.ts @@ -18,6 +18,7 @@ export * from "./components/id"; export * from "./components/info-tooltip"; export * from "./components/inline-link"; export * from "./components/loading"; +export * from "./components/llm-search"; export * from "./components/settings-card"; export * from "./components/timestamp-info"; export * from "./components/tooltip"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b785b3770b..f9fb8e52ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -968,6 +968,9 @@ importers: '@testing-library/react': specifier: ^16.2.0 version: 16.2.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@testing-library/react-hooks': + specifier: ^8.0.1 + version: 8.0.1(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@types/node': specifier: ^20.14.9 version: 20.14.9