11'use client'
22
3- import { useMemo } from 'react'
3+ import { useMemo , useState } from 'react'
44import { ChipCombobox , type ComboboxOption , Loader } from '@sim/emcn'
5+ import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
56import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context'
67import type {
78 ConfigFieldMap ,
89 ConfigFieldValue ,
910} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
1011import { getDependsOnFields } from '@/blocks/utils'
1112import type { ConnectorConfigField } from '@/connectors/types'
13+ import { getSelectorDefinition } from '@/hooks/selectors/registry'
1214import type { SelectorContext , SelectorKey } from '@/hooks/selectors/types'
13- import { useSelectorOptions } from '@/hooks/selectors/use-selector-query'
15+ import {
16+ useSelectorOptionDetail ,
17+ useSelectorOptionDetails ,
18+ useSelectorOptions ,
19+ } from '@/hooks/selectors/use-selector-query'
20+ import { useDebounce } from '@/hooks/use-debounce'
1421
1522interface ConnectorSelectorFieldProps {
1623 field : ConnectorConfigField & { selectorKey : SelectorKey }
@@ -34,6 +41,7 @@ export function ConnectorSelectorField({
3441 disabled,
3542} : ConnectorSelectorFieldProps ) {
3643 const isMulti = Boolean ( field . multi )
44+ const [ searchTerm , setSearchTerm ] = useState ( '' )
3745
3846 const context = useMemo < SelectorContext > ( ( ) => {
3947 const ctx : SelectorContext = { }
@@ -62,15 +70,68 @@ export function ConnectorSelectorField({
6270 } , [ field . dependsOn , sourceConfig , configFields , canonicalModes ] )
6371
6472 const isEnabled = ! disabled && ! ! credentialId && depsResolved
65- const { data : options = [ ] , isLoading } = useSelectorOptions ( field . selectorKey , {
73+ const {
74+ data : options = [ ] ,
75+ isLoading,
76+ hasMore,
77+ isFetchingMore,
78+ truncated,
79+ error,
80+ } = useSelectorOptions ( field . selectorKey , {
6681 context,
6782 enabled : isEnabled ,
6883 } )
6984
70- const comboboxOptions = useMemo < ComboboxOption [ ] > (
71- ( ) => options . map ( ( opt ) => ( { label : opt . label , value : opt . id } ) ) ,
72- [ options ]
85+ /**
86+ * Label every selected value, including values restored from saved config that no
87+ * in-session search would have resolved. Queries are keyed on `context`, so a label
88+ * can never outlive the context that produced it, and they share keys with the
89+ * speculative lookup below so an already-resolved id costs no extra request.
90+ */
91+ const singleValue = Array . isArray ( value ) ? value [ 0 ] : value
92+ const selectedIds = useMemo (
93+ ( ) => ( Array . isArray ( value ) ? value : value ? [ value ] : [ ] ) . filter ( Boolean ) ,
94+ [ value ]
7395 )
96+ const selectedOptions = useSelectorOptionDetails ( field . selectorKey , {
97+ context,
98+ detailIds : isEnabled ? selectedIds : [ ] ,
99+ } )
100+
101+ /**
102+ * The option list fills by draining pages in the background and the combobox filters
103+ * it client-side, so an option is only findable once its page has arrived. Where the
104+ * selector's `fetchById` tolerates an unknown id, whatever the user typed is resolved
105+ * directly so an exact key is selectable immediately. Gated on that flag because most
106+ * implementations resolve a record by id, where a partial keystroke is a guaranteed
107+ * failed upstream request rather than an empty result.
108+ */
109+ const resolvesUnknownIds = Boolean ( getSelectorDefinition ( field . selectorKey ) . resolvesUnknownIds )
110+ const debouncedSearch = useDebounce ( searchTerm . trim ( ) , SEARCH_DEBOUNCE_MS )
111+ const { data : searchedOption } = useSelectorOptionDetail ( field . selectorKey , {
112+ context,
113+ detailId :
114+ resolvesUnknownIds && isEnabled && debouncedSearch . length > 0 ? debouncedSearch : undefined ,
115+ } )
116+
117+ const emptyMessage = getEmptyMessage ( field . title . toLowerCase ( ) , {
118+ error,
119+ hasMore,
120+ isFetchingMore,
121+ truncated,
122+ } )
123+
124+ const comboboxOptions = useMemo < ComboboxOption [ ] > ( ( ) => {
125+ const base = options . map ( ( opt ) => ( { label : opt . label , value : opt . id } ) )
126+ const seen = new Set ( base . map ( ( opt ) => opt . value ) )
127+ const extras : ComboboxOption [ ] = [ ]
128+ for ( const option of searchedOption ? [ ...selectedOptions , searchedOption ] : selectedOptions ) {
129+ if ( seen . has ( option . id ) ) continue
130+ seen . add ( option . id )
131+ extras . push ( { label : option . label , value : option . id } )
132+ }
133+ return extras . length > 0 ? [ ...extras , ...base ] : base
134+ } , [ options , selectedOptions , searchedOption ] )
74135
75136 if ( isLoading && isEnabled ) {
76137 return (
@@ -88,8 +149,9 @@ export function ConnectorSelectorField({
88149 multiSelect
89150 options = { comboboxOptions }
90151 multiSelectValues = { multiValues }
91- onMultiSelectChange = { ( values ) => onChange ( values ) }
152+ onMultiSelectChange = { onChange }
92153 searchable
154+ onSearchChange = { setSearchTerm }
93155 searchPlaceholder = { `Search ${ field . title . toLowerCase ( ) } ...` }
94156 placeholder = {
95157 ! credentialId
@@ -99,18 +161,18 @@ export function ConnectorSelectorField({
99161 : field . placeholder || `Select ${ field . title . toLowerCase ( ) } `
100162 }
101163 disabled = { disabled || ! credentialId || ! depsResolved }
102- emptyMessage = { `No ${ field . title . toLowerCase ( ) } found` }
164+ emptyMessage = { emptyMessage }
103165 />
104166 )
105167 }
106168
107- const singleValue = Array . isArray ( value ) ? value [ 0 ] : value
108169 return (
109170 < ChipCombobox
110171 options = { comboboxOptions }
111172 value = { singleValue || undefined }
112- onChange = { ( next ) => onChange ( next ) }
173+ onChange = { onChange }
113174 searchable
175+ onSearchChange = { setSearchTerm }
114176 searchPlaceholder = { `Search ${ field . title . toLowerCase ( ) } ...` }
115177 placeholder = {
116178 ! credentialId
@@ -120,11 +182,36 @@ export function ConnectorSelectorField({
120182 : field . placeholder || `Select ${ field . title . toLowerCase ( ) } `
121183 }
122184 disabled = { disabled || ! credentialId || ! depsResolved }
123- emptyMessage = { `No ${ field . title . toLowerCase ( ) } found` }
185+ emptyMessage = { emptyMessage }
124186 />
125187 )
126188}
127189
190+ /**
191+ * Only visible once the first page has landed (`isLoading` renders a spinner
192+ * before that), so "no match" here means no match among the options drained
193+ * *so far* — a flat "none found" would wrongly read as "does not exist".
194+ *
195+ * `error` is checked before `hasMore`: a failed page halts the drain but leaves
196+ * `hasMore` set, which would otherwise claim to be loading forever.
197+ */
198+ function getEmptyMessage (
199+ noun : string ,
200+ state : {
201+ error : Error | null
202+ hasMore : boolean
203+ isFetchingMore : boolean
204+ truncated : boolean
205+ }
206+ ) : string {
207+ if ( state . error ) return 'No match — the list failed to load. Try reopening'
208+ if ( state . hasMore || state . isFetchingMore ) return 'No match yet — still loading…'
209+ if ( state . truncated ) return 'No match — too many to list. Try a more exact term'
210+ // `noun` is singular on some connectors ("Base") and plural on others ("Spaces"),
211+ // so only this settled message puts it behind a quantifier.
212+ return `No ${ noun } found`
213+ }
214+
128215function resolveDepValue (
129216 depFieldId : string ,
130217 configFields : ConnectorConfigField [ ] ,
0 commit comments