diff --git a/apps/sim/app/api/tools/confluence/selector-spaces/route.ts b/apps/sim/app/api/tools/confluence/selector-spaces/route.ts index e8a8b032480..4ad5c0f2629 100644 --- a/apps/sim/app/api/tools/confluence/selector-spaces/route.ts +++ b/apps/sim/app/api/tools/confluence/selector-spaces/route.ts @@ -23,6 +23,27 @@ const PAGE_LIMIT = 250 type SpaceStatus = 'current' | 'archived' +/** A row as Confluence returns it. `status` is not marked required in the v2 schema. */ +interface SpaceRow { + id: string + name: string + key: string + status?: SpaceStatus +} + +interface SpacesResponse { + results?: SpaceRow[] + _links?: { next?: string } +} + +/** A row as this selector emits it, with `status` always resolved. */ +interface SelectorSpace { + id: string + name: string + key: string + status: SpaceStatus +} + /** * Cursor format: `:`. Empty inner cursor means "first page * of that status". When current is exhausted we hand back `archived:` so the @@ -45,7 +66,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(confluenceSpacesSelectorContract, request, {}) if (!parsed.success) return parsed.response - const { credential, workflowId, domain, cursor } = parsed.data.body + const { credential, workflowId, domain, cursor, spaceKey } = parsed.data.body if (!credential) { logger.error('Missing credential in request') @@ -101,32 +122,73 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const baseUrl = `https://api.atlassian.com/ex/confluence/${cloudIdValidation.sanitized}/wiki/api/v2/spaces` const { status, inner } = parseCursor(cursor) - const params = new URLSearchParams({ limit: String(PAGE_LIMIT), status }) - if (inner) params.set('cursor', inner) - const url = `${baseUrl}?${params.toString()}` + const requestSpaces = async ( + search: URLSearchParams + ): Promise<{ ok: true; data: SpacesResponse } | { ok: false; response: NextResponse }> => { + const response = await fetch(`${baseUrl}?${search.toString()}`, { + method: 'GET', + headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}` }, + }) + + if (!response.ok) { + const errorText = await response.text() + const message = parseAtlassianErrorMessage(response.status, response.statusText, errorText) + logger.error('Confluence API error response', { error: message, status: response.status }) + return { ok: false, response: NextResponse.json({ error: message }, { status: 502 }) } + } - const response = await fetch(url, { - method: 'GET', - headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}` }, - }) + return { ok: true, data: await response.json() } + } - if (!response.ok) { - const errorText = await response.text() - const message = parseAtlassianErrorMessage(response.status, response.statusText, errorText) - logger.error('Confluence API error response', { error: message, status: response.status }) - return NextResponse.json({ error: message }, { status: 502 }) + const toSpaces = (rows: SpaceRow[] | undefined, queried: SpaceStatus): SelectorSpace[] => + (rows ?? []).map((space) => ({ + id: space.id, + name: space.name, + key: space.key, + // Trust the row's own status; fall back to the status this call asked for. + status: space.status ?? queried, + })) + + if (spaceKey) { + /** + * Exact-key lookup, bypassing the paged drain the dropdown otherwise depends + * on. Both statuses are queried explicitly rather than omitting `status`: it + * takes a single value on this endpoint (unlike `/pages`, where it is an array + * with a documented `current,archived` default) and `/spaces` documents no + * default, while archived spaces are reachable in the paged path and sync works + * against them. Concurrent because the key is user-typed text, so a miss — which + * dominates while typing — would otherwise pay two round-trips. + */ + const [current, archived] = await Promise.all([ + requestSpaces( + new URLSearchParams({ keys: spaceKey, limit: String(PAGE_LIMIT), status: 'current' }) + ), + requestSpaces( + new URLSearchParams({ keys: spaceKey, limit: String(PAGE_LIMIT), status: 'archived' }) + ), + ]) + // Only a total failure is fatal: one leg erroring must not discard a match + // the other leg found. + if (!current.ok && !archived.ok) return current.response + + // A single resolution, never a page in a drained stream, so no cursor. + return NextResponse.json({ + spaces: [ + ...(current.ok ? toSpaces(current.data.results, 'current') : []), + ...(archived.ok ? toSpaces(archived.data.results, 'archived') : []), + ], + nextCursor: undefined, + }) } - const data = await response.json() - const spaces = (data.results || []).map((space: { id: string; name: string; key: string }) => ({ - id: space.id, - name: space.name, - key: space.key, - status, - })) + const params = new URLSearchParams({ limit: String(PAGE_LIMIT), status }) + if (inner) params.set('cursor', inner) + const result = await requestSpaces(params) + if (!result.ok) return result.response + const data = result.data let nextInner: string | undefined - const nextLink = data._links?.next as string | undefined + const nextLink = data._links?.next if (nextLink) { try { nextInner = new URL(nextLink, 'https://placeholder').searchParams.get('cursor') || undefined @@ -142,7 +204,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { nextCursor = 'archived:' } - return NextResponse.json({ spaces, nextCursor }) + return NextResponse.json({ spaces: toSpaces(data.results, status), nextCursor }) } catch (error) { logger.error('Error listing Confluence spaces:', error) return NextResponse.json( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx index 3b5a9d0cc56..e9053558edf 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx @@ -1,7 +1,8 @@ 'use client' -import { useMemo } from 'react' +import { useMemo, useState } from 'react' import { ChipCombobox, type ComboboxOption, Loader } from '@sim/emcn' +import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context' import type { ConfigFieldMap, @@ -9,8 +10,14 @@ import type { } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' import { getDependsOnFields } from '@/blocks/utils' import type { ConnectorConfigField } from '@/connectors/types' +import { getSelectorDefinition } from '@/hooks/selectors/registry' import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types' -import { useSelectorOptions } from '@/hooks/selectors/use-selector-query' +import { + useSelectorOptionDetail, + useSelectorOptionDetails, + useSelectorOptions, +} from '@/hooks/selectors/use-selector-query' +import { useDebounce } from '@/hooks/use-debounce' interface ConnectorSelectorFieldProps { field: ConnectorConfigField & { selectorKey: SelectorKey } @@ -34,6 +41,7 @@ export function ConnectorSelectorField({ disabled, }: ConnectorSelectorFieldProps) { const isMulti = Boolean(field.multi) + const [searchTerm, setSearchTerm] = useState('') const context = useMemo(() => { const ctx: SelectorContext = {} @@ -62,15 +70,68 @@ export function ConnectorSelectorField({ }, [field.dependsOn, sourceConfig, configFields, canonicalModes]) const isEnabled = !disabled && !!credentialId && depsResolved - const { data: options = [], isLoading } = useSelectorOptions(field.selectorKey, { + const { + data: options = [], + isLoading, + hasMore, + isFetchingMore, + truncated, + error, + } = useSelectorOptions(field.selectorKey, { context, enabled: isEnabled, }) - const comboboxOptions = useMemo( - () => options.map((opt) => ({ label: opt.label, value: opt.id })), - [options] + /** + * Label every selected value, including values restored from saved config that no + * in-session search would have resolved. Queries are keyed on `context`, so a label + * can never outlive the context that produced it, and they share keys with the + * speculative lookup below so an already-resolved id costs no extra request. + */ + const singleValue = Array.isArray(value) ? value[0] : value + const selectedIds = useMemo( + () => (Array.isArray(value) ? value : value ? [value] : []).filter(Boolean), + [value] ) + const selectedOptions = useSelectorOptionDetails(field.selectorKey, { + context, + detailIds: isEnabled ? selectedIds : [], + }) + + /** + * The option list fills by draining pages in the background and the combobox filters + * it client-side, so an option is only findable once its page has arrived. Where the + * selector's `fetchById` tolerates an unknown id, whatever the user typed is resolved + * directly so an exact key is selectable immediately. Gated on that flag because most + * implementations resolve a record by id, where a partial keystroke is a guaranteed + * failed upstream request rather than an empty result. + */ + const resolvesUnknownIds = Boolean(getSelectorDefinition(field.selectorKey).resolvesUnknownIds) + const debouncedSearch = useDebounce(searchTerm.trim(), SEARCH_DEBOUNCE_MS) + const { data: searchedOption } = useSelectorOptionDetail(field.selectorKey, { + context, + detailId: + resolvesUnknownIds && isEnabled && debouncedSearch.length > 0 ? debouncedSearch : undefined, + }) + + const emptyMessage = getEmptyMessage(field.title.toLowerCase(), { + error, + hasMore, + isFetchingMore, + truncated, + }) + + const comboboxOptions = useMemo(() => { + const base = options.map((opt) => ({ label: opt.label, value: opt.id })) + const seen = new Set(base.map((opt) => opt.value)) + const extras: ComboboxOption[] = [] + for (const option of searchedOption ? [...selectedOptions, searchedOption] : selectedOptions) { + if (seen.has(option.id)) continue + seen.add(option.id) + extras.push({ label: option.label, value: option.id }) + } + return extras.length > 0 ? [...extras, ...base] : base + }, [options, selectedOptions, searchedOption]) if (isLoading && isEnabled) { return ( @@ -88,8 +149,9 @@ export function ConnectorSelectorField({ multiSelect options={comboboxOptions} multiSelectValues={multiValues} - onMultiSelectChange={(values) => onChange(values)} + onMultiSelectChange={onChange} searchable + onSearchChange={setSearchTerm} searchPlaceholder={`Search ${field.title.toLowerCase()}...`} placeholder={ !credentialId @@ -99,18 +161,18 @@ export function ConnectorSelectorField({ : field.placeholder || `Select ${field.title.toLowerCase()}` } disabled={disabled || !credentialId || !depsResolved} - emptyMessage={`No ${field.title.toLowerCase()} found`} + emptyMessage={emptyMessage} /> ) } - const singleValue = Array.isArray(value) ? value[0] : value return ( onChange(next)} + onChange={onChange} searchable + onSearchChange={setSearchTerm} searchPlaceholder={`Search ${field.title.toLowerCase()}...`} placeholder={ !credentialId @@ -120,11 +182,36 @@ export function ConnectorSelectorField({ : field.placeholder || `Select ${field.title.toLowerCase()}` } disabled={disabled || !credentialId || !depsResolved} - emptyMessage={`No ${field.title.toLowerCase()} found`} + emptyMessage={emptyMessage} /> ) } +/** + * Only visible once the first page has landed (`isLoading` renders a spinner + * before that), so "no match" here means no match among the options drained + * *so far* — a flat "none found" would wrongly read as "does not exist". + * + * `error` is checked before `hasMore`: a failed page halts the drain but leaves + * `hasMore` set, which would otherwise claim to be loading forever. + */ +function getEmptyMessage( + noun: string, + state: { + error: Error | null + hasMore: boolean + isFetchingMore: boolean + truncated: boolean + } +): string { + if (state.error) return 'No match — the list failed to load. Try reopening' + if (state.hasMore || state.isFetchingMore) return 'No match yet — still loading…' + if (state.truncated) return 'No match — too many to list. Try a more exact term' + // `noun` is singular on some connectors ("Base") and plural on others ("Spaces"), + // so only this settled message puts it behind a quantifier. + return `No ${noun} found` +} + function resolveDepValue( depFieldId: string, configFields: ConnectorConfigField[], diff --git a/apps/sim/hooks/selectors/providers/confluence/selectors.ts b/apps/sim/hooks/selectors/providers/confluence/selectors.ts index 91cddd8cea5..a0c2d352142 100644 --- a/apps/sim/hooks/selectors/providers/confluence/selectors.ts +++ b/apps/sim/hooks/selectors/providers/confluence/selectors.ts @@ -49,11 +49,12 @@ export const confluenceSelectors = { nextCursor: data.nextCursor, } }, + /** The server filters by key and returns nothing for a key that does not exist. */ + resolvesUnknownIds: true, /** - * Resolves a single space label. Hits only the first page — the dropdown's - * `fetchPage` stream populates the options cache for spaces beyond page 1, - * and `useSelectorOptionMap` merges them in. Walking all pages here would - * double API load since the stream is already running in parallel. + * Resolves a single space by key via the server's exact-key lookup, independent + * of how far the page drain has run — a space sorting beyond page 1 would + * otherwise never resolve, which on a large site is most of them. */ fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { if (!detailId) return null @@ -64,6 +65,7 @@ export const confluenceSelectors = { credential: credentialId, workflowId: context.workflowId, domain, + spaceKey: detailId, }, signal, }) @@ -87,6 +89,16 @@ export const confluenceSelectors = { search ?? '', ], enabled: ({ context }) => Boolean(context.oauthCredential && context.domain), + /** + * Deliberately a single request, not a drain. `/pages` is cursor-paginated and + * this list is therefore capped at `limit`, which is a real gap — but draining it + * is worse: with no search term `title` is unset, so the drain would walk the + * entire site (up to `MAX_AUTO_DRAIN_PAGES` requests) every time the dropdown + * opens, and the route does not forward an abort signal upstream, so superseded + * drains still bill the tenant's rate limit. Fixing this properly needs + * server-side search whose `title` semantics have been confirmed against a live + * instance, not brute-force loading. + */ fetchList: async ({ context, search, signal }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'confluence.pages') const domain = ensureDomain(context, 'confluence.pages') diff --git a/apps/sim/hooks/selectors/types.ts b/apps/sim/hooks/selectors/types.ts index 8a6eec1ebb3..8eddda86805 100644 --- a/apps/sim/hooks/selectors/types.ts +++ b/apps/sim/hooks/selectors/types.ts @@ -136,6 +136,13 @@ export interface SelectorDefinition { */ fetchPage?: (args: SelectorPageArgs) => Promise fetchById?: (args: SelectorQueryArgs) => Promise + /** + * Set when `fetchById` tolerates an id that may not exist, returning `null` rather + * than erroring. Only then is it safe to speculatively resolve whatever a user has + * typed — most implementations resolve a record by id and would turn every partial + * keystroke into a failed upstream request. + */ + resolvesUnknownIds?: boolean enabled?: (args: SelectorQueryArgs) => boolean staleTime?: number } diff --git a/apps/sim/hooks/selectors/use-selector-query.ts b/apps/sim/hooks/selectors/use-selector-query.ts index 0bf7979d4c5..ea95d7e879d 100644 --- a/apps/sim/hooks/selectors/use-selector-query.ts +++ b/apps/sim/hooks/selectors/use-selector-query.ts @@ -1,6 +1,6 @@ import { useEffect, useMemo } from 'react' import { createLogger } from '@sim/logger' -import { useInfiniteQuery, useQuery } from '@tanstack/react-query' +import { useInfiniteQuery, useQueries, useQuery } from '@tanstack/react-query' import { extractEnvVarName, isEnvVarReference, isReference } from '@/executor/constants' import { usePersonalEnvironment } from '@/hooks/queries/environment' import { getSelectorDefinition, mergeOption } from '@/hooks/selectors/registry' @@ -51,6 +51,15 @@ const EMPTY_PAGE: SelectorPage = { items: [], nextCursor: undefined } */ const MAX_AUTO_DRAIN_PAGES = 50 +/** Fallback freshness for selectors that do not declare their own `staleTime`. */ +export const DEFAULT_SELECTOR_STALE_TIME = 30_000 + +/** + * Fallback for a single-option resolution when the definition declares no + * `staleTime`: keyed by an exact id, so it changes far less often than a list. + */ +export const DEFAULT_SELECTOR_DETAIL_STALE_TIME = 300_000 + export function useSelectorOptions( key: SelectorKey, args: SelectorHookArgs @@ -69,7 +78,7 @@ export function useSelectorOptions( queryFn: ({ signal }) => definition.fetchList?.({ ...queryArgs, signal }) ?? Promise.resolve([]), enabled: !supportsPagination && isEnabled, - staleTime: definition.staleTime ?? 30_000, + staleTime: definition.staleTime ?? DEFAULT_SELECTOR_STALE_TIME, }) const pagedQuery = useInfiniteQuery({ @@ -85,7 +94,7 @@ export function useSelectorOptions( getNextPageParam: (last) => last.nextCursor, initialPageParam: undefined as string | undefined, enabled: supportsPagination && isEnabled, - staleTime: definition.staleTime ?? 30_000, + staleTime: definition.staleTime ?? DEFAULT_SELECTOR_STALE_TIME, }) const { hasNextPage, isFetchingNextPage, fetchNextPage, isError } = pagedQuery @@ -169,24 +178,80 @@ export function useSelectorOptionDetail( detailId: resolvedDetailId, } const hasRealDetailId = Boolean(resolvedDetailId) - const baseEnabled = - hasRealDetailId && definition.fetchById !== undefined - ? definition.enabled - ? definition.enabled(queryArgs) - : true - : false - const enabled = args.enabled ?? baseEnabled + /** + * Hard precondition: `queryFn` asserts `fetchById` is defined, so this must hold + * however the caller configures the query — otherwise the assertion throws for the + * many selectors that declare no `fetchById`. + */ + const canResolveDetail = hasRealDetailId && definition.fetchById !== undefined + /** + * `definition.enabled` describes when the *list* can be fetched, so it gates on + * context a list needs (credential, domain, region). Resolving one already-known id + * can need far less — `cloudwatch.*` echoes the id back without calling AWS at all — + * so a caller that opts in explicitly is only narrowed by the hard precondition. + * Callers that pass nothing keep the list predicate as their default. + */ + const enabled = + (args.enabled ?? (definition.enabled ? definition.enabled(queryArgs) : true)) && + canResolveDetail const query = useQuery({ queryKey: [...definition.getQueryKey(queryArgs), 'detail', resolvedDetailId ?? 'none'], queryFn: ({ signal }) => definition.fetchById!({ ...queryArgs, signal }), enabled, - staleTime: definition.staleTime ?? 300_000, + staleTime: definition.staleTime ?? DEFAULT_SELECTOR_DETAIL_STALE_TIME, }) return query } +/** + * Resolves several ids at once, so a multi-select field can label every selected + * value — including values restored from saved config, which no in-session search + * would have resolved. Query keys match {@link useSelectorOptionDetail} exactly, so + * the two share a cache and an id already resolved by search costs no extra request. + */ +export function useSelectorOptionDetails( + key: SelectorKey, + args: Omit & { detailIds: string[] } +): SelectorOption[] { + const { data: envVariables = {} } = usePersonalEnvironment() + const definition = getSelectorDefinition(key) + + const resolvedIds = useMemo(() => { + const out: string[] = [] + for (const id of args.detailIds) { + if (!id || isReference(id)) continue + if (isEnvVarReference(id)) { + const value = envVariables[extractEnvVarName(id)]?.value + if (value) out.push(value) + continue + } + out.push(id) + } + return Array.from(new Set(out)) + }, [args.detailIds, envVariables]) + + const results = useQueries({ + queries: resolvedIds.map((detailId) => { + const queryArgs: SelectorQueryArgs = { key, context: args.context, detailId } + const canResolveDetail = definition.fetchById !== undefined + return { + queryKey: [...definition.getQueryKey(queryArgs), 'detail', detailId], + queryFn: ({ signal }: { signal: AbortSignal }) => + definition.fetchById!({ ...queryArgs, signal }), + enabled: + args.enabled !== undefined + ? args.enabled && canResolveDetail + : canResolveDetail && (definition.enabled ? definition.enabled(queryArgs) : true), + staleTime: definition.staleTime ?? DEFAULT_SELECTOR_DETAIL_STALE_TIME, + } + }), + }) + + return useMemo(() => results.flatMap((result) => (result.data ? [result.data] : [])), [results]) +} + export function useSelectorOptionMap(options: SelectorOption[], extra?: SelectorOption | null) { return useMemo(() => { const merged = mergeOption(options, extra) diff --git a/apps/sim/lib/api/contracts/selectors/confluence.ts b/apps/sim/lib/api/contracts/selectors/confluence.ts index 8243361e8cf..b680a1d8c93 100644 --- a/apps/sim/lib/api/contracts/selectors/confluence.ts +++ b/apps/sim/lib/api/contracts/selectors/confluence.ts @@ -366,6 +366,16 @@ const defineConfluenceGetContract = (path: string, que export const confluenceSpacesSelectorBodySchema = credentialWorkflowDomainBodySchema.extend({ cursor: optionalString, + /** + * Exact space key to resolve server-side, bypassing pagination. Confluence v2 + * `/spaces` supports a `keys` filter, so a known key resolves in one request + * instead of depending on how far the background page drain has progressed. + */ + spaceKey: z + .string() + .min(1, 'spaceKey cannot be empty') + .max(255, 'spaceKey must be 255 characters or fewer') + .optional(), }) export const confluenceSpacesSelectorContract = definePostSelector( diff --git a/packages/emcn/src/components/combobox/combobox.tsx b/packages/emcn/src/components/combobox/combobox.tsx index fc4804c4b66..e3ed175e3d4 100644 --- a/packages/emcn/src/components/combobox/combobox.tsx +++ b/packages/emcn/src/components/combobox/combobox.tsx @@ -118,6 +118,19 @@ export interface ComboboxProps onArrowLeft?: () => void /** Enable search input in dropdown (useful for multiselect) */ searchable?: boolean + /** + * Notified when the dropdown's search box changes value, including the `''` a + * select, close, Escape, or ArrowLeft resets it to. Deduped, so an already-empty + * query resetting again is silent and a consumer sees nothing while `searchable` + * is false. + * + * This is the `searchable` search box only. In `editable` mode the typed text + * arrives via `onChange`, not here. + * + * Client-side filtering of `options` is unaffected — this is an additional + * signal for consumers that also resolve matches server-side. + */ + onSearchChange?: (query: string) => void /** Placeholder for search input */ searchPlaceholder?: string /** Size variant */ @@ -169,6 +182,7 @@ const Combobox = memo( onOpenChange, onArrowLeft, searchable = false, + onSearchChange, searchPlaceholder = 'Search...', align = 'start', dropdownWidth = 'trigger', @@ -184,7 +198,29 @@ const Combobox = memo( const listboxId = useId() const [open, setOpen] = useState(false) const [highlightedIndex, setHighlightedIndex] = useState(-1) - const [searchQuery, setSearchQuery] = useState('') + const [searchQuery, setSearchQueryState] = useState('') + /** + * Read through a ref so `updateSearchQuery` keeps a stable identity — + * `handleSelect`, `handleBlur`, and `handleKeyDown` all capture it without + * listing it as a dependency. + */ + const onSearchChangeRef = useRef(onSearchChange) + useEffect(() => { + onSearchChangeRef.current = onSearchChange + }, [onSearchChange]) + /** + * Single write path for the search box so `onSearchChange` cannot be missed on a + * reset. Deduped because several paths reset redundantly — Escape both handles the + * key and lets the popover dismiss, and an editable select blurs after selecting — + * which the raw setState absorbed silently but a consumer callback would not. + */ + const searchQueryRef = useRef('') + const updateSearchQuery = useCallback((next: string) => { + if (searchQueryRef.current === next) return + searchQueryRef.current = next + setSearchQueryState(next) + onSearchChangeRef.current?.(next) + }, []) const searchInputRef = useRef(null) const containerRef = useRef(null) const dropdownRef = useRef(null) @@ -299,7 +335,7 @@ const Combobox = memo( if (customOnSelect) { customOnSelect() // Always reset search/highlight so stale queries don't filter new options - setSearchQuery('') + updateSearchQuery('') setHighlightedIndex(-1) if (!keepOpen) { setOpen(false) @@ -318,7 +354,7 @@ const Combobox = memo( if (!keepOpen) { setOpen(false) setHighlightedIndex(-1) - setSearchQuery('') + updateSearchQuery('') if (editable && inputRef.current) { inputRef.current.blur() } @@ -365,7 +401,7 @@ const Combobox = memo( if (!activeElement || (!isInContainer && !isInDropdown && !isSearchInput)) { setOpen(false) setHighlightedIndex(-1) - setSearchQuery('') + updateSearchQuery('') } }, 150) }, []) @@ -380,7 +416,7 @@ const Combobox = memo( if (e.key === 'Escape') { setOpen(false) setHighlightedIndex(-1) - setSearchQuery('') + updateSearchQuery('') if (editable && inputRef.current) { inputRef.current.blur() } @@ -442,7 +478,7 @@ const Combobox = memo( if (open && onArrowLeft) { e.preventDefault() onArrowLeft() - setSearchQuery('') + updateSearchQuery('') setHighlightedIndex(-1) } } @@ -525,7 +561,7 @@ const Combobox = memo( open={open} onOpenChange={(next) => { setOpen(next) - if (!next) setSearchQuery('') + if (!next) updateSearchQuery('') onOpenChange?.(next) }} > @@ -664,7 +700,7 @@ const Combobox = memo( className='w-full bg-transparent text-[var(--text-primary)] text-small placeholder:text-[var(--text-muted)] focus:outline-none' placeholder={searchPlaceholder} value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} + onChange={(e) => updateSearchQuery(e.target.value)} onKeyDown={(e) => { // Forward navigation keys to main handler // Only forward ArrowLeft/ArrowRight when cursor is at the boundary