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'
55import { useQueryStates } from 'nuqs'
66import { ActivityStatus } from '@/components/ui/activity-status'
77import 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'
1213import { getBaseUrl } from '@/lib/core/utils/urls'
1314import { matchSnippet } from '@/lib/knowledge/search/snippet'
1415import { connectorDisplayName } from '@/lib/sim-search/connectors'
@@ -25,8 +26,6 @@ import {
2526import { useSearchIndex , useSearchSourceOverview } from '@/hooks/queries/kb/connectors'
2627import { 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
3029const DAY_MS = 24 * 60 * 60 * 1000
3130/** Every result without a connector is an upload; the filter names them so. */
3231const 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. */
110102export 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