@@ -13,6 +13,7 @@ import { objectToSearchParams } from "~/utils/searchParams";
1313import { type TaskRunListSearchFilters } from "./RunFilters" ;
1414import { cn } from "~/utils/cn" ;
1515import { motion } from "framer-motion" ;
16+ import { Popover , PopoverContent , PopoverTrigger } from "~/components/primitives/Popover" ;
1617
1718type AIFilterResult =
1819 | {
@@ -90,74 +91,111 @@ export function AIFilterInput() {
9091 action = { `/resources/orgs/${ organization . slug } /projects/${ project . slug } /env/${ environment . slug } /runs/ai-filter` }
9192 method = "post"
9293 >
93- < motion . div
94- initial = { { width : "auto" } }
95- animate = { { width : isFocused && text . length > 0 ? "24rem" : "auto" } }
96- transition = { {
97- type : "spring" ,
98- stiffness : 300 ,
99- damping : 30 ,
100- } }
101- className = "animated-gradient-glow relative"
102- >
103- < Input
104- type = "text"
105- name = "text"
106- variant = "secondary-small"
107- placeholder = "Describe your filters…"
108- value = { text }
109- onChange = { ( e ) => setText ( e . target . value ) }
110- disabled = { isLoading }
111- fullWidth
112- ref = { inputRef }
113- className = { cn (
114- "placeholder:text-text-bright" ,
115- isFocused && "placeholder:text-text-dimmed"
116- ) }
117- onKeyDown = { ( e ) => {
118- if ( e . key === "Enter" && text . trim ( ) && ! isLoading ) {
119- e . preventDefault ( ) ;
120- const form = e . currentTarget . closest ( "form" ) ;
121- if ( form ) {
122- form . requestSubmit ( ) ;
123- }
124- }
94+ < ErrorPopover error = { fetcher . data ?. success === false ? fetcher . data . error : undefined } >
95+ < motion . div
96+ initial = { { width : "auto" } }
97+ animate = { { width : isFocused && text . length > 0 ? "24rem" : "auto" } }
98+ transition = { {
99+ type : "spring" ,
100+ stiffness : 300 ,
101+ damping : 30 ,
125102 } }
126- onFocus = { ( ) => setIsFocused ( true ) }
127- onBlur = { ( ) => {
128- // Only blur if the text is empty or we're not loading
129- if ( text . length === 0 || ! isLoading ) {
130- setIsFocused ( false ) ;
103+ className = "animated-gradient-glow relative"
104+ >
105+ < Input
106+ type = "text"
107+ name = "text"
108+ variant = "secondary-small"
109+ placeholder = "Describe your filters…"
110+ value = { text }
111+ onChange = { ( e ) => setText ( e . target . value ) }
112+ disabled = { isLoading }
113+ fullWidth
114+ ref = { inputRef }
115+ className = { cn (
116+ "placeholder:text-text-bright" ,
117+ isFocused && "placeholder:text-text-dimmed"
118+ ) }
119+ onKeyDown = { ( e ) => {
120+ if ( e . key === "Enter" && text . trim ( ) && ! isLoading ) {
121+ e . preventDefault ( ) ;
122+ const form = e . currentTarget . closest ( "form" ) ;
123+ if ( form ) {
124+ form . requestSubmit ( ) ;
125+ }
126+ }
127+ } }
128+ onFocus = { ( ) => setIsFocused ( true ) }
129+ onBlur = { ( ) => {
130+ // Only blur if the text is empty or we're not loading
131+ if ( text . length === 0 || ! isLoading ) {
132+ setIsFocused ( false ) ;
133+ }
134+ } }
135+ icon = { < AISparkleIcon className = "size-4" /> }
136+ accessory = {
137+ isLoading ? (
138+ < Spinner color = "muted" className = "size-4" />
139+ ) : text . length > 0 ? (
140+ < ShortcutKey
141+ shortcut = { { key : "enter" } }
142+ variant = "small"
143+ className = { cn ( "transition-opacity" , text . length === 0 && "opacity-0" ) }
144+ />
145+ ) : undefined
131146 }
132- } }
133- icon = { < AISparkleIcon className = "size-4" /> }
134- accessory = {
135- isLoading ? (
136- < Spinner color = "muted" className = "size-4" />
137- ) : text . length > 0 ? (
138- < ShortcutKey
139- shortcut = { { key : "enter" } }
140- variant = "small"
141- className = { cn ( "transition-opacity" , text . length === 0 && "opacity-0" ) }
142- />
143- ) : undefined
144- }
145- />
146- { fetcher . data ?. success === false && (
147- < Portal >
148- < div
149- className = "fixed z-[9999] rounded-md bg-rose-500 px-3 py-2 text-sm text-white shadow-lg"
150- style = { {
151- top : `${ errorPosition . top + 8 } px` ,
152- left : `${ errorPosition . left } px` ,
153- width : `${ errorPosition . width } px` ,
154- } }
155- >
156- { fetcher . data . error }
157- </ div >
158- </ Portal >
159- ) }
160- </ motion . div >
147+ />
148+ { fetcher . data ?. success === false && (
149+ < Portal >
150+ < div
151+ className = "fixed z-[9999] rounded-md bg-rose-500 px-3 py-2 text-sm text-white shadow-lg"
152+ style = { {
153+ top : `${ errorPosition . top + 8 } px` ,
154+ left : `${ errorPosition . left } px` ,
155+ width : `${ errorPosition . width } px` ,
156+ } }
157+ >
158+ { fetcher . data . error }
159+ </ div >
160+ </ Portal >
161+ ) }
162+ </ motion . div >
163+ </ ErrorPopover >
161164 </ fetcher . Form >
162165 ) ;
163166}
167+
168+ function ErrorPopover ( {
169+ children,
170+ error,
171+ durationMs = 2_000 ,
172+ } : {
173+ children : React . ReactNode ;
174+ error ?: string ;
175+ durationMs ?: number ;
176+ } ) {
177+ const [ isOpen , setIsOpen ] = useState ( false ) ;
178+ const timeout = useRef < NodeJS . Timeout | undefined > ( ) ;
179+
180+ useEffect ( ( ) => {
181+ if ( timeout . current ) {
182+ clearTimeout ( timeout . current ) ;
183+ }
184+ timeout . current = setTimeout ( ( ) => {
185+ setIsOpen ( ( s ) => true ) ;
186+ } , durationMs ) ;
187+
188+ return ( ) => {
189+ if ( timeout . current ) {
190+ clearTimeout ( timeout . current ) ;
191+ }
192+ } ;
193+ } , [ error , durationMs ] ) ;
194+
195+ return (
196+ < Popover open = { isOpen } onOpenChange = { setIsOpen } >
197+ < PopoverTrigger asChild > { children } </ PopoverTrigger >
198+ < PopoverContent > { error } </ PopoverContent >
199+ </ Popover >
200+ ) ;
201+ }
0 commit comments