fix(tool-input): search fixed

This commit is contained in:
Emir Karabeg
2025-03-04 09:28:57 -08:00
parent 2472e28f85
commit 790d73d40b
4 changed files with 413 additions and 73 deletions
@@ -347,7 +347,7 @@ export function CustomToolModal({
<DialogHeader className="px-6 py-4 border-b">
<div className="flex items-center justify-between">
<DialogTitle className="text-lg font-medium">
{isEditing ? 'Edit AgentTool' : 'Create Agent Tool'}
{isEditing ? 'Edit Agent Tool' : 'Create Agent Tool'}
</DialogTitle>
<Button variant="ghost" size="icon" className="h-8 w-8 p-0" onClick={handleClose}>
<X className="h-4 w-4" />
@@ -0,0 +1,331 @@
import React, {
ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { Search } from 'lucide-react'
import { cn } from '@/lib/utils'
// Context for the command component
type CommandContextType = {
searchQuery: string
setSearchQuery: (value: string) => void
activeIndex: number
setActiveIndex: (index: number) => void
filteredItems: string[]
registerItem: (id: string) => void
unregisterItem: (id: string) => void
selectItem: (id: string) => void
}
const CommandContext = createContext<CommandContextType | undefined>(undefined)
// Hook to use the command context
const useCommandContext = () => {
const context = useContext(CommandContext)
if (!context) {
throw new Error('Command components must be used within a CommandProvider')
}
return context
}
// Types for the components
interface CommandProps {
children: ReactNode
className?: string
filter?: (value: string, search: string) => number
}
interface CommandInputProps {
placeholder?: string
className?: string
onValueChange?: (value: string) => void
}
interface CommandListProps {
children: ReactNode
className?: string
}
interface CommandEmptyProps {
children: ReactNode
className?: string
}
interface CommandGroupProps {
children: ReactNode
className?: string
heading?: string
}
interface CommandItemProps {
children: ReactNode
className?: string
value: string
onSelect?: () => void
disabled?: boolean
}
interface CommandSeparatorProps {
className?: string
}
// Main Command component
export function Command({ children, className, filter }: CommandProps) {
const [searchQuery, setSearchQuery] = useState('')
const [activeIndex, setActiveIndex] = useState(-1)
const [items, setItems] = useState<string[]>([])
const [filteredItems, setFilteredItems] = useState<string[]>([])
// Register and unregister items - memoize to prevent infinite loops
const registerItem = useCallback((id: string) => {
setItems((prev) => {
// Only add if not already in the array
if (prev.includes(id)) return prev
return [...prev, id]
})
}, [])
const unregisterItem = useCallback((id: string) => {
setItems((prev) => prev.filter((item) => item !== id))
}, [])
// Handle item selection
const selectItem = useCallback(
(id: string) => {
const index = filteredItems.indexOf(id)
if (index >= 0) {
setActiveIndex(index)
}
},
[filteredItems]
)
// Filter items based on search query
useEffect(() => {
if (!searchQuery) {
setFilteredItems(items)
return
}
const filtered = items
.map((item) => {
const score = filter ? filter(item, searchQuery) : defaultFilter(item, searchQuery)
return { item, score }
})
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score)
.map((item) => item.item)
setFilteredItems(filtered)
setActiveIndex(filtered.length > 0 ? 0 : -1)
}, [searchQuery, items, filter])
// Default filter function
const defaultFilter = useCallback((value: string, search: string): number => {
const normalizedValue = value.toLowerCase()
const normalizedSearch = search.toLowerCase()
if (normalizedValue === normalizedSearch) return 1
if (normalizedValue.startsWith(normalizedSearch)) return 0.8
if (normalizedValue.includes(normalizedSearch)) return 0.6
return 0
}, [])
// Handle keyboard navigation
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (filteredItems.length === 0) return
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setActiveIndex((prev) => (prev + 1) % filteredItems.length)
break
case 'ArrowUp':
e.preventDefault()
setActiveIndex((prev) => (prev - 1 + filteredItems.length) % filteredItems.length)
break
case 'Enter':
if (activeIndex >= 0) {
e.preventDefault()
document.getElementById(filteredItems[activeIndex])?.click()
}
break
}
},
[filteredItems, activeIndex]
)
// Memoize context value to prevent unnecessary re-renders
const contextValue = useMemo(
() => ({
searchQuery,
setSearchQuery,
activeIndex,
setActiveIndex,
filteredItems,
registerItem,
unregisterItem,
selectItem,
}),
[searchQuery, activeIndex, filteredItems, registerItem, unregisterItem, selectItem]
)
return (
<CommandContext.Provider value={contextValue}>
<div
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5',
className
)}
onKeyDown={handleKeyDown}
>
{children}
</div>
</CommandContext.Provider>
)
}
// Command Input component
export function CommandInput({
placeholder = 'Search...',
className,
onValueChange,
}: CommandInputProps) {
const { searchQuery, setSearchQuery } = useCommandContext()
const inputRef = useRef<HTMLInputElement>(null)
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setSearchQuery(value)
onValueChange?.(value)
}
// Focus input on mount
useEffect(() => {
inputRef.current?.focus()
}, [])
return (
<div className="flex items-center border-b px-3">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<input
ref={inputRef}
className={cn(
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className
)}
placeholder={placeholder}
value={searchQuery}
onChange={handleChange}
/>
</div>
)
}
// Command List component
export function CommandList({ children, className }: CommandListProps) {
return (
<div className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}>
{children}
</div>
)
}
// Command Empty component
export function CommandEmpty({ children, className }: CommandEmptyProps) {
const { filteredItems } = useCommandContext()
if (filteredItems.length > 0) return null
return (
<div className={cn('pt-3.5 pb-2 text-center text-sm text-muted-foreground', className)}>
{children}
</div>
)
}
// Command Group component
export function CommandGroup({ children, className, heading }: CommandGroupProps) {
return (
<div
className={cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
className
)}
>
{heading && (
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">{heading}</div>
)}
{children}
</div>
)
}
// Command Item component
export function CommandItem({
children,
className,
value,
onSelect,
disabled = false,
}: CommandItemProps) {
const { activeIndex, filteredItems, registerItem, unregisterItem } = useCommandContext()
const isActive = filteredItems.indexOf(value) === activeIndex
const [isHovered, setIsHovered] = useState(false)
// Register and unregister item
useEffect(() => {
// Only register if value is defined
if (value) {
registerItem(value)
return () => unregisterItem(value)
}
}, [value, registerItem, unregisterItem])
// Check if item should be displayed based on search
const shouldDisplay = filteredItems.includes(value)
if (!shouldDisplay) return null
return (
<button
id={value}
className={cn(
'relative flex w-full cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 hover:bg-accent hover:text-accent-foreground',
isActive && 'bg-accent text-accent-foreground',
className
)}
onClick={() => !disabled && onSelect?.()}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
data-selected={isActive || isHovered}
data-disabled={disabled}
disabled={disabled}
>
{children}
</button>
)
}
// Command Separator component
export function CommandSeparator({ className }: CommandSeparatorProps) {
return <div className={cn('-mx-1 h-px bg-border', className)} />
}
// Export all components
export const ToolCommand = {
Root: Command,
Input: CommandInput,
List: CommandList,
Empty: CommandEmpty,
Group: CommandGroup,
Item: CommandItem,
Separator: CommandSeparator,
}
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useCallback, useState } from 'react'
import { PencilIcon, PlusIcon, WrenchIcon, XIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
@@ -26,6 +26,7 @@ import { getTool } from '@/tools'
import { useSubBlockValue } from '../../hooks/use-sub-block-value'
import { ShortInput } from '../short-input'
import { CustomTool, CustomToolModal } from './components/custom-tool-modal'
import { ToolCommand } from './components/tool-command'
interface ToolInputProps {
blockId: string
@@ -126,6 +127,26 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
const toolBlocks = getAllBlocks().filter((block) => block.category === 'tools')
// Custom filter function for the Command component
const customFilter = useCallback((value: string, search: string) => {
if (!search.trim()) return 1
const normalizedValue = value.toLowerCase()
const normalizedSearch = search.toLowerCase()
// Exact match gets highest priority
if (normalizedValue === normalizedSearch) return 1
// Starts with search term gets high priority
if (normalizedValue.startsWith(normalizedSearch)) return 0.8
// Contains search term gets medium priority
if (normalizedValue.includes(normalizedSearch)) return 0.6
// No match
return 0
}, [])
const selectedTools: StoredTool[] =
Array.isArray(value) && value.length > 0 && typeof value[0] === 'object'
? (value as unknown as StoredTool[])
@@ -300,12 +321,13 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
</div>
</PopoverTrigger>
<PopoverContent className="p-0 w-[200px]" align="start">
<Command>
<CommandInput placeholder="Search tools..." />
<CommandList>
<CommandEmpty>No tools found.</CommandEmpty>
<CommandGroup>
<CommandItem
<ToolCommand.Root filter={customFilter}>
<ToolCommand.Input placeholder="Search tools..." />
<ToolCommand.List>
<ToolCommand.Empty>No tools found</ToolCommand.Empty>
<ToolCommand.Group>
<ToolCommand.Item
value="Create Tool"
onSelect={() => {
setOpen(false)
setCustomToolModalOpen(true)
@@ -316,19 +338,20 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
<WrenchIcon className="w-4 h-4 text-muted-foreground" />
</div>
<span>Create Tool</span>
</CommandItem>
</ToolCommand.Item>
{/* Display saved custom tools at the top */}
{customTools.length > 0 && (
<>
<CommandSeparator />
<ToolCommand.Separator />
<div className="px-2 pt-2.5 pb-0.5 text-xs font-medium text-muted-foreground">
Custom Tools
</div>
<CommandGroup className="-mx-1 -px-1">
<ToolCommand.Group className="-mx-1 -px-1">
{customTools.map((customTool) => (
<CommandItem
<ToolCommand.Item
key={customTool.id}
value={customTool.title}
onSelect={() => {
const newTool: StoredTool = {
type: 'custom-tool',
@@ -363,10 +386,10 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
<WrenchIcon className="w-4 h-4 text-white" />
</div>
<span className="truncate max-w-[140px]">{customTool.title}</span>
</CommandItem>
</ToolCommand.Item>
))}
</CommandGroup>
<CommandSeparator />
</ToolCommand.Group>
<ToolCommand.Separator />
</>
)}
@@ -374,10 +397,11 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
<div className="px-2 pt-2.5 pb-0.5 text-xs font-medium text-muted-foreground">
Built-in Tools
</div>
<CommandGroup className="-mx-1 -px-1">
<ToolCommand.Group className="-mx-1 -px-1">
{toolBlocks.map((block) => (
<CommandItem
<ToolCommand.Item
key={block.type}
value={block.name}
onSelect={() => handleSelectTool(block)}
className="flex items-center gap-2 cursor-pointer"
>
@@ -388,12 +412,12 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
<IconComponent icon={block.icon} className="w-4 h-4 text-white" />
</div>
<span className="truncate max-w-[140px]">{block.name}</span>
</CommandItem>
</ToolCommand.Item>
))}
</CommandGroup>
</CommandGroup>
</CommandList>
</Command>
</ToolCommand.Group>
</ToolCommand.Group>
</ToolCommand.List>
</ToolCommand.Root>
</PopoverContent>
</Popover>
) : (
@@ -540,12 +564,13 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[200px]" align="start">
<Command>
<CommandInput placeholder="Search tools..." />
<CommandList>
<CommandEmpty>No tools found.</CommandEmpty>
<CommandGroup>
<CommandItem
<ToolCommand.Root filter={customFilter}>
<ToolCommand.Input placeholder="Search tools..." />
<ToolCommand.List>
<ToolCommand.Empty>No tools found.</ToolCommand.Empty>
<ToolCommand.Group>
<ToolCommand.Item
value="Create Tool"
onSelect={() => {
setOpen(false)
setCustomToolModalOpen(true)
@@ -556,19 +581,20 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
<WrenchIcon className="w-4 h-4 text-muted-foreground" />
</div>
<span>Create Tool</span>
</CommandItem>
</ToolCommand.Item>
{/* Display saved custom tools at the top */}
{customTools.length > 0 && (
<>
<CommandSeparator />
<ToolCommand.Separator />
<div className="px-2 pt-2.5 pb-0.5 text-xs font-medium text-muted-foreground">
Custom Tools
</div>
<CommandGroup className="-mx-1 -px-1">
<ToolCommand.Group className="-mx-1 -px-1">
{customTools.map((customTool) => (
<CommandItem
<ToolCommand.Item
key={customTool.id}
value={customTool.title}
onSelect={() => {
const newTool: StoredTool = {
type: 'custom-tool',
@@ -606,10 +632,10 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
<WrenchIcon className="w-4 h-4 text-white" />
</div>
<span className="truncate max-w-[140px]">{customTool.title}</span>
</CommandItem>
</ToolCommand.Item>
))}
</CommandGroup>
<CommandSeparator />
</ToolCommand.Group>
<ToolCommand.Separator />
</>
)}
@@ -617,10 +643,11 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
<div className="px-2 pt-2.5 pb-0.5 text-xs font-medium text-muted-foreground">
Built-in Tools
</div>
<CommandGroup className="-mx-1 -px-1">
<ToolCommand.Group className="-mx-1 -px-1">
{toolBlocks.map((block) => (
<CommandItem
<ToolCommand.Item
key={block.type}
value={block.name}
onSelect={() => handleSelectTool(block)}
className="flex items-center gap-2 cursor-pointer"
>
@@ -631,27 +658,29 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
<IconComponent icon={block.icon} className="w-4 h-4 text-white" />
</div>
<span className="truncate max-w-[140px]">{block.name}</span>
</CommandItem>
</ToolCommand.Item>
))}
</CommandGroup>
</CommandGroup>
</CommandList>
</Command>
</ToolCommand.Group>
</ToolCommand.Group>
</ToolCommand.List>
</ToolCommand.Root>
</PopoverContent>
</Popover>
</div>
)}
{/* Custom Tool Modal */}
<CustomToolModal
open={customToolModalOpen}
onOpenChange={(open) => {
setCustomToolModalOpen(open)
if (!open) setEditingToolIndex(null)
}}
onSave={handleSaveCustomTool}
onSave={editingToolIndex !== null ? handleSaveCustomTool : handleAddCustomTool}
initialValues={
editingToolIndex !== null && selectedTools[editingToolIndex]?.type === 'custom-tool'
? {
id: '',
schema: selectedTools[editingToolIndex].schema,
code: selectedTools[editingToolIndex].code || '',
}
+10 -30
View File
@@ -18,45 +18,20 @@ import { cn } from '@/lib/utils'
// This file is not typed correctly from shadcn, so we're disabling the type checker
// @ts-nocheck
// This file is not typed correctly from shadcn, so we're disabling the type checker
// @ts-nocheck
// This file is not typed correctly from shadcn, so we're disabling the type checker
// @ts-nocheck
// This file is not typed correctly from shadcn, so we're disabling the type checker
// @ts-nocheck
// This file is not typed correctly from shadcn, so we're disabling the type checker
// @ts-nocheck
// This file is not typed correctly from shadcn, so we're disabling the type checker
// @ts-nocheck
// This file is not typed correctly from shadcn, so we're disabling the type checker
// @ts-nocheck
// This file is not typed correctly from shadcn, so we're disabling the type checker
// @ts-nocheck
// This file is not typed correctly from shadcn, so we're disabling the type checker
// @ts-nocheck
// This file is not typed correctly from shadcn, so we're disabling the type checker
// @ts-nocheck
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive> & {
children?: React.ReactNode
filter?: (value: string, search: string) => number
}
>(({ className, ...props }, ref) => (
>(({ className, filter, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className
)}
filter={filter}
{...props}
/>
))
@@ -78,8 +53,9 @@ const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> & {
placeholder?: string
onValueChange?: (value: string) => void
}
>(({ className, ...props }, ref) => (
>(({ className, onValueChange, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
@@ -89,6 +65,7 @@ const CommandInput = React.forwardRef<
className
)}
{...props}
onChange={(e) => onValueChange?.(e.target.value)}
/>
</div>
))
@@ -158,14 +135,17 @@ const CommandItem = React.forwardRef<
children?: React.ReactNode
onSelect?: () => void
className?: string
value?: string
keywords?: string
}
>(({ className, ...props }, ref) => (
>(({ className, value, keywords, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
value={value}
{...props}
/>
))