Skip to content

Commit f7a44a1

Browse files
committed
Merge branch 'worktree-agent-a703bff345dcfce4d' into feat/permission-aware-knowledge
2 parents 0c5a030 + 240e321 commit f7a44a1

21 files changed

Lines changed: 601 additions & 59 deletions

File tree

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
1-
export { groupResultsByDocument, KnowledgeSearchResults } from './knowledge-search-results'
1+
export {
2+
groupResultsByDocument,
3+
indexingSourceNames,
4+
KnowledgeSearchResults,
5+
} from './knowledge-search-results'
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { describe, expect, it, vi } from 'vitest'
5+
import type { WorkspaceMemberConnector } from '@/hooks/queries/kb/connectors'
6+
7+
vi.mock('@/hooks/queries/kb/connectors', () => ({ useWorkspaceMemberConnectors: vi.fn() }))
8+
vi.mock('@/hooks/queries/kb/knowledge', () => ({
9+
useKnowledgeBasesQuery: vi.fn(),
10+
useWorkspaceKnowledgeSearch: vi.fn(),
11+
}))
12+
vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({
13+
useWorkspaceHostContext: vi.fn(),
14+
}))
15+
vi.mock(
16+
'@/app/workspace/[workspaceId]/home/components/message-content/components/source-card',
17+
() => ({ SourceCard: () => null })
18+
)
19+
20+
import { indexingSourceNames } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results'
21+
22+
function memberConnector(
23+
overrides: Partial<WorkspaceMemberConnector> = {}
24+
): WorkspaceMemberConnector {
25+
return {
26+
knowledgeBaseId: 'kb-1',
27+
knowledgeBaseName: 'Sim Search',
28+
connectorId: 'connector-1',
29+
connectorType: 'google_drive',
30+
memberSyncStatus: 'running',
31+
viewerMembership: 'connected',
32+
viewerDocumentCount: 0,
33+
...overrides,
34+
}
35+
}
36+
37+
describe('indexingSourceNames', () => {
38+
it('names each source still indexing for the viewer once, in the searched bases only', () => {
39+
const names = indexingSourceNames(
40+
[
41+
memberConnector({ connectorId: 'a', connectorType: 'google_drive' }),
42+
memberConnector({
43+
connectorId: 'b',
44+
connectorType: 'google_drive',
45+
knowledgeBaseId: 'kb-2',
46+
}),
47+
memberConnector({ connectorId: 'c', connectorType: 'slack', memberSyncStatus: 'pending' }),
48+
memberConnector({ connectorId: 'd', connectorType: 'notion', knowledgeBaseId: 'kb-3' }),
49+
],
50+
['kb-1', 'kb-2']
51+
)
52+
53+
expect(names).toEqual(['Google Drive', 'Slack'])
54+
})
55+
56+
it('ignores sources that are idle or not connected for the viewer', () => {
57+
expect(
58+
indexingSourceNames(
59+
[
60+
memberConnector({ connectorId: 'a', memberSyncStatus: 'idle' }),
61+
memberConnector({ connectorId: 'b', viewerMembership: 'invited' }),
62+
],
63+
['kb-1']
64+
)
65+
).toEqual([])
66+
})
67+
})

apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,15 @@ import { connectorDisplayName } from '@/lib/sim-search/connectors'
77
import { SourceCard } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-card'
88
import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
99
import { isIndexing } from '@/app/workspace/[workspaceId]/home/components/search-sources'
10-
import { useWorkspaceMemberConnectors } from '@/hooks/queries/kb/connectors'
10+
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
11+
import {
12+
useWorkspaceMemberConnectors,
13+
type WorkspaceMemberConnector,
14+
} from '@/hooks/queries/kb/connectors'
1115
import { useKnowledgeBasesQuery, useWorkspaceKnowledgeSearch } from '@/hooks/queries/kb/knowledge'
1216

17+
const EMPTY_MEMBER_CONNECTORS: WorkspaceMemberConnector[] = []
18+
1319
/** A search spans at most this many knowledge bases. */
1420
const MAX_SEARCHED_KNOWLEDGE_BASES = 20
1521
/** Characters of the matching chunk shown under a result. */
@@ -47,6 +53,25 @@ export function groupResultsByDocument(
4753
return grouped
4854
}
4955

56+
/**
57+
* The names of the sources still indexing for the viewer among the bases the
58+
* search spans, each once. A base outside the search cannot grow its results,
59+
* so its indexing is not the reader's concern here.
60+
*/
61+
export function indexingSourceNames(
62+
memberConnectors: readonly WorkspaceMemberConnector[],
63+
knowledgeBaseIds: readonly string[]
64+
): string[] {
65+
const searched = new Set(knowledgeBaseIds)
66+
return [
67+
...new Set(
68+
memberConnectors
69+
.filter((connection) => searched.has(connection.knowledgeBaseId) && isIndexing(connection))
70+
.map((connection) => connectorDisplayName(connection.connectorType))
71+
),
72+
]
73+
}
74+
5075
/**
5176
* A result as the source card renders it: the row's second line names the
5277
* source app, or the knowledge base for an upload. A document without a
@@ -124,15 +149,17 @@ export function KnowledgeSearchResults({
124149
isFetching,
125150
error,
126151
} = useWorkspaceKnowledgeSearch(workspaceId, knowledgeBaseIds, query)
127-
const { data: memberConnectors = [] } = useWorkspaceMemberConnectors(workspaceId)
128-
/** Every per-member connector still indexing for the viewer, in any base the search spans. */
129-
const indexing = [
130-
...new Set(
131-
memberConnectors
132-
.filter(isIndexing)
133-
.map((connection) => connectorDisplayName(connection.connectorType))
134-
),
135-
]
152+
const { features } = useWorkspaceHostContext()
153+
/**
154+
* Judged by the workspace, as the server judges it: with per-member access
155+
* off, member-scoped documents are hidden, so no source is indexing anything
156+
* the viewer will see, and the list is not worth asking for.
157+
*/
158+
const memberAccessAvailable = features?.knowledgeMemberAccess === true
159+
const { data: memberConnectors = EMPTY_MEMBER_CONNECTORS } = useWorkspaceMemberConnectors(
160+
memberAccessAvailable ? workspaceId : undefined
161+
)
162+
const indexing = indexingSourceNames(memberConnectors, knowledgeBaseIds)
136163
const documents = useMemo(() => groupResultsByDocument(results ?? []), [results])
137164
const sourceTypes = useMemo(
138165
() => [...new Set(documents.map((result) => result.connectorType ?? 'upload'))],

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card.tsx

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import { type ReactNode, useState } from 'react'
44
import { Button, cn, Tooltip } from '@sim/emcn'
55
import { Check, Link as LinkIcon } from '@sim/emcn/icons'
6+
import { createLogger } from '@sim/logger'
7+
import { getErrorMessage } from '@sim/utils/errors'
68
import { formatDate } from '@sim/utils/formatting'
79
import { faviconUrl } from '@/lib/core/utils/favicon'
810
import {
@@ -17,6 +19,8 @@ import {
1719
import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
1820
import { BrandIcon } from '@/blocks/brand-icon'
1921

22+
const logger = createLogger('SourceCard')
23+
2024
/** Query terms shorter than this are too common to bold. */
2125
const MIN_HIGHLIGHT_TERM_LENGTH = 3
2226
/** How long the copied state shows on the copy-link action. */
@@ -63,7 +67,11 @@ interface CopyLinkActionProps {
6367
url: string
6468
}
6569

66-
/** Copies the document's link; confirms with a check for a moment. */
70+
/**
71+
* Copies the document's link; confirms with a check for a moment. The check
72+
* only shows once the clipboard accepted the write: a page denied clipboard
73+
* access is left at "Copy link" rather than claiming a copy that never landed.
74+
*/
6775
function CopyLinkAction({ url }: CopyLinkActionProps) {
6876
const [copied, setCopied] = useState(false)
6977
return (
@@ -74,10 +82,17 @@ function CopyLinkAction({ url }: CopyLinkActionProps) {
7482
size='sm'
7583
aria-label='Copy link'
7684
onClick={() => {
77-
void navigator.clipboard.writeText(url).then(() => {
78-
setCopied(true)
79-
window.setTimeout(() => setCopied(false), COPIED_FEEDBACK_MS)
80-
})
85+
navigator.clipboard.writeText(url).then(
86+
() => {
87+
setCopied(true)
88+
window.setTimeout(() => setCopied(false), COPIED_FEEDBACK_MS)
89+
},
90+
(error: unknown) => {
91+
logger.warn('Copying the document link failed', {
92+
error: getErrorMessage(error),
93+
})
94+
}
95+
)
8196
}}
8297
>
8398
{copied ? (

apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ interface MothershipChatProps {
7070
fileAttachments?: FileAttachmentForApi[],
7171
contexts?: ChatContext[]
7272
) => void
73+
/** Whether the composer offers Search mode; only the Home composer answers a search. */
74+
canSearch?: boolean
7375
/** Off in Search mode, where the query stays put so the person can refine it. */
7476
clearOnSubmit?: boolean
7577
/** Fires when the composer's text goes from something to nothing. */
@@ -324,6 +326,7 @@ export function MothershipChat({
324326
isReconnecting = false,
325327
isLoading = false,
326328
onSubmit,
329+
canSearch = false,
327330
clearOnSubmit,
328331
onCleared,
329332
onStopGeneration,
@@ -844,6 +847,7 @@ export function MothershipChat({
844847
key={draftScopeKey}
845848
ref={userInputRef}
846849
onSubmit={onSubmit}
850+
canSearch={canSearch}
847851
clearOnSubmit={clearOnSubmit}
848852
onCleared={onCleared}
849853
isSending={isStreamActive}

apps/sim/app/workspace/[workspaceId]/home/components/search-sources/search-sources.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ export function SearchSources({ workspaceId }: SearchSourcesProps) {
166166
setupConnector,
167167
closeSetup,
168168
isAwaiting,
169+
isAwaitingSource,
169170
isPending,
170171
error,
171172
} = useMemberEnrollment({ membershipQueryKeys, connectedConnectorIds })
@@ -193,7 +194,9 @@ export function SearchSources({ workspaceId }: SearchSourcesProps) {
193194
integrationAvailability,
194195
memberAccessAvailable
195196
)}
196-
waiting={connection ? isAwaiting(connection.connectorId) : false}
197+
waiting={
198+
connection ? isAwaiting(connection.connectorId) : isAwaitingSource(connector.type)
199+
}
197200
disabled={isPending}
198201
onConnect={() => connectSearchSource(workspaceId, connector, connection)}
199202
/>

apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ interface UserInputProps {
7070
onStopGeneration: () => void
7171
isInitialView?: boolean
7272
onSendQueuedHead?: () => void
73+
/**
74+
* Whether the composer offers Search mode. Only the Home composer answers a
75+
* search with documents; the workflow copilot always talks to the agent, so
76+
* it must not show a mode it cannot honour.
77+
*/
78+
canSearch?: boolean
7379
/**
7480
* Whether the text is cleared once submitted. A search keeps its query in
7581
* the box, the way a search bar does, so it can be read and refined against
@@ -107,6 +113,7 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
107113
isInitialView = true,
108114
onSendQueuedHead,
109115
onEditQueuedTail,
116+
canSearch = false,
110117
clearOnSubmit = true,
111118
onCleared,
112119
},
@@ -712,7 +719,7 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
712719
</Tooltip.Root>
713720
</div>
714721
<div className='flex items-center gap-1.5'>
715-
<ModeSwitcher />
722+
{canSearch && <ModeSwitcher />}
716723
{isSttSupported && (
717724
<MicButton
718725
audioLevelsRef={audioLevelsRef}

apps/sim/app/workspace/[workspaceId]/home/home.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -462,9 +462,12 @@ export function Home({ chatId, userName, userId }: HomeProps) {
462462
is_new_task: !chatId,
463463
})
464464

465-
/** Search mode answers with documents, not a turn of the agent. */
466-
if (useMothershipModeStore.getState().mode === 'search' && trimmed) {
467-
setSearchQuery(trimmed)
465+
/**
466+
* Search mode answers with documents, not a turn of the agent, and only
467+
* a query can be answered: attachments alone have nothing to search for.
468+
*/
469+
if (useMothershipModeStore.getState().mode === 'search') {
470+
if (trimmed) setSearchQuery(trimmed)
468471
return
469472
}
470473

@@ -726,6 +729,7 @@ export function Home({ chatId, userName, userId }: HomeProps) {
726729
defaultValue={initialPrompt || initialSearchQuery}
727730
draftScopeKey={draftScopeKey}
728731
onSubmit={handleSubmit}
732+
canSearch
729733
clearOnSubmit={composerMode !== 'search'}
730734
onCleared={clearSearch}
731735
isSending={isSending}
@@ -754,6 +758,7 @@ export function Home({ chatId, userName, userId }: HomeProps) {
754758
isReconnecting={isReconnecting}
755759
isLoading={showChatSkeleton}
756760
onSubmit={handleSubmit}
761+
canSearch
757762
clearOnSubmit={composerMode !== 'search'}
758763
onCleared={clearSearch}
759764
onStopGeneration={handleStopGeneration}

0 commit comments

Comments
 (0)