Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/sim/app/o/[organizationId]/search/search.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ const mocks = vi.hoisted(() => ({
}))

vi.mock('@/hooks/use-speech-to-text', () => ({ useSpeechToText: mocks.speech }))
vi.mock('@/lib/auth/auth-client', () => ({
useSession: () => ({ data: { user: { id: 'reader' } } }),
}))
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mocks.push }),
usePathname: () => '/o/organization-a/search',
Expand All @@ -32,9 +35,6 @@ vi.mock('@/hooks/queries/kb/connectors', () => ({
useSearchIndex: () => ({ data: { knowledgeBaseId: 'index-a' }, isPending: false }),
useSearchSourceOverview: () => ({ data: { providers: [], hasSearchableDocuments: true } }),
}))
vi.mock('@/app/workspace/[workspaceId]/home/components/search-sources', () => ({
isIndexing: () => false,
}))
vi.mock(
'@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags',
() => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ const mocks = vi.hoisted(() => ({
search: vi.fn(),
retry: vi.fn(),
}))
vi.mock('@/lib/auth/auth-client', () => ({
useSession: () => ({ data: { user: { id: 'reader' } } }),
}))
vi.mock('@/hooks/queries/kb/connectors', () => ({
useSearchIndex: mocks.index,
useSearchSourceOverview: mocks.overview,
Expand Down Expand Up @@ -69,7 +72,8 @@ describe('source indexing context in search results', () => {
await render()
expect(mocks.overview).toHaveBeenCalledWith({ kind: 'workspace', workspaceId: 'workspace' })
expect(container.textContent).toContain('Google Drive')
expect(container.textContent).not.toContain('Slack')
expect(container.textContent).toContain('Slack')
expect(container.textContent).toContain('Still indexing Google Drive;')
})
it('does not invent indexing progress while the overview is unavailable', async () => {
mocks.overview.mockReturnValue({ data: undefined })
Expand All @@ -81,7 +85,7 @@ describe('source indexing context in search results', () => {

describe('incomplete search coverage', () => {
it.each([false, true])(
'shows matches without timeout copy or retry controls (hasResults=%s)',
'distinguishes incomplete retrieval and permits retry (hasResults=%s)',
async (hasResults) => {
mocks.search.mockReturnValue({
data: {
Expand Down Expand Up @@ -113,17 +117,17 @@ describe('incomplete search coverage', () => {
await render()
expect(container.textContent).not.toContain('Search couldn’t run')
expect(container.textContent).not.toContain('No documents')
expect(container.textContent).not.toContain('Some results may be missing.')
expect(container.textContent).not.toContain('Search is incomplete.')
expect(container.textContent).toContain(
hasResults ? '1 document' : 'Search found no results.'
hasResults ? '1 document · some results may be missing.' : 'Search didn’t finish.'
)
expect(container.textContent).not.toContain('Search found no results.')
if (hasResults) expect(container.textContent).toContain('Release plan')
const retry = [...container.querySelectorAll('button')].find(
(button) => button.textContent === 'Try again'
)
expect(retry).toBeUndefined()
expect(mocks.retry).not.toHaveBeenCalled()
expect(retry).toBeDefined()
await act(async () => retry?.click())
expect(mocks.retry).toHaveBeenCalledOnce()
}
)
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
'use client'

import { useMemo } from 'react'
import { Chip, ChipLink } from '@sim/emcn'
import { useState } from 'react'
import { Chip, ChipLink, cn } from '@sim/emcn'
import { useQueryStates } from 'nuqs'
import { ActivityStatus } from '@/components/ui/activity-status'
import type {
WorkspaceKnowledgeSearchResult,
WorkspaceSearchFilters,
} from '@/lib/api/contracts/knowledge'
import type { ResourceScope } from '@/lib/core/resource-scope'
import { useSession } from '@/lib/auth/auth-client'
import { type ResourceScope, resourceScopeKey } from '@/lib/core/resource-scope'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { matchSnippet } from '@/lib/knowledge/search/snippet'
import { connectorDisplayName } from '@/lib/sim-search/connectors'
Expand All @@ -25,8 +26,6 @@ import {
import { useSearchIndex, useSearchSourceOverview } from '@/hooks/queries/kb/connectors'
import { useWorkspaceKnowledgeSearch } from '@/hooks/queries/kb/knowledge'

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

/**
* Search results include documents the signed-in person may read that
* match their query in the canonical Enterprise Search index, as rows
* that open the source. A header says how many and that the search ran as
* them; while a connected source is still indexing it says so, and the list
* grows as documents land. Filters by source and recency appear only once the
* list is long and mixed enough to need them, and live in the URL beside the
* query so a filtered search is a shareable link.
*/
/** A new query or access scope starts a fresh search and rolling-date anchor. */
export function KnowledgeSearchResults({
workspaceId,
scope: suppliedScope,
query,
onSummarize,
}: KnowledgeSearchResultsProps) {
const scope: ResourceScope = suppliedScope ?? { kind: 'workspace', workspaceId: workspaceId! }
const { data: session } = useSession()
const trimmed = query.trim()
return (
<SearchResults
key={JSON.stringify([resourceScopeKey(scope), session?.user?.id, trimmed])}
scope={scope}
query={trimmed}
onSummarize={onSummarize}
/>
)
}

interface SearchResultsProps {
scope: ResourceScope
query: string
onSummarize: KnowledgeSearchResultsProps['onSummarize']
}

function SearchResults({ scope, query, onSummarize }: SearchResultsProps) {
const [searchedAt] = useState(Date.now)
const {
data: index,
isPending: basesPending,
isError: basesFailed,
isFetching: basesFetching,
refetch: refetchIndex,
} = useSearchIndex(scope)
const knowledgeBaseIds = index?.knowledgeBaseId ? [index.knowledgeBaseId] : []
const [filters, setFilters] = useQueryStates(searchFilterParsers, resourceUrlKeys)
const searchFilters = useMemo<WorkspaceSearchFilters>(() => {
const window = UPDATED_WINDOWS.find((entry) => entry.id === filters.updated)
return {
...(filters.source ? { source: filters.source } : {}),
...(window?.days
? { modifiedAfter: new Date(Date.now() - window.days * DAY_MS).toISOString() }
: {}),
}
}, [filters.source, filters.updated])
const window = UPDATED_WINDOWS.find((entry) => entry.id === filters.updated)
const searchFilters: WorkspaceSearchFilters = {
...(filters.source ? { source: filters.source } : {}),
...(window?.days
? { modifiedAfter: new Date(searchedAt - window.days * DAY_MS).toISOString() }
: {}),
}
const {
data: search,
isPending,
isFetching,
isPlaceholderData,
isError: searchFailed,
refetch: refetchSearch,
} = useWorkspaceKnowledgeSearch(scope, query, searchFilters)
const { data: overview } = useSearchSourceOverview(scope)
const indexing = (overview?.providers ?? [])
.filter((provider) => provider.isSyncing)
.map((provider) => connectorDisplayName(provider.connectorType))
const documents = useMemo(() => groupResultsByDocument(search?.results ?? []), [search?.results])
const documents = groupResultsByDocument(search?.results ?? [])
const sourceTypes = [
...new Set([
...(filters.source ? [filters.source] : []),
...documents.map((result) => result.connectorType ?? UPLOAD_SOURCE),
...(overview?.providers.map((provider) => provider.connectorType) ?? []),
UPLOAD_SOURCE,
]),
]
const filtersActive = filters.source !== null || filters.updated !== 'any'
/** The controls appear once the list is long and mixed, and stay while a filter from the link is active. */
const showFilters =
filtersActive || (documents.length >= FILTERS_MIN_RESULTS && sourceTypes.length > 1)
].sort((left, right) => connectorDisplayName(left).localeCompare(connectorDisplayName(right)))
const failed = basesFailed || searchFailed
const pending = basesPending || isPending
const fetching = basesFetching || isFetching
const noSources = !basesPending && !basesFailed && !index?.knowledgeBaseId
const partial = search?.retrieval.status === 'partial'
const documentCount = documents.length === 1 ? '1 document' : `${documents.length} documents`

/** A failed search offers a retry; server diagnostics carry the cause. */
if (basesFailed || searchFailed) {
const retrying = basesFetching || isFetching
return (
<div className='flex items-center gap-2 px-2 py-2'>
<p className='text-[var(--text-muted)] text-caption'>Search couldn’t run.</p>
<Chip
variant='border'
disabled={retrying}
onClick={() => void (basesFailed ? refetchIndex() : refetchSearch())}
>
{retrying ? 'Retrying…' : 'Try again'}
</Chip>
</div>
)
}
if (!basesPending && knowledgeBaseIds.length === 0) {
if (noSources) {
return (
<div className='flex items-center gap-2 px-2 py-2'>
<p className='text-[var(--text-muted)] text-caption'>No sources are set up yet.</p>
Expand All @@ -187,14 +184,6 @@ export function KnowledgeSearchResults({
</div>
)
}
if (isPending || (isFetching && !search)) {
return (
<div className='px-2 py-2'>
<ActivityStatus label='Searching…' isActive />
</div>
)
}

const indexingNote =
indexing.length > 0
? `Still indexing ${indexing.join(', ')}; results grow as documents land.`
Expand All @@ -203,66 +192,96 @@ export function KnowledgeSearchResults({
return (
<div className='flex flex-col'>
<div className='flex items-center gap-2 px-2 py-2'>
<span className='min-w-0 flex-1 text-[var(--text-muted)] text-caption'>
{documents.length === 0 ? (
'Search found no results.'
<div className='min-w-0 flex-1'>
{fetching || (pending && !failed) ? (
<ActivityStatus label={pending ? 'Searching…' : 'Updating results…'} isActive />
) : (
<>
<span className='tabular-nums'>
{documents.length === 1 ? '1 document' : `${documents.length} documents`}
</span>
{' · searched as you'}
</>
<p role='status' className='text-[var(--text-muted)] text-caption'>
{failed
? 'Search couldn’t run.'
: partial
? documents.length === 0
? 'Search didn’t finish.'
: `${documentCount} · some results may be missing.`
: documents.length === 0
? 'Search found no results.'
: `${documentCount} · searched as you`}
</p>
)}
{indexingNote && !failed && (
<p className='text-[var(--text-muted)] text-caption'>{indexingNote}</p>
)}
{indexingNote && <span className='block'>{indexingNote}</span>}
</span>
</div>
{(failed || partial) && (
<Chip
variant='border'
disabled={fetching}
onClick={() => void (basesFailed ? refetchIndex() : refetchSearch())}
>
{fetching ? 'Retrying…' : 'Try again'}
</Chip>
)}
</div>
{showFilters && (
<div className='flex flex-wrap items-center gap-1.5 px-2 pb-2'>
<div
role='group'
aria-label='Search filters'
className='flex flex-wrap items-center gap-1.5 px-2 pb-2'
>
<Chip
shape='round'
active={filters.source === null}
aria-pressed={filters.source === null}
onClick={() => setFilters({ source: null })}
>
All sources
</Chip>
{sourceTypes.map((type) => (
<Chip
key={type}
shape='round'
active={filters.source === null}
onClick={() => setFilters({ source: null })}
active={filters.source === type}
aria-pressed={filters.source === type}
onClick={() => setFilters({ source: filters.source === type ? null : type })}
>
All sources
{type === UPLOAD_SOURCE ? 'Uploads' : connectorDisplayName(type)}
</Chip>
{sourceTypes.map((type) => (
<Chip
key={type}
shape='round'
active={filters.source === type}
onClick={() => setFilters({ source: filters.source === type ? null : type })}
>
{type === UPLOAD_SOURCE ? 'Uploads' : connectorDisplayName(type)}
</Chip>
))}
<span aria-hidden className='mx-0.5 h-[16px] w-px bg-[var(--border)]' />
{UPDATED_WINDOWS.map((window) => (
<Chip
key={window.id}
shape='round'
active={filters.updated === window.id}
onClick={() => setFilters({ updated: window.id })}
>
{window.label}
</Chip>
))}
</div>
)}
{documents.length > 0 && (
<div className='flex flex-col' onKeyDown={handleResultsKeyDown}>
))}
<span aria-hidden className='mx-0.5 h-[16px] w-px bg-[var(--border)]' />
{UPDATED_WINDOWS.map((window) => (
<Chip
key={window.id}
shape='round'
active={filters.updated === window.id}
aria-pressed={filters.updated === window.id}
onClick={() => setFilters({ updated: window.id })}
>
{window.label}
</Chip>
))}
</div>
{!failed && !basesPending && documents.length > 0 && (
<div
role='region'
aria-label='Search results'
aria-busy={isFetching}
className={cn('flex flex-col', isPlaceholderData && 'opacity-60')}
onKeyDown={handleResultsKeyDown}
>
{documents.map((result) => {
const source = toSource(result, query, scope)
return (
<SourceCard
key={result.documentId}
source={source}
query={query}
onSummarize={(cited) =>
onSummarize(`Summarize "${cited.title ?? cited.url}"`, {
...searchFilters,
documentIds: [result.documentId],
})
onSummarize={
isPlaceholderData
? undefined
: (cited) =>
onSummarize(`Summarize "${cited.title ?? cited.url}"`, {
...searchFilters,
documentIds: [result.documentId],
})
}
/>
)
Expand Down
Loading
Loading