11'use client'
22
3- import { useMemo , useState } from 'react'
4- import { Button , Chip } from '@sim/emcn'
3+ import { useMemo } from 'react'
4+ import { Button , Chip , OverflowText } from '@sim/emcn'
5+ import { FileText } from '@sim/emcn/icons'
6+ import { formatDate } from '@sim/utils/formatting'
7+ import { useQueryStates } from 'nuqs'
58import type { WorkspaceKnowledgeSearchResult } from '@/lib/api/contracts/knowledge'
69import { matchSnippet } from '@/lib/knowledge/search/snippet'
710import { connectorDisplayName } from '@/lib/sim-search/connectors'
8- import { SourceCard } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-card'
11+ import {
12+ highlightTerms ,
13+ SOURCE_ROW_CLASSES ,
14+ SOURCE_ROW_MARK_CLASSES ,
15+ SourceCard ,
16+ } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-card'
917import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
1018import { isIndexing } from '@/app/workspace/[workspaceId]/home/components/search-sources'
19+ import {
20+ resourceUrlKeys ,
21+ searchFilterParsers ,
22+ UPDATED_WINDOWS ,
23+ } from '@/app/workspace/[workspaceId]/home/search-params'
1124import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
1225import {
1326 useWorkspaceMemberConnectors ,
@@ -22,13 +35,8 @@ const MAX_SEARCHED_KNOWLEDGE_BASES = 20
2235/** Filters appear only once a list is long and mixed enough for them to help. */
2336const FILTERS_MIN_RESULTS = 10
2437const DAY_MS = 24 * 60 * 60 * 1000
25-
26- const UPDATED_WINDOWS = [
27- { id : 'any' , label : 'Any time' , days : null } ,
28- { id : '7d' , label : 'Past week' , days : 7 } ,
29- { id : '30d' , label : 'Past month' , days : 30 } ,
30- ] as const
31- type UpdatedWindow = ( typeof UPDATED_WINDOWS ) [ number ] [ 'id' ]
38+ /** Every result without a connector is an upload; the filter names them so. */
39+ const UPLOAD_SOURCE = 'upload'
3240
3341/**
3442 * One card per document, keeping the best-ranked chunk of each: the list is
@@ -102,6 +110,41 @@ function handleResultsKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
102110 links [ next ] . focus ( )
103111}
104112
113+ interface UnlinkedResultRowProps {
114+ result : WorkspaceKnowledgeSearchResult
115+ query : string
116+ }
117+
118+ /**
119+ * A document with nowhere to open, such as an upload: the same row as a
120+ * linked result, with the file mark in place of a brand mark, so the list's
121+ * columns and the matched passage stay aligned whatever the document is.
122+ */
123+ function UnlinkedResultRow ( { result, query } : UnlinkedResultRowProps ) {
124+ const meta = [
125+ result . knowledgeBaseName ,
126+ result . author ,
127+ result . sourceModifiedAt ? formatDate ( new Date ( result . sourceModifiedAt ) ) : null ,
128+ ] . filter ( ( part ) : part is string => Boolean ( part ) )
129+ return (
130+ < div className = { SOURCE_ROW_CLASSES } >
131+ < span className = { SOURCE_ROW_MARK_CLASSES } >
132+ < FileText className = 'size-[16px] text-[var(--text-icon)]' />
133+ </ span >
134+ < div className = 'flex min-w-0 flex-1 flex-col gap-0.5' >
135+ < OverflowText
136+ label = { result . documentName ?? 'Untitled document' }
137+ className = 'text-[var(--text-primary)] text-sm'
138+ />
139+ < OverflowText label = { meta . join ( ' · ' ) } className = 'text-[var(--text-muted)] text-caption' />
140+ < p className = 'line-clamp-2 text-[var(--text-body)] text-small leading-snug' >
141+ { highlightTerms ( matchSnippet ( result . content , query ) , query ) }
142+ </ p >
143+ </ div >
144+ </ div >
145+ )
146+ }
147+
105148interface KnowledgeSearchResultsProps {
106149 workspaceId : string
107150 query : string
@@ -117,7 +160,8 @@ interface KnowledgeSearchResultsProps {
117160 * that open the source. A header says how many and that the search ran as
118161 * them; while a connected source is still indexing it says so, and the list
119162 * grows as documents land. Filters by source and recency appear only once the
120- * list is long and mixed enough to need them.
163+ * list is long and mixed enough to need them, and live in the URL beside the
164+ * query so a filtered search is a shareable link.
121165 */
122166export function KnowledgeSearchResults ( {
123167 workspaceId,
@@ -152,44 +196,44 @@ export function KnowledgeSearchResults({
152196 */
153197 const memberAccessAvailable = features ?. knowledgeMemberAccess === true
154198 const { data : memberConnectors = EMPTY_MEMBER_CONNECTORS } = useWorkspaceMemberConnectors (
155- memberAccessAvailable ? workspaceId : undefined
199+ workspaceId ,
200+ { enabled : memberAccessAvailable }
156201 )
157202 const indexing = indexingSourceNames ( memberConnectors , knowledgeBaseIds )
158203 const documents = useMemo ( ( ) => groupResultsByDocument ( results ?? [ ] ) , [ results ] )
159204 const sourceTypes = useMemo (
160- ( ) => [ ...new Set ( documents . map ( ( result ) => result . connectorType ?? 'upload' ) ) ] ,
205+ ( ) => [ ...new Set ( documents . map ( ( result ) => result . connectorType ?? UPLOAD_SOURCE ) ) ] ,
161206 [ documents ]
162207 )
163- const [ sourceFilter , setSourceFilter ] = useState < string | null > ( null )
164- const [ updatedFilter , setUpdatedFilter ] = useState < UpdatedWindow > ( 'any' )
208+ const [ filters , setFilters ] = useQueryStates ( searchFilterParsers , resourceUrlKeys )
165209 const showFilters = documents . length >= FILTERS_MIN_RESULTS && sourceTypes . length > 1
166210 const visible = useMemo ( ( ) => {
167211 if ( ! showFilters ) return documents
168- const window = UPDATED_WINDOWS . find ( ( entry ) => entry . id === updatedFilter )
212+ const window = UPDATED_WINDOWS . find ( ( entry ) => entry . id === filters . updated )
169213 const cutoff = window ?. days ? Date . now ( ) - window . days * DAY_MS : null
170214 return documents . filter ( ( result ) => {
171- if ( sourceFilter && ( result . connectorType ?? 'upload' ) !== sourceFilter ) return false
215+ if ( filters . source && ( result . connectorType ?? UPLOAD_SOURCE ) !== filters . source ) return false
172216 if ( cutoff !== null ) {
173217 const modified = result . sourceModifiedAt ? Date . parse ( result . sourceModifiedAt ) : Number . NaN
174218 if ( Number . isNaN ( modified ) || modified < cutoff ) return false
175219 }
176220 return true
177221 } )
178- } , [ documents , showFilters , sourceFilter , updatedFilter ] )
222+ } , [ documents , showFilters , filters . source , filters . updated ] )
179223
180224 const failure = basesError ?? error
181225 if ( failure ) {
182- return < p className = 'px-2 py-3 text-[var(--text-error)] text-small ' > { failure . message } </ p >
226+ return < p className = 'px-2 py-2 text-[var(--text-error)] text-caption ' > { failure . message } </ p >
183227 }
184228 if ( ! basesPending && knowledgeBaseIds . length === 0 ) {
185229 return (
186- < p className = 'px-2 py-3 text-[var(--text-muted)] text-small ' >
230+ < p className = 'px-2 py-2 text-[var(--text-muted)] text-caption ' >
187231 Nothing to search yet. Connect a source above to index what you can open.
188232 </ p >
189233 )
190234 }
191235 if ( isPending || ( isFetching && ! results ) ) {
192- return < p className = 'px-2 py-3 text-[var(--text-muted)] text-small ' > Searching…</ p >
236+ return < p className = 'px-2 py-2 text-[var(--text-muted)] text-caption ' > Searching…</ p >
193237 }
194238
195239 const indexingNote =
@@ -198,53 +242,59 @@ export function KnowledgeSearchResults({
198242 : null
199243
200244 return (
201- < div className = 'flex flex-col gap-1' >
202- < div className = 'flex flex-wrap items-center gap-x-3 gap-y-1 px-2 py-1' >
203- < span className = 'text-[var(--text-muted)] text-caption' >
204- { documents . length === 1 ? '1 document' : `${ documents . length } documents` } · searched as
205- you
206- { indexingNote ? ` · ${ indexingNote } ` : '' }
245+ < div className = 'flex flex-col' >
246+ < div className = 'flex items-center gap-2 px-2 py-2' >
247+ < span className = 'min-w-0 flex-1 text-[var(--text-muted)] text-caption' >
248+ < span className = 'tabular-nums' >
249+ { documents . length === 1 ? '1 document' : `${ documents . length } documents` }
250+ </ span >
251+ { ' · searched as you' }
252+ { indexingNote && < span className = 'block' > { indexingNote } </ span > }
207253 </ span >
208- < Button variant = 'ghost' size = 'sm' className = 'ml-auto' onClick = { ( ) => onAnswer ( query ) } >
254+ < Button variant = 'ghost' size = 'sm' onClick = { ( ) => onAnswer ( query ) } >
209255 Answer with Sim
210256 </ Button >
211257 </ div >
212258 { showFilters && (
213- < div className = 'flex flex-wrap gap-1.5 px-2 pb-1' >
214- < Chip shape = 'round' active = { sourceFilter === null } onClick = { ( ) => setSourceFilter ( null ) } >
259+ < div className = 'flex flex-wrap items-center gap-1.5 px-2 pb-2' >
260+ < Chip
261+ shape = 'round'
262+ active = { filters . source === null }
263+ onClick = { ( ) => setFilters ( { source : null } ) }
264+ >
215265 All sources
216266 </ Chip >
217267 { sourceTypes . map ( ( type ) => (
218268 < Chip
219269 key = { type }
220270 shape = 'round'
221- active = { sourceFilter === type }
222- onClick = { ( ) => setSourceFilter ( sourceFilter === type ? null : type ) }
271+ active = { filters . source === type }
272+ onClick = { ( ) => setFilters ( { source : filters . source === type ? null : type } ) }
223273 >
224- { type === 'upload' ? 'Uploads' : connectorDisplayName ( type ) }
274+ { type === UPLOAD_SOURCE ? 'Uploads' : connectorDisplayName ( type ) }
225275 </ Chip >
226276 ) ) }
227- < span className = 'mx-1 self-center text- [var(--text-muted)] text-caption' > · </ span >
277+ < span aria-hidden className = 'mx-0.5 h-[16px] w-px bg- [var(--border)]' / >
228278 { UPDATED_WINDOWS . map ( ( window ) => (
229279 < Chip
230280 key = { window . id }
231281 shape = 'round'
232- active = { updatedFilter === window . id }
233- onClick = { ( ) => setUpdatedFilter ( window . id ) }
282+ active = { filters . updated === window . id }
283+ onClick = { ( ) => setFilters ( { updated : window . id } ) }
234284 >
235285 { window . label }
236286 </ Chip >
237287 ) ) }
238288 </ div >
239289 ) }
240290 { visible . length === 0 ? (
241- < p className = 'px-2 py-3 text-[var(--text-muted)] text-small ' >
291+ < p className = 'px-2 py-2 text-[var(--text-muted)] text-caption ' >
242292 { documents . length === 0
243293 ? `No documents you can read match “${ query } ”.`
244294 : 'No documents match these filters.' }
245295 </ p >
246296 ) : (
247- < div className = 'flex flex-col gap-0.5 ' onKeyDown = { handleResultsKeyDown } >
297+ < div className = 'flex flex-col' onKeyDown = { handleResultsKeyDown } >
248298 { visible . map ( ( result ) => {
249299 const source = toSource ( result , query )
250300 return source ? (
@@ -257,17 +307,7 @@ export function KnowledgeSearchResults({
257307 }
258308 />
259309 ) : (
260- < div key = { result . documentId } className = 'flex flex-col gap-0.5 px-2 py-2' >
261- < p className = 'truncate text-[var(--text-primary)] text-sm' >
262- { result . documentName ?? 'Untitled document' }
263- </ p >
264- < p className = 'truncate text-[var(--text-muted)] text-caption' >
265- { result . knowledgeBaseName }
266- </ p >
267- < p className = 'line-clamp-2 text-[var(--text-body)] text-small leading-snug' >
268- { matchSnippet ( result . content , query ) }
269- </ p >
270- </ div >
310+ < UnlinkedResultRow key = { result . documentId } result = { result } query = { query } />
271311 )
272312 } ) }
273313 </ div >
0 commit comments