diff --git a/frontend/src/components/shared/Tooltip.tsx b/frontend/src/components/shared/Tooltip.tsx index 6deda77c4..812bd5733 100644 --- a/frontend/src/components/shared/Tooltip.tsx +++ b/frontend/src/components/shared/Tooltip.tsx @@ -22,6 +22,7 @@ export interface TooltipProps { title: string; logo?: React.ReactNode; }; + delay?: number; } export const Tooltip: React.FC = ({ @@ -37,12 +38,20 @@ export const Tooltip: React.FC = ({ arrow = false, portalTarget, header, + delay = 0, }) => { const [internalOpen, setInternalOpen] = useState(false); const [isPinned, setIsPinned] = useState(false); const triggerRef = useRef(null); const tooltipRef = useRef(null); - const hoverTimeoutRef = useRef | null>(null); + const openTimeoutRef = useRef | null>(null); + + const clearTimers = () => { + if (openTimeoutRef.current) { + clearTimeout(openTimeoutRef.current); + openTimeoutRef.current = null; + } + }; // Get sidebar context for tooltip positioning const sidebarContext = sidebarTooltip ? useSidebarContext() : null; @@ -52,6 +61,7 @@ export const Tooltip: React.FC = ({ const open = isControlled ? controlledOpen : internalOpen; const handleOpenChange = (newOpen: boolean) => { + clearTimers(); if (isControlled) { onOpenChange?.(newOpen); } else { @@ -62,6 +72,7 @@ export const Tooltip: React.FC = ({ if (!newOpen) { setIsPinned(false); } + }; const handleTooltipClick = (e: React.MouseEvent) => { @@ -96,6 +107,13 @@ export const Tooltip: React.FC = ({ } }, [isPinned]); + useEffect(() => { + return () => { + clearTimers(); + }; + }, []); + + const getArrowClass = () => { // No arrow for sidebar tooltips if (sidebarTooltip) return null; @@ -175,27 +193,27 @@ export const Tooltip: React.FC = ({ ) : null; const handleMouseEnter = (e: React.MouseEvent) => { - // Clear any existing timeout - if (hoverTimeoutRef.current) { - clearTimeout(hoverTimeoutRef.current); - hoverTimeoutRef.current = null; + if (openTimeoutRef.current) { + clearTimeout(openTimeoutRef.current); } - - // Only show on hover if not pinned if (!isPinned) { - handleOpenChange(true); + const effectiveDelay = Math.max(0, delay || 0); + openTimeoutRef.current = setTimeout(() => { + handleOpenChange(true); + }, effectiveDelay); } (children.props as any)?.onMouseEnter?.(e); }; const handleMouseLeave = (e: React.MouseEvent) => { - // Only hide on mouse leave if not pinned + if (openTimeoutRef.current) { + clearTimeout(openTimeoutRef.current); + openTimeoutRef.current = null; + } + if (!isPinned) { - // Add a small delay to prevent flickering - hoverTimeoutRef.current = setTimeout(() => { - handleOpenChange(false); - }, 100); + handleOpenChange(false); } (children.props as any)?.onMouseLeave?.(e); @@ -206,6 +224,7 @@ export const Tooltip: React.FC = ({ if (open) { setIsPinned(!isPinned); } else { + clearTimers(); handleOpenChange(true); setIsPinned(true); } diff --git a/frontend/src/components/shared/tooltip/Tooltip.README.md b/frontend/src/components/shared/tooltip/Tooltip.README.md index df1a977b0..d9b339c14 100644 --- a/frontend/src/components/shared/tooltip/Tooltip.README.md +++ b/frontend/src/components/shared/tooltip/Tooltip.README.md @@ -14,6 +14,7 @@ A flexible, accessible tooltip component that supports both regular positioning - 📌 **Click-to-Pin**: Click to pin tooltips open, click outside or the close button to unpin - 🔗 **Link Support**: Full support for clickable links in descriptions, bullets, and body content - 🎮 **Controlled by Default**: Always uses controlled state management for consistent behavior +- ⏱️ **Hover Timing Controls**: Optional long-hover requirement via `delayAppearance` and `delay` ## Behavior @@ -61,6 +62,7 @@ function MyComponent() { | `arrow` | `boolean` | `false` | Shows a small triangular arrow pointing to the trigger element | | `portalTarget` | `HTMLElement` | `undefined` | DOM node to portal the tooltip into | | `header` | `{ title: string; logo?: ReactNode }` | - | Optional header with title and logo | +| `delay` | `number` | `0` | Optional hover-open delay (ms). If omitted or 0, opens immediately | ### TooltipTip Interface @@ -106,6 +108,17 @@ interface TooltipTip { ``` +### Optional Hover Delay + +```tsx +// Show after a 1s hover + + +// Custom long-hover duration (2 seconds) + +``` + + ### Custom JSX Content ```tsx @@ -214,6 +227,12 @@ Links automatically get proper styling with hover states and open in new tabs wh - Calculates optimal position based on trigger element's `getBoundingClientRect()` - **Dynamic arrow positioning**: Arrow stays aligned with trigger even when tooltip is clamped +## Timing Details + +- Opening uses `delay` (ms) if provided; otherwise opens immediately. Closing occurs immediately when the cursor leaves (unless pinned). +- All internal timers are cleared on state changes, mouse transitions, and unmount to avoid overlaps. +- Only one tooltip can be open at a time; hovering a new trigger closes others immediately. + ### Sidebar Tooltips - When `sidebarTooltip={true}`, horizontal positioning is locked to the right of the sidebar - Vertical positioning follows the trigger but clamps to viewport diff --git a/frontend/src/components/tools/SearchResults.tsx b/frontend/src/components/tools/SearchResults.tsx new file mode 100644 index 000000000..75017fdc0 --- /dev/null +++ b/frontend/src/components/tools/SearchResults.tsx @@ -0,0 +1,69 @@ +import React, { useMemo } from 'react'; +import { Box, Stack, Text } from '@mantine/core'; +import { type ToolRegistryEntry } from '../../data/toolRegistry'; +import ToolButton from './toolPicker/ToolButton'; + +interface SearchResultsProps { + filteredTools: [string, ToolRegistryEntry][]; + onSelect: (id: string) => void; +} + +const SearchResults: React.FC = ({ filteredTools, onSelect }) => { + const groups = useMemo(() => { + const subMap: Record> = {}; + const seen = new Set(); + + filteredTools.forEach(([id, tool]) => { + if (seen.has(id)) return; + seen.add(id); + const sub = tool?.subcategory || 'General'; + if (!subMap[sub]) subMap[sub] = []; + subMap[sub].push({ id, tool }); + }); + + return Object.entries(subMap) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([subcategory, tools]) => ({ + subcategory, + tools + })); + }, [filteredTools]); + + if (groups.length === 0) { + return ( + + No tools found + + ); + } + + return ( + + {groups.map(group => ( + + + {group.subcategory} + + + {group.tools.map(({ id, tool }) => ( + + ))} + + {/* bottom spacer within each group not strictly required, outer list can add a spacer if needed */} + + ))} + {/* global spacer to allow scrolling past last row in search mode */} +
+ + ); +}; + +export default SearchResults; + + diff --git a/frontend/src/components/tools/ToolPanel.tsx b/frontend/src/components/tools/ToolPanel.tsx index 1551ea6c9..97172e7ed 100644 --- a/frontend/src/components/tools/ToolPanel.tsx +++ b/frontend/src/components/tools/ToolPanel.tsx @@ -1,9 +1,10 @@ import React from 'react'; -import { TextInput } from '@mantine/core'; +import { TextInput, useMantineColorScheme } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { useRainbowThemeContext } from '../shared/RainbowThemeProvider'; import { useToolPanelState, useToolSelection, useWorkbenchState } from '../../contexts/ToolWorkflowContext'; import ToolPicker from './ToolPicker'; +import SearchResults from './SearchResults'; import ToolRenderer from './ToolRenderer'; import { useSidebarContext } from "../../contexts/SidebarContext"; import rainbowStyles from '../../styles/rainbow.module.css'; @@ -13,6 +14,7 @@ import rainbowStyles from '../../styles/rainbow.module.css'; export default function ToolPanel() { const { t } = useTranslation(); const { isRainbowMode } = useRainbowThemeContext(); + const { colorScheme } = useMantineColorScheme(); const { sidebarRefs } = useSidebarContext(); const { toolPanelRef } = sidebarRefs; @@ -37,10 +39,7 @@ export default function ToolPanel() { className={`h-screen flex flex-col overflow-hidden bg-[var(--bg-toolbar)] border-r border-[var(--border-subtle)] transition-all duration-300 ease-out ${ isRainbowMode ? rainbowStyles.rainbowPaper : '' }`} - style={{ - width: isPanelVisible ? '20rem' : '0', - padding: isPanelVisible ? '0.5rem' : '0' - }} + style={{width: isPanelVisible ? '20rem' : '0'}} >
{/* Search Bar - Always visible at the top */} -
+
setSearchQuery(e.currentTarget.value)} autoComplete="off" size="sm" + styles={{ + root: { + marginTop: '0.5rem', + marginBottom: '0.5rem', + }, + input: { + backgroundColor: colorScheme === 'dark' ? '#4B525A' : '#FFFFFF', + color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382', + border: 'none', + boxShadow: 'none', + borderBottom: leftPanelView === 'toolContent' ? `1px solid ${colorScheme === 'dark' ? '#3A4047' : '#E0E0E0'}` : 'none', + }, + section: { + color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382', + } + }} + leftSection={search} />
- {leftPanelView === 'toolPicker' ? ( + {searchQuery.trim().length > 0 ? ( + // Searching view (replaces both picker and content) +
+
+ +
+
+ ) : leftPanelView === 'toolPicker' ? ( // Tool Picker View
0)} />
) : ( diff --git a/frontend/src/components/tools/ToolPicker.tsx b/frontend/src/components/tools/ToolPicker.tsx index 675b06cdd..9b0c7968d 100644 --- a/frontend/src/components/tools/ToolPicker.tsx +++ b/frontend/src/components/tools/ToolPicker.tsx @@ -1,7 +1,7 @@ import React, { useMemo, useRef, useLayoutEffect, useState } from "react"; import { Box, Text, Stack } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { baseToolRegistry, type ToolRegistryEntry } from "../../data/toolRegistry"; +import { type ToolRegistryEntry, SUBCATEGORY_ORDER } from "../../data/toolRegistry"; import ToolButton from "./toolPicker/ToolButton"; import "./toolPicker/ToolPicker.css"; @@ -9,6 +9,7 @@ interface ToolPickerProps { selectedToolKey: string | null; onSelect: (id: string) => void; filteredTools: [string, ToolRegistryEntry][]; + isSearching?: boolean; } interface GroupedTools { @@ -17,7 +18,7 @@ interface GroupedTools { }; } -const ToolPicker = ({ selectedToolKey, onSelect, filteredTools }: ToolPickerProps) => { +const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = false }: ToolPickerProps) => { const { t } = useTranslation(); const [quickHeaderHeight, setQuickHeaderHeight] = useState(0); const [allHeaderHeight, setAllHeaderHeight] = useState(0); @@ -45,9 +46,8 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools }: ToolPickerProp const groupedTools = useMemo(() => { const grouped: GroupedTools = {}; filteredTools.forEach(([id, tool]) => { - const baseTool = baseToolRegistry[id as keyof typeof baseToolRegistry]; - const category = baseTool?.category || "OTHER"; - const subcategory = baseTool?.subcategory || "General"; + const category = tool?.category || "OTHER"; + const subcategory = tool?.subcategory || "General"; if (!grouped[category]) grouped[category] = {}; if (!grouped[category][subcategory]) grouped[category][subcategory] = []; grouped[category][subcategory].push({ id, tool }); @@ -56,34 +56,54 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools }: ToolPickerProp }, [filteredTools]); const sections = useMemo(() => { - const mapping: Record = { - "RECOMMENDED TOOLS": "QUICK ACCESS", - "STANDARD TOOLS": "ALL TOOLS", - "ADVANCED TOOLS": "ALL TOOLS" + + const getOrderIndex = (name: string) => { + const idx = SUBCATEGORY_ORDER.indexOf(name); + return idx === -1 ? Number.MAX_SAFE_INTEGER : idx; }; + // Build two buckets: Quick includes only Recommended; All includes all categories (including Recommended) const quick: Record> = {}; const all: Record> = {}; + Object.entries(groupedTools).forEach(([origCat, subs]) => { - const bucket = mapping[origCat.toUpperCase()] || "ALL TOOLS"; - const target = bucket === "QUICK ACCESS" ? quick : all; + const upperCat = origCat.toUpperCase(); + + // Always add to ALL Object.entries(subs).forEach(([sub, tools]) => { - if (!target[sub]) target[sub] = []; - target[sub].push(...tools); + if (!all[sub]) all[sub] = []; + all[sub].push(...tools); }); + + // Add Recommended to QUICK ACCESS + if (upperCat === 'RECOMMENDED TOOLS') { + Object.entries(subs).forEach(([sub, tools]) => { + if (!quick[sub]) quick[sub] = []; + quick[sub].push(...tools); + }); + } }); const sortSubs = (obj: Record>) => Object.entries(obj) - .sort(([a], [b]) => a.localeCompare(b)) + .sort(([a], [b]) => { + const ai = getOrderIndex(a); + const bi = getOrderIndex(b); + if (ai !== bi) return ai - bi; + return a.localeCompare(b); + }) .map(([subcategory, tools]) => ({ subcategory, - tools: tools.sort((a, b) => a.tool.name.localeCompare(b.tool.name)) + // preserve original insertion order coming from filteredTools + tools })); - return [ + // Build sections and filter out any with no tools (avoids empty headers during search) + const built = [ { title: "QUICK ACCESS", ref: quickAccessRef, subcategories: sortSubs(quick) }, { title: "ALL TOOLS", ref: allToolsRef, subcategories: sortSubs(all) } ]; + + return built.filter(section => section.subcategories.some(sc => sc.tools.length > 0)); }, [groupedTools]); const visibleSections = sections; @@ -112,6 +132,27 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools }: ToolPickerProp } }; + // Build flat list by subcategory for search mode + const searchGroups = useMemo(() => { + if (!isSearching) return [] as Array<{ subcategory: string; tools: Array<{ id: string; tool: ToolRegistryEntry }> }>; + const subMap: Record> = {}; + const seen = new Set(); + filteredTools.forEach(([id, tool]) => { + if (seen.has(id)) return; + seen.add(id); + const sub = tool?.subcategory || 'General'; + if (!subMap[sub]) subMap[sub] = []; + subMap[sub].push({ id, tool }); + }); + return Object.entries(subMap) + .sort(([a],[b]) => a.localeCompare(b)) + .map(([subcategory, tools]) => ({ + subcategory, + // preserve insertion order + tools + })); + }, [isSearching, filteredTools]); + return ( + {isSearching ? ( + + {searchGroups.length === 0 ? ( + + {t("toolPicker.noToolsFound", "No tools found")} + + ) : ( + searchGroups.map(group => ( + + + {group.subcategory} + + + {group.tools.map(({ id, tool }) => ( + + ))} + + + )) + )} + + ) : ( + <> {quickSection && ( <>
- {quickSection.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)} + {quickSection?.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)}
- {quickSection.subcategories.map(sc => ( + {quickSection?.subcategories.map(sc => ( - {quickSection.subcategories.length > 1 && ( + {quickSection?.subcategories.length > 1 && ( - {allSection.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)} + {allSection?.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)}
- {allSection.subcategories.map(sc => ( + {allSection?.subcategories.map(sc => ( - {allSection.subcategories.length > 1 && ( + {allSection?.subcategories.length > 1 && ( )} + + {/* bottom spacer to allow scrolling past the last row */} +
+ + )} ); diff --git a/frontend/src/components/tools/toolPicker/ToolButton.tsx b/frontend/src/components/tools/toolPicker/ToolButton.tsx index 06e3d5fd2..7319fbf0d 100644 --- a/frontend/src/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/src/components/tools/toolPicker/ToolButton.tsx @@ -1,5 +1,6 @@ import React from "react"; -import { Button, Tooltip } from "@mantine/core"; +import { Button } from "@mantine/core"; +import { Tooltip } from "../../shared/Tooltip"; import { type ToolRegistryEntry } from "../../../data/toolRegistry"; interface ToolButtonProps { @@ -11,7 +12,7 @@ interface ToolButtonProps { const ToolButton: React.FC = ({ id, tool, isSelected, onSelect }) => { return ( - +