diff --git a/apps/docs/content/docs/search/confluence.mdx b/apps/docs/content/docs/search/confluence.mdx index 1f7887a2de1..d6f5b3f806b 100644 --- a/apps/docs/content/docs/search/confluence.mdx +++ b/apps/docs/content/docs/search/confluence.mdx @@ -132,7 +132,7 @@ Central sources combine space permissions, page and ancestor restrictions, and g Open **Settings → Sources → Confluence**, then a source's **Documents**, **Settings**, or **Sync history**. Invite teammates through **Settings → Members → Invite** or SSO, then have them connect through **Integrations**. **People → Request connections** only requests a provider connection; it does not invite people to the organization. -Syncing runs automatically. Admins can use **Sync now** for an immediate update, **Pause syncing** to stop scheduled syncs, or **Resume syncing** to restart them. **Full resync**, available for service-account connections, fetches unchanged content again and asks for confirmation. Successful manual syncs have a one-minute cooldown; failed syncs can be retried immediately. +Syncing runs automatically. Admins can use **Sync now** for an immediate update, **Pause syncing** to stop scheduled syncs, or **Resume syncing** to restart them. Successful manual syncs have a one-minute cooldown; failed syncs can be retried immediately. ## Troubleshooting diff --git a/apps/docs/content/docs/search/connect-your-account.mdx b/apps/docs/content/docs/search/connect-your-account.mdx index 353fc520956..b82cdf817a2 100644 --- a/apps/docs/content/docs/search/connect-your-account.mdx +++ b/apps/docs/content/docs/search/connect-your-account.mdx @@ -78,7 +78,7 @@ On the main **Integrations** page, select **Reconnect** beside the integration i Admins manage setup from **Settings → Sources**. Open an integration, then its connection to see **Documents**, **Settings**, and **Sync history**. **People** shows account contributors across integrations and supports filtering by integration. This does not grant the admin access to every document. -Syncing runs automatically. Admins can use **Sync now** when they need an update; another manual run is available 60 seconds after a successful sync finishes. Failed or partial runs can be retried immediately. The connection header also offers **Pause syncing** or **Resume syncing**, and **Remove connection**. Where supported, **Full resync** fetches and reindexes all content and asks for confirmation first. +Syncing runs automatically. Admins can use **Sync now** when they need an update; another manual run is available 60 seconds after a successful sync finishes. Failed or partial runs can be retried immediately. The connection header also offers **Pause syncing** or **Resume syncing**, and **Remove connection**. Removing a Search connection also removes its indexed documents from Sim. The originals remain in the connected app. diff --git a/apps/docs/content/docs/search/gitlab.mdx b/apps/docs/content/docs/search/gitlab.mdx index 7dbc4f15fa5..55848547f6c 100644 --- a/apps/docs/content/docs/search/gitlab.mdx +++ b/apps/docs/content/docs/search/gitlab.mdx @@ -146,7 +146,7 @@ Open a project to use these administrator actions: | **Settings** | Change the token, project, filters, or CSV permissions. | | **Remove connection** | Confirm removal of the connection and its indexed documents. Documents cannot be retained without the connection that maintains their permissions. | -GitLab does not expose a separate **Full resync** action. Each sync checks the selected content. CSV grants change only when you replace the files. +Each sync checks the selected content. CSV grants change only when you replace the files. ## Troubleshooting diff --git a/apps/docs/content/docs/search/index.mdx b/apps/docs/content/docs/search/index.mdx index 68b53e20f2a..063d27d9243 100644 --- a/apps/docs/content/docs/search/index.mdx +++ b/apps/docs/content/docs/search/index.mdx @@ -104,7 +104,7 @@ Edit **Settings** to change an existing connection's filters. Adding another con **Sync using** shows the method selected when the source was created. Add a new connection to change that method. To replace a supported indexing credential, select its replacement and use **Change service account** or **Change account**, as shown. -The connection header offers **Sync now**, **Pause syncing** or **Resume syncing**, **Remove connection**, and, where supported, **Full resync**. Full resync fetches all content again and requires confirmation. Manual runs have a 60-second cooldown after a successful sync finishes; failed or partial runs can be retried immediately. **Pause syncing** becomes available when the current sync finishes. +The connection header offers **Sync now**, **Pause syncing** or **Resume syncing**, and **Remove connection**. Manual runs have a 60-second cooldown after a successful sync finishes; failed or partial runs can be retried immediately. **Pause syncing** becomes available when the current sync finishes. To deactivate an entire integration, open it from **Settings → Sources**, select **Deactivate**, and confirm. Its content becomes unavailable in Search, Assistant, and MCP; saved connections remain. Select **Activate** on that integration to enable it again. diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.test.tsx index b94535be58c..c6050bf0f43 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.test.tsx @@ -347,6 +347,8 @@ describe('ConnectOAuthModal reauthorization', () => { }, }) + expect(container.querySelector('header')).toHaveTextContent('Reconnect Slack') + expect(container).not.toHaveTextContent('tool requires access') await clickConnect() expect(mocks.writeOAuthReturnContext).toHaveBeenCalledExactlyOnceWith( @@ -424,6 +426,8 @@ describe('ConnectOAuthModal reauthorization', () => { }, }) + expect(container.querySelector('header')).toHaveTextContent('Connect Slack') + expect(container).toHaveTextContent('tool requires access') await clickConnect() expect(mocks.writeOAuthReturnContext).toHaveBeenCalledOnce() diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx index 4ec62347415..e624f434d0e 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx @@ -506,7 +506,16 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { ? `An integration named "${existingCredential.displayName}" already exists.` : undefined) - const title = `Connect ${providerName}` + const isConnectorReconnect = !isConnect && props.returnContext?.origin === 'kb-connectors' + const connectLabel = isConnectorReconnect + ? newScopes.length > 0 + ? 'Update access' + : 'Reconnect' + : 'Connect' + const title = + isConnectorReconnect && newScopes.length > 0 + ? `Update ${providerName} access` + : `${connectLabel} ${providerName}` return ( @@ -519,7 +528,11 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { {!isConnect && (

- The "{props.toolName}" tool requires access to your account. + {isConnectorReconnect + ? newScopes.length > 0 + ? 'Approve the requested permissions to continue syncing.' + : `Continue to ${providerName} to restore this connection.` + : `The "${props.toolName}" tool requires access to your account.`}

)} @@ -651,7 +664,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { : undefined } primaryAction={{ - label: isPending ? 'Connecting...' : 'Connect', + label: isPending ? 'Connecting...' : connectLabel, onClick: handleConnect, disabled: isDisabled, }} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-documents/connector-document-status-filter.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-documents/connector-document-status-filter.tsx new file mode 100644 index 00000000000..749b08d852c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-documents/connector-document-status-filter.tsx @@ -0,0 +1,39 @@ +'use client' + +import { ChipDropdown } from '@sim/emcn' +import type { + ConnectorDocumentFilter, + ConnectorDocumentsData, +} from '@/lib/api/contracts/knowledge/connectors' + +interface ConnectorDocumentStatusFilterProps { + filter: ConnectorDocumentFilter + onFilterChange: (filter: ConnectorDocumentFilter) => void + counts?: ConnectorDocumentsData['counts'] + isLoading: boolean +} + +export function ConnectorDocumentStatusFilter({ + filter, + onFilterChange, + counts = { active: 0, excluded: 0, failed: 0, skipped: 0 }, + isLoading, +}: ConnectorDocumentStatusFilterProps) { + return ( + { + if (value === 'active' || value === 'excluded' || value === 'failed' || value === 'skipped') + onFilterChange(value) + }} + matchTriggerWidth={false} + options={[ + { value: 'active', label: isLoading ? 'Included' : `Included (${counts.active})` }, + { value: 'excluded', label: isLoading ? 'Excluded' : `Excluded (${counts.excluded})` }, + { value: 'failed', label: isLoading ? 'Failed' : `Failed (${counts.failed})` }, + { value: 'skipped', label: isLoading ? 'Skipped' : `Skipped (${counts.skipped})` }, + ]} + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-documents/connector-documents.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-documents/connector-documents.tsx index a63cf633a05..7323a54c0ac 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-documents/connector-documents.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-documents/connector-documents.tsx @@ -1,10 +1,11 @@ 'use client' -import { Chip, ChipDropdown, ChipInput, ChipLink, Skeleton } from '@sim/emcn' +import { Chip, ChipInput, ChipLink, Skeleton } from '@sim/emcn' import { RefreshCw, Search, SquareArrowUpRight } from '@sim/emcn/icons' import type { ConnectorDocumentFilter } from '@/lib/api/contracts/knowledge/connectors' import type { ResourceScope } from '@/lib/core/resource-scope' import { getDocumentIndexingStatus } from '@/lib/knowledge/documents/types' +import { ConnectorDocumentStatusFilter } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-documents/connector-document-status-filter' import { SettingsEmptyState, SettingsQueryErrorState, @@ -25,6 +26,7 @@ interface ConnectorDocumentsProps { connectorId: string search?: string searchControl?: { value: string; onChange: (value: string) => void } + showToolbar?: boolean progressScope?: ResourceScope isSearchIndex?: boolean syncing?: boolean @@ -38,6 +40,7 @@ export function ConnectorDocuments({ filter, search, searchControl, + showToolbar = true, progressScope, isSearchIndex = false, syncing, @@ -79,41 +82,26 @@ export function ConnectorDocuments({ {isSearchIndex && (

Documents you can access

)} -
- {searchControl && ( - searchControl.onChange(event.target.value)} - autoComplete='off' - className='min-w-0 flex-1' + {showToolbar && ( +
+ {searchControl && ( + searchControl.onChange(event.target.value)} + autoComplete='off' + className='min-w-0 flex-1' + /> + )} + - )} - { - if ( - value === 'active' || - value === 'excluded' || - value === 'failed' || - value === 'skipped' - ) - onFilterChange(value) - }} - matchTriggerWidth={false} - options={[ - { value: 'active', label: isLoading ? 'Included' : `Included (${counts.active})` }, - { - value: 'excluded', - label: isLoading ? 'Excluded' : `Excluded (${counts.excluded})`, - }, - { value: 'failed', label: isLoading ? 'Failed' : `Failed (${counts.failed})` }, - { value: 'skipped', label: isLoading ? 'Skipped' : `Skipped (${counts.skipped})` }, - ]} - /> -
+
+ )}
{query.isError && !query.isFetchNextPageError ? ( + history?: { + expanded: boolean + contentId: string + onToggle: () => void + } +} + +export function ConnectorActions({ state, triggerRef, history }: ConnectorActionsProps) { + if (!state.canEdit && !history) return null + const actions = orderHeaderActions([ + ...state.actions, + ...(history + ? [ + { + id: 'history', + text: history.expanded ? 'Hide history' : 'Sync history', + onSelect: history.onToggle, + }, + ] + : []), + ]) return ( -
-
- -
- -
+ + + + + + {actions.map(({ action }, index) => ( + + {action.id === 'delete' && index > 0 && } + + + ))} + + + ) +} + +interface ConnectorActionMenuItemProps { + action: SettingsAction + history?: ConnectorActionsProps['history'] +} + +function ConnectorActionMenuItem({ action, history }: ConnectorActionMenuItemProps) { + const item = ( + + {action.text} + + ) + return action.tooltip ? ( + + + {item} + + {action.tooltip} + + ) : ( + item ) } @@ -30,7 +101,7 @@ interface ConnectorActionFeedbackProps { export function ConnectorActionFeedback({ state }: ConnectorActionFeedbackProps) { const deleteDocumentsId = useId() if (!state.canEdit) return null - const { removal, fullResync } = state + const { removal } = state return ( <> {state.error && ( @@ -38,22 +109,6 @@ export function ConnectorActionFeedback({ state }: ConnectorActionFeedbackProps) {state.error.message} )} - - {fullResync.error?.message} - { - if ( - showOAuthModal && - (requiresAccountSettings || - (connector.credentialId && !selectedCredential && !credentialsLoading)) - ) { + if (showOAuthModal && requiresAccountSettings) { setShowOAuthModal(false) } - }, [ - showOAuthModal, - connector.credentialId, - selectedCredential, - credentialsLoading, - requiresAccountSettings, - ]) + }, [showOAuthModal, requiresAccountSettings]) function openReconnect() { if (!canEdit || disabled || requiresAccountSettings) return @@ -102,13 +93,14 @@ export function ConnectorRecovery({ )} {connector.status === 'disabled' ? ( ({ children, disabled, onSelect, - }: { - children?: ReactNode - disabled?: boolean + ...props + }: React.ButtonHTMLAttributes & { onSelect: () => void }) => ( - ), @@ -252,14 +251,29 @@ vi.mock('@/hooks/use-credential-refresh-triggers', () => ({ })) import { - ConnectorActions, + ConnectorActions as ConnectorActionMenu, ConnectorRecovery, ConnectorSyncHistory, ConnectorsSection, SyncHistory, } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section' +import { ConnectorActionFeedback } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-actions' +import { + type ConnectorActionsOptions, + useConnectorActions, +} from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/use-connector-actions' import { type ConnectorData, useConnectorDetail } from '@/hooks/queries/kb/connectors' +function ConnectorActions(props: ConnectorActionsOptions) { + const state = useConnectorActions(props) + return ( + <> + + + + ) +} + let root: Root | null = null function makeLog(overrides: Partial & Pick): SyncLogData { @@ -373,6 +387,37 @@ afterEach(() => { }) describe('Connector credential reauthorization', () => { + it('expands each connection history independently through its labeled control', () => { + const container = renderSection(makeConnector(), [makeConnector({ id: 'connector-2' })]) + const controls = Array.from( + container.querySelectorAll('button[aria-label="Sync history"]') + ) + expect(controls).toHaveLength(2) + const historyId = controls[0].getAttribute('aria-controls') + expect(historyId).toBeTruthy() + expect(historyId).not.toBe(controls[1].getAttribute('aria-controls')) + + act(() => controls[0].click()) + + expect(controls[0].getAttribute('aria-expanded')).toBe('true') + expect(controls[1].getAttribute('aria-expanded')).toBe('false') + const history = document.getElementById(historyId!)! + const collapse = findButton(history, 'Hide history') + expect(collapse.getAttribute('aria-controls')).toBe(historyId) + expect(collapse.getAttribute('aria-expanded')).toBe('true') + act(() => { + collapse.focus() + collapse.click() + }) + expect(document.getElementById(historyId!)).toBeNull() + expect(controls[0].getAttribute('aria-expanded')).toBe('false') + expect(controls[1].getAttribute('aria-expanded')).toBe('false') + expect(document.activeElement).toBe( + container.querySelector('button[aria-label="Connection actions"]') + ) + expect(lifecycle.sync.mutate).not.toHaveBeenCalled() + }) + it('distinguishes configured sites and spaces without exposing credential fields', () => { const container = renderSection( makeConnector({ @@ -392,19 +437,45 @@ describe('Connector credential reauthorization', () => { expect(container.textContent).not.toContain('private-token') }) - it('fails closed when the connector credential cannot be resolved', () => { - const container = renderSection(makeConnector()) - const reconnectButton = Array.from(container.querySelectorAll('button')).find( - (button) => button.textContent === 'Reconnect' + it('routes an unavailable credential to settings without starting OAuth', () => { + const onEdit = vi.fn() + const container = renderComponent( + ) + act(() => findButton(container, 'Settings').click()) + expect(onEdit).toHaveBeenCalledOnce() + expect(connectOAuthModalMock).not.toHaveBeenCalled() + }) - expect(reconnectButton?.disabled).toBe(true) - - act(() => reconnectButton?.click()) - + it('waits for credential loading before deciding how to recover', () => { + oauthCredentialsState.isFetching = true + const container = renderSection(makeConnector()) + expect(findButton(container, 'Reconnect')).toBeDisabled() expect(connectOAuthModalMock).not.toHaveBeenCalled() }) + it.each([true, false])('renders loading and empty states with canEdit=%s', (canEdit) => { + const renderEmpty = (isLoading: boolean) => ( + + ) + const container = renderComponent(renderEmpty(true)) + expect(container.textContent).toContain('Loading connections…') + act(() => root?.render(renderEmpty(false))) + expect(container.textContent).toContain('No connected sources yet.') + }) + it('reauthorizes with the resolved credential provider and identity', () => { oauthCredentialsState.current = [ { id: 'credential-1', name: 'Workspace Slack', provider: 'slack-custom' }, @@ -515,6 +586,15 @@ describe('Connector credential reauthorization', () => { expect(connectOAuthModalMock).not.toHaveBeenCalled() }) + it('keeps successful-sync notices available when history is opened', () => { + const notice = 'Deletion reconciliation deferred until the next complete listing.' + const container = renderSection(makeConnector({ status: 'active', lastSyncError: notice })) + expect(container.textContent).not.toContain(notice) + act(() => findButton(container, 'Sync history').click()) + expect(container.textContent).toContain(notice) + expect(lifecycle.sync.mutate).not.toHaveBeenCalled() + }) + it('preserves pending OAuth return context when the recovery modal closes', () => { oauthCredentialsState.current = [ { id: 'credential-1', name: 'Workspace Slack', provider: 'slack-custom' }, @@ -741,32 +821,25 @@ describe('Connector credential reauthorization', () => { }) describe('shared connector lifecycle actions', () => { - it('distinguishes an incremental sync from a supported full resync', () => { - const container = renderComponent( - - ) - act(() => findButton(container, 'Sync now').click()) - expect(lifecycle.sync.mutate).toHaveBeenLastCalledWith({ - knowledgeBaseId: 'knowledge-1', - connectorId: 'connector-1', - rehydrate: false, - }) - lifecycle.sync.mutate.mockClear() - act(() => findButton(container, 'Full resync').click()) - expect(lifecycle.sync.mutate).not.toHaveBeenCalled() - const dialog = container.querySelector('[role="dialog"]')! - act(() => findButton(dialog, 'Full resync').click()) - expect(lifecycle.sync.mutate).toHaveBeenLastCalledWith( - { knowledgeBaseId: 'knowledge-1', connectorId: 'connector-1', rehydrate: true }, - expect.objectContaining({ onSuccess: expect.any(Function) }) - ) - act(() => lifecycle.sync.mutate.mock.lastCall![1].onSuccess()) - expect(container.querySelector('[role="dialog"]')).toBeNull() - }) + it.each(['confluence', 'databricks', 'github', 'gitlab'])( + 'offers only normal sync for %s', + (connectorType) => { + const container = renderComponent( + + ) + expect(container.textContent).not.toContain('Full resync') + act(() => findButton(container, 'Sync now').click()) + expect(lifecycle.sync.mutate).toHaveBeenCalledExactlyOnceWith({ + knowledgeBaseId: 'knowledge-1', + connectorId: 'connector-1', + }) + expect(container.querySelector('[role="dialog"]')).toBeNull() + } + ) it.each(['pending', 'running', 'disabled'] as const)( 'does not offer content resync or dispatch work while the member engine is %s', @@ -793,20 +866,6 @@ describe('shared connector lifecycle actions', () => { } ) - it('cancels a full resync without dispatching work', () => { - const container = renderComponent( - - ) - act(() => findButton(container, 'Full resync').click()) - act(() => findButton(container.querySelector('[role="dialog"]')!, 'Cancel').click()) - expect(lifecycle.sync.mutate).not.toHaveBeenCalled() - expect(container.querySelector('[role="dialog"]')).toBeNull() - }) - it.each([ { status: 'syncing', accessMode: 'admin', memberSyncStatus: 'idle', blocked: true }, { status: 'active', accessMode: 'members', memberSyncStatus: 'running', blocked: true }, diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx index 8b8d5c4e68a..b2848f37e64 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx @@ -1,18 +1,24 @@ 'use client' -import { useState } from 'react' -import { Badge, Chip, cn, OverflowText, Tooltip } from '@sim/emcn' -import { ChevronDown, ChevronUp, CircleAlert, Loader, Users } from '@sim/emcn/icons' +import { useId, useRef, useState } from 'react' +import { Badge, Chip, cn } from '@sim/emcn' +import { Loader, Users } from '@sim/emcn/icons' import { format, formatDistanceToNow, isPast } from 'date-fns' import type { ConnectorData } from '@/lib/api/contracts/knowledge/connectors' import { type ResourceScope, resourceScopeFromOwner } from '@/lib/core/resource-scope' import { describeSearchSource } from '@/lib/sim-search/source-identity' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' -import { ConnectorActions } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-actions' +import { + ConnectorActionFeedback, + ConnectorActions, +} from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-actions' import { ConnectorRecovery } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-recovery' import { ConnectorSyncHistory } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-sync-history' import { getConnectorSyncState } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-sync-state' +import { useConnectorActions } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/use-connector-actions' import { EditConnectorModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' interface ConnectorsSectionProps { @@ -47,18 +53,15 @@ export function ConnectorsSection({ }: ConnectorsSectionProps) { const scope = explicitScope ?? resourceScopeFromOwner({ workspaceId }) const [editingConnector, setEditingConnector] = useState(null) - if (connectors.length === 0 && !canEdit && !isLoading) return null return (
{isLoading ? ( -
+ Loading connections… ) : connectors.length === 0 ? ( -

- No connected sources yet. Connect an external source to automatically sync documents. -

+ No connected sources yet. ) : ( -
+
{connectors.map((connector) => ( (null) + const actions = useConnectorActions({ connector, knowledgeBaseId, canEdit, onEdit }) + const historyId = useId() const [expanded, setExpanded] = useState(false) const connectorDef = CONNECTOR_META_REGISTRY[connector.connectorType] const sourceDescription = connectorDef @@ -113,101 +119,58 @@ function ConnectorCard({ getConnectorSyncState(connector) const statusConfig = STATUS_CONFIG[effectiveStatus as keyof typeof STATUS_CONFIG] || STATUS_CONFIG.active + const syncDetails = [ + lastSyncAt && `Last sync: ${format(new Date(lastSyncAt), 'MMM d, h:mm a')}`, + !syncsPerMember && connector.lastSyncDocCount !== null && `${connector.lastSyncDocCount} docs`, + nextSyncAt && + connector.status === 'active' && + !syncInFlight && + `Next sync: ${ + isPast(new Date(nextSyncAt)) + ? 'pending' + : formatDistanceToNow(new Date(nextSyncAt), { addSuffix: true }) + }`, + ] + .filter(Boolean) + .join(' · ') return ( -
-
-
- {Icon && } -
-
- - - {syncInFlight && } - - - {statusConfig.label} +
+ } + iconVariant='custom' + title={connectorDef?.name || connector.connectorType} + description={sourceDescription || undefined} + badge={ + <> + + {statusConfig.label} + + {syncsPerMember && ( + + Per member - {syncsPerMember && ( - - Per member - - )} -
- {sourceDescription && ( - - - )} -
- {lastSyncAt && ( - Last sync: {format(new Date(lastSyncAt), 'MMM d, h:mm a')} - )} - {!syncsPerMember && connector.lastSyncDocCount !== null && ( - <> - · - {connector.lastSyncDocCount} docs - - )} - {nextSyncAt && connector.status === 'active' && !syncInFlight && ( - <> - · - - Next sync:{' '} - {isPast(new Date(nextSyncAt)) - ? 'pending' - : formatDistanceToNow(new Date(nextSyncAt), { addSuffix: true })} - - - )} - {lastSyncError && ( - - - - - {lastSyncError} - - )} - {connector.accessRewritePending && ( - <> - · - - - Updating access - - - )} -
-
-
- -
+ + } + trailing={ setExpanded((previous) => !previous), + }} /> - - - setExpanded((prev) => !prev)} - aria-label={expanded ? 'Hide history' : 'Sync history'} - aria-expanded={expanded} - leftIcon={expanded ? ChevronUp : ChevronDown} - /> - - {expanded ? 'Hide history' : 'Sync history'} - -
-
+ } + /> + {connector.accessRewritePending && ( + + + Updating access + + )} + {expanded && ( -
+
+
+ {syncDetails && ( +

{syncDetails}

+ )} + { + setExpanded(false) + actionsTriggerRef.current?.focus() + }} + > + Hide history + +
+ {lastSyncError && }
)} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/use-connector-actions.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/use-connector-actions.ts index 7c8f45295c2..9fbbcda5690 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/use-connector-actions.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/use-connector-actions.ts @@ -33,7 +33,6 @@ export function useConnectorActions({ const update = useUpdateConnector() const remove = useDeleteConnector() const [confirmRemove, setConfirmRemove] = useState(false) - const [confirmFullResync, setConfirmFullResync] = useState(false) const [deleteDocuments, setDeleteDocuments] = useState(false) const requiresDocumentDeletion = connector.accessMode !== 'workspace' const state = getConnectorSyncState(connector) @@ -49,13 +48,10 @@ export function useConnectorActions({ remove.reset() } - function triggerSync(rehydrate = false) { - if (!canEdit || actionsDisabled || state.syncDisabled || (rehydrate && !state.canFullResync)) - return + function triggerSync() { + if (!canEdit || actionsDisabled || state.syncDisabled) return resetErrors() - const input = { knowledgeBaseId, connectorId: connector.id, rehydrate } - if (rehydrate) sync.mutate(input, { onSuccess: () => setConfirmFullResync(false) }) - else sync.mutate(input) + sync.mutate({ knowledgeBaseId, connectorId: connector.id }) } function setRemoveOpen(open: boolean) { @@ -74,20 +70,6 @@ export function useConnectorActions({ tooltip: state.syncTooltip, onSelect: () => triggerSync(), }, - ...(state.canFullResync - ? [ - { - id: 'full-resync', - text: 'Full resync', - disabled: state.syncDisabled || actionsDisabled, - onSelect: () => { - if (!canEdit || actionsDisabled || state.syncDisabled) return - resetErrors() - setConfirmFullResync(true) - }, - }, - ] - : []), ...(onEdit ? [{ id: 'settings', text: 'Settings', disabled: actionsDisabled, onSelect: onEdit }] : []), @@ -123,19 +105,7 @@ export function useConnectorActions({ actions, actionsDisabled, canEdit, - error: (confirmFullResync ? null : sync.error) ?? update.error, - fullResync: { - open: confirmFullResync, - onOpenChange: (open: boolean) => { - if (sync.isPending) return - setConfirmFullResync(open) - if (!open) sync.reset() - }, - pending: sync.isPending, - disabled: actionsDisabled || state.syncDisabled || !state.canFullResync, - error: sync.error, - onConfirm: () => triggerSync(true), - }, + error: sync.error ?? update.error, removal: { open: confirmRemove, onOpenChange: setRemoveOpen, diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-documents-tab.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-documents-tab.test.tsx index d748e7ff572..8405ec68aec 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-documents-tab.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-documents-tab.test.tsx @@ -24,22 +24,65 @@ vi.mock('@/hooks/queries/kb/knowledge', () => ({ useUpdateDocument: () => ({ ...mocks.retryState, mutate: mocks.retry }), })) +vi.mock( + '@/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form', + () => ({ + useConnectorSettingsForm: () => ({ + displayName: 'GitHub', + saving: false, + canSave: true, + save: vi.fn(), + fieldsProps: {}, + }), + }) +) +vi.mock( + '@/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields', + () => ({ ConnectorSettingsFields: () =>
Settings content
}) +) + +import type { ConnectorData } from '@/lib/api/contracts/knowledge/connectors' import { ConnectorDocuments } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-documents/connector-documents' -import { ConnectorDocumentsTab } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-documents-tab' +import { EditConnectorModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal' + +const connector: ConnectorData = { + id: 'connector', + knowledgeBaseId: 'kb', + connectorType: 'github', + credentialId: null, + sourceConfig: {}, + syncMode: 'full', + syncIntervalMinutes: 60, + status: 'active', + lastSyncAt: null, + lastSyncError: null, + lastSyncDocCount: null, + nextSyncAt: null, + consecutiveFailures: 0, + accessMode: 'workspace', + viewerMembership: null, + credentialGroupId: null, + credentialGroupOptionId: null, + memberSyncStatus: 'idle', + lastMemberSyncAt: null, + nextMemberSyncAt: null, + lastMemberSyncError: null, + memberSyncConsecutiveFailures: 0, + accessRewritePending: false, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', +} let root: Root -function render() { +function render(showDocuments = true) { root = createRoot(document.createElement('div')) rerender() + if (showDocuments) act(() => button('Documents').click()) } function rerender() { act(() => root.render( - - - - - + ) ) } @@ -118,6 +161,31 @@ afterEach(() => { }) describe('connector document recovery', () => { + it('keeps focused navigation mounted and document filters selected across tabs', () => { + render(false) + expect(mocks.query).not.toHaveBeenCalled() + const settings = button('Settings') + const documents = button('Documents') + act(() => { + documents.focus() + documents.click() + }) + expect(document.activeElement).toBe(documents) + selectStatus('Failed (1)') + act(() => { + settings.focus() + settings.click() + }) + expect(button('Settings')).toBe(settings) + expect(button('Documents')).toBe(documents) + expect(document.activeElement).toBe(settings) + expect(document.querySelector('[aria-label="Document status"]')).toBeNull() + act(() => documents.click()) + expect(document.querySelector('[aria-label="Document status"]')?.textContent).toContain( + 'Failed (1)' + ) + }) + it('labels Search documents by the current viewer access without changing general knowledge bases', () => { render() expect(document.body.textContent).not.toContain('Documents you can access') diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-documents-tab.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-documents-tab.tsx index da2b7a5188d..6acf63c4d06 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-documents-tab.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-documents-tab.tsx @@ -1,28 +1,49 @@ 'use client' -import { useState } from 'react' -import { ChipModalField } from '@sim/emcn' import type { ConnectorDocumentFilter } from '@/lib/api/contracts/knowledge/connectors' +import { ConnectorDocumentStatusFilter } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-documents/connector-document-status-filter' import { ConnectorDocuments } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-documents/connector-documents' +import { useConnectorDocuments } from '@/hooks/queries/kb/connectors' interface ConnectorDocumentsTabProps { knowledgeBaseId: string connectorId: string + filter: ConnectorDocumentFilter + onFilterChange: (filter: ConnectorDocumentFilter) => void } export function ConnectorDocumentsTab({ knowledgeBaseId, connectorId, + filter, + onFilterChange, }: ConnectorDocumentsTabProps) { - const [filter, setFilter] = useState('active') return ( - +
- +
+ ) +} + +export function ConnectorDocumentsTabFilter({ + knowledgeBaseId, + connectorId, + filter, + onFilterChange, +}: ConnectorDocumentsTabProps) { + const query = useConnectorDocuments(knowledgeBaseId, connectorId, { filter }) + return ( + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx index 1ca946f2895..298ba33260d 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx @@ -8,8 +8,12 @@ import { ChipModalHeader, ChipModalTabs, } from '@sim/emcn' +import type { ConnectorDocumentFilter } from '@/lib/api/contracts/knowledge/connectors' import type { ResourceScope } from '@/lib/core/resource-scope' -import { ConnectorDocumentsTab } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-documents-tab' +import { + ConnectorDocumentsTab, + ConnectorDocumentsTabFilter, +} from '@/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-documents-tab' import { ConnectorSettingsFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields' import { useConnectorSettingsForm } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form' import { withBrandIcon } from '@/blocks/brand-icon' @@ -33,6 +37,7 @@ export function EditConnectorModal({ scope, }: EditConnectorModalProps) { const [activeTab, setActiveTab] = useState('settings') + const [documentFilter, setDocumentFilter] = useState('active') const form = useConnectorSettingsForm({ scope, knowledgeBaseId, @@ -56,19 +61,34 @@ export function EditConnectorModal({ Edit {form.displayName} - +
+ + {activeTab === 'documents' && ( + + )} +
{activeTab === 'settings' ? ( ) : ( - + )}
{activeTab === 'settings' && ( diff --git a/apps/sim/hooks/queries/kb/connectors.ts b/apps/sim/hooks/queries/kb/connectors.ts index f0c284635f2..e7f89997665 100644 --- a/apps/sim/hooks/queries/kb/connectors.ts +++ b/apps/sim/hooks/queries/kb/connectors.ts @@ -757,18 +757,12 @@ export function useDeleteConnector() { interface TriggerSyncParams { knowledgeBaseId: string connectorId: string - /** Force re-hydration + re-index of rendered content (the "Full resync" action). */ - rehydrate?: boolean } -async function triggerSync({ - knowledgeBaseId, - connectorId, - rehydrate, -}: TriggerSyncParams): Promise { +async function triggerSync({ knowledgeBaseId, connectorId }: TriggerSyncParams): Promise { await requestJson(triggerKnowledgeConnectorSyncContract, { params: { id: knowledgeBaseId, connectorId }, - query: rehydrate ? { rehydrate: true } : {}, + query: {}, }) } diff --git a/packages/emcn/src/components/chip-modal/chip-modal.tsx b/packages/emcn/src/components/chip-modal/chip-modal.tsx index b714d85f060..87f1b872abf 100644 --- a/packages/emcn/src/components/chip-modal/chip-modal.tsx +++ b/packages/emcn/src/components/chip-modal/chip-modal.tsx @@ -387,7 +387,7 @@ function ChipModalTabs({ onChange={onChange} aria-label={ariaLabel} options={tabs.map((tab) => ({ value: tab.value, label: tab.label, icon: tab.icon }))} - className={className} + className={cn('shrink-0', className)} /> ) }