Skip to content

Commit 27ef3c9

Browse files
authored
v0.8.44: more search improvements
2 parents 93e4e13 + b0a68f2 commit 27ef3c9

34 files changed

Lines changed: 56522 additions & 480 deletions

apps/sim/app/o/[organizationId]/search/search.test.tsx

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,31 @@ describe('organization Search query navigation', () => {
222222
})
223223

224224
describe('organization Search header placement', () => {
225+
it('tracks result scroll edges after submitting from the centered layout', async () => {
226+
await render()
227+
await editDraft('Orion')
228+
await act(async () =>
229+
searchInput().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
230+
)
231+
const results = container.querySelector('[aria-label="Search results"]')!
232+
const scroller = results.closest<HTMLDivElement>('.overflow-y-auto')!
233+
Object.defineProperties(scroller, {
234+
scrollHeight: { value: 1000 },
235+
clientHeight: { value: 400 },
236+
})
237+
await act(async () => {
238+
scroller.scrollTop = 100
239+
scroller.dispatchEvent(new Event('scroll'))
240+
})
241+
expect(scroller.getAttribute('data-scroll-fade-top')).toBe('true')
242+
expect(scroller.getAttribute('data-scroll-fade-bottom')).toBe('true')
243+
await act(async () => {
244+
scroller.scrollTop = 600
245+
scroller.dispatchEvent(new Event('scroll'))
246+
})
247+
expect(scroller.getAttribute('data-scroll-fade-bottom')).toBeNull()
248+
})
249+
225250
it.each([
226251
['pending', { isPending: true, isFetching: true }],
227252
['failed', { isError: true, isPending: false }],
@@ -230,38 +255,53 @@ describe('organization Search header placement', () => {
230255
'timed out',
231256
{ data: { results: [], retrieval: { status: 'partial', timedOutLegs: ['vector'] } } },
232257
],
233-
])('keeps the initial %s search in the centered layout', async (_state, response) => {
258+
])('keeps a submitted %s search at the top', async (_state, response) => {
234259
mocks.search.mockReturnValue(response)
235260
await render('?q=Orion')
236-
expect(container.querySelector('h1')?.textContent).toBe('Search Acme')
261+
expect(container.querySelector('h1')).toBeNull()
237262
expect(container.querySelector('[aria-label="Search results"]')).toBeNull()
238263
expect(document.activeElement).toBe(searchInput())
239264
})
240265

241-
it('docks only when results arrive without replacing the field or losing a draft', async () => {
266+
it('moves to the top on submit and reveals filters after results without losing a draft', async () => {
242267
const completed = mocks.search(scope, 'Orion')
243268
mocks.search.mockReturnValue({ isPending: true, isFetching: true })
244-
await render('?q=Orion')
269+
await render()
270+
expect(container.querySelector('h1')?.textContent).toBe('Search Acme')
271+
await editDraft('Orion')
272+
await act(async () =>
273+
searchInput().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
274+
)
275+
expect(container.querySelector('h1')).toBeNull()
276+
expect(container.textContent).toContain('Searching…')
277+
expect(container.querySelector('[aria-label="Search filters"]')).toBeNull()
245278
const input = searchInput()
246-
const filters = container.querySelector('[aria-label="Search filters"]')
247279
await editDraft('Unsubmitted draft')
248280
mocks.search.mockReturnValue(completed)
249281
await render('?q=Orion')
250282
expect(container.querySelector('h1')).toBeNull()
251283
expect(searchInput()).toBe(input)
252284
expect(input.value).toBe('Unsubmitted draft')
253285
expect(document.activeElement).toBe(input)
254-
expect(container.querySelector('[aria-label="Search filters"]')).toBe(filters)
286+
const filters = container.querySelector('[aria-label="Search filters"]')
287+
expect(filters).not.toBeNull()
255288

256289
mocks.search.mockReturnValue({
257290
data: { results: [], retrieval: { status: 'complete', timedOutLegs: [] } },
258291
})
259292
await render('?q=Orion')
260293
expect(container.querySelector('h1')).toBeNull()
261294
expect(searchInput()).toBe(input)
295+
expect(container.querySelector('[aria-label="Search filters"]')).toBe(filters)
262296

297+
mocks.search.mockReturnValue({ isPending: true, isFetching: true })
263298
await render('?q=Vega')
264-
expect(container.querySelector('h1')?.textContent).toBe('Search Acme')
299+
expect(container.querySelector('h1')).toBeNull()
300+
expect(container.querySelector('[aria-label="Search filters"]')).toBeNull()
265301
expect(searchInput().value).toBe('Vega')
302+
303+
await render()
304+
expect(container.querySelector('h1')?.textContent).toBe('Search Acme')
305+
expect(container.querySelector('[aria-label="Search filters"]')).toBeNull()
266306
})
267307
})

apps/sim/app/o/[organizationId]/search/search.tsx

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

3-
import { type ReactNode, useEffect, useRef, useState } from 'react'
3+
import { useEffect, useRef, useState } from 'react'
44
import { Button, cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn'
55
import { ArrowUp, Search } from '@sim/emcn/icons'
66
import { useRouter } from 'next/navigation'
@@ -122,7 +122,7 @@ function SearchField({
122122

123123
/**
124124
* Sim Search over the organization's sources. Empty, it is the greeting over the
125-
* query field, centered like Home; once results arrive the field docks at
125+
* query field, centered like Home; once a query is submitted the field docks at
126126
* the top of the page — where every other organization page's title sits — and
127127
* the results scroll beneath it under the sidebar's edge fade. The submitted
128128
* query lives in the URL; the field holds the draft until the next submit.
@@ -141,6 +141,13 @@ function OrganizationSearchContent() {
141141
const query = q.trim()
142142
const scope: ResourceScope = { kind: 'organization', organizationId: organization.id }
143143

144+
const scrollContainerRef = useRef<HTMLDivElement>(null)
145+
const scrollContentRef = useRef<HTMLDivElement>(null)
146+
const scrollEdges = useScrollEdges(scrollContainerRef, {
147+
contentRef: scrollContentRef,
148+
enabled: query.length > 0,
149+
})
150+
144151
const summarize = (message: string, assistantSearch: WorkspaceSearchFilters) => {
145152
MothershipHandoffStorage.store(
146153
{ message, assistantSearch },
@@ -155,95 +162,45 @@ function OrganizationSearchContent() {
155162
void setParams({ q: next })
156163
}
157164

158-
const renderLayout = (results: ReactNode, docked: boolean) => (
159-
<SearchLayout query={q} onSubmit={submit} docked={docked}>
160-
{results}
161-
</SearchLayout>
162-
)
163-
164-
return query ? (
165-
<KnowledgeSearchResults
166-
scope={scope}
167-
query={query}
168-
onSummarize={summarize}
169-
renderLayout={renderLayout}
170-
/>
171-
) : (
172-
renderLayout(null, false)
173-
)
174-
}
175-
176-
interface SearchLayoutProps {
177-
query: string
178-
onSubmit: (draft: string) => void
179-
docked: boolean
180-
children: ReactNode
181-
}
182-
183-
function SearchLayout({ query, onSubmit, docked, children }: SearchLayoutProps) {
184-
const { organization } = useOrganizationContext()
185-
const scrollContainerRef = useRef<HTMLDivElement>(null)
186-
const scrollContentRef = useRef<HTMLDivElement>(null)
187-
const scrollEdges = useScrollEdges(scrollContainerRef, { contentRef: scrollContentRef })
165+
const searching = query.length > 0
188166

189167
return (
190168
<div className='flex h-full min-h-0 flex-col bg-[var(--bg)]'>
191169
<div className={PAGE_HEADER_BAR}>
192170
<div className={HEADER_ACTION_CLUSTER} />
193171
</div>
194-
<div
195-
className={cn(
196-
'flex min-h-0 flex-1 flex-col',
197-
!docked && 'overflow-y-auto [scrollbar-gutter:stable_both-edges]'
198-
)}
199-
>
200-
<div
201-
className={cn(
202-
'flex min-h-0 flex-col',
203-
docked ? 'flex-1' : 'min-h-full items-center justify-center px-6 pt-[2vh] pb-[22vh]'
204-
)}
205-
>
206-
<div
207-
className={cn(
208-
'shrink-0',
209-
docked
210-
? cn(PAGE_COLUMN_CLASS, SIDEBAR_DIVIDER_PAD_ABOVE_CLASS, 'pt-8')
211-
: 'w-full max-w-chat'
212-
)}
213-
>
214-
{!docked && (
215-
<h1 className='mb-7 text-balance text-center font-season text-[26px] text-[var(--text-primary)] leading-[1.15] tracking-[-0.01em] sm:text-[28px]'>
216-
Search {organization.name}
217-
</h1>
218-
)}
219-
<SearchField
220-
key={query}
221-
initialValue={query}
222-
onSubmit={onSubmit}
223-
docked={docked}
224-
focusOnMount
225-
/>
172+
{searching ? (
173+
<>
174+
<div className={cn(PAGE_COLUMN_CLASS, SIDEBAR_DIVIDER_PAD_ABOVE_CLASS, 'shrink-0 pt-8')}>
175+
<SearchField key={q} initialValue={q} onSubmit={submit} docked focusOnMount />
226176
</div>
227177
<div
228178
ref={scrollContainerRef}
229179
className={cn(
230-
docked
231-
? cn(
232-
SIDEBAR_DIVIDER_PAD_BELOW_CLASS,
233-
SIDEBAR_DIVIDER_PAD_ABOVE_CLASS,
234-
scrollFadeClass,
235-
'min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable_both-edges]'
236-
)
237-
: 'w-full max-w-chat'
180+
SIDEBAR_DIVIDER_PAD_BELOW_CLASS,
181+
SIDEBAR_DIVIDER_PAD_ABOVE_CLASS,
182+
scrollFadeClass,
183+
'min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable_both-edges]'
238184
)}
239185
{...scrollFadeAttributes(scrollEdges)}
240186
>
241-
<div ref={scrollContentRef} className={docked ? cn(PAGE_COLUMN_CLASS, 'px-8') : 'px-2'}>
242-
{children}
187+
<div ref={scrollContentRef} className={cn(PAGE_COLUMN_CLASS, 'px-8')}>
188+
<KnowledgeSearchResults scope={scope} query={query} onSummarize={summarize} />
189+
</div>
190+
</div>
191+
</>
192+
) : (
193+
<div className='min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable_both-edges]'>
194+
<div className='flex min-h-full flex-col items-center justify-center px-6 pt-[2vh] pb-[22vh]'>
195+
<h1 className='mb-7 max-w-chat text-balance text-center font-season text-[26px] text-[var(--text-primary)] leading-[1.15] tracking-[-0.01em] sm:text-[28px]'>
196+
Search {organization.name}
197+
</h1>
198+
<div className='w-full max-w-chat'>
199+
<SearchField key={q} initialValue={q} onSubmit={submit} focusOnMount />
243200
</div>
244201
</div>
245202
</div>
246-
</div>
203+
)}
247204
</div>
248205
)
249206
}

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

Lines changed: 41 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { type ReactNode, useState } from 'react'
3+
import { useState } from 'react'
44
import { Chip, ChipLink, cn } from '@sim/emcn'
55
import { useQueryStates } from 'nuqs'
66
import { ActivityStatus } from '@/components/ui/activity-status'
@@ -94,8 +94,6 @@ type KnowledgeSearchResultsProps = (
9494
| { scope: ResourceScope; workspaceId?: never }
9595
) & {
9696
query: string
97-
/** Lets the page dock its header after this query has displayed results. */
98-
renderLayout?: (results: ReactNode, hasDisplayedResults: boolean) => ReactNode
9997
/** Binds the Assistant turn to the selected canonical document. */
10098
onSummarize: (prompt: string, filters: WorkspaceSearchFilters) => void
10199
}
@@ -106,7 +104,6 @@ export function KnowledgeSearchResults({
106104
scope: suppliedScope,
107105
query,
108106
onSummarize,
109-
renderLayout,
110107
}: KnowledgeSearchResultsProps) {
111108
const scope: ResourceScope = suppliedScope ?? { kind: 'workspace', workspaceId: workspaceId! }
112109
const { data: session } = useSession()
@@ -117,7 +114,6 @@ export function KnowledgeSearchResults({
117114
scope={scope}
118115
query={trimmed}
119116
onSummarize={onSummarize}
120-
renderLayout={renderLayout}
121117
/>
122118
)
123119
}
@@ -126,11 +122,10 @@ interface SearchResultsProps {
126122
scope: ResourceScope
127123
query: string
128124
onSummarize: KnowledgeSearchResultsProps['onSummarize']
129-
renderLayout: KnowledgeSearchResultsProps['renderLayout']
130125
}
131126

132-
function SearchResults({ scope, query, onSummarize, renderLayout }: SearchResultsProps) {
133-
const [hasDisplayedResults, setHasDisplayedResults] = useState(false)
127+
function SearchResults({ scope, query, onSummarize }: SearchResultsProps) {
128+
const [hasShownFilters, setHasShownFilters] = useState(false)
134129
const [searchedAt] = useState(Date.now)
135130
const {
136131
data: index,
@@ -180,9 +175,11 @@ function SearchResults({ scope, query, onSummarize, renderLayout }: SearchResult
180175
: null
181176

182177
const showResults = !noSources && !failed && !basesPending && documents.length > 0
183-
if (showResults && !hasDisplayedResults) setHasDisplayedResults(true)
178+
const showFilters =
179+
hasShownFilters || showResults || (!noSources && !pending && !failed && !!search && !partial)
180+
if (showFilters && !hasShownFilters) setHasShownFilters(true)
184181

185-
const content = noSources ? (
182+
return noSources ? (
186183
<div className='flex items-center gap-2 px-2 py-2'>
187184
<p className='text-[var(--text-muted)] text-caption'>No sources are set up yet.</p>
188185
<ChipLink
@@ -228,43 +225,45 @@ function SearchResults({ scope, query, onSummarize, renderLayout }: SearchResult
228225
</Chip>
229226
)}
230227
</div>
231-
<div
232-
role='group'
233-
aria-label='Search filters'
234-
className='flex flex-wrap items-center gap-1.5 px-2 pb-2'
235-
>
236-
<Chip
237-
shape='round'
238-
active={filters.source === null}
239-
aria-pressed={filters.source === null}
240-
onClick={() => setFilters({ source: null })}
228+
{showFilters && (
229+
<div
230+
role='group'
231+
aria-label='Search filters'
232+
className='flex flex-wrap items-center gap-1.5 px-2 pb-2'
241233
>
242-
All sources
243-
</Chip>
244-
{sourceTypes.map((type) => (
245234
<Chip
246-
key={type}
247235
shape='round'
248-
active={filters.source === type}
249-
aria-pressed={filters.source === type}
250-
onClick={() => setFilters({ source: filters.source === type ? null : type })}
236+
active={filters.source === null}
237+
aria-pressed={filters.source === null}
238+
onClick={() => setFilters({ source: null })}
251239
>
252-
{type === UPLOAD_SOURCE ? 'Uploads' : connectorDisplayName(type)}
240+
All sources
253241
</Chip>
254-
))}
255-
<span aria-hidden className='mx-0.5 h-[16px] w-px bg-[var(--border)]' />
256-
{UPDATED_WINDOWS.map((window) => (
257-
<Chip
258-
key={window.id}
259-
shape='round'
260-
active={filters.updated === window.id}
261-
aria-pressed={filters.updated === window.id}
262-
onClick={() => setFilters({ updated: window.id })}
263-
>
264-
{window.label}
265-
</Chip>
266-
))}
267-
</div>
242+
{sourceTypes.map((type) => (
243+
<Chip
244+
key={type}
245+
shape='round'
246+
active={filters.source === type}
247+
aria-pressed={filters.source === type}
248+
onClick={() => setFilters({ source: filters.source === type ? null : type })}
249+
>
250+
{type === UPLOAD_SOURCE ? 'Uploads' : connectorDisplayName(type)}
251+
</Chip>
252+
))}
253+
<span aria-hidden className='mx-0.5 h-[16px] w-px bg-[var(--border)]' />
254+
{UPDATED_WINDOWS.map((window) => (
255+
<Chip
256+
key={window.id}
257+
shape='round'
258+
active={filters.updated === window.id}
259+
aria-pressed={filters.updated === window.id}
260+
onClick={() => setFilters({ updated: window.id })}
261+
>
262+
{window.label}
263+
</Chip>
264+
))}
265+
</div>
266+
)}
268267
{showResults && (
269268
<div
270269
role='region'
@@ -296,5 +295,4 @@ function SearchResults({ scope, query, onSummarize, renderLayout }: SearchResult
296295
)}
297296
</div>
298297
)
299-
return renderLayout ? renderLayout(content, hasDisplayedResults || showResults) : content
300298
}

0 commit comments

Comments
 (0)