11'use client'
22
3- import { useMemo , useState } from 'react'
3+ import { useEffect , useMemo , useState } from 'react'
44import { ChipCombobox , type ComboboxOption , Loader } from '@sim/emcn'
55import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
66import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context'
@@ -10,7 +10,7 @@ import type {
1010} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
1111import { getDependsOnFields } from '@/blocks/utils'
1212import type { ConnectorConfigField } from '@/connectors/types'
13- import type { SelectorContext , SelectorKey } from '@/hooks/selectors/types'
13+ import type { SelectorContext , SelectorKey , SelectorOption } from '@/hooks/selectors/types'
1414import { useSelectorOptionDetail , useSelectorOptions } from '@/hooks/selectors/use-selector-query'
1515import { useDebounce } from '@/hooks/use-debounce'
1616
@@ -77,54 +77,72 @@ export function ConnectorSelectorField({
7777 enabled : isEnabled ,
7878 } )
7979
80- const emptyMessage = getEmptyMessage ( field . title . toLowerCase ( ) , {
81- error,
82- hasMore,
83- isFetchingMore,
84- truncated,
85- } )
86-
8780 /**
8881 * The option list fills by draining pages in the background and the combobox
8982 * filters it client-side, so an option is only findable once its page has
9083 * arrived. Resolving the typed value directly makes an exact id/key selectable
9184 * immediately, independent of drain progress. Debounced so typing does not
92- * issue a request per keystroke; selectors without a `fetchById` simply
93- * resolve nothing.
85+ * issue a request per keystroke; selectors without a `fetchById` resolve nothing.
9486 */
9587 const debouncedSearch = useDebounce ( searchTerm . trim ( ) , SEARCH_DEBOUNCE_MS )
96- const { data : searchedOption } = useSelectorOptionDetail ( field . selectorKey , {
88+ const { data : searchedOption , error : searchError } = useSelectorOptionDetail ( field . selectorKey , {
9789 context,
9890 detailId : isEnabled && debouncedSearch . length > 0 ? debouncedSearch : undefined ,
9991 } )
10092
10193 /**
102- * Resolve the *selected* value too, not just the typed one. Selecting resets the
103- * search box, which drops `searchedOption` a debounce later, and the drain may
104- * not reach that option's page for tens of seconds — never, when truncated — so
105- * the trigger would fall back to the placeholder for a value just picked.
106- * Mirrors `SelectorCombobox` in the workflow editor. Multi-select is not covered:
107- * resolving N ids needs N hooks, so a multi value beyond the drain still renders
108- * as its raw id.
94+ * Resolve the *selected* value too, not just the typed one, so the trigger does
95+ * not fall back to a raw id for something just picked. Single-select only:
96+ * resolving N ids would need N hooks, so multi-select relies on the remembered
97+ * options below.
10998 */
11099 const singleValue = Array . isArray ( value ) ? value [ 0 ] : value
111100 const { data : selectedOption } = useSelectorOptionDetail ( field . selectorKey , {
112101 context,
113102 detailId : ! isMulti && isEnabled && singleValue ? singleValue : undefined ,
114103 } )
115104
105+ const emptyMessage = getEmptyMessage ( field . title . toLowerCase ( ) , {
106+ error,
107+ lookupFailed : Boolean ( searchError ) ,
108+ hasMore,
109+ isFetchingMore,
110+ truncated,
111+ } )
112+
113+ /**
114+ * Resolved options are remembered for the lifetime of the field. Both lookups are
115+ * keyed on values that change — the search box clears on select and close, and a
116+ * multi-select field resolves no id at all (that would need one hook per id) — so
117+ * reading them directly would drop a label moments after it appeared, leaving the
118+ * trigger showing a raw id for something the user just picked.
119+ */
120+ const [ resolvedOptions , setResolvedOptions ] = useState < Record < string , string > > ( { } )
121+ useEffect ( ( ) => {
122+ const found = [ searchedOption , selectedOption ] . filter ( Boolean ) as SelectorOption [ ]
123+ if ( found . length === 0 ) return
124+ setResolvedOptions ( ( prev ) => {
125+ let next = prev
126+ for ( const option of found ) {
127+ if ( next [ option . id ] === option . label ) continue
128+ if ( next === prev ) next = { ...prev }
129+ next [ option . id ] = option . label
130+ }
131+ return next
132+ } )
133+ } , [ searchedOption , selectedOption ] )
134+
116135 const comboboxOptions = useMemo < ComboboxOption [ ] > ( ( ) => {
117136 const base = options . map ( ( opt ) => ( { label : opt . label , value : opt . id } ) )
118137 const seen = new Set ( base . map ( ( opt ) => opt . value ) )
119138 const extras : ComboboxOption [ ] = [ ]
120- for ( const extra of [ searchedOption , selectedOption ] ) {
121- if ( extra && ! seen . has ( extra . id ) ) {
122- seen . add ( extra . id )
123- extras . push ( { label : extra . label , value : extra . id } )
124- }
139+ for ( const [ id , label ] of Object . entries ( resolvedOptions ) ) {
140+ if ( seen . has ( id ) ) continue
141+ seen . add ( id )
142+ extras . push ( { label, value : id } )
125143 }
126144 return extras . length > 0 ? [ ...extras , ...base ] : base
127- } , [ options , searchedOption , selectedOption ] )
145+ } , [ options , resolvedOptions ] )
128146
129147 if ( isLoading && isEnabled ) {
130148 return (
@@ -190,11 +208,19 @@ export function ConnectorSelectorField({
190208 */
191209function getEmptyMessage (
192210 noun : string ,
193- state : { error : Error | null ; hasMore : boolean ; isFetchingMore : boolean ; truncated : boolean }
211+ state : {
212+ error : Error | null
213+ lookupFailed : boolean
214+ hasMore : boolean
215+ isFetchingMore : boolean
216+ truncated : boolean
217+ }
194218) : string {
195219 // `field.title` is singular on some connectors ("Base") and plural on others
196220 // ("Spaces"), so only the settled message puts the noun behind a quantifier.
197221 if ( state . error ) return 'No match — the list failed to load. Try reopening'
222+ // Distinct from the list failing: the list is fine, resolving the typed value is not.
223+ if ( state . lookupFailed ) return 'No match — could not check that exact value'
198224 if ( state . hasMore || state . isFetchingMore ) return 'No match yet — still loading…'
199225 if ( state . truncated ) return 'No match — too many to list. Try a more exact term'
200226 return `No ${ noun } found`
0 commit comments