11'use client'
22
33import { useMemo } from 'react'
4- import { Chip , OverflowText } from '@sim/emcn'
5- import { FileText } from '@sim/emcn/icons'
6- import { formatDate } from '@sim/utils/formatting'
4+ import { Chip } from '@sim/emcn'
75import { useQueryStates } from 'nuqs'
8- import type { WorkspaceKnowledgeSearchResult } from '@/lib/api/contracts/knowledge'
6+ import type {
7+ WorkspaceKnowledgeSearchResult ,
8+ WorkspaceSearchFilters ,
9+ } from '@/lib/api/contracts/knowledge'
10+ import { getBaseUrl } from '@/lib/core/utils/urls'
911import { matchSnippet } from '@/lib/knowledge/search/snippet'
1012import { connectorDisplayName } from '@/lib/sim-search/connectors'
11- import { searchedKnowledgeBases } from '@/lib/sim-search/knowledge-bases'
12- import {
13- highlightTerms ,
14- SOURCE_ROW_CLASSES ,
15- SOURCE_ROW_MARK_CLASSES ,
16- SourceCard ,
17- } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-card'
13+ import { SourceCard } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-card'
1814import {
1915 isHttpUrl ,
2016 type SourceTagData ,
@@ -78,14 +74,18 @@ export function indexingSourceNames(
7874
7975/**
8076 * A result as the source card renders it: the row's second line names the
81- * source app, or the knowledge base for an upload. A document without an
82- * http(s) source URL cannot be opened, and a connector-supplied value of any
83- * other scheme is never handed to the browser as a link.
77+ * source app, or the knowledge base for an upload. Without an HTTP(S) source
78+ * URL, the link opens the canonical document in Sim.
8479 */
85- function toSource ( result : WorkspaceKnowledgeSearchResult , query : string ) : SourceTagData | null {
86- if ( ! isHttpUrl ( result . sourceUrl ) ) return null
80+ function toSource (
81+ result : WorkspaceKnowledgeSearchResult ,
82+ query : string ,
83+ workspaceId : string
84+ ) : SourceTagData {
8785 return {
88- url : result . sourceUrl ,
86+ url : isHttpUrl ( result . sourceUrl )
87+ ? result . sourceUrl
88+ : `${ getBaseUrl ( ) } /workspace/${ encodeURIComponent ( workspaceId ) } /knowledge/${ encodeURIComponent ( result . knowledgeBaseId ) } /${ encodeURIComponent ( result . documentId ) } ` ,
8989 title : result . documentName ?? undefined ,
9090 siteName : result . connectorType
9191 ? connectorDisplayName ( result . connectorType )
@@ -113,51 +113,16 @@ function handleResultsKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
113113 links [ next ] . focus ( )
114114}
115115
116- interface UnlinkedResultRowProps {
117- result : WorkspaceKnowledgeSearchResult
118- query : string
119- }
120-
121- /**
122- * A document with nowhere to open, such as an upload: the same row as a
123- * linked result, with the file mark in place of a brand mark, so the list's
124- * columns and the matched passage stay aligned whatever the document is.
125- */
126- function UnlinkedResultRow ( { result, query } : UnlinkedResultRowProps ) {
127- const meta = [
128- result . knowledgeBaseName ,
129- result . author ,
130- result . sourceModifiedAt ? formatDate ( new Date ( result . sourceModifiedAt ) ) : null ,
131- ] . filter ( ( part ) : part is string => Boolean ( part ) )
132- return (
133- < div className = { SOURCE_ROW_CLASSES } >
134- < span className = { SOURCE_ROW_MARK_CLASSES } >
135- < FileText className = 'size-[16px] text-[var(--text-icon)]' />
136- </ span >
137- < div className = 'flex min-w-0 flex-1 flex-col gap-0.5' >
138- < OverflowText
139- label = { result . documentName ?? 'Untitled document' }
140- className = 'text-[var(--text-primary)] text-sm'
141- />
142- < OverflowText label = { meta . join ( ' · ' ) } className = 'text-[var(--text-muted)] text-caption' />
143- < p className = 'line-clamp-2 text-[var(--text-body)] text-small leading-snug' >
144- { highlightTerms ( matchSnippet ( result . content , query ) , query ) }
145- </ p >
146- </ div >
147- </ div >
148- )
149- }
150-
151116interface KnowledgeSearchResultsProps {
152117 workspaceId : string
153118 query : string
154- /** Asks the agent about one document; the prompt names it and links to it . */
155- onSummarize : ( prompt : string ) => void
119+ /** Binds the Assistant turn to the selected canonical document . */
120+ onSummarize : ( prompt : string , filters : WorkspaceSearchFilters ) => void
156121}
157122
158123/**
159124 * The composer's Search mode: the documents the signed-in person may read that
160- * match their query, across every knowledge base in the workspace , as rows
125+ * match their query in the canonical Enterprise Search index , as rows
161126 * that open the source. A header says how many and that the search ran as
162127 * them; while a connected source is still indexing it says so, and the list
163128 * grows as documents land. Filters by source and recency appear only once the
@@ -174,14 +139,26 @@ export function KnowledgeSearchResults({
174139 isPending : basesPending ,
175140 error : basesError ,
176141 } = useKnowledgeBasesQuery ( workspaceId )
177- const knowledgeBaseIds = searchedKnowledgeBases ( knowledgeBases , workspaceId ) . map ( ( kb ) => kb . id )
142+ const index = knowledgeBases . find (
143+ ( base ) => base . workspaceId === workspaceId && base . isSearchIndex
144+ )
145+ const knowledgeBaseIds = index ? [ index . id ] : [ ]
146+ const [ filters , setFilters ] = useQueryStates ( searchFilterParsers , resourceUrlKeys )
147+ const searchFilters = useMemo < WorkspaceSearchFilters > ( ( ) => {
148+ const window = UPDATED_WINDOWS . find ( ( entry ) => entry . id === filters . updated )
149+ return {
150+ ...( filters . source ? { source : filters . source } : { } ) ,
151+ ...( window ?. days
152+ ? { modifiedAfter : new Date ( Date . now ( ) - window . days * DAY_MS ) . toISOString ( ) }
153+ : { } ) ,
154+ }
155+ } , [ filters . source , filters . updated ] )
178156 const {
179157 data : results ,
180158 isPending,
181159 isFetching,
182- isPlaceholderData,
183160 error,
184- } = useWorkspaceKnowledgeSearch ( workspaceId , knowledgeBaseIds , query )
161+ } = useWorkspaceKnowledgeSearch ( workspaceId , query , searchFilters )
185162 /**
186163 * With per-member access off, member-scoped documents are hidden, so the
187164 * indexing list is not worth asking for.
@@ -196,28 +173,16 @@ export function KnowledgeSearchResults({
196173 : EMPTY_MEMBER_CONNECTORS
197174 const indexing = indexingSourceNames ( memberConnectors , knowledgeBaseIds )
198175 const documents = useMemo ( ( ) => groupResultsByDocument ( results ?? [ ] ) , [ results ] )
199- const sourceTypes = useMemo (
200- ( ) => [ ...new Set ( documents . map ( ( result ) => result . connectorType ?? UPLOAD_SOURCE ) ) ] ,
201- [ documents ]
202- )
203- const [ filters , setFilters ] = useQueryStates ( searchFilterParsers , resourceUrlKeys )
176+ const sourceTypes = [
177+ ...new Set ( [
178+ ...( filters . source ? [ filters . source ] : [ ] ) ,
179+ ...documents . map ( ( result ) => result . connectorType ?? UPLOAD_SOURCE ) ,
180+ ] ) ,
181+ ]
204182 const filtersActive = filters . source !== null || filters . updated !== 'any'
205183 /** The controls appear once the list is long and mixed, and stay while a filter from the link is active. */
206184 const showFilters =
207185 filtersActive || ( documents . length >= FILTERS_MIN_RESULTS && sourceTypes . length > 1 )
208- const visible = useMemo ( ( ) => {
209- if ( ! filtersActive ) return documents
210- const window = UPDATED_WINDOWS . find ( ( entry ) => entry . id === filters . updated )
211- const cutoff = window ?. days ? Date . now ( ) - window . days * DAY_MS : null
212- return documents . filter ( ( result ) => {
213- if ( filters . source && ( result . connectorType ?? UPLOAD_SOURCE ) !== filters . source ) return false
214- if ( cutoff !== null ) {
215- const modified = result . sourceModifiedAt ? Date . parse ( result . sourceModifiedAt ) : Number . NaN
216- if ( Number . isNaN ( modified ) || modified < cutoff ) return false
217- }
218- return true
219- } )
220- } , [ documents , filtersActive , filters . source , filters . updated ] )
221186
222187 const failure = basesError ?? error
223188 if ( failure ) {
@@ -230,8 +195,7 @@ export function KnowledgeSearchResults({
230195 </ p >
231196 )
232197 }
233- /** Kept results belong to the previous query; a new query shows its own state. */
234- if ( isPending || isPlaceholderData || ( isFetching && ! results ) ) {
198+ if ( isPending || ( isFetching && ! results ) ) {
235199 return < p className = 'px-2 py-2 text-[var(--text-muted)] text-caption' > Searching…</ p >
236200 }
237201
@@ -283,27 +247,28 @@ export function KnowledgeSearchResults({
283247 ) ) }
284248 </ div >
285249 ) }
286- { visible . length === 0 ? (
250+ { documents . length === 0 ? (
287251 < p className = 'px-2 py-2 text-[var(--text-muted)] text-caption' >
288- { documents . length === 0
289- ? ` No documents you can read match “ ${ query } ”.`
290- : ' No documents match these filters.' }
252+ { filtersActive
253+ ? ' No documents match these filters.'
254+ : ` No documents you can read match “ ${ query } ”.` }
291255 </ p >
292256 ) : (
293257 < div className = 'flex flex-col' onKeyDown = { handleResultsKeyDown } >
294- { visible . map ( ( result ) => {
295- const source = toSource ( result , query )
296- return source ? (
258+ { documents . map ( ( result ) => {
259+ const source = toSource ( result , query , workspaceId )
260+ return (
297261 < SourceCard
298262 key = { result . documentId }
299263 source = { source }
300264 query = { query }
301265 onSummarize = { ( cited ) =>
302- onSummarize ( `Summarize "${ cited . title ?? cited . url } " (${ cited . url } )` )
266+ onSummarize ( `Summarize "${ cited . title ?? cited . url } "` , {
267+ ...searchFilters ,
268+ documentIds : [ result . documentId ] ,
269+ } )
303270 }
304271 />
305- ) : (
306- < UnlinkedResultRow key = { result . documentId } result = { result } query = { query } />
307272 )
308273 } ) }
309274 </ div >
0 commit comments