Skip to content

Commit bb20239

Browse files
authored
fix(search): preserve filters and report incomplete results (#7855)
1 parent d48d985 commit bb20239

11 files changed

Lines changed: 572 additions & 127 deletions

File tree

‎apps/sim/app/o/[organizationId]/search/search.test.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ const mocks = vi.hoisted(() => ({
1717
}))
1818

1919
vi.mock('@/hooks/use-speech-to-text', () => ({ useSpeechToText: mocks.speech }))
20+
vi.mock('@/lib/auth/auth-client', () => ({
21+
useSession: () => ({ data: { user: { id: 'reader' } } }),
22+
}))
2023
vi.mock('next/navigation', () => ({
2124
useRouter: () => ({ push: mocks.push }),
2225
usePathname: () => '/o/organization-a/search',
@@ -32,9 +35,6 @@ vi.mock('@/hooks/queries/kb/connectors', () => ({
3235
useSearchIndex: () => ({ data: { knowledgeBaseId: 'index-a' }, isPending: false }),
3336
useSearchSourceOverview: () => ({ data: { providers: [], hasSearchableDocuments: true } }),
3437
}))
35-
vi.mock('@/app/workspace/[workspaceId]/home/components/search-sources', () => ({
36-
isIndexing: () => false,
37-
}))
3838
vi.mock(
3939
'@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags',
4040
() => ({

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

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ const mocks = vi.hoisted(() => ({
1010
search: vi.fn(),
1111
retry: vi.fn(),
1212
}))
13+
vi.mock('@/lib/auth/auth-client', () => ({
14+
useSession: () => ({ data: { user: { id: 'reader' } } }),
15+
}))
1316
vi.mock('@/hooks/queries/kb/connectors', () => ({
1417
useSearchIndex: mocks.index,
1518
useSearchSourceOverview: mocks.overview,
@@ -69,7 +72,8 @@ describe('source indexing context in search results', () => {
6972
await render()
7073
expect(mocks.overview).toHaveBeenCalledWith({ kind: 'workspace', workspaceId: 'workspace' })
7174
expect(container.textContent).toContain('Google Drive')
72-
expect(container.textContent).not.toContain('Slack')
75+
expect(container.textContent).toContain('Slack')
76+
expect(container.textContent).toContain('Still indexing Google Drive;')
7377
})
7478
it('does not invent indexing progress while the overview is unavailable', async () => {
7579
mocks.overview.mockReturnValue({ data: undefined })
@@ -81,7 +85,7 @@ describe('source indexing context in search results', () => {
8185

8286
describe('incomplete search coverage', () => {
8387
it.each([false, true])(
84-
'shows matches without timeout copy or retry controls (hasResults=%s)',
88+
'distinguishes incomplete retrieval and permits retry (hasResults=%s)',
8589
async (hasResults) => {
8690
mocks.search.mockReturnValue({
8791
data: {
@@ -113,17 +117,17 @@ describe('incomplete search coverage', () => {
113117
await render()
114118
expect(container.textContent).not.toContain('Search couldn’t run')
115119
expect(container.textContent).not.toContain('No documents')
116-
expect(container.textContent).not.toContain('Some results may be missing.')
117-
expect(container.textContent).not.toContain('Search is incomplete.')
118120
expect(container.textContent).toContain(
119-
hasResults ? '1 document' : 'Search found no results.'
121+
hasResults ? '1 document · some results may be missing.' : 'Search didn’t finish.'
120122
)
123+
expect(container.textContent).not.toContain('Search found no results.')
121124
if (hasResults) expect(container.textContent).toContain('Release plan')
122125
const retry = [...container.querySelectorAll('button')].find(
123126
(button) => button.textContent === 'Try again'
124127
)
125-
expect(retry).toBeUndefined()
126-
expect(mocks.retry).not.toHaveBeenCalled()
128+
expect(retry).toBeDefined()
129+
await act(async () => retry?.click())
130+
expect(mocks.retry).toHaveBeenCalledOnce()
127131
}
128132
)
129133
})

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

Lines changed: 121 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
'use client'
22

3-
import { useMemo } from 'react'
4-
import { Chip, ChipLink } from '@sim/emcn'
3+
import { useState } from 'react'
4+
import { Chip, ChipLink, cn } from '@sim/emcn'
55
import { useQueryStates } from 'nuqs'
66
import { ActivityStatus } from '@/components/ui/activity-status'
77
import type {
88
WorkspaceKnowledgeSearchResult,
99
WorkspaceSearchFilters,
1010
} from '@/lib/api/contracts/knowledge'
11-
import type { ResourceScope } from '@/lib/core/resource-scope'
11+
import { useSession } from '@/lib/auth/auth-client'
12+
import { type ResourceScope, resourceScopeKey } from '@/lib/core/resource-scope'
1213
import { getBaseUrl } from '@/lib/core/utils/urls'
1314
import { matchSnippet } from '@/lib/knowledge/search/snippet'
1415
import { connectorDisplayName } from '@/lib/sim-search/connectors'
@@ -25,8 +26,6 @@ import {
2526
import { useSearchIndex, useSearchSourceOverview } from '@/hooks/queries/kb/connectors'
2627
import { useWorkspaceKnowledgeSearch } from '@/hooks/queries/kb/knowledge'
2728

28-
/** Filters appear only once a list is long and mixed enough for them to help. */
29-
const FILTERS_MIN_RESULTS = 10
3029
const DAY_MS = 24 * 60 * 60 * 1000
3130
/** Every result without a connector is an upload; the filter names them so. */
3231
const UPLOAD_SOURCE = 'upload'
@@ -82,6 +81,7 @@ function handleResultsKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
8281
const links = [...event.currentTarget.querySelectorAll<HTMLAnchorElement>('a[data-source-link]')]
8382
if (links.length === 0) return
8483
const index = links.findIndex((link) => link === document.activeElement)
84+
if (index < 0) return
8585
const next =
8686
event.key === 'ArrowDown' ? Math.min(index + 1, links.length - 1) : Math.max(index - 1, 0)
8787
if (next === index) return
@@ -98,80 +98,77 @@ type KnowledgeSearchResultsProps = (
9898
onSummarize: (prompt: string, filters: WorkspaceSearchFilters) => void
9999
}
100100

101-
/**
102-
* Search results include documents the signed-in person may read that
103-
* match their query in the canonical Enterprise Search index, as rows
104-
* that open the source. A header says how many and that the search ran as
105-
* them; while a connected source is still indexing it says so, and the list
106-
* grows as documents land. Filters by source and recency appear only once the
107-
* list is long and mixed enough to need them, and live in the URL beside the
108-
* query so a filtered search is a shareable link.
109-
*/
101+
/** A new query or access scope starts a fresh search and rolling-date anchor. */
110102
export function KnowledgeSearchResults({
111103
workspaceId,
112104
scope: suppliedScope,
113105
query,
114106
onSummarize,
115107
}: KnowledgeSearchResultsProps) {
116108
const scope: ResourceScope = suppliedScope ?? { kind: 'workspace', workspaceId: workspaceId! }
109+
const { data: session } = useSession()
110+
const trimmed = query.trim()
111+
return (
112+
<SearchResults
113+
key={JSON.stringify([resourceScopeKey(scope), session?.user?.id, trimmed])}
114+
scope={scope}
115+
query={trimmed}
116+
onSummarize={onSummarize}
117+
/>
118+
)
119+
}
120+
121+
interface SearchResultsProps {
122+
scope: ResourceScope
123+
query: string
124+
onSummarize: KnowledgeSearchResultsProps['onSummarize']
125+
}
126+
127+
function SearchResults({ scope, query, onSummarize }: SearchResultsProps) {
128+
const [searchedAt] = useState(Date.now)
117129
const {
118130
data: index,
119131
isPending: basesPending,
120132
isError: basesFailed,
121133
isFetching: basesFetching,
122134
refetch: refetchIndex,
123135
} = useSearchIndex(scope)
124-
const knowledgeBaseIds = index?.knowledgeBaseId ? [index.knowledgeBaseId] : []
125136
const [filters, setFilters] = useQueryStates(searchFilterParsers, resourceUrlKeys)
126-
const searchFilters = useMemo<WorkspaceSearchFilters>(() => {
127-
const window = UPDATED_WINDOWS.find((entry) => entry.id === filters.updated)
128-
return {
129-
...(filters.source ? { source: filters.source } : {}),
130-
...(window?.days
131-
? { modifiedAfter: new Date(Date.now() - window.days * DAY_MS).toISOString() }
132-
: {}),
133-
}
134-
}, [filters.source, filters.updated])
137+
const window = UPDATED_WINDOWS.find((entry) => entry.id === filters.updated)
138+
const searchFilters: WorkspaceSearchFilters = {
139+
...(filters.source ? { source: filters.source } : {}),
140+
...(window?.days
141+
? { modifiedAfter: new Date(searchedAt - window.days * DAY_MS).toISOString() }
142+
: {}),
143+
}
135144
const {
136145
data: search,
137146
isPending,
138147
isFetching,
148+
isPlaceholderData,
139149
isError: searchFailed,
140150
refetch: refetchSearch,
141151
} = useWorkspaceKnowledgeSearch(scope, query, searchFilters)
142152
const { data: overview } = useSearchSourceOverview(scope)
143153
const indexing = (overview?.providers ?? [])
144154
.filter((provider) => provider.isSyncing)
145155
.map((provider) => connectorDisplayName(provider.connectorType))
146-
const documents = useMemo(() => groupResultsByDocument(search?.results ?? []), [search?.results])
156+
const documents = groupResultsByDocument(search?.results ?? [])
147157
const sourceTypes = [
148158
...new Set([
149159
...(filters.source ? [filters.source] : []),
150-
...documents.map((result) => result.connectorType ?? UPLOAD_SOURCE),
160+
...(overview?.providers.map((provider) => provider.connectorType) ?? []),
161+
UPLOAD_SOURCE,
151162
]),
152-
]
153-
const filtersActive = filters.source !== null || filters.updated !== 'any'
154-
/** The controls appear once the list is long and mixed, and stay while a filter from the link is active. */
155-
const showFilters =
156-
filtersActive || (documents.length >= FILTERS_MIN_RESULTS && sourceTypes.length > 1)
163+
].sort((left, right) => connectorDisplayName(left).localeCompare(connectorDisplayName(right)))
164+
const failed = basesFailed || searchFailed
165+
const pending = basesPending || isPending
166+
const fetching = basesFetching || isFetching
167+
const noSources = !basesPending && !basesFailed && !index?.knowledgeBaseId
168+
const partial = search?.retrieval.status === 'partial'
169+
const documentCount = documents.length === 1 ? '1 document' : `${documents.length} documents`
157170

158-
/** A failed search offers a retry; server diagnostics carry the cause. */
159-
if (basesFailed || searchFailed) {
160-
const retrying = basesFetching || isFetching
161-
return (
162-
<div className='flex items-center gap-2 px-2 py-2'>
163-
<p className='text-[var(--text-muted)] text-caption'>Search couldn’t run.</p>
164-
<Chip
165-
variant='border'
166-
disabled={retrying}
167-
onClick={() => void (basesFailed ? refetchIndex() : refetchSearch())}
168-
>
169-
{retrying ? 'Retrying…' : 'Try again'}
170-
</Chip>
171-
</div>
172-
)
173-
}
174-
if (!basesPending && knowledgeBaseIds.length === 0) {
171+
if (noSources) {
175172
return (
176173
<div className='flex items-center gap-2 px-2 py-2'>
177174
<p className='text-[var(--text-muted)] text-caption'>No sources are set up yet.</p>
@@ -187,14 +184,6 @@ export function KnowledgeSearchResults({
187184
</div>
188185
)
189186
}
190-
if (isPending || (isFetching && !search)) {
191-
return (
192-
<div className='px-2 py-2'>
193-
<ActivityStatus label='Searching…' isActive />
194-
</div>
195-
)
196-
}
197-
198187
const indexingNote =
199188
indexing.length > 0
200189
? `Still indexing ${indexing.join(', ')}; results grow as documents land.`
@@ -203,66 +192,96 @@ export function KnowledgeSearchResults({
203192
return (
204193
<div className='flex flex-col'>
205194
<div className='flex items-center gap-2 px-2 py-2'>
206-
<span className='min-w-0 flex-1 text-[var(--text-muted)] text-caption'>
207-
{documents.length === 0 ? (
208-
'Search found no results.'
195+
<div className='min-w-0 flex-1'>
196+
{fetching || (pending && !failed) ? (
197+
<ActivityStatus label={pending ? 'Searching…' : 'Updating results…'} isActive />
209198
) : (
210-
<>
211-
<span className='tabular-nums'>
212-
{documents.length === 1 ? '1 document' : `${documents.length} documents`}
213-
</span>
214-
{' · searched as you'}
215-
</>
199+
<p role='status' className='text-[var(--text-muted)] text-caption'>
200+
{failed
201+
? 'Search couldn’t run.'
202+
: partial
203+
? documents.length === 0
204+
? 'Search didn’t finish.'
205+
: `${documentCount} · some results may be missing.`
206+
: documents.length === 0
207+
? 'Search found no results.'
208+
: `${documentCount} · searched as you`}
209+
</p>
210+
)}
211+
{indexingNote && !failed && (
212+
<p className='text-[var(--text-muted)] text-caption'>{indexingNote}</p>
216213
)}
217-
{indexingNote && <span className='block'>{indexingNote}</span>}
218-
</span>
214+
</div>
215+
{(failed || partial) && (
216+
<Chip
217+
variant='border'
218+
disabled={fetching}
219+
onClick={() => void (basesFailed ? refetchIndex() : refetchSearch())}
220+
>
221+
{fetching ? 'Retrying…' : 'Try again'}
222+
</Chip>
223+
)}
219224
</div>
220-
{showFilters && (
221-
<div className='flex flex-wrap items-center gap-1.5 px-2 pb-2'>
225+
<div
226+
role='group'
227+
aria-label='Search filters'
228+
className='flex flex-wrap items-center gap-1.5 px-2 pb-2'
229+
>
230+
<Chip
231+
shape='round'
232+
active={filters.source === null}
233+
aria-pressed={filters.source === null}
234+
onClick={() => setFilters({ source: null })}
235+
>
236+
All sources
237+
</Chip>
238+
{sourceTypes.map((type) => (
222239
<Chip
240+
key={type}
223241
shape='round'
224-
active={filters.source === null}
225-
onClick={() => setFilters({ source: null })}
242+
active={filters.source === type}
243+
aria-pressed={filters.source === type}
244+
onClick={() => setFilters({ source: filters.source === type ? null : type })}
226245
>
227-
All sources
246+
{type === UPLOAD_SOURCE ? 'Uploads' : connectorDisplayName(type)}
228247
</Chip>
229-
{sourceTypes.map((type) => (
230-
<Chip
231-
key={type}
232-
shape='round'
233-
active={filters.source === type}
234-
onClick={() => setFilters({ source: filters.source === type ? null : type })}
235-
>
236-
{type === UPLOAD_SOURCE ? 'Uploads' : connectorDisplayName(type)}
237-
</Chip>
238-
))}
239-
<span aria-hidden className='mx-0.5 h-[16px] w-px bg-[var(--border)]' />
240-
{UPDATED_WINDOWS.map((window) => (
241-
<Chip
242-
key={window.id}
243-
shape='round'
244-
active={filters.updated === window.id}
245-
onClick={() => setFilters({ updated: window.id })}
246-
>
247-
{window.label}
248-
</Chip>
249-
))}
250-
</div>
251-
)}
252-
{documents.length > 0 && (
253-
<div className='flex flex-col' onKeyDown={handleResultsKeyDown}>
248+
))}
249+
<span aria-hidden className='mx-0.5 h-[16px] w-px bg-[var(--border)]' />
250+
{UPDATED_WINDOWS.map((window) => (
251+
<Chip
252+
key={window.id}
253+
shape='round'
254+
active={filters.updated === window.id}
255+
aria-pressed={filters.updated === window.id}
256+
onClick={() => setFilters({ updated: window.id })}
257+
>
258+
{window.label}
259+
</Chip>
260+
))}
261+
</div>
262+
{!failed && !basesPending && documents.length > 0 && (
263+
<div
264+
role='region'
265+
aria-label='Search results'
266+
aria-busy={isFetching}
267+
className={cn('flex flex-col', isPlaceholderData && 'opacity-60')}
268+
onKeyDown={handleResultsKeyDown}
269+
>
254270
{documents.map((result) => {
255271
const source = toSource(result, query, scope)
256272
return (
257273
<SourceCard
258274
key={result.documentId}
259275
source={source}
260276
query={query}
261-
onSummarize={(cited) =>
262-
onSummarize(`Summarize "${cited.title ?? cited.url}"`, {
263-
...searchFilters,
264-
documentIds: [result.documentId],
265-
})
277+
onSummarize={
278+
isPlaceholderData
279+
? undefined
280+
: (cited) =>
281+
onSummarize(`Summarize "${cited.title ?? cited.url}"`, {
282+
...searchFilters,
283+
documentIds: [result.documentId],
284+
})
266285
}
267286
/>
268287
)

0 commit comments

Comments
 (0)