Skip to content

Commit 4d88736

Browse files
committed
refactor(search): style the results and sources as one surface with the composer
Result rows take the chat surface's row rhythm with hairlines between them, fade-clipped titles and meta lines, a proper icon button for Copy link, a ghost Summarize matching Answer with Sim, actions revealed on keyboard focus, and a linkless document rendered in the same row with its author, date, and bolded passage. The source and date filters live in the URL beside the query, cleared with it. The sources strip keeps connected chips at full weight, and the member-connector query is gated with an enabled option and cancelled before an optimistic queue write.
1 parent cedf424 commit 4d88736

10 files changed

Lines changed: 198 additions & 97 deletions

File tree

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

Lines changed: 89 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,26 @@
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'
58
import type { WorkspaceKnowledgeSearchResult } from '@/lib/api/contracts/knowledge'
69
import { matchSnippet } from '@/lib/knowledge/search/snippet'
710
import { 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'
917
import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
1018
import { 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'
1124
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
1225
import {
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. */
2336
const FILTERS_MIN_RESULTS = 10
2437
const 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+
105148
interface 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
*/
122166
export 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>
Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,6 @@
1-
export { highlightTerms, SourceCard } from './source-card'
1+
export {
2+
highlightTerms,
3+
SOURCE_ROW_CLASSES,
4+
SOURCE_ROW_MARK_CLASSES,
5+
SourceCard,
6+
} from './source-card'

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card.tsx

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import { type ReactNode, useState } from 'react'
4-
import { Button, cn, Tooltip } from '@sim/emcn'
4+
import { Button, chipIconSlotClass, cn, OverflowText, Tooltip } from '@sim/emcn'
55
import { Check, Link as LinkIcon } from '@sim/emcn/icons'
66
import { createLogger } from '@sim/logger'
77
import { getErrorMessage } from '@sim/utils/errors'
@@ -26,6 +26,17 @@ const MIN_HIGHLIGHT_TERM_LENGTH = 3
2626
/** How long the copied state shows on the copy-link action. */
2727
const COPIED_FEEDBACK_MS = 1_500
2828

29+
/**
30+
* The row every source card and its linkless sibling share: the chat surface's
31+
* row rhythm, a hairline between adjacent rows, and the surface fill on hover
32+
* or focus, so a list of results reads like the lists around the composer.
33+
*/
34+
export const SOURCE_ROW_CLASSES =
35+
'group/source not-prose flex items-start gap-2 border-[var(--border)] px-2 py-2 transition-colors focus-within:bg-[var(--surface-5)] hover-hover:bg-[var(--surface-5)] [&+&]:border-t'
36+
37+
/** The 16px mark slot, nudged to centre on the title's first line. */
38+
export const SOURCE_ROW_MARK_CLASSES = cn(chipIconSlotClass, 'mt-[3px]')
39+
2940
function escapeRegExp(value: string): string {
3041
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
3142
}
@@ -48,7 +59,7 @@ export function highlightTerms(text: string, query: string | undefined): ReactNo
4859
const parts = text.split(pattern)
4960
return parts.map((part, index) =>
5061
index % 2 === 1 ? (
51-
<strong key={index} className='font-semibold text-[var(--text-primary)]'>
62+
<strong key={index} className='font-medium text-[var(--text-primary)]'>
5263
{part}
5364
</strong>
5465
) : (
@@ -79,7 +90,7 @@ function CopyLinkAction({ url }: CopyLinkActionProps) {
7990
<Tooltip.Trigger asChild>
8091
<Button
8192
variant='ghost'
82-
size='sm'
93+
size='icon'
8394
aria-label='Copy link'
8495
onClick={() => {
8596
navigator.clipboard.writeText(url).then(
@@ -95,14 +106,10 @@ function CopyLinkAction({ url }: CopyLinkActionProps) {
95106
)
96107
}}
97108
>
98-
{copied ? (
99-
<Check className='size-[14px] text-[var(--text-icon)]' />
100-
) : (
101-
<LinkIcon className='size-[14px] text-[var(--text-icon)]' />
102-
)}
109+
{copied ? <Check className='size-[14px]' /> : <LinkIcon className='size-[14px]' />}
103110
</Button>
104111
</Tooltip.Trigger>
105-
<Tooltip.Content>{copied ? 'Copied' : 'Copy link'}</Tooltip.Content>
112+
<Tooltip.Content side='top'>{copied ? 'Copied' : 'Copy link'}</Tooltip.Content>
106113
</Tooltip.Root>
107114
)
108115
}
@@ -117,11 +124,11 @@ interface SourceCardProps {
117124

118125
/**
119126
* One document a search found, laid out to be scanned: the source's brand
120-
* mark or favicon, the title as a link back to the document, where it lives
121-
* and when it last changed, and the passage that matched with the query terms
122-
* in bold. Actions stay out of the way until the row is hovered or focused.
123-
* The same row serves the composer's search results and the footer of a reply
124-
* that cited its sources with a snippet.
127+
* mark or favicon, the title as a link back to the document, where it lives,
128+
* who it is from, and when it last changed, and the passage that matched with
129+
* the query terms in bold. Actions stay out of the way until the row is
130+
* hovered or its title focused. The same row serves the composer's search
131+
* results and the footer of a reply that cited its sources with a snippet.
125132
*/
126133
export function SourceCard({ source, query, onSummarize }: SourceCardProps) {
127134
const hostname = externalLinkHostname(source.url)
@@ -136,8 +143,8 @@ export function SourceCard({ source, query, onSummarize }: SourceCardProps) {
136143
].filter((part): part is string => Boolean(part))
137144

138145
return (
139-
<div className='group/source not-prose flex items-start gap-3 rounded-md px-2 py-2 transition-colors focus-within:bg-[var(--surface-5)] hover-hover:bg-[var(--surface-5)]'>
140-
<span className='mt-[3px] flex size-[16px] flex-shrink-0 items-center justify-center'>
146+
<div className={SOURCE_ROW_CLASSES}>
147+
<span className={SOURCE_ROW_MARK_CLASSES}>
141148
{ConnectorIcon ? (
142149
<BrandIcon icon={ConnectorIcon} className='size-[16px]' />
143150
) : hostname ? (
@@ -156,29 +163,24 @@ export function SourceCard({ source, query, onSummarize }: SourceCardProps) {
156163
rel='noopener noreferrer'
157164
data-source-link=''
158165
onClick={(event) => handleExternalLinkClick(event, source.url)}
159-
className={cn(
160-
'truncate text-[var(--text-primary)] text-sm no-underline hover:underline',
161-
'underline-offset-2'
162-
)}
166+
className='block min-w-0 text-[var(--text-primary)] text-sm no-underline underline-offset-2 hover:underline'
163167
>
164-
{source.title?.trim() || sourceLabel(source)}
168+
<OverflowText
169+
label={source.title?.trim() || sourceLabel(source)}
170+
focusTarget='nearest-interactive'
171+
/>
165172
</a>
166-
<p className='truncate text-[var(--text-muted)] text-caption'>{meta.join(' · ')}</p>
173+
<OverflowText label={meta.join(' · ')} className='text-[var(--text-muted)] text-caption' />
167174
{source.snippet && (
168175
<p className='line-clamp-2 text-[var(--text-body)] text-small leading-snug'>
169176
{highlightTerms(source.snippet, query)}
170177
</p>
171178
)}
172179
</div>
173-
<div
174-
className={cn(
175-
'flex flex-shrink-0 items-center gap-1 opacity-0 transition-opacity',
176-
'focus-within:opacity-100 group-hover/source:opacity-100'
177-
)}
178-
>
180+
<div className='flex flex-shrink-0 items-center gap-1 self-start opacity-0 transition-opacity group-focus-within/source:opacity-100 group-hover/source:opacity-100'>
179181
<CopyLinkAction url={source.url} />
180182
{onSummarize && (
181-
<Button variant='default' size='sm' onClick={() => onSummarize(source)}>
183+
<Button variant='ghost' size='sm' onClick={() => onSummarize(source)}>
182184
Summarize
183185
</Button>
184186
)}

0 commit comments

Comments
 (0)