Skip to content

Commit ea8563f

Browse files
committed
feat(knowledge): the composer's Search mode shows every source and reads like a search
- Under the input, Search mode lists every Sim Search source as a chip with the person's own state: connected with a document count, indexing, reconnect, or one click to connect; sources that need a site link to Knowledge. The sampled four-row list goes, and with it the rows that offered sources the server had to refuse - Results carry a header (how many documents, searched as you, and which source is still indexing), hover actions to copy the link or summarize, an Answer with Sim action for a prose answer, and source and recency filters once a list is long and mixed enough to need them - An existing chat opens in Build; a new chat keeps the last mode. Results never join a transcript
1 parent 75df3e5 commit ea8563f

10 files changed

Lines changed: 458 additions & 343 deletions

File tree

Lines changed: 138 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,32 @@
11
'use client'
22

3-
import { useMemo } from 'react'
3+
import { useMemo, useState } from 'react'
4+
import { Button, Chip } from '@sim/emcn'
45
import type { WorkspaceKnowledgeSearchResult } from '@/lib/api/contracts/knowledge'
56
import { SourceCard } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-card'
67
import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
8+
import {
9+
isIndexing,
10+
simSearchConnectionsByType,
11+
} from '@/app/workspace/[workspaceId]/home/components/search-sources'
12+
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
13+
import { useWorkspaceMemberConnectors } from '@/hooks/queries/kb/connectors'
714
import { useKnowledgeBasesQuery, useWorkspaceKnowledgeSearch } from '@/hooks/queries/kb/knowledge'
815

916
/** A search spans at most this many knowledge bases. */
1017
const MAX_SEARCHED_KNOWLEDGE_BASES = 20
1118
/** Characters of the matching chunk shown under a result. */
1219
const SNIPPET_LENGTH = 280
20+
/** Filters appear only once a list is long and mixed enough for them to help. */
21+
const FILTERS_MIN_RESULTS = 10
22+
const DAY_MS = 24 * 60 * 60 * 1000
23+
24+
const UPDATED_WINDOWS = [
25+
{ id: 'any', label: 'Any time', days: null },
26+
{ id: '7d', label: 'Past week', days: 7 },
27+
{ id: '30d', label: 'Past month', days: 30 },
28+
] as const
29+
type UpdatedWindow = (typeof UPDATED_WINDOWS)[number]['id']
1330

1431
function toSnippet(content: string): string {
1532
const flat = content.replace(/\s+/g, ' ').trim()
@@ -46,22 +63,32 @@ function toSource(result: WorkspaceKnowledgeSearchResult): SourceTagData | null
4663
}
4764
}
4865

66+
function connectorName(connectorType: string): string {
67+
return CONNECTOR_META_REGISTRY[connectorType]?.name ?? connectorType
68+
}
69+
4970
interface KnowledgeSearchResultsProps {
5071
workspaceId: string
5172
query: string
5273
/** Asks the agent about one document; the prompt names it and links to it. */
5374
onSummarize: (prompt: string) => void
75+
/** Asks the agent the query itself, for a prose answer with citations. */
76+
onAnswer: (query: string) => void
5477
}
5578

5679
/**
5780
* The composer's Search mode: the documents the signed-in person may read that
58-
* match their query, across every knowledge base in the workspace, as cards
59-
* that open the source. Summarize hands one document to the agent.
81+
* match their query, across every knowledge base in the workspace, as rows
82+
* that open the source. A header says how many and that the search ran as
83+
* them; while a connected source is still indexing it says so, and the list
84+
* grows as documents land. Filters by source and recency appear only once the
85+
* list is long and mixed enough to need them.
6086
*/
6187
export function KnowledgeSearchResults({
6288
workspaceId,
6389
query,
6490
onSummarize,
91+
onAnswer,
6592
}: KnowledgeSearchResultsProps) {
6693
const { data: knowledgeBases = [], isPending: basesPending } = useKnowledgeBasesQuery(workspaceId)
6794
const knowledgeBaseIds = useMemo(
@@ -74,12 +101,40 @@ export function KnowledgeSearchResults({
74101
isFetching,
75102
error,
76103
} = useWorkspaceKnowledgeSearch(workspaceId, knowledgeBaseIds, query)
104+
const { data: memberConnectors = [] } = useWorkspaceMemberConnectors(workspaceId)
105+
const indexing = useMemo(
106+
() =>
107+
[...simSearchConnectionsByType(memberConnectors).values()]
108+
.filter(isIndexing)
109+
.map((connection) => connectorName(connection.connectorType)),
110+
[memberConnectors]
111+
)
77112
const documents = useMemo(() => groupResultsByDocument(results ?? []), [results])
113+
const sourceTypes = useMemo(
114+
() => [...new Set(documents.map((result) => result.connectorType ?? 'upload'))],
115+
[documents]
116+
)
117+
const [sourceFilter, setSourceFilter] = useState<string | null>(null)
118+
const [updatedFilter, setUpdatedFilter] = useState<UpdatedWindow>('any')
119+
const showFilters = documents.length >= FILTERS_MIN_RESULTS && sourceTypes.length > 1
120+
const visible = useMemo(() => {
121+
if (!showFilters) return documents
122+
const window = UPDATED_WINDOWS.find((entry) => entry.id === updatedFilter)
123+
const cutoff = window?.days ? Date.now() - window.days * DAY_MS : null
124+
return documents.filter((result) => {
125+
if (sourceFilter && (result.connectorType ?? 'upload') !== sourceFilter) return false
126+
if (cutoff !== null) {
127+
const modified = result.sourceModifiedAt ? Date.parse(result.sourceModifiedAt) : Number.NaN
128+
if (Number.isNaN(modified) || modified < cutoff) return false
129+
}
130+
return true
131+
})
132+
}, [documents, showFilters, sourceFilter, updatedFilter])
78133

79134
if (!basesPending && knowledgeBaseIds.length === 0) {
80135
return (
81136
<p className='px-2 py-3 text-[var(--text-muted)] text-small'>
82-
No knowledge bases to search yet. Add one from the Knowledge tab.
137+
Nothing to search yet. Connect a source above to index what you can open.
83138
</p>
84139
)
85140
}
@@ -89,41 +144,87 @@ export function KnowledgeSearchResults({
89144
if (isPending || (isFetching && !results)) {
90145
return <p className='px-2 py-3 text-[var(--text-muted)] text-small'>Searching…</p>
91146
}
92-
if (documents.length === 0) {
93-
return (
94-
<p className='px-2 py-3 text-[var(--text-muted)] text-small'>
95-
No documents you can read match “{query}”.
96-
</p>
97-
)
98-
}
147+
148+
const indexingNote =
149+
indexing.length > 0
150+
? `Still indexing ${indexing.join(', ')}; results grow as documents land.`
151+
: null
99152

100153
return (
101-
<div className='flex flex-col gap-0.5'>
102-
{documents.map((result) => {
103-
const source = toSource(result)
104-
return source ? (
105-
<SourceCard
106-
key={result.documentId}
107-
source={source}
108-
query={query}
109-
onSummarize={(cited) =>
110-
onSummarize(`Summarize "${cited.title ?? cited.url}" (${cited.url})`)
111-
}
112-
/>
113-
) : (
114-
<div key={result.documentId} className='flex flex-col gap-0.5 px-2 py-2'>
115-
<p className='truncate text-[var(--text-primary)] text-sm'>
116-
{result.documentName ?? 'Untitled document'}
117-
</p>
118-
<p className='truncate text-[var(--text-muted)] text-caption'>
119-
{result.knowledgeBaseName}
120-
</p>
121-
<p className='line-clamp-2 text-[var(--text-body)] text-small leading-snug'>
122-
{toSnippet(result.content)}
123-
</p>
124-
</div>
125-
)
126-
})}
154+
<div className='flex flex-col gap-1'>
155+
<div className='flex flex-wrap items-center gap-x-3 gap-y-1 px-2 py-1'>
156+
<span className='text-[var(--text-muted)] text-caption'>
157+
{documents.length === 1 ? '1 document' : `${documents.length} documents`} · searched as
158+
you
159+
{indexingNote ? ` · ${indexingNote}` : ''}
160+
</span>
161+
<Button variant='ghost' size='sm' className='ml-auto' onClick={() => onAnswer(query)}>
162+
Answer with Sim
163+
</Button>
164+
</div>
165+
{showFilters && (
166+
<div className='flex flex-wrap gap-1.5 px-2 pb-1'>
167+
<Chip shape='round' active={sourceFilter === null} onClick={() => setSourceFilter(null)}>
168+
All sources
169+
</Chip>
170+
{sourceTypes.map((type) => (
171+
<Chip
172+
key={type}
173+
shape='round'
174+
active={sourceFilter === type}
175+
onClick={() => setSourceFilter(sourceFilter === type ? null : type)}
176+
>
177+
{type === 'upload' ? 'Uploads' : connectorName(type)}
178+
</Chip>
179+
))}
180+
<span className='mx-1 self-center text-[var(--text-muted)] text-caption'>·</span>
181+
{UPDATED_WINDOWS.map((window) => (
182+
<Chip
183+
key={window.id}
184+
shape='round'
185+
active={updatedFilter === window.id}
186+
onClick={() => setUpdatedFilter(window.id)}
187+
>
188+
{window.label}
189+
</Chip>
190+
))}
191+
</div>
192+
)}
193+
{visible.length === 0 ? (
194+
<p className='px-2 py-3 text-[var(--text-muted)] text-small'>
195+
{documents.length === 0
196+
? `No documents you can read match “${query}”.`
197+
: 'No documents match these filters.'}
198+
</p>
199+
) : (
200+
<div className='flex flex-col gap-0.5'>
201+
{visible.map((result) => {
202+
const source = toSource(result)
203+
return source ? (
204+
<SourceCard
205+
key={result.documentId}
206+
source={source}
207+
query={query}
208+
onSummarize={(cited) =>
209+
onSummarize(`Summarize "${cited.title ?? cited.url}" (${cited.url})`)
210+
}
211+
/>
212+
) : (
213+
<div key={result.documentId} className='flex flex-col gap-0.5 px-2 py-2'>
214+
<p className='truncate text-[var(--text-primary)] text-sm'>
215+
{result.documentName ?? 'Untitled document'}
216+
</p>
217+
<p className='truncate text-[var(--text-muted)] text-caption'>
218+
{result.knowledgeBaseName}
219+
</p>
220+
<p className='line-clamp-2 text-[var(--text-body)] text-small leading-snug'>
221+
{toSnippet(result.content)}
222+
</p>
223+
</div>
224+
)
225+
})}
226+
</div>
227+
)}
127228
</div>
128229
)
129230
}

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

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

3-
import type { ReactNode } from 'react'
4-
import { Button, cn } from '@sim/emcn'
3+
import { type ReactNode, useState } from 'react'
4+
import { Button, cn, Tooltip } from '@sim/emcn'
5+
import { Check, Link as LinkIcon } from '@sim/emcn/icons'
56
import { formatDate } from '@sim/utils/formatting'
67
import { faviconUrl } from '@/lib/core/utils/favicon'
78
import {
@@ -18,6 +19,8 @@ import { BrandIcon } from '@/blocks/brand-icon'
1819

1920
/** Query terms shorter than this are too common to bold. */
2021
const MIN_HIGHLIGHT_TERM_LENGTH = 3
22+
/** How long the copied state shows on the copy-link action. */
23+
const COPIED_FEEDBACK_MS = 1_500
2124

2225
function escapeRegExp(value: string): string {
2326
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
@@ -56,6 +59,39 @@ function parseUpdatedAt(value: string | undefined): Date | null {
5659
return Number.isNaN(date.getTime()) ? null : date
5760
}
5861

62+
interface CopyLinkActionProps {
63+
url: string
64+
}
65+
66+
/** Copies the document's link; confirms with a check for a moment. */
67+
function CopyLinkAction({ url }: CopyLinkActionProps) {
68+
const [copied, setCopied] = useState(false)
69+
return (
70+
<Tooltip.Root>
71+
<Tooltip.Trigger asChild>
72+
<Button
73+
variant='ghost'
74+
size='sm'
75+
aria-label='Copy link'
76+
onClick={() => {
77+
void navigator.clipboard.writeText(url).then(() => {
78+
setCopied(true)
79+
window.setTimeout(() => setCopied(false), COPIED_FEEDBACK_MS)
80+
})
81+
}}
82+
>
83+
{copied ? (
84+
<Check className='size-[14px] text-[var(--text-icon)]' />
85+
) : (
86+
<LinkIcon className='size-[14px] text-[var(--text-icon)]' />
87+
)}
88+
</Button>
89+
</Tooltip.Trigger>
90+
<Tooltip.Content>{copied ? 'Copied' : 'Copy link'}</Tooltip.Content>
91+
</Tooltip.Root>
92+
)
93+
}
94+
5995
interface SourceCardProps {
6096
source: SourceTagData
6197
/** The query the document was found for; its terms are bolded in the snippet. */
@@ -68,8 +104,9 @@ interface SourceCardProps {
68104
* One document a search found, laid out to be scanned: the source's brand
69105
* mark or favicon, the title as a link back to the document, where it lives
70106
* and when it last changed, and the passage that matched with the query terms
71-
* in bold. The same card serves the composer's search results and the
72-
* footer of a reply that cited its sources with a snippet.
107+
* in bold. Actions stay out of the way until the row is hovered or focused.
108+
* The same row serves the composer's search results and the footer of a reply
109+
* that cited its sources with a snippet.
73110
*/
74111
export function SourceCard({ source, query, onSummarize }: SourceCardProps) {
75112
const hostname = externalLinkHostname(source.url)
@@ -82,7 +119,7 @@ export function SourceCard({ source, query, onSummarize }: SourceCardProps) {
82119
)
83120

84121
return (
85-
<div className='not-prose flex items-start gap-3 rounded-md px-2 py-2 transition-colors hover-hover:bg-[var(--surface-5)]'>
122+
<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)]'>
86123
<span className='mt-[3px] flex size-[16px] flex-shrink-0 items-center justify-center'>
87124
{ConnectorIcon ? (
88125
<BrandIcon icon={ConnectorIcon} className='size-[16px]' />
@@ -115,16 +152,19 @@ export function SourceCard({ source, query, onSummarize }: SourceCardProps) {
115152
</p>
116153
)}
117154
</div>
118-
{onSummarize && (
119-
<Button
120-
variant='default'
121-
size='sm'
122-
className='flex-shrink-0'
123-
onClick={() => onSummarize(source)}
124-
>
125-
Summarize
126-
</Button>
127-
)}
155+
<div
156+
className={cn(
157+
'flex flex-shrink-0 items-center gap-1 opacity-0 transition-opacity',
158+
'focus-within:opacity-100 group-hover/source:opacity-100'
159+
)}
160+
>
161+
<CopyLinkAction url={source.url} />
162+
{onSummarize && (
163+
<Button variant='default' size='sm' onClick={() => onSummarize(source)}>
164+
Summarize
165+
</Button>
166+
)}
167+
</div>
128168
</div>
129169
)
130170
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { isIndexing, SearchSources, simSearchConnectionsByType } from './search-sources'

0 commit comments

Comments
 (0)