From 78804c93aa2904305efe9e9fd632e7e6b67b2933 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 21 Aug 2026 18:38:40 -0700 Subject: [PATCH 1/2] fix(kb): show spinner for pending connector sync --- .../[workspaceId]/knowledge/[id]/base.tsx | 2 +- apps/sim/hooks/queries/kb/connectors.test.ts | 81 ++++++++++++++++++- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 95c2e69871d..99d3e6a2e62 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -1091,7 +1091,7 @@ export function KnowledgeBase({ className={cn(chipVariants({ variant: 'filled' }), 'max-w-[180px]')} > - {connector.status === 'syncing' ? ( + {isConnectorSyncingOrPending(connector) ? ( ) : ( ConnectorIcon && diff --git a/apps/sim/hooks/queries/kb/connectors.test.ts b/apps/sim/hooks/queries/kb/connectors.test.ts index 14d7ead56ea..43243244e55 100644 --- a/apps/sim/hooks/queries/kb/connectors.test.ts +++ b/apps/sim/hooks/queries/kb/connectors.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ requestJson: vi.fn(), @@ -21,9 +21,37 @@ vi.mock('@/lib/api/client/request', () => ({ requestJson: mocks.requestJson, })) -import { listKnowledgeConnectorDocumentsContract } from '@/lib/api/contracts/knowledge' +import { + type ConnectorData, + listKnowledgeConnectorDocumentsContract, +} from '@/lib/api/contracts/knowledge' import { MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_PAGE_SIZE } from '@/lib/knowledge/constants' -import { useConnectorDocuments } from '@/hooks/queries/kb/connectors' +import { isConnectorSyncingOrPending, useConnectorDocuments } from '@/hooks/queries/kb/connectors' + +const NOW_MS = new Date('2026-08-21T12:00:00.000Z').getTime() + +function makeConnector(overrides: Partial = {}): ConnectorData { + const createdAt = new Date(NOW_MS - 60_000).toISOString() + + return { + id: 'connector-1', + knowledgeBaseId: 'knowledge-1', + connectorType: 'hubspot', + credentialId: 'credential-1', + sourceConfig: {}, + syncMode: 'full', + syncIntervalMinutes: 1440, + status: 'active', + lastSyncAt: null, + lastSyncError: null, + lastSyncDocCount: null, + nextSyncAt: null, + consecutiveFailures: 0, + createdAt, + updatedAt: createdAt, + ...overrides, + } +} interface ConnectorDocumentsPage { documents: Array<{ id: string }> @@ -39,6 +67,53 @@ interface ConnectorDocumentsQueryOptions { ) => number | undefined } +describe('isConnectorSyncingOrPending', () => { + beforeEach(() => { + vi.spyOn(Date, 'now').mockReturnValue(NOW_MS) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('treats a recently created active connector without a completed sync as pending', () => { + expect(isConnectorSyncingOrPending(makeConnector())).toBe(true) + }) + + it('treats a syncing connector as syncing regardless of its age or sync history', () => { + const connector = makeConnector({ + status: 'syncing', + createdAt: new Date(NOW_MS - 24 * 60 * 60 * 1000).toISOString(), + lastSyncAt: new Date(NOW_MS - 60 * 60 * 1000).toISOString(), + }) + + expect(isConnectorSyncingOrPending(connector)).toBe(true) + }) + + it('does not treat an active connector with a completed sync as pending', () => { + const connector = makeConnector({ + lastSyncAt: new Date(NOW_MS - 30_000).toISOString(), + }) + + expect(isConnectorSyncingOrPending(connector)).toBe(false) + }) + + it('stops treating an active connector as pending at the two-minute boundary', () => { + const connector = makeConnector({ + createdAt: new Date(NOW_MS - 2 * 60 * 1000).toISOString(), + }) + + expect(isConnectorSyncingOrPending(connector)).toBe(false) + }) + + it.each(['error', 'paused', 'disabled'] as const)( + 'does not treat a recent %s connector as pending', + (status) => { + expect(isConnectorSyncingOrPending(makeConnector({ status }))).toBe(false) + } + ) +}) + describe('useConnectorDocuments', () => { beforeEach(() => { vi.clearAllMocks() From 7a327378ecbd8f42efaa301f0b354dbe23c7d71f Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 21 Aug 2026 18:55:49 -0700 Subject: [PATCH 2/2] fix(kb): expire pending connector state --- apps/sim/hooks/queries/kb/connectors.test.ts | 26 ++++++++++++++++++-- apps/sim/hooks/queries/kb/connectors.ts | 3 +++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/sim/hooks/queries/kb/connectors.test.ts b/apps/sim/hooks/queries/kb/connectors.test.ts index 43243244e55..f7a282eb067 100644 --- a/apps/sim/hooks/queries/kb/connectors.test.ts +++ b/apps/sim/hooks/queries/kb/connectors.test.ts @@ -7,13 +7,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ requestJson: vi.fn(), useInfiniteQuery: vi.fn(), + useQuery: vi.fn(), })) vi.mock('@tanstack/react-query', () => ({ keepPreviousData: Symbol('keepPreviousData'), useInfiniteQuery: mocks.useInfiniteQuery, useMutation: vi.fn(), - useQuery: vi.fn(), + useQuery: mocks.useQuery, useQueryClient: vi.fn(() => ({ invalidateQueries: vi.fn() })), })) @@ -26,7 +27,11 @@ import { listKnowledgeConnectorDocumentsContract, } from '@/lib/api/contracts/knowledge' import { MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_PAGE_SIZE } from '@/lib/knowledge/constants' -import { isConnectorSyncingOrPending, useConnectorDocuments } from '@/hooks/queries/kb/connectors' +import { + isConnectorSyncingOrPending, + useConnectorDocuments, + useConnectorList, +} from '@/hooks/queries/kb/connectors' const NOW_MS = new Date('2026-08-21T12:00:00.000Z').getTime() @@ -67,6 +72,10 @@ interface ConnectorDocumentsQueryOptions { ) => number | undefined } +interface ConnectorListQueryOptions { + notifyOnChangeProps?: 'all' +} + describe('isConnectorSyncingOrPending', () => { beforeEach(() => { vi.spyOn(Date, 'now').mockReturnValue(NOW_MS) @@ -114,6 +123,19 @@ describe('isConnectorSyncingOrPending', () => { ) }) +describe('useConnectorList', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('notifies consumers when identical polls complete so pending UI can expire', () => { + useConnectorList('knowledge-1') + + const options = mocks.useQuery.mock.calls[0]?.[0] as ConnectorListQueryOptions + expect(options.notifyOnChangeProps).toBe('all') + }) +}) + describe('useConnectorDocuments', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/hooks/queries/kb/connectors.ts b/apps/sim/hooks/queries/kb/connectors.ts index a00aad93346..b2f3aebf05d 100644 --- a/apps/sim/hooks/queries/kb/connectors.ts +++ b/apps/sim/hooks/queries/kb/connectors.ts @@ -86,6 +86,9 @@ export function useConnectorList(knowledgeBaseId?: string) { enabled: Boolean(knowledgeBaseId), staleTime: CONNECTOR_LIST_STALE_TIME, placeholderData: keepPreviousData, + // Pending state is time-based, so identical poll responses must still trigger a render + // for consumers to drop the pending UI when its two-minute window expires. + notifyOnChangeProps: 'all', refetchInterval: (query) => { const connectors = query.state.data if (!connectors?.length) return false