Skip to content

Commit cbdb864

Browse files
authored
fix(search): dock the search header after results arrive (#7948)
1 parent 97f4e04 commit cbdb864

5 files changed

Lines changed: 239 additions & 58 deletions

File tree

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,3 +220,48 @@ describe('organization Search query navigation', () => {
220220
expectVisibleQuery('Orion')
221221
})
222222
})
223+
224+
describe('organization Search header placement', () => {
225+
it.each([
226+
['pending', { isPending: true, isFetching: true }],
227+
['failed', { isError: true, isPending: false }],
228+
['empty', { data: { results: [], retrieval: { status: 'complete', timedOutLegs: [] } } }],
229+
[
230+
'timed out',
231+
{ data: { results: [], retrieval: { status: 'partial', timedOutLegs: ['vector'] } } },
232+
],
233+
])('keeps the initial %s search in the centered layout', async (_state, response) => {
234+
mocks.search.mockReturnValue(response)
235+
await render('?q=Orion')
236+
expect(container.querySelector('h1')?.textContent).toBe('Search Acme')
237+
expect(container.querySelector('[aria-label="Search results"]')).toBeNull()
238+
expect(document.activeElement).toBe(searchInput())
239+
})
240+
241+
it('docks only when results arrive without replacing the field or losing a draft', async () => {
242+
const completed = mocks.search(scope, 'Orion')
243+
mocks.search.mockReturnValue({ isPending: true, isFetching: true })
244+
await render('?q=Orion')
245+
const input = searchInput()
246+
const filters = container.querySelector('[aria-label="Search filters"]')
247+
await editDraft('Unsubmitted draft')
248+
mocks.search.mockReturnValue(completed)
249+
await render('?q=Orion')
250+
expect(container.querySelector('h1')).toBeNull()
251+
expect(searchInput()).toBe(input)
252+
expect(input.value).toBe('Unsubmitted draft')
253+
expect(document.activeElement).toBe(input)
254+
expect(container.querySelector('[aria-label="Search filters"]')).toBe(filters)
255+
256+
mocks.search.mockReturnValue({
257+
data: { results: [], retrieval: { status: 'complete', timedOutLegs: [] } },
258+
})
259+
await render('?q=Orion')
260+
expect(container.querySelector('h1')).toBeNull()
261+
expect(searchInput()).toBe(input)
262+
263+
await render('?q=Vega')
264+
expect(container.querySelector('h1')?.textContent).toBe('Search Acme')
265+
expect(searchInput().value).toBe('Vega')
266+
})
267+
})

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

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

3-
import { useEffect, useRef, useState } from 'react'
3+
import { type ReactNode, 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 a query is submitted the field docks at
125+
* query field, centered like Home; once results arrive 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,10 +141,6 @@ 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, { contentRef: scrollContentRef })
147-
148144
const summarize = (message: string, assistantSearch: WorkspaceSearchFilters) => {
149145
MothershipHandoffStorage.store(
150146
{ message, assistantSearch },
@@ -159,49 +155,95 @@ function OrganizationSearchContent() {
159155
void setParams({ q: next })
160156
}
161157

162-
const searching = query.length > 0
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 })
163188

164189
return (
165190
<div className='flex h-full min-h-0 flex-col bg-[var(--bg)]'>
166-
{/* Reserved even while empty so the field docks where the page header sits. */}
167191
<div className={PAGE_HEADER_BAR}>
168192
<div className={HEADER_ACTION_CLUSTER} />
169193
</div>
170-
{searching ? (
171-
<>
172-
<div className={cn(PAGE_COLUMN_CLASS, SIDEBAR_DIVIDER_PAD_ABOVE_CLASS, 'shrink-0 pt-8')}>
173-
<SearchField key={q} initialValue={q} onSubmit={submit} docked focusOnMount />
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+
/>
174226
</div>
175227
<div
176228
ref={scrollContainerRef}
177229
className={cn(
178-
SIDEBAR_DIVIDER_PAD_BELOW_CLASS,
179-
SIDEBAR_DIVIDER_PAD_ABOVE_CLASS,
180-
scrollFadeClass,
181-
'min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable_both-edges]'
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'
182238
)}
183239
{...scrollFadeAttributes(scrollEdges)}
184240
>
185-
{/* The rows carry their own `px-2`; this gutter brings each row's mark under the
186-
field's own search glyph, so results read as a column hanging from the field. */}
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-
{/* Asymmetric padding biases the group up so heading and field sit at the optical center, as on Home */}
195-
<div className='flex min-h-full flex-col items-center justify-center px-6 pt-[2vh] pb-[22vh]'>
196-
<h1 className='mb-7 max-w-chat text-balance font-season text-[26px] text-[var(--text-primary)] leading-[1.15] tracking-[-0.01em] sm:text-[28px]'>
197-
Search {organization.name}
198-
</h1>
199-
<div className='w-full max-w-chat'>
200-
<SearchField key={q} initialValue={q} onSubmit={submit} focusOnMount />
241+
<div ref={scrollContentRef} className={docked ? cn(PAGE_COLUMN_CLASS, 'px-8') : 'px-2'}>
242+
{children}
201243
</div>
202244
</div>
203245
</div>
204-
)}
246+
</div>
205247
</div>
206248
)
207249
}

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ describe('incomplete search coverage', () => {
8787
it.each([false, true])(
8888
'distinguishes incomplete retrieval and permits retry (hasResults=%s)',
8989
async (hasResults) => {
90+
mocks.overview.mockReturnValue({
91+
data: { providers: [{ connectorType: 'gmail', isSyncing: true }] },
92+
})
9093
mocks.search.mockReturnValue({
9194
data: {
9295
query: 'launch',
@@ -118,9 +121,10 @@ describe('incomplete search coverage', () => {
118121
expect(container.textContent).not.toContain('Search couldn’t run')
119122
expect(container.textContent).not.toContain('No documents')
120123
expect(container.textContent).toContain(
121-
hasResults ? '1 document · some results may be missing.' : 'Search didn’t finish.'
124+
hasResults ? '1 document · some results may be missing.' : 'Search timed out.'
122125
)
123126
expect(container.textContent).not.toContain('Search found no results.')
127+
expect(container.textContent).not.toContain('Still indexing')
124128
if (hasResults) expect(container.textContent).toContain('Release plan')
125129
const retry = [...container.querySelectorAll('button')].find(
126130
(button) => button.textContent === 'Try again'

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

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

3-
import { useState } from 'react'
3+
import { type ReactNode, 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,6 +94,8 @@ 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
9799
/** Binds the Assistant turn to the selected canonical document. */
98100
onSummarize: (prompt: string, filters: WorkspaceSearchFilters) => void
99101
}
@@ -104,6 +106,7 @@ export function KnowledgeSearchResults({
104106
scope: suppliedScope,
105107
query,
106108
onSummarize,
109+
renderLayout,
107110
}: KnowledgeSearchResultsProps) {
108111
const scope: ResourceScope = suppliedScope ?? { kind: 'workspace', workspaceId: workspaceId! }
109112
const { data: session } = useSession()
@@ -114,6 +117,7 @@ export function KnowledgeSearchResults({
114117
scope={scope}
115118
query={trimmed}
116119
onSummarize={onSummarize}
120+
renderLayout={renderLayout}
117121
/>
118122
)
119123
}
@@ -122,9 +126,11 @@ interface SearchResultsProps {
122126
scope: ResourceScope
123127
query: string
124128
onSummarize: KnowledgeSearchResultsProps['onSummarize']
129+
renderLayout: KnowledgeSearchResultsProps['renderLayout']
125130
}
126131

127-
function SearchResults({ scope, query, onSummarize }: SearchResultsProps) {
132+
function SearchResults({ scope, query, onSummarize, renderLayout }: SearchResultsProps) {
133+
const [hasDisplayedResults, setHasDisplayedResults] = useState(false)
128134
const [searchedAt] = useState(Date.now)
129135
const {
130136
data: index,
@@ -168,28 +174,28 @@ function SearchResults({ scope, query, onSummarize }: SearchResultsProps) {
168174
const partial = search?.retrieval.status === 'partial'
169175
const documentCount = documents.length === 1 ? '1 document' : `${documents.length} documents`
170176

171-
if (noSources) {
172-
return (
173-
<div className='flex items-center gap-2 px-2 py-2'>
174-
<p className='text-[var(--text-muted)] text-caption'>No sources are set up yet.</p>
175-
<ChipLink
176-
href={
177-
scope.kind === 'organization'
178-
? `/o/${scope.organizationId}/integrations`
179-
: `/workspace/${scope.workspaceId}/knowledge`
180-
}
181-
>
182-
View sources
183-
</ChipLink>
184-
</div>
185-
)
186-
}
187177
const indexingNote =
188178
indexing.length > 0
189179
? `Still indexing ${indexing.join(', ')}; results grow as documents land.`
190180
: null
191181

192-
return (
182+
const showResults = !noSources && !failed && !basesPending && documents.length > 0
183+
if (showResults && !hasDisplayedResults) setHasDisplayedResults(true)
184+
185+
const content = noSources ? (
186+
<div className='flex items-center gap-2 px-2 py-2'>
187+
<p className='text-[var(--text-muted)] text-caption'>No sources are set up yet.</p>
188+
<ChipLink
189+
href={
190+
scope.kind === 'organization'
191+
? `/o/${scope.organizationId}/integrations`
192+
: `/workspace/${scope.workspaceId}/knowledge`
193+
}
194+
>
195+
View sources
196+
</ChipLink>
197+
</div>
198+
) : (
193199
<div className='flex flex-col'>
194200
<div className='flex items-center gap-2 px-2 py-2'>
195201
<div className='min-w-0 flex-1'>
@@ -201,14 +207,14 @@ function SearchResults({ scope, query, onSummarize }: SearchResultsProps) {
201207
? 'Search couldn’t run.'
202208
: partial
203209
? documents.length === 0
204-
? 'Search didn’t finish.'
210+
? 'Search timed out.'
205211
: `${documentCount} · some results may be missing.`
206212
: documents.length === 0
207213
? 'Search found no results.'
208214
: `${documentCount} · searched as you`}
209215
</p>
210216
)}
211-
{indexingNote && !failed && (
217+
{indexingNote && !failed && !partial && (
212218
<p className='text-[var(--text-muted)] text-caption'>{indexingNote}</p>
213219
)}
214220
</div>
@@ -259,7 +265,7 @@ function SearchResults({ scope, query, onSummarize }: SearchResultsProps) {
259265
</Chip>
260266
))}
261267
</div>
262-
{!failed && !basesPending && documents.length > 0 && (
268+
{showResults && (
263269
<div
264270
role='region'
265271
aria-label='Search results'
@@ -290,4 +296,5 @@ function SearchResults({ scope, query, onSummarize }: SearchResultsProps) {
290296
)}
291297
</div>
292298
)
299+
return renderLayout ? renderLayout(content, hasDisplayedResults || showResults) : content
293300
}

0 commit comments

Comments
 (0)