@@ -13,6 +13,7 @@ import { objectToSearchParams } from "~/utils/searchParams";
13
13
import { type TaskRunListSearchFilters } from "./RunFilters" ;
14
14
import { cn } from "~/utils/cn" ;
15
15
import { motion } from "framer-motion" ;
16
+ import { Popover , PopoverContent , PopoverTrigger } from "~/components/primitives/Popover" ;
16
17
17
18
type AIFilterResult =
18
19
| {
@@ -90,74 +91,111 @@ export function AIFilterInput() {
90
91
action = { `/resources/orgs/${ organization . slug } /projects/${ project . slug } /env/${ environment . slug } /runs/ai-filter` }
91
92
method = "post"
92
93
>
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 ,
125
102
} }
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
131
146
}
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 >
161
164
</ fetcher . Form >
162
165
) ;
163
166
}
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