Skip to content

Commit 75df3e5

Browse files
committed
feat(knowledge): one click connects a Sim Search source and indexes it for the person
Connecting a source on the Search tab, or from the composer's Search-mode suggestions, no longer creates a bare credential that indexes nothing. It finds or creates the workspace's Sim Search knowledge base and a per-member connector for the source, then enrolls the person; the OAuth completion queues their member run, so indexing starts on its own and the row counts their documents up as they land. Sources that need a site or space are set up from a knowledge base instead. The credential-only detail page goes.
1 parent b7f638b commit 75df3e5

21 files changed

Lines changed: 630 additions & 619 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { connectSimSearchConnectorContract } from '@/lib/api/contracts/knowledge'
2+
import {
3+
defineInternalJsonRoute,
4+
internalRateLimits,
5+
internalSessionAuth,
6+
} from '@/lib/api/server/routes'
7+
import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies'
8+
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
9+
import { connectSimSearchConnector } from '@/lib/knowledge/application/sim-search'
10+
11+
export const POST = defineInternalJsonRoute({
12+
contract: connectSimSearchConnectorContract,
13+
auth: internalSessionAuth,
14+
operation: knowledgeOperations.simSearchConnect,
15+
rateLimit: internalRateLimits.none({ reason: 'One click per source; mints a single-use link' }),
16+
errorPolicy: internalKnowledgeErrorPolicies.connectors,
17+
mapInput: ({ body }) => ({ workspaceId: body.workspaceId, connectorType: body.connectorType }),
18+
useCase: connectSimSearchConnector,
19+
present: (result) => ({ success: true as const, data: result }),
20+
})

apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/connector-actions.test.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,6 @@ vi.mock('@/lib/sim-search/connectors', () => {
2222
blockType: type,
2323
})
2424
return {
25-
isSearchConnectorConnected: (
26-
candidate: { providerIds: string[] },
27-
connected: ReadonlySet<string>
28-
) => candidate.providerIds.some((providerId) => connected.has(providerId)),
2925
SEARCH_CONNECTORS: [
3026
connector('airtable', 'Airtable', 'airtable'),
3127
connector('confluence', 'Confluence', 'confluence'),
@@ -63,11 +59,12 @@ describe('computeConnectorActions', () => {
6359
})
6460
})
6561

66-
it('drops every connector on a connected provider and refills from the rotation', () => {
62+
it('drops every connected source and refills from the rotation', () => {
6763
const actions = computeConnectorActions(new Set(['jira', 'airtable']), ALL_AVAILABLE)
6864

6965
expect(actions.map((action) => action.id)).toEqual([
7066
'connect-confluence',
67+
'connect-jsm',
7168
'connect-notion',
7269
'connect-slack',
7370
])

apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/connector-actions.ts

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
1-
import {
2-
isSearchConnectorConnected,
3-
SEARCH_CONNECTORS,
4-
type SearchConnector,
5-
} from '@/lib/sim-search/connectors'
1+
import { SEARCH_CONNECTORS, type SearchConnector } from '@/lib/sim-search/connectors'
62
import type { Action } from '@/app/workspace/[workspaceId]/home/components/suggested-actions/types'
73
import { weightedSample } from '@/app/workspace/[workspaceId]/home/components/suggested-actions/weighted-sample'
84

@@ -40,18 +36,17 @@ function toConnectorAction(connector: SearchConnector): Action {
4036

4137
/**
4238
* Builds the Search-mode rows: the pinned connectors first, then a uniform
43-
* sample of the rest to fill four slots. A connector whose provider the viewer
44-
* has already connected is dropped from both halves — so Jira and Jira Service
45-
* Management, which share one provider, leave together — and a pinned slot
46-
* freed that way is taken by the rotation. Connectors this deployment cannot
47-
* connect are dropped the same way, so a row never opens a modal that fails.
39+
* sample of the rest to fill four slots. A source the viewer has already
40+
* connected is dropped from both halves, and a pinned slot freed that way is
41+
* taken by the rotation. Sources this deployment cannot connect are dropped
42+
* the same way, so a row never starts a connection that fails.
4843
*/
4944
export function computeConnectorActions(
50-
connectedProviderIds: ReadonlySet<string>,
45+
connectedTypes: ReadonlySet<string>,
5146
isAvailable: (connector: SearchConnector) => boolean
5247
): Action[] {
5348
const offered = (connector: SearchConnector) =>
54-
isAvailable(connector) && !isSearchConnectorConnected(connector, connectedProviderIds)
49+
isAvailable(connector) && !connectedTypes.has(connector.type)
5550
const pinned = PINNED.filter(offered)
5651
const pool = ROTATING.filter(offered)
5752
const rotating = weightedSample(pool, CONNECTOR_ACTION_COUNT - pinned.length, () => 1)

apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.test.tsx

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@ import { act } from 'react'
55
import { createRoot, type Root } from 'react-dom/client'
66
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
77

8-
const { mockCaptureEvent, mockUseSearchCredentials } = vi.hoisted(() => ({
9-
mockCaptureEvent: vi.fn(),
10-
mockUseSearchCredentials: vi.fn(),
11-
}))
8+
const { mockCaptureEvent, mockUseWorkspaceMemberConnectors, mockConnectSource } = vi.hoisted(
9+
() => ({
10+
mockCaptureEvent: vi.fn(),
11+
mockUseWorkspaceMemberConnectors: vi.fn(),
12+
mockConnectSource: vi.fn(),
13+
})
14+
)
1215

1316
vi.mock('next/navigation', () => ({
1417
useParams: () => ({ workspaceId: 'workspace-1' }),
@@ -29,8 +32,12 @@ vi.mock('@/hooks/queries/tables', () => ({
2932
vi.mock('@/hooks/queries/kb/knowledge', () => ({
3033
useKnowledgeBasesQuery: () => ({ data: [] }),
3134
}))
32-
vi.mock('@/app/workspace/[workspaceId]/search/hooks/use-search-credentials', () => ({
33-
useSearchCredentials: mockUseSearchCredentials,
35+
vi.mock('@/hooks/queries/kb/connectors', () => ({
36+
memberConnectorKeys: { list: (workspaceId?: string) => ['member-connectors', workspaceId] },
37+
useWorkspaceMemberConnectors: mockUseWorkspaceMemberConnectors,
38+
}))
39+
vi.mock('@/hooks/use-member-enrollment', () => ({
40+
useMemberEnrollment: () => ({ connectSource: mockConnectSource }),
3441
}))
3542
vi.mock('@/hooks/use-permission-config', () => ({
3643
usePermissionConfig: () => ({
@@ -54,10 +61,6 @@ vi.mock('@/lib/sim-search/connectors', () => {
5461
blockType: type,
5562
})
5663
return {
57-
isSearchConnectorConnected: (
58-
candidate: { providerIds: string[] },
59-
connected: ReadonlySet<string>
60-
) => candidate.providerIds.some((providerId) => connected.has(providerId)),
6164
isSearchConnectorAvailable: (
6265
candidate: { blockType: string },
6366
availability: ReadonlyMap<string, { oauthAvailable: boolean }>
@@ -110,9 +113,19 @@ function connectModal(): string | null {
110113
beforeEach(() => {
111114
onSelectPrompt.mockClear()
112115
mockCaptureEvent.mockClear()
113-
mockUseSearchCredentials.mockReturnValue({
114-
credentials: [{ id: 'cred-jira', providerId: 'jira' }],
116+
mockUseWorkspaceMemberConnectors.mockReturnValue({
115117
isPending: false,
118+
data: [
119+
{
120+
knowledgeBaseId: 'kb-search',
121+
knowledgeBaseName: 'Sim Search',
122+
connectorId: 'conn-jira',
123+
connectorType: 'jira',
124+
memberSyncStatus: 'idle',
125+
viewerMembership: 'connected',
126+
viewerDocumentCount: 3,
127+
},
128+
],
116129
})
117130
useMothershipModeStore.getState().reset()
118131
})
@@ -140,12 +153,13 @@ describe('SuggestedActions', () => {
140153
expect(heading()).toBe('Connect Sim Search')
141154
expect(rows().map((row) => row.textContent)).toEqual([
142155
'Connect Confluence',
156+
'Connect Jira Service Management',
143157
'Connect Airtable',
144158
'Connect Slack',
145159
])
146160
})
147161

148-
it('opens the OAuth connect modal for a connector row instead of populating the input', () => {
162+
it('connects a source through its per-member connector instead of populating the input', () => {
149163
mount()
150164
act(() => useMothershipModeStore.getState().setMode('search'))
151165
expect(connectModal()).toBeNull()
@@ -154,7 +168,8 @@ describe('SuggestedActions', () => {
154168
rows()[0].dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 }))
155169
})
156170

157-
expect(connectModal()).toBe('confluence')
171+
expect(connectModal()).toBeNull()
172+
expect(mockConnectSource).toHaveBeenCalledWith('workspace-1', 'confluence')
158173
expect(onSelectPrompt).not.toHaveBeenCalled()
159174
expect(mockCaptureEvent).toHaveBeenCalledWith(
160175
null,

apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,19 @@ import type {
2222
OAuthConnectTarget,
2323
} from '@/app/workspace/[workspaceId]/home/components/suggested-actions/types'
2424
import { weightedSample } from '@/app/workspace/[workspaceId]/home/components/suggested-actions/weighted-sample'
25-
import { useSearchCredentials } from '@/app/workspace/[workspaceId]/search/hooks/use-search-credentials'
2625
import { BrandIcon } from '@/blocks/brand-icon'
2726
import { getAllBlockMeta } from '@/blocks/registry'
2827
import type { ModuleTag } from '@/blocks/types'
2928
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
29+
import {
30+
memberConnectorKeys,
31+
useWorkspaceMemberConnectors,
32+
type WorkspaceMemberConnector,
33+
} from '@/hooks/queries/kb/connectors'
3034
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
3135
import { useOAuthConnections } from '@/hooks/queries/oauth/oauth-connections'
3236
import { useTablesList } from '@/hooks/queries/tables'
37+
import { useMemberEnrollment } from '@/hooks/use-member-enrollment'
3338
import { usePermissionConfig } from '@/hooks/use-permission-config'
3439
import { type MothershipMode, useMothershipModeStore } from '@/stores/mothership-mode/store'
3540

@@ -151,6 +156,7 @@ function scoreCandidate(c: Candidate, signals: Signals): number {
151156
}
152157

153158
const EMPTY_CREDENTIALS: NonNullable<ReturnType<typeof useWorkspaceCredentials>['data']> = []
159+
const EMPTY_MEMBER_CONNECTORS: WorkspaceMemberConnector[] = []
154160
const EMPTY_SERVICES: NonNullable<ReturnType<typeof useOAuthConnections>['data']> = []
155161

156162
type ServiceInfo = NonNullable<ReturnType<typeof useOAuthConnections>['data']>[number]
@@ -258,8 +264,8 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) {
258264
const { data: knowledgeBases = [] } = useKnowledgeBasesQuery(workspaceId, {
259265
enabled: Boolean(workspaceId),
260266
})
261-
const { credentials: searchCredentials, isPending: searchCredentialsPending } =
262-
useSearchCredentials(workspaceId)
267+
const { data: memberConnectors = EMPTY_MEMBER_CONNECTORS, isPending: connectionsPending } =
268+
useWorkspaceMemberConnectors(workspaceId)
263269

264270
const [expanded, setExpanded] = useState(true)
265271
/**
@@ -296,15 +302,27 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) {
296302
[connectedProviders, tables.length, knowledgeBases.length]
297303
)
298304

299-
const connectedSearchProviders = useMemo(
305+
/** Sources the viewer has already connected, by connector type. */
306+
const connectedSearchTypes = useMemo(
300307
() =>
301308
new Set(
302-
searchCredentials
303-
.map((credential) => credential.providerId)
304-
.filter((providerId): providerId is string => Boolean(providerId))
309+
memberConnectors
310+
.filter((connector) => connector.viewerMembership === 'connected')
311+
.map((connector) => connector.connectorType)
305312
),
306-
[searchCredentials]
313+
[memberConnectors]
307314
)
315+
const connectedConnectorIds = useMemo(
316+
() =>
317+
new Set(
318+
memberConnectors
319+
.filter((connector) => connector.viewerMembership === 'connected')
320+
.map((connector) => connector.connectorId)
321+
),
322+
[memberConnectors]
323+
)
324+
const membershipQueryKeys = useMemo(() => [memberConnectorKeys.list(workspaceId)], [workspaceId])
325+
const { connectSource } = useMemberEnrollment({ membershipQueryKeys, connectedConnectorIds })
308326

309327
/**
310328
* Each mode's list is memoized on its own inputs alone, so switching modes —
@@ -321,12 +339,12 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) {
321339
*/
322340
const searchActions = useMemo(
323341
() =>
324-
searchCredentialsPending
342+
connectionsPending
325343
? []
326-
: computeConnectorActions(connectedSearchProviders, (connector) =>
344+
: computeConnectorActions(connectedSearchTypes, (connector) =>
327345
isSearchConnectorAvailable(connector, integrationAvailability)
328346
),
329-
[searchCredentialsPending, connectedSearchProviders, integrationAvailability]
347+
[connectionsPending, connectedSearchTypes, integrationAvailability]
330348
)
331349
const buildActions = useMemo(() => {
332350
const personalized = services.length > 0 && connectedProviders.size > 0
@@ -343,14 +361,18 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) {
343361
label: action.label,
344362
position,
345363
connected_provider_count:
346-
action.kind === 'connector' ? connectedSearchProviders.size : connectedProviders.size,
364+
action.kind === 'connector' ? connectedSearchTypes.size : connectedProviders.size,
347365
})
348366
if (action.kind === 'prompt') {
349367
onSelectPrompt(action.prompt)
350368
return
351369
}
352-
const target =
353-
action.kind === 'connector' ? action.target : resolveOAuthServiceForSlug(action.slug)
370+
/** A Sim Search source connects through its per-member connector, not a bare credential. */
371+
if (action.kind === 'connector') {
372+
if (workspaceId) connectSource(workspaceId, action.target.type)
373+
return
374+
}
375+
const target = resolveOAuthServiceForSlug(action.slug)
354376
if (target) setOAuthTarget(target)
355377
}
356378

apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/types.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ComponentType, CSSProperties } from 'react'
2+
import type { SearchConnector } from '@/lib/sim-search/connectors'
23

34
export type ActionIcon = ComponentType<{ className?: string; style?: CSSProperties }>
45

@@ -13,10 +14,11 @@ export interface OAuthConnectTarget {
1314
/**
1415
* One suggested-action row. `prompt` rows populate the input with a curated
1516
* prompt; `integration` rows resolve their OAuth service from the catalog slug
16-
* on click; `connector` rows — the Search-mode "Connect X" rows — carry their
17-
* connect target directly. Both connecting kinds open the OAuth connect modal.
17+
* on click and open the OAuth connect modal; `connector` rows — the Search-mode
18+
* "Connect X" rows — carry the Sim Search source, which connects through its
19+
* per-member connector.
1820
*/
1921
export type Action =
2022
| { kind: 'prompt'; id: string; label: string; icon: ActionIcon; prompt: string }
2123
| { kind: 'integration'; id: string; label: string; icon: ActionIcon; slug: string }
22-
| { kind: 'connector'; id: string; label: string; icon: ActionIcon; target: OAuthConnectTarget }
24+
| { kind: 'connector'; id: string; label: string; icon: ActionIcon; target: SearchConnector }

apps/sim/app/workspace/[workspaceId]/search/connected/[credentialId]/page.tsx

Lines changed: 0 additions & 15 deletions
This file was deleted.

0 commit comments

Comments
 (0)