2026-03-28 23:16:02 +02:00
"use client" ;
import {
BookOpen ,
Check ,
Globe ,
Languages ,
List ,
Minimize2 ,
PenLine ,
Search ,
Zap ,
2026-03-29 00:33:02 +02:00
Plus ,
2026-03-28 23:16:02 +02:00
} from "lucide-react" ;
2026-03-29 00:33:02 +02:00
import { useSetAtom } from "jotai" ;
2026-03-28 23:16:02 +02:00
import {
forwardRef ,
useCallback ,
useEffect ,
useImperativeHandle ,
useMemo ,
useRef ,
useState ,
} from "react" ;
2026-03-29 00:33:02 +02:00
import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms" ;
2026-03-29 00:07:08 +02:00
import type { PromptRead } from "@/contracts/types/prompts.types" ;
import { promptsApiService } from "@/lib/apis/prompts-api.service" ;
2026-03-28 23:16:02 +02:00
import { cn } from "@/lib/utils" ;
2026-03-29 00:07:08 +02:00
export interface PromptPickerRef {
2026-03-28 23:16:02 +02:00
selectHighlighted : ( ) = > void ;
moveUp : ( ) = > void ;
moveDown : ( ) = > void ;
}
2026-03-29 00:07:08 +02:00
interface PromptPickerProps {
2026-03-28 23:16:02 +02:00
onSelect : ( action : { name : string ; prompt : string ; mode : "transform" | "explore" } ) = > void ;
onDone : ( ) = > void ;
externalSearch? : string ;
containerStyle? : React.CSSProperties ;
}
const ICONS : Record < string , React.ReactNode > = {
check : < Check className = "size-3.5" / > ,
minimize : < Minimize2 className = "size-3.5" / > ,
languages : < Languages className = "size-3.5" / > ,
"pen-line" : < PenLine className = "size-3.5" / > ,
"book-open" : < BookOpen className = "size-3.5" / > ,
list : < List className = "size-3.5" / > ,
search : < Search className = "size-3.5" / > ,
globe : < Globe className = "size-3.5" / > ,
zap : < Zap className = "size-3.5" / > ,
} ;
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" } ,
] ;
2026-03-29 00:07:08 +02:00
export const PromptPicker = forwardRef < PromptPickerRef , PromptPickerProps > (
function PromptPicker ( { onSelect , onDone , externalSearch = "" , containerStyle } , ref ) {
2026-03-29 00:33:02 +02:00
const setUserSettingsDialog = useSetAtom ( userSettingsDialogAtom ) ;
2026-03-28 23:16:02 +02:00
const [ highlightedIndex , setHighlightedIndex ] = useState ( 0 ) ;
2026-03-29 00:07:08 +02:00
const [ customPrompts , setCustomPrompts ] = useState < PromptRead [ ] > ( [ ] ) ;
2026-03-28 23:16:02 +02:00
const scrollContainerRef = useRef < HTMLDivElement > ( null ) ;
const shouldScrollRef = useRef ( false ) ;
const itemRefs = useRef < Map < number , HTMLButtonElement > > ( new Map ( ) ) ;
2026-03-28 23:51:33 +02:00
useEffect ( ( ) = > {
2026-03-29 00:07:08 +02:00
promptsApiService . list ( ) . then ( setCustomPrompts ) . catch ( ( ) = > { } ) ;
2026-03-28 23:51:33 +02:00
} , [ ] ) ;
const allActions = useMemo ( ( ) = > {
2026-03-29 00:07:08 +02:00
const customs = customPrompts . map ( ( a ) = > ( {
2026-03-28 23:51:33 +02:00
name : a.name ,
prompt : a.prompt ,
mode : a.mode as "transform" | "explore" ,
icon : a.icon || "zap" ,
} ) ) ;
return [ . . . DEFAULT_ACTIONS , . . . customs ] ;
2026-03-29 00:07:08 +02:00
} , [ customPrompts ] ) ;
2026-03-28 23:16:02 +02:00
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 } ) ;
} ,
2026-03-29 02:54:48 +02:00
[ filtered , onSelect ]
2026-03-28 23:16:02 +02:00
) ;
// 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 ;
2026-03-29 00:33:02 +02:00
const defaultFiltered = filtered . filter ( ( _ , i ) = > i < DEFAULT_ACTIONS . length ) ;
const customFiltered = filtered . filter ( ( _ , i ) = > i >= DEFAULT_ACTIONS . length ) ;
2026-03-28 23:16:02 +02:00
return (
< div
className = "w-64 rounded-lg border bg-popover shadow-lg overflow-hidden"
style = { containerStyle }
>
< div ref = { scrollContainerRef } className = "max-h-48 overflow-y-auto py-1" >
2026-03-29 00:33:02 +02:00
{ defaultFiltered . map ( ( action , index ) = > (
2026-03-28 23:16:02 +02:00
< button
key = { action . name }
ref = { ( el ) = > {
if ( el ) itemRefs . current . set ( index , el ) ;
else itemRefs . current . delete ( index ) ;
} }
type = "button"
onClick = { ( ) = > handleSelect ( index ) }
onMouseEnter = { ( ) = > setHighlightedIndex ( index ) }
className = { cn (
"flex w-full items-center gap-2 px-3 py-1.5 text-sm cursor-pointer" ,
index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50"
) }
>
< span className = "text-muted-foreground" > { ICONS [ action . icon ] ? ? < Zap className = "size-3.5" / > } < / span >
< span className = "truncate" > { action . name } < / span >
< / button >
) ) }
2026-03-29 00:33:02 +02:00
{ customFiltered . length > 0 && (
< div className = "my-1 h-px bg-border mx-2" / >
) }
{ customFiltered . map ( ( action , i ) = > {
const index = defaultFiltered . length + i ;
return (
< button
key = { action . name }
ref = { ( el ) = > {
if ( el ) itemRefs . current . set ( index , el ) ;
else itemRefs . current . delete ( index ) ;
} }
type = "button"
onClick = { ( ) = > handleSelect ( index ) }
onMouseEnter = { ( ) = > setHighlightedIndex ( index ) }
className = { cn (
"flex w-full items-center gap-2 px-3 py-1.5 text-sm cursor-pointer" ,
index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50"
) }
>
< span className = "text-muted-foreground" > < Zap className = "size-3.5" / > < / span >
< span className = "truncate" > { action . name } < / span >
< / button >
) ;
} ) }
< div className = "my-1 h-px bg-border mx-2" / >
< button
type = "button"
onClick = { ( ) = > {
onDone ( ) ;
setUserSettingsDialog ( { open : true , initialTab : "prompts" } ) ;
} }
className = "flex w-full items-center gap-2 px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-accent/50 cursor-pointer"
>
< Plus className = "size-3.5" / >
< span > Create prompt < / span >
< / button >
2026-03-28 23:16:02 +02:00
< / div >
< / div >
) ;
}
) ;