diff --git a/surfsense_web/components/new-chat/action-picker.tsx b/surfsense_web/components/new-chat/action-picker.tsx new file mode 100644 index 000000000..d5ef01ae1 --- /dev/null +++ b/surfsense_web/components/new-chat/action-picker.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { + BookOpen, + Check, + Globe, + Languages, + List, + Minimize2, + PenLine, + Search, + Zap, +} from "lucide-react"; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; + +import { cn } from "@/lib/utils"; + +export interface ActionPickerRef { + selectHighlighted: () => void; + moveUp: () => void; + moveDown: () => void; +} + +interface ActionPickerProps { + onSelect: (action: { name: string; prompt: string; mode: "transform" | "explore" }) => void; + onDone: () => void; + externalSearch?: string; + containerStyle?: React.CSSProperties; +} + +const ICONS: Record = { + check: , + minimize: , + languages: , + "pen-line": , + "book-open": , + list: , + search: , + globe: , + zap: , +}; + +const DEFAULT_ACTIONS: { name: string; prompt: string; mode: "transform" | "explore"; icon: string }[] = [ + { name: "Fix grammar", prompt: "Fix the grammar and spelling in the following text. Return only the corrected text, nothing else.\n\n{selection}", mode: "transform", icon: "check" }, + { name: "Make shorter", prompt: "Make the following text more concise while preserving its meaning. Return only the shortened text, nothing else.\n\n{selection}", mode: "transform", icon: "minimize" }, + { name: "Translate", prompt: "Translate the following text to English. If it is already in English, translate it to French. Return only the translation, nothing else.\n\n{selection}", mode: "transform", icon: "languages" }, + { name: "Rewrite", prompt: "Rewrite the following text to improve clarity and readability. Return only the rewritten text, nothing else.\n\n{selection}", mode: "transform", icon: "pen-line" }, + { name: "Summarize", prompt: "Summarize the following text concisely. Return only the summary, nothing else.\n\n{selection}", mode: "transform", icon: "list" }, + { name: "Explain", prompt: "Explain the following text in simple terms:\n\n{selection}", mode: "explore", icon: "book-open" }, + { name: "Ask my knowledge base", prompt: "Search my knowledge base for information related to:\n\n{selection}", mode: "explore", icon: "search" }, + { name: "Look up on the web", prompt: "Search the web for information about:\n\n{selection}", mode: "explore", icon: "globe" }, +]; + +export const ActionPicker = forwardRef( + function ActionPicker({ onSelect, onDone, externalSearch = "", containerStyle }, ref) { + const [highlightedIndex, setHighlightedIndex] = useState(0); + const scrollContainerRef = useRef(null); + const shouldScrollRef = useRef(false); + const itemRefs = useRef>(new Map()); + + const allActions = DEFAULT_ACTIONS; + + const filtered = useMemo(() => { + if (!externalSearch) return allActions; + return allActions.filter((a) => + a.name.toLowerCase().includes(externalSearch.toLowerCase()) + ); + }, [allActions, externalSearch]); + + // Reset highlight when results change + const prevSearchRef = useRef(externalSearch); + if (prevSearchRef.current !== externalSearch) { + prevSearchRef.current = externalSearch; + if (highlightedIndex !== 0) { + setHighlightedIndex(0); + } + } + + const handleSelect = useCallback( + (index: number) => { + const action = filtered[index]; + if (!action) return; + onSelect({ name: action.name, prompt: action.prompt, mode: action.mode }); + onDone(); + }, + [filtered, onSelect, onDone] + ); + + // Auto-scroll highlighted item into view + useEffect(() => { + if (!shouldScrollRef.current) return; + shouldScrollRef.current = false; + + const rafId = requestAnimationFrame(() => { + const item = itemRefs.current.get(highlightedIndex); + const container = scrollContainerRef.current; + if (item && container) { + const itemRect = item.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + if (itemRect.top < containerRect.top || itemRect.bottom > containerRect.bottom) { + item.scrollIntoView({ block: "nearest" }); + } + } + }); + + return () => cancelAnimationFrame(rafId); + }, [highlightedIndex]); + + useImperativeHandle( + ref, + () => ({ + selectHighlighted: () => handleSelect(highlightedIndex), + moveUp: () => { + shouldScrollRef.current = true; + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1)); + }, + moveDown: () => { + shouldScrollRef.current = true; + setHighlightedIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0)); + }, + }), + [filtered.length, highlightedIndex, handleSelect] + ); + + if (filtered.length === 0) return null; + + return ( +
+
+ {filtered.map((action, index) => ( + + ))} +
+
+ ); + } +);