From 2b313cb11692e3ad879b8791e5bfaf0e2bc544df Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 16:15:50 -0700 Subject: [PATCH 1/8] fix(connectors): stop the connector selector implying an option does not exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connector selector field ignored the drain state the shared selector hook already exposes. Paginated selectors fill in the background and the combobox filters client-side, so a not-yet-drained option is genuinely absent from the list — and the dropdown said "No spaces found", which reads as "this space does not exist" and sends users off to enter the value by hand. That is what happened with a Confluence space on a site with thousands of personal spaces. It now reports that the list is still filling, surfaces a failed drain instead of claiming to load forever, and is honest when the drain stops at its page cap. --- .../connector-selector-field.tsx | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) 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..8bf83543884 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 @@ -62,11 +62,41 @@ 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, }) + /** + * Shown when the combobox has nothing to display. `isLoading` renders a spinner + * instead until the first page lands, so by the time this is visible the list has + * loaded at least once and is empty because the user's search matched none of the + * options loaded *so far* — the wording is phrased for that case. + * + * Paginated selectors drain in the background and filter client-side, so an + * option that has not drained yet is genuinely absent. A flat "none found" reads + * as "it does not exist" and sends users off to enter the value by hand, which is + * exactly what happened with a Confluence space on a site whose drain runs for + * ~38s. Each branch instead explains why the list may still be incomplete. + * + * `error` is checked first: the drain halts on a failed page but leaves `hasMore` + * set, so a failure would otherwise claim to be loading forever. + */ + const emptyMessage = useMemo(() => { + const noun = field.title.toLowerCase() + if (error) return `No match — could not load all ${noun}. Enter the value directly` + if (hasMore || isFetchingMore) return `No match yet — still loading ${noun}…` + if (truncated) return `No match in the ${noun} loaded — enter the value directly` + return `No ${noun} found` + }, [field.title, error, hasMore, isFetchingMore, truncated]) + const comboboxOptions = useMemo( () => options.map((opt) => ({ label: opt.label, value: opt.id })), [options] @@ -99,7 +129,7 @@ export function ConnectorSelectorField({ : field.placeholder || `Select ${field.title.toLowerCase()}` } disabled={disabled || !credentialId || !depsResolved} - emptyMessage={`No ${field.title.toLowerCase()} found`} + emptyMessage={emptyMessage} /> ) } @@ -120,7 +150,7 @@ export function ConnectorSelectorField({ : field.placeholder || `Select ${field.title.toLowerCase()}` } disabled={disabled || !credentialId || !depsResolved} - emptyMessage={`No ${field.title.toLowerCase()} found`} + emptyMessage={emptyMessage} /> ) } From 3d779e9bc665cde4c1e8f502dbe9fb62677e2575 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 17:10:02 -0700 Subject: [PATCH 2/8] feat(connectors): resolve selector options by exact key without waiting on the drain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connector selector fills by draining pages in the background and filters client-side, so an option is only findable once its page has arrived. On a large Confluence site that is ~38 seconds, during which searching for a real space returned nothing. Resolves the typed value and the selected value directly through the selector's `fetchById`, merging both into the option list, so an exact key is selectable immediately regardless of drain progress. `confluence.spaces.fetchById` now uses the documented v2 `keys` filter instead of scanning only the first page — which never resolved a space sorting beyond it. Adds `onSearchChange` to the shared Combobox so a consumer can observe the search box, held in a ref so its identity stays stable for the handlers that capture it without declaring it. Also fixes a pre-existing hazard in useSelectorOptionDetail: a caller-supplied `enabled` replaced the guard that checks a definition declares `fetchById`, so `queryFn`'s non-null assertion would throw for the ~12 selectors without one. --- .../tools/confluence/selector-spaces/route.ts | 35 ++++-- .../connector-selector-field.tsx | 101 +++++++++++++----- .../providers/confluence/selectors.ts | 9 +- .../sim/hooks/selectors/use-selector-query.ts | 22 +++- .../lib/api/contracts/selectors/confluence.ts | 6 ++ .../emcn/src/components/combobox/combobox.tsx | 39 +++++-- 6 files changed, 159 insertions(+), 53 deletions(-) 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..ae414b0eace 100644 --- a/apps/sim/app/api/tools/confluence/selector-spaces/route.ts +++ b/apps/sim/app/api/tools/confluence/selector-spaces/route.ts @@ -45,7 +45,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,8 +101,17 @@ 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) + /** + * Exact-key lookup: one request, no pagination. The dropdown drains pages in + * the background and filters client-side, so a space only becomes findable + * once its page has arrived — on a large site that is tens of seconds away. + * Resolving a known key directly makes it available immediately. `status` is + * left unset so the key matches whether the space is current or archived. + */ + const params = spaceKey + ? new URLSearchParams({ keys: spaceKey, limit: String(PAGE_LIMIT) }) + : new URLSearchParams({ limit: String(PAGE_LIMIT), status }) + if (inner && !spaceKey) params.set('cursor', inner) const url = `${baseUrl}?${params.toString()}` const response = await fetch(url, { @@ -118,12 +127,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } 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 spaces = (data.results || []).map( + (space: { id: string; name: string; key: string; status?: SpaceStatus }) => ({ + id: space.id, + name: space.name, + key: space.key, + // An exact-key lookup is not scoped to one status, so trust the row's own. + status: space.status ?? status, + }) + ) let nextInner: string | undefined const nextLink = data._links?.next as string | undefined @@ -135,8 +147,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } } + // An exact-key lookup is a single resolution, never a page in a drained stream. let nextCursor: string | undefined - if (nextInner) { + if (spaceKey) { + nextCursor = undefined + } else if (nextInner) { nextCursor = `${status}:${nextInner}` } else if (status === 'current') { nextCursor = 'archived:' 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 8bf83543884..bd0c8eed7a3 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, @@ -10,7 +11,8 @@ import type { import { getDependsOnFields } from '@/blocks/utils' import type { ConnectorConfigField } from '@/connectors/types' import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types' -import { useSelectorOptions } from '@/hooks/selectors/use-selector-query' +import { useSelectorOptionDetail, useSelectorOptions } from '@/hooks/selectors/use-selector-query' +import { useDebounce } from '@/hooks/use-debounce' interface ConnectorSelectorFieldProps { field: ConnectorConfigField & { selectorKey: SelectorKey } @@ -34,6 +36,7 @@ export function ConnectorSelectorField({ disabled, }: ConnectorSelectorFieldProps) { const isMulti = Boolean(field.multi) + const [searchTerm, setSearchTerm] = useState('') const context = useMemo(() => { const ctx: SelectorContext = {} @@ -74,33 +77,54 @@ export function ConnectorSelectorField({ enabled: isEnabled, }) + const emptyMessage = getEmptyMessage(field.title.toLowerCase(), { + error, + hasMore, + isFetchingMore, + truncated, + }) + /** - * Shown when the combobox has nothing to display. `isLoading` renders a spinner - * instead until the first page lands, so by the time this is visible the list has - * loaded at least once and is empty because the user's search matched none of the - * options loaded *so far* — the wording is phrased for that case. - * - * Paginated selectors drain in the background and filter client-side, so an - * option that has not drained yet is genuinely absent. A flat "none found" reads - * as "it does not exist" and sends users off to enter the value by hand, which is - * exactly what happened with a Confluence space on a site whose drain runs for - * ~38s. Each branch instead explains why the list may still be incomplete. - * - * `error` is checked first: the drain halts on a failed page but leaves `hasMore` - * set, so a failure would otherwise claim to be loading forever. + * 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. Resolving the typed value directly makes an exact id/key selectable + * immediately, independent of drain progress. Debounced so typing does not + * issue a request per keystroke; selectors without a `fetchById` simply + * resolve nothing. */ - const emptyMessage = useMemo(() => { - const noun = field.title.toLowerCase() - if (error) return `No match — could not load all ${noun}. Enter the value directly` - if (hasMore || isFetchingMore) return `No match yet — still loading ${noun}…` - if (truncated) return `No match in the ${noun} loaded — enter the value directly` - return `No ${noun} found` - }, [field.title, error, hasMore, isFetchingMore, truncated]) - - const comboboxOptions = useMemo( - () => options.map((opt) => ({ label: opt.label, value: opt.id })), - [options] - ) + const debouncedSearch = useDebounce(searchTerm.trim(), SEARCH_DEBOUNCE_MS) + const { data: searchedOption } = useSelectorOptionDetail(field.selectorKey, { + context, + detailId: isEnabled && debouncedSearch.length > 0 ? debouncedSearch : undefined, + }) + + /** + * Resolve the *selected* value too, not just the typed one. Selecting resets the + * search box, which drops `searchedOption` a debounce later, and the drain may + * not reach that option's page for tens of seconds — never, when truncated — so + * the trigger would fall back to the placeholder for a value just picked. + * Mirrors `SelectorCombobox` in the workflow editor. Multi-select is not covered: + * resolving N ids needs N hooks, so a multi value beyond the drain still renders + * as its raw id. + */ + const singleValue = Array.isArray(value) ? value[0] : value + const { data: selectedOption } = useSelectorOptionDetail(field.selectorKey, { + context, + detailId: !isMulti && isEnabled && singleValue ? singleValue : undefined, + }) + + 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 extra of [searchedOption, selectedOption]) { + if (extra && !seen.has(extra.id)) { + seen.add(extra.id) + extras.push({ label: extra.label, value: extra.id }) + } + } + return extras.length > 0 ? [...extras, ...base] : base + }, [options, searchedOption, selectedOption]) if (isLoading && isEnabled) { return ( @@ -120,6 +144,7 @@ export function ConnectorSelectorField({ multiSelectValues={multiValues} onMultiSelectChange={(values) => onChange(values)} searchable + onSearchChange={setSearchTerm} searchPlaceholder={`Search ${field.title.toLowerCase()}...`} placeholder={ !credentialId @@ -134,13 +159,13 @@ export function ConnectorSelectorField({ ) } - const singleValue = Array.isArray(value) ? value[0] : value return ( onChange(next)} searchable + onSearchChange={setSearchTerm} searchPlaceholder={`Search ${field.title.toLowerCase()}...`} placeholder={ !credentialId @@ -155,6 +180,26 @@ export function ConnectorSelectorField({ ) } +/** + * 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 { + // `field.title` is singular on some connectors ("Base") and plural on others + // ("Spaces"), so only the settled message puts the noun behind a quantifier. + if (state.error) return 'No match — the list is incomplete. Enter the value directly' + if (state.hasMore || state.isFetchingMore) return 'No match yet — still loading…' + if (state.truncated) return 'No match in what loaded — enter the value directly' + 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..47309ebfba3 100644 --- a/apps/sim/hooks/selectors/providers/confluence/selectors.ts +++ b/apps/sim/hooks/selectors/providers/confluence/selectors.ts @@ -50,10 +50,10 @@ export const confluenceSelectors = { } }, /** - * 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 in one request via the server's exact-key + * lookup. Previously this fetched the first page and scanned it, so a space + * that sorts beyond page 1 never resolved — on a large site that is most of + * them. Keyed resolution is independent of how far the page drain has run. */ fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { if (!detailId) return null @@ -64,6 +64,7 @@ export const confluenceSelectors = { credential: credentialId, workflowId: context.workflowId, domain, + spaceKey: detailId, }, signal, }) diff --git a/apps/sim/hooks/selectors/use-selector-query.ts b/apps/sim/hooks/selectors/use-selector-query.ts index 0bf7979d4c5..193d8db3d36 100644 --- a/apps/sim/hooks/selectors/use-selector-query.ts +++ b/apps/sim/hooks/selectors/use-selector-query.ts @@ -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 + +/** + * Single-option resolutions are keyed by an exact id, so they change far less + * often than a list and can stay fresh longer. + */ +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 @@ -175,13 +184,18 @@ export function useSelectorOptionDetail( ? definition.enabled(queryArgs) : true : false - const enabled = args.enabled ?? baseEnabled + /** + * A caller's `enabled` narrows `baseEnabled` rather than replacing it: `queryFn` + * asserts `fetchById` is defined, so dropping that guard would throw for every + * selector that declares no `fetchById`. + */ + const enabled = (args.enabled ?? true) && baseEnabled 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 diff --git a/apps/sim/lib/api/contracts/selectors/confluence.ts b/apps/sim/lib/api/contracts/selectors/confluence.ts index 8243361e8cf..9ecae79884f 100644 --- a/apps/sim/lib/api/contracts/selectors/confluence.ts +++ b/apps/sim/lib/api/contracts/selectors/confluence.ts @@ -366,6 +366,12 @@ 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).max(255).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..509abfd929b 100644 --- a/packages/emcn/src/components/combobox/combobox.tsx +++ b/packages/emcn/src/components/combobox/combobox.tsx @@ -118,6 +118,18 @@ export interface ComboboxProps onArrowLeft?: () => void /** Enable search input in dropdown (useful for multiselect) */ searchable?: boolean + /** + * Notified when the dropdown's search box changes, and with `''` whenever the + * query is reset (select, close, blur, Escape, ArrowLeft) — including when + * `searchable` is false, since those resets are unconditional. + * + * 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 +181,7 @@ const Combobox = memo( onOpenChange, onArrowLeft, searchable = false, + onSearchChange, searchPlaceholder = 'Search...', align = 'start', dropdownWidth = 'trigger', @@ -185,6 +198,18 @@ const Combobox = memo( const [open, setOpen] = useState(false) const [highlightedIndex, setHighlightedIndex] = useState(-1) const [searchQuery, setSearchQuery] = 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) + onSearchChangeRef.current = onSearchChange + /** Single write path for the search box so `onSearchChange` cannot be missed on a reset. */ + const updateSearchQuery = useCallback((next: string) => { + setSearchQuery(next) + onSearchChangeRef.current?.(next) + }, []) const searchInputRef = useRef(null) const containerRef = useRef(null) const dropdownRef = useRef(null) @@ -299,7 +324,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 +343,7 @@ const Combobox = memo( if (!keepOpen) { setOpen(false) setHighlightedIndex(-1) - setSearchQuery('') + updateSearchQuery('') if (editable && inputRef.current) { inputRef.current.blur() } @@ -365,7 +390,7 @@ const Combobox = memo( if (!activeElement || (!isInContainer && !isInDropdown && !isSearchInput)) { setOpen(false) setHighlightedIndex(-1) - setSearchQuery('') + updateSearchQuery('') } }, 150) }, []) @@ -380,7 +405,7 @@ const Combobox = memo( if (e.key === 'Escape') { setOpen(false) setHighlightedIndex(-1) - setSearchQuery('') + updateSearchQuery('') if (editable && inputRef.current) { inputRef.current.blur() } @@ -442,7 +467,7 @@ const Combobox = memo( if (open && onArrowLeft) { e.preventDefault() onArrowLeft() - setSearchQuery('') + updateSearchQuery('') setHighlightedIndex(-1) } } @@ -525,7 +550,7 @@ const Combobox = memo( open={open} onOpenChange={(next) => { setOpen(next) - if (!next) setSearchQuery('') + if (!next) updateSearchQuery('') onOpenChange?.(next) }} > @@ -664,7 +689,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 From 58fb25a1af67759e949d9a23aea0be78b5dada7b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 17:16:55 -0700 Subject: [PATCH 3/8] fix(connectors): paginate the Confluence pages selector instead of returning one page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/api/tools/confluence/pages` issued a single request and discarded the `_links.next` cursor Confluence returns, so the picker showed only the first `limit` pages of however many exist — a search for a real page found nothing, with no error and no truncation signal. Threads the documented opaque cursor and moves the selector to `fetchPage`, so the option list drains like `confluence.spaces` and reports real `hasMore` / `truncated` state. The `title` server-side filter is unchanged and now paginates too. `limit` is deliberately left at its existing default: the endpoint's maximum is not confirmable from Atlassian's published reference, and raising it is not needed for correctness. Also guards `data.results`, which threw when the response omitted it. --- .../app/api/tools/confluence/pages/route.ts | 25 +++++++++++++++++-- .../providers/confluence/selectors.ts | 21 ++++++++++++---- .../lib/api/contracts/selectors/confluence.ts | 4 ++- 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/api/tools/confluence/pages/route.ts b/apps/sim/app/api/tools/confluence/pages/route.ts index 7d470190c42..c2c124a86e0 100644 --- a/apps/sim/app/api/tools/confluence/pages/route.ts +++ b/apps/sim/app/api/tools/confluence/pages/route.ts @@ -23,7 +23,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(confluencePagesSelectorContract, request, {}) if (!parsed.success) return parsed.response - const { domain, accessToken, title, cloudId: providedCloudId, limit } = parsed.data.body + const { domain, accessToken, title, cloudId: providedCloudId, limit, cursor } = parsed.data.body const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken)) @@ -43,6 +43,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { queryParams.append('title', title) } + if (cursor) { + queryParams.append('cursor', cursor) + } + const queryString = queryParams.toString() const url = queryString ? `${baseUrl}?${queryString}` : baseUrl @@ -82,8 +86,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } } + /** + * Confluence paginates with an opaque cursor carried in `_links.next`. Reading + * it is what lets the selector continue past the first page — without it the + * dropdown silently showed only `limit` pages of however many exist. + */ + let nextCursor: string | undefined + const nextLink = data._links?.next as string | undefined + if (nextLink) { + try { + nextCursor = + new URL(nextLink, 'https://placeholder').searchParams.get('cursor') || undefined + } catch { + nextCursor = undefined + } + } + return NextResponse.json({ - files: data.results.map((page: any) => ({ + files: (data.results || []).map((page: any) => ({ id: page.id, name: page.title, mimeType: 'confluence/page', @@ -92,6 +112,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { spaceId: page.spaceId, webViewLink: page._links?.webui || '', })), + nextCursor, }) } catch (error) { logger.error('Error fetching Confluence pages:', error) diff --git a/apps/sim/hooks/selectors/providers/confluence/selectors.ts b/apps/sim/hooks/selectors/providers/confluence/selectors.ts index 47309ebfba3..1d05576fae6 100644 --- a/apps/sim/hooks/selectors/providers/confluence/selectors.ts +++ b/apps/sim/hooks/selectors/providers/confluence/selectors.ts @@ -88,7 +88,14 @@ export const confluenceSelectors = { search ?? '', ], enabled: ({ context }) => Boolean(context.oauthCredential && context.domain), - fetchList: async ({ context, search, signal }: SelectorQueryArgs) => { + /** + * Paged rather than a single fetch: `/pages` is cursor-paginated, so one request + * returned only the first `limit` pages of however many exist and the rest were + * unreachable — a search for a real page silently found nothing. `search` is + * still forwarded as the server-side `title` filter, and pagination now applies + * to the filtered stream too. + */ + fetchPage: async ({ context, search, cursor, signal }) => { const credentialId = ensureCredential(context, 'confluence.pages') const domain = ensureDomain(context, 'confluence.pages') const bundle = await fetchOAuthToken(credentialId, context.workflowId) @@ -101,13 +108,17 @@ export const confluenceSelectors = { accessToken: bundle.accessToken, cloudId: bundle.cloudId, title: search, + cursor, }, signal, }) - return (data.files || []).map((file) => ({ - id: file.id, - label: file.name, - })) + return { + items: (data.files || []).map((file) => ({ + id: file.id, + label: file.name, + })), + nextCursor: data.nextCursor, + } }, fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { if (!detailId) return null diff --git a/apps/sim/lib/api/contracts/selectors/confluence.ts b/apps/sim/lib/api/contracts/selectors/confluence.ts index 9ecae79884f..6de1c972ef3 100644 --- a/apps/sim/lib/api/contracts/selectors/confluence.ts +++ b/apps/sim/lib/api/contracts/selectors/confluence.ts @@ -24,6 +24,8 @@ export const confluencePagesBodySchema = z.object({ cloudId: optionalString, title: optionalString, limit: z.number().int().positive().optional().default(50), + /** Opaque Confluence cursor for the next page, echoed back from `nextCursor`. */ + cursor: optionalString, }) /** @@ -386,7 +388,7 @@ export const confluenceSpacesSelectorContract = definePostSelector( export const confluencePagesSelectorContract = definePostSelector( '/api/tools/confluence/pages', confluencePagesBodySchema, - z.object({ files: z.array(fileOptionSchema) }) + z.object({ files: z.array(fileOptionSchema), nextCursor: optionalString }) ) export const confluencePageSelectorContract = definePostSelector( From 63603b5a90215fc212ec4c17cabd9c179685af68 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 17:25:46 -0700 Subject: [PATCH 4/8] fix(connectors): query both space statuses explicitly on exact-key lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exact-key lookup omitted `status`, assuming that matched a space whether it was current or archived. Atlassian documents a `current,archived` default for `/pages` but documents no default for `/spaces`, where `status` takes a single value rather than an array — so the assumption was unverified, and resolving only current spaces would silently miss archived ones. Archived spaces are reachable through the paged path and sync works against them. Queries `current` first and falls back to `archived` only when it finds nothing, so the common case stays one request and the behaviour no longer depends on an undocumented default. --- .../tools/confluence/selector-spaces/route.ts | 66 ++++++++++++------- 1 file changed, 43 insertions(+), 23 deletions(-) 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 ae414b0eace..aa806899150 100644 --- a/apps/sim/app/api/tools/confluence/selector-spaces/route.ts +++ b/apps/sim/app/api/tools/confluence/selector-spaces/route.ts @@ -101,32 +101,52 @@ 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) - /** - * Exact-key lookup: one request, no pagination. The dropdown drains pages in - * the background and filters client-side, so a space only becomes findable - * once its page has arrived — on a large site that is tens of seconds away. - * Resolving a known key directly makes it available immediately. `status` is - * left unset so the key matches whether the space is current or archived. - */ - const params = spaceKey - ? new URLSearchParams({ keys: spaceKey, limit: String(PAGE_LIMIT) }) - : new URLSearchParams({ limit: String(PAGE_LIMIT), status }) - if (inner && !spaceKey) params.set('cursor', inner) - const url = `${baseUrl}?${params.toString()}` - - const response = await fetch(url, { - method: 'GET', - headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}` }, - }) + const requestSpaces = async ( + search: URLSearchParams + ): Promise<{ ok: true; data: any } | { 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 NextResponse.json({ error: message }, { status: 502 }) + 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 }) } + } + + return { ok: true, data: await response.json() } } - const data = await response.json() + let data: any + if (spaceKey) { + /** + * Exact-key lookup, bypassing the paged drain the dropdown otherwise depends + * on. `status` is queried explicitly per value rather than omitted: it takes a + * single value on this endpoint (unlike `/pages`, where it is an array with a + * documented `current,archived` default), and no default is documented for + * `/spaces`. Archived spaces are reachable in the paged path and sync works + * against them, so resolving only `current` would silently miss them. + */ + let result = await requestSpaces( + new URLSearchParams({ keys: spaceKey, limit: String(PAGE_LIMIT), status: 'current' }) + ) + if (!result.ok) return result.response + if (!result.data.results?.length) { + result = await requestSpaces( + new URLSearchParams({ keys: spaceKey, limit: String(PAGE_LIMIT), status: 'archived' }) + ) + if (!result.ok) return result.response + } + data = result.data + } else { + 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 + data = result.data + } const spaces = (data.results || []).map( (space: { id: string; name: string; key: string; status?: SpaceStatus }) => ({ id: space.id, From e0d2a87043727a468ec0f914fa7fd838c5cd8a66 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 17:44:31 -0700 Subject: [PATCH 5/8] fix(connectors): revert pages drain, restore CloudWatch labels, tighten key lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six adversarial verification passes against the provider spec and the surrounding plumbing found three defects in the prior commits. Reverts the Confluence pages selector to a single request. Draining it was not strictly better: with no search term `title` is unset, so opening the dropdown walked the entire site — up to 50 sequential requests and 2,500 options where there had been one request and 50 — and the route forwards no abort signal upstream, so superseded drains still bill the tenant's rate limit. The same fan-out reached `loadAllSelectorOptions`, and the justification rested on `title` semantics Atlassian does not publish. The list cap is a real gap, but it needs confirmed server-side search, not brute force. Keeps the `results` guard. Restores selector display names for CloudWatch blocks. Narrowing a caller's `enabled` against `definition.enabled` disabled a detail query those selectors satisfy without AWS context, so collapsed blocks rendered "-" instead of the log group name. A caller that opts in is now narrowed only by the hard precondition that `fetchById` exists, which is what `queryFn` actually asserts. Queries both space statuses concurrently rather than sequentially: the key is user-typed text, so a miss dominates while typing and paid two round-trips. Each row now falls back to the status its own call requested. Also drops the "enter the value directly" empty states — the combobox is not editable, so there is no such affordance — and dedupes `onSearchChange`, which several reset paths fire redundantly. --- .../app/api/tools/confluence/pages/route.ts | 25 +---- .../tools/confluence/selector-spaces/route.ts | 102 +++++++++++------- .../connector-selector-field.tsx | 4 +- .../providers/confluence/selectors.ts | 27 +++-- .../sim/hooks/selectors/use-selector-query.ts | 25 +++-- .../lib/api/contracts/selectors/confluence.ts | 4 +- .../emcn/src/components/combobox/combobox.tsx | 10 +- 7 files changed, 106 insertions(+), 91 deletions(-) diff --git a/apps/sim/app/api/tools/confluence/pages/route.ts b/apps/sim/app/api/tools/confluence/pages/route.ts index c2c124a86e0..7d470190c42 100644 --- a/apps/sim/app/api/tools/confluence/pages/route.ts +++ b/apps/sim/app/api/tools/confluence/pages/route.ts @@ -23,7 +23,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(confluencePagesSelectorContract, request, {}) if (!parsed.success) return parsed.response - const { domain, accessToken, title, cloudId: providedCloudId, limit, cursor } = parsed.data.body + const { domain, accessToken, title, cloudId: providedCloudId, limit } = parsed.data.body const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken)) @@ -43,10 +43,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { queryParams.append('title', title) } - if (cursor) { - queryParams.append('cursor', cursor) - } - const queryString = queryParams.toString() const url = queryString ? `${baseUrl}?${queryString}` : baseUrl @@ -86,24 +82,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } } - /** - * Confluence paginates with an opaque cursor carried in `_links.next`. Reading - * it is what lets the selector continue past the first page — without it the - * dropdown silently showed only `limit` pages of however many exist. - */ - let nextCursor: string | undefined - const nextLink = data._links?.next as string | undefined - if (nextLink) { - try { - nextCursor = - new URL(nextLink, 'https://placeholder').searchParams.get('cursor') || undefined - } catch { - nextCursor = undefined - } - } - return NextResponse.json({ - files: (data.results || []).map((page: any) => ({ + files: data.results.map((page: any) => ({ id: page.id, name: page.title, mimeType: 'confluence/page', @@ -112,7 +92,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { spaceId: page.spaceId, webViewLink: page._links?.webui || '', })), - nextCursor, }) } catch (error) { logger.error('Error fetching Confluence pages:', error) 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 aa806899150..3cd85aaf4a5 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 @@ -103,7 +124,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const requestSpaces = async ( search: URLSearchParams - ): Promise<{ ok: true; data: any } | { ok: false; response: NextResponse }> => { + ): 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}` }, @@ -119,46 +140,54 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return { ok: true, data: await response.json() } } - let data: any + 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. `status` is queried explicitly per value rather than omitted: it takes a - * single value on this endpoint (unlike `/pages`, where it is an array with a - * documented `current,archived` default), and no default is documented for - * `/spaces`. Archived spaces are reachable in the paged path and sync works - * against them, so resolving only `current` would silently miss them. + * 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. */ - let result = await requestSpaces( - new URLSearchParams({ keys: spaceKey, limit: String(PAGE_LIMIT), status: 'current' }) - ) - if (!result.ok) return result.response - if (!result.data.results?.length) { - result = await requestSpaces( + 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' }) - ) - if (!result.ok) return result.response - } - data = result.data - } else { - 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 - data = result.data - } - const spaces = (data.results || []).map( - (space: { id: string; name: string; key: string; status?: SpaceStatus }) => ({ - id: space.id, - name: space.name, - key: space.key, - // An exact-key lookup is not scoped to one status, so trust the row's own. - status: space.status ?? status, + ), + ]) + if (!current.ok) return current.response + if (!archived.ok) return archived.response + + // A single resolution, never a page in a drained stream, so no cursor. + return NextResponse.json({ + spaces: [ + ...toSpaces(current.data.results, 'current'), + ...toSpaces(archived.data.results, 'archived'), + ], + nextCursor: undefined, }) - ) + } + + 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 @@ -167,17 +196,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } } - // An exact-key lookup is a single resolution, never a page in a drained stream. let nextCursor: string | undefined - if (spaceKey) { - nextCursor = undefined - } else if (nextInner) { + if (nextInner) { nextCursor = `${status}:${nextInner}` } else if (status === 'current') { 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 bd0c8eed7a3..0fc88471c74 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 @@ -194,9 +194,9 @@ function getEmptyMessage( ): string { // `field.title` is singular on some connectors ("Base") and plural on others // ("Spaces"), so only the settled message puts the noun behind a quantifier. - if (state.error) return 'No match — the list is incomplete. Enter the value directly' + 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 in what loaded — enter the value directly' + if (state.truncated) return 'No match — too many to list. Try a more exact term' return `No ${noun} found` } diff --git a/apps/sim/hooks/selectors/providers/confluence/selectors.ts b/apps/sim/hooks/selectors/providers/confluence/selectors.ts index 1d05576fae6..98e3247a7d5 100644 --- a/apps/sim/hooks/selectors/providers/confluence/selectors.ts +++ b/apps/sim/hooks/selectors/providers/confluence/selectors.ts @@ -89,13 +89,16 @@ export const confluenceSelectors = { ], enabled: ({ context }) => Boolean(context.oauthCredential && context.domain), /** - * Paged rather than a single fetch: `/pages` is cursor-paginated, so one request - * returned only the first `limit` pages of however many exist and the rest were - * unreachable — a search for a real page silently found nothing. `search` is - * still forwarded as the server-side `title` filter, and pagination now applies - * to the filtered stream too. + * 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. */ - fetchPage: async ({ context, search, cursor, signal }) => { + fetchList: async ({ context, search, signal }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'confluence.pages') const domain = ensureDomain(context, 'confluence.pages') const bundle = await fetchOAuthToken(credentialId, context.workflowId) @@ -108,17 +111,13 @@ export const confluenceSelectors = { accessToken: bundle.accessToken, cloudId: bundle.cloudId, title: search, - cursor, }, signal, }) - return { - items: (data.files || []).map((file) => ({ - id: file.id, - label: file.name, - })), - nextCursor: data.nextCursor, - } + return (data.files || []).map((file) => ({ + id: file.id, + label: file.name, + })) }, fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { if (!detailId) return null diff --git a/apps/sim/hooks/selectors/use-selector-query.ts b/apps/sim/hooks/selectors/use-selector-query.ts index 193d8db3d36..efe372f03a4 100644 --- a/apps/sim/hooks/selectors/use-selector-query.ts +++ b/apps/sim/hooks/selectors/use-selector-query.ts @@ -178,18 +178,23 @@ export function useSelectorOptionDetail( detailId: resolvedDetailId, } const hasRealDetailId = Boolean(resolvedDetailId) - const baseEnabled = - hasRealDetailId && definition.fetchById !== undefined - ? definition.enabled - ? definition.enabled(queryArgs) - : true - : false /** - * A caller's `enabled` narrows `baseEnabled` rather than replacing it: `queryFn` - * asserts `fetchById` is defined, so dropping that guard would throw for every - * selector that declares no `fetchById`. + * 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 enabled = (args.enabled ?? true) && baseEnabled + 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 !== undefined + ? args.enabled && canResolveDetail + : canResolveDetail && (definition.enabled ? definition.enabled(queryArgs) : true) const query = useQuery({ queryKey: [...definition.getQueryKey(queryArgs), 'detail', resolvedDetailId ?? 'none'], diff --git a/apps/sim/lib/api/contracts/selectors/confluence.ts b/apps/sim/lib/api/contracts/selectors/confluence.ts index 6de1c972ef3..9ecae79884f 100644 --- a/apps/sim/lib/api/contracts/selectors/confluence.ts +++ b/apps/sim/lib/api/contracts/selectors/confluence.ts @@ -24,8 +24,6 @@ export const confluencePagesBodySchema = z.object({ cloudId: optionalString, title: optionalString, limit: z.number().int().positive().optional().default(50), - /** Opaque Confluence cursor for the next page, echoed back from `nextCursor`. */ - cursor: optionalString, }) /** @@ -388,7 +386,7 @@ export const confluenceSpacesSelectorContract = definePostSelector( export const confluencePagesSelectorContract = definePostSelector( '/api/tools/confluence/pages', confluencePagesBodySchema, - z.object({ files: z.array(fileOptionSchema), nextCursor: optionalString }) + z.object({ files: z.array(fileOptionSchema) }) ) export const confluencePageSelectorContract = definePostSelector( diff --git a/packages/emcn/src/components/combobox/combobox.tsx b/packages/emcn/src/components/combobox/combobox.tsx index 509abfd929b..fd40ff88647 100644 --- a/packages/emcn/src/components/combobox/combobox.tsx +++ b/packages/emcn/src/components/combobox/combobox.tsx @@ -205,8 +205,16 @@ const Combobox = memo( */ const onSearchChangeRef = useRef(onSearchChange) onSearchChangeRef.current = onSearchChange - /** Single write path for the search box so `onSearchChange` cannot be missed on a reset. */ + /** + * 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 `setSearchQuery` absorbed silently but a consumer callback would not. + */ + const searchQueryRef = useRef('') const updateSearchQuery = useCallback((next: string) => { + if (searchQueryRef.current === next) return + searchQueryRef.current = next setSearchQuery(next) onSearchChangeRef.current?.(next) }, []) From 63216f971ea7d843017052848b31d03d6ee75f8a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 17:53:34 -0700 Subject: [PATCH 6/8] fix(connectors): keep resolved option labels and survive a half-failed key lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses three review findings. A key lookup failed entirely when either status leg errored, discarding a match the other leg had found. Only a total failure is fatal now. Resolved option labels are remembered for the lifetime of the field. Both lookups key on values that change — the search box clears on select and close, and a multi-select field resolves no id at all — so the label for a just-picked option vanished a debounce later and the trigger fell back to a raw id. This covers the multi-select case, which is what the Confluence space field uses. A failed exact-value lookup now reads differently from a failed list load, rather than being reported as "still loading" or "none found". --- .../tools/confluence/selector-spaces/route.ts | 9 ++- .../connector-selector-field.tsx | 78 ++++++++++++------- 2 files changed, 57 insertions(+), 30 deletions(-) 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 3cd85aaf4a5..4ad5c0f2629 100644 --- a/apps/sim/app/api/tools/confluence/selector-spaces/route.ts +++ b/apps/sim/app/api/tools/confluence/selector-spaces/route.ts @@ -167,14 +167,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { new URLSearchParams({ keys: spaceKey, limit: String(PAGE_LIMIT), status: 'archived' }) ), ]) - if (!current.ok) return current.response - if (!archived.ok) return archived.response + // 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: [ - ...toSpaces(current.data.results, 'current'), - ...toSpaces(archived.data.results, 'archived'), + ...(current.ok ? toSpaces(current.data.results, 'current') : []), + ...(archived.ok ? toSpaces(archived.data.results, 'archived') : []), ], nextCursor: undefined, }) 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 0fc88471c74..e193992f5a8 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,6 +1,6 @@ 'use client' -import { useMemo, useState } from 'react' +import { useEffect, 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' @@ -10,7 +10,7 @@ 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 type { SelectorContext, SelectorKey } from '@/hooks/selectors/types' +import type { SelectorContext, SelectorKey, SelectorOption } from '@/hooks/selectors/types' import { useSelectorOptionDetail, useSelectorOptions } from '@/hooks/selectors/use-selector-query' import { useDebounce } from '@/hooks/use-debounce' @@ -77,35 +77,24 @@ export function ConnectorSelectorField({ enabled: isEnabled, }) - const emptyMessage = getEmptyMessage(field.title.toLowerCase(), { - error, - hasMore, - isFetchingMore, - truncated, - }) - /** * 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. Resolving the typed value directly makes an exact id/key selectable * immediately, independent of drain progress. Debounced so typing does not - * issue a request per keystroke; selectors without a `fetchById` simply - * resolve nothing. + * issue a request per keystroke; selectors without a `fetchById` resolve nothing. */ const debouncedSearch = useDebounce(searchTerm.trim(), SEARCH_DEBOUNCE_MS) - const { data: searchedOption } = useSelectorOptionDetail(field.selectorKey, { + const { data: searchedOption, error: searchError } = useSelectorOptionDetail(field.selectorKey, { context, detailId: isEnabled && debouncedSearch.length > 0 ? debouncedSearch : undefined, }) /** - * Resolve the *selected* value too, not just the typed one. Selecting resets the - * search box, which drops `searchedOption` a debounce later, and the drain may - * not reach that option's page for tens of seconds — never, when truncated — so - * the trigger would fall back to the placeholder for a value just picked. - * Mirrors `SelectorCombobox` in the workflow editor. Multi-select is not covered: - * resolving N ids needs N hooks, so a multi value beyond the drain still renders - * as its raw id. + * Resolve the *selected* value too, not just the typed one, so the trigger does + * not fall back to a raw id for something just picked. Single-select only: + * resolving N ids would need N hooks, so multi-select relies on the remembered + * options below. */ const singleValue = Array.isArray(value) ? value[0] : value const { data: selectedOption } = useSelectorOptionDetail(field.selectorKey, { @@ -113,18 +102,47 @@ export function ConnectorSelectorField({ detailId: !isMulti && isEnabled && singleValue ? singleValue : undefined, }) + const emptyMessage = getEmptyMessage(field.title.toLowerCase(), { + error, + lookupFailed: Boolean(searchError), + hasMore, + isFetchingMore, + truncated, + }) + + /** + * Resolved options are remembered for the lifetime of the field. Both lookups are + * keyed on values that change — the search box clears on select and close, and a + * multi-select field resolves no id at all (that would need one hook per id) — so + * reading them directly would drop a label moments after it appeared, leaving the + * trigger showing a raw id for something the user just picked. + */ + const [resolvedOptions, setResolvedOptions] = useState>({}) + useEffect(() => { + const found = [searchedOption, selectedOption].filter(Boolean) as SelectorOption[] + if (found.length === 0) return + setResolvedOptions((prev) => { + let next = prev + for (const option of found) { + if (next[option.id] === option.label) continue + if (next === prev) next = { ...prev } + next[option.id] = option.label + } + return next + }) + }, [searchedOption, selectedOption]) + 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 extra of [searchedOption, selectedOption]) { - if (extra && !seen.has(extra.id)) { - seen.add(extra.id) - extras.push({ label: extra.label, value: extra.id }) - } + for (const [id, label] of Object.entries(resolvedOptions)) { + if (seen.has(id)) continue + seen.add(id) + extras.push({ label, value: id }) } return extras.length > 0 ? [...extras, ...base] : base - }, [options, searchedOption, selectedOption]) + }, [options, resolvedOptions]) if (isLoading && isEnabled) { return ( @@ -190,11 +208,19 @@ export function ConnectorSelectorField({ */ function getEmptyMessage( noun: string, - state: { error: Error | null; hasMore: boolean; isFetchingMore: boolean; truncated: boolean } + state: { + error: Error | null + lookupFailed: boolean + hasMore: boolean + isFetchingMore: boolean + truncated: boolean + } ): string { // `field.title` is singular on some connectors ("Base") and plural on others // ("Spaces"), so only the settled message puts the noun behind a quantifier. if (state.error) return 'No match — the list failed to load. Try reopening' + // Distinct from the list failing: the list is fine, resolving the typed value is not. + if (state.lookupFailed) return 'No match — could not check that exact value' 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' return `No ${noun} found` From e92262329260805d0c7cf67d0f3048bf6d857797 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 18:00:19 -0700 Subject: [PATCH 7/8] fix(connectors): drop remembered option labels when the selector context changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolved ids are only meaningful within one selector context, so switching credential, domain, or a dependency has to discard what was remembered under the old one — the queries re-key, but the remembered labels would linger and mislabel until the field remounted. Keyed on the serialized context rather than its identity: the context memo also depends on `sourceConfig`, so its identity changes on unrelated field edits and would clear the cache far more often than intended. --- .../connector-selector-field.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 e193992f5a8..19861666834 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 @@ -118,6 +118,18 @@ export function ConnectorSelectorField({ * trigger showing a raw id for something the user just picked. */ const [resolvedOptions, setResolvedOptions] = useState>({}) + /** + * Ids are only meaningful within one selector context, so switching credential, + * domain, or a dependency must drop what was resolved under the old one — the + * queries re-key, but remembered labels would otherwise linger and mislabel. + * Keyed on the serialized context rather than its identity: the memo also depends + * on `sourceConfig`, so its identity changes on unrelated field edits. + */ + const contextKey = useMemo(() => JSON.stringify(context), [context]) + useEffect(() => { + setResolvedOptions((prev) => (Object.keys(prev).length > 0 ? {} : prev)) + }, [contextKey]) + useEffect(() => { const found = [searchedOption, selectedOption].filter(Boolean) as SelectorOption[] if (found.length === 0) return From 10bb5083df74b23e574e9d303d6204b5d24ac9c4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 18:08:35 -0700 Subject: [PATCH 8/8] refactor(connectors): resolve selected option labels with useQueries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the remembered-label map with per-id queries over the selected values, following the pattern the knowledge-base selector already uses. The map only ever held ids searched for in the current session, so a multi-select field restored from saved config still rendered raw ids — the case that actually matters. It also needed two effects and a serialized-context key to avoid leaking labels across a context change, all of which disappear: queries key on the context, so a label cannot outlive it. Gates the speculative lookup of typed text on a new `resolvesUnknownIds` flag, set only where `fetchById` returns null for an id that does not exist. Most implementations resolve a record by id, so every partial keystroke was a failed upstream request, retried once, and its error made "could not check that exact value" the normal empty state on those selectors. Also: pass handlers straight to the combobox rather than through identity wrappers that defeated its memo, move the latest-callback ref write into an effect, rename the raw search setter so the deduping write path cannot be bypassed, and correct the prop and staleTime docs. --- .../connector-selector-field.tsx | 104 +++++++----------- .../providers/confluence/selectors.ts | 9 +- apps/sim/hooks/selectors/types.ts | 7 ++ .../sim/hooks/selectors/use-selector-query.ts | 58 +++++++++- .../lib/api/contracts/selectors/confluence.ts | 6 +- .../emcn/src/components/combobox/combobox.tsx | 17 +-- 6 files changed, 118 insertions(+), 83 deletions(-) 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 19861666834..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,6 +1,6 @@ 'use client' -import { useEffect, useMemo, useState } 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' @@ -10,8 +10,13 @@ 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 type { SelectorContext, SelectorKey, SelectorOption } from '@/hooks/selectors/types' -import { useSelectorOptionDetail, useSelectorOptions } from '@/hooks/selectors/use-selector-query' +import { getSelectorDefinition } from '@/hooks/selectors/registry' +import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types' +import { + useSelectorOptionDetail, + useSelectorOptionDetails, + useSelectorOptions, +} from '@/hooks/selectors/use-selector-query' import { useDebounce } from '@/hooks/use-debounce' interface ConnectorSelectorFieldProps { @@ -78,83 +83,55 @@ export function ConnectorSelectorField({ }) /** - * 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. Resolving the typed value directly makes an exact id/key selectable - * immediately, independent of drain progress. Debounced so typing does not - * issue a request per keystroke; selectors without a `fetchById` resolve nothing. + * 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 debouncedSearch = useDebounce(searchTerm.trim(), SEARCH_DEBOUNCE_MS) - const { data: searchedOption, error: searchError } = useSelectorOptionDetail(field.selectorKey, { + 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, - detailId: isEnabled && debouncedSearch.length > 0 ? debouncedSearch : undefined, + detailIds: isEnabled ? selectedIds : [], }) /** - * Resolve the *selected* value too, not just the typed one, so the trigger does - * not fall back to a raw id for something just picked. Single-select only: - * resolving N ids would need N hooks, so multi-select relies on the remembered - * options below. + * 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 singleValue = Array.isArray(value) ? value[0] : value - const { data: selectedOption } = useSelectorOptionDetail(field.selectorKey, { + const resolvesUnknownIds = Boolean(getSelectorDefinition(field.selectorKey).resolvesUnknownIds) + const debouncedSearch = useDebounce(searchTerm.trim(), SEARCH_DEBOUNCE_MS) + const { data: searchedOption } = useSelectorOptionDetail(field.selectorKey, { context, - detailId: !isMulti && isEnabled && singleValue ? singleValue : undefined, + detailId: + resolvesUnknownIds && isEnabled && debouncedSearch.length > 0 ? debouncedSearch : undefined, }) const emptyMessage = getEmptyMessage(field.title.toLowerCase(), { error, - lookupFailed: Boolean(searchError), hasMore, isFetchingMore, truncated, }) - /** - * Resolved options are remembered for the lifetime of the field. Both lookups are - * keyed on values that change — the search box clears on select and close, and a - * multi-select field resolves no id at all (that would need one hook per id) — so - * reading them directly would drop a label moments after it appeared, leaving the - * trigger showing a raw id for something the user just picked. - */ - const [resolvedOptions, setResolvedOptions] = useState>({}) - /** - * Ids are only meaningful within one selector context, so switching credential, - * domain, or a dependency must drop what was resolved under the old one — the - * queries re-key, but remembered labels would otherwise linger and mislabel. - * Keyed on the serialized context rather than its identity: the memo also depends - * on `sourceConfig`, so its identity changes on unrelated field edits. - */ - const contextKey = useMemo(() => JSON.stringify(context), [context]) - useEffect(() => { - setResolvedOptions((prev) => (Object.keys(prev).length > 0 ? {} : prev)) - }, [contextKey]) - - useEffect(() => { - const found = [searchedOption, selectedOption].filter(Boolean) as SelectorOption[] - if (found.length === 0) return - setResolvedOptions((prev) => { - let next = prev - for (const option of found) { - if (next[option.id] === option.label) continue - if (next === prev) next = { ...prev } - next[option.id] = option.label - } - return next - }) - }, [searchedOption, selectedOption]) - 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 [id, label] of Object.entries(resolvedOptions)) { - if (seen.has(id)) continue - seen.add(id) - extras.push({ label, value: id }) + 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, resolvedOptions]) + }, [options, selectedOptions, searchedOption]) if (isLoading && isEnabled) { return ( @@ -172,7 +149,7 @@ export function ConnectorSelectorField({ multiSelect options={comboboxOptions} multiSelectValues={multiValues} - onMultiSelectChange={(values) => onChange(values)} + onMultiSelectChange={onChange} searchable onSearchChange={setSearchTerm} searchPlaceholder={`Search ${field.title.toLowerCase()}...`} @@ -193,7 +170,7 @@ export function ConnectorSelectorField({ onChange(next)} + onChange={onChange} searchable onSearchChange={setSearchTerm} searchPlaceholder={`Search ${field.title.toLowerCase()}...`} @@ -222,19 +199,16 @@ function getEmptyMessage( noun: string, state: { error: Error | null - lookupFailed: boolean hasMore: boolean isFetchingMore: boolean truncated: boolean } ): string { - // `field.title` is singular on some connectors ("Base") and plural on others - // ("Spaces"), so only the settled message puts the noun behind a quantifier. if (state.error) return 'No match — the list failed to load. Try reopening' - // Distinct from the list failing: the list is fine, resolving the typed value is not. - if (state.lookupFailed) return 'No match — could not check that exact value' 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` } diff --git a/apps/sim/hooks/selectors/providers/confluence/selectors.ts b/apps/sim/hooks/selectors/providers/confluence/selectors.ts index 98e3247a7d5..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 by key in one request via the server's exact-key - * lookup. Previously this fetched the first page and scanned it, so a space - * that sorts beyond page 1 never resolved — on a large site that is most of - * them. Keyed resolution is independent of how far the page drain has run. + * 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 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 efe372f03a4..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' @@ -55,8 +55,8 @@ const MAX_AUTO_DRAIN_PAGES = 50 export const DEFAULT_SELECTOR_STALE_TIME = 30_000 /** - * Single-option resolutions are keyed by an exact id, so they change far less - * often than a list and can stay fresh longer. + * 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 @@ -192,9 +192,8 @@ export function useSelectorOptionDetail( * Callers that pass nothing keep the list predicate as their default. */ const enabled = - args.enabled !== undefined - ? args.enabled && canResolveDetail - : canResolveDetail && (definition.enabled ? definition.enabled(queryArgs) : true) + (args.enabled ?? (definition.enabled ? definition.enabled(queryArgs) : true)) && + canResolveDetail const query = useQuery({ queryKey: [...definition.getQueryKey(queryArgs), 'detail', resolvedDetailId ?? 'none'], @@ -206,6 +205,53 @@ export function useSelectorOptionDetail( 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 9ecae79884f..b680a1d8c93 100644 --- a/apps/sim/lib/api/contracts/selectors/confluence.ts +++ b/apps/sim/lib/api/contracts/selectors/confluence.ts @@ -371,7 +371,11 @@ export const confluenceSpacesSelectorBodySchema = credentialWorkflowDomainBodySc * `/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).max(255).optional(), + 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 fd40ff88647..e3ed175e3d4 100644 --- a/packages/emcn/src/components/combobox/combobox.tsx +++ b/packages/emcn/src/components/combobox/combobox.tsx @@ -119,9 +119,10 @@ export interface ComboboxProps /** Enable search input in dropdown (useful for multiselect) */ searchable?: boolean /** - * Notified when the dropdown's search box changes, and with `''` whenever the - * query is reset (select, close, blur, Escape, ArrowLeft) — including when - * `searchable` is false, since those resets are unconditional. + * 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. @@ -197,25 +198,27 @@ 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) - onSearchChangeRef.current = 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 `setSearchQuery` absorbed silently but a consumer callback would not. + * 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 - setSearchQuery(next) + setSearchQueryState(next) onSearchChangeRef.current?.(next) }, []) const searchInputRef = useRef(null)