Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions apps/sim/app/o/[organizationId]/search/search.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -220,3 +220,48 @@ describe('organization Search query navigation', () => {
expectVisibleQuery('Orion')
})
})

describe('organization Search header placement', () => {
it.each([
['pending', { isPending: true, isFetching: true }],
['failed', { isError: true, isPending: false }],
['empty', { data: { results: [], retrieval: { status: 'complete', timedOutLegs: [] } } }],
[
'timed out',
{ data: { results: [], retrieval: { status: 'partial', timedOutLegs: ['vector'] } } },
],
])('keeps the initial %s search in the centered layout', async (_state, response) => {
mocks.search.mockReturnValue(response)
await render('?q=Orion')
expect(container.querySelector('h1')?.textContent).toBe('Search Acme')
expect(container.querySelector('[aria-label="Search results"]')).toBeNull()
expect(document.activeElement).toBe(searchInput())
})

it('docks only when results arrive without replacing the field or losing a draft', async () => {
const completed = mocks.search(scope, 'Orion')
mocks.search.mockReturnValue({ isPending: true, isFetching: true })
await render('?q=Orion')
const input = searchInput()
const filters = container.querySelector('[aria-label="Search filters"]')
await editDraft('Unsubmitted draft')
mocks.search.mockReturnValue(completed)
await render('?q=Orion')
expect(container.querySelector('h1')).toBeNull()
expect(searchInput()).toBe(input)
expect(input.value).toBe('Unsubmitted draft')
expect(document.activeElement).toBe(input)
expect(container.querySelector('[aria-label="Search filters"]')).toBe(filters)

mocks.search.mockReturnValue({
data: { results: [], retrieval: { status: 'complete', timedOutLegs: [] } },
})
await render('?q=Orion')
expect(container.querySelector('h1')).toBeNull()
expect(searchInput()).toBe(input)

await render('?q=Vega')
expect(container.querySelector('h1')?.textContent).toBe('Search Acme')
expect(searchInput().value).toBe('Vega')
})
})
108 changes: 75 additions & 33 deletions apps/sim/app/o/[organizationId]/search/search.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

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

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

const scrollContainerRef = useRef<HTMLDivElement>(null)
const scrollContentRef = useRef<HTMLDivElement>(null)
const scrollEdges = useScrollEdges(scrollContainerRef, { contentRef: scrollContentRef })

const summarize = (message: string, assistantSearch: WorkspaceSearchFilters) => {
MothershipHandoffStorage.store(
{ message, assistantSearch },
Expand All @@ -159,49 +155,95 @@ function OrganizationSearchContent() {
void setParams({ q: next })
}

const searching = query.length > 0
const renderLayout = (results: ReactNode, docked: boolean) => (
<SearchLayout query={q} onSubmit={submit} docked={docked}>
{results}
</SearchLayout>
)

return query ? (
<KnowledgeSearchResults
scope={scope}
query={query}
onSummarize={summarize}
renderLayout={renderLayout}
/>
) : (
renderLayout(null, false)
)
}

interface SearchLayoutProps {
query: string
onSubmit: (draft: string) => void
docked: boolean
children: ReactNode
}

function SearchLayout({ query, onSubmit, docked, children }: SearchLayoutProps) {
const { organization } = useOrganizationContext()
const scrollContainerRef = useRef<HTMLDivElement>(null)
const scrollContentRef = useRef<HTMLDivElement>(null)
const scrollEdges = useScrollEdges(scrollContainerRef, { contentRef: scrollContentRef })

return (
<div className='flex h-full min-h-0 flex-col bg-[var(--bg)]'>
{/* Reserved even while empty so the field docks where the page header sits. */}
<div className={PAGE_HEADER_BAR}>
<div className={HEADER_ACTION_CLUSTER} />
</div>
{searching ? (
<>
<div className={cn(PAGE_COLUMN_CLASS, SIDEBAR_DIVIDER_PAD_ABOVE_CLASS, 'shrink-0 pt-8')}>
<SearchField key={q} initialValue={q} onSubmit={submit} docked focusOnMount />
<div
className={cn(
'flex min-h-0 flex-1 flex-col',
!docked && 'overflow-y-auto [scrollbar-gutter:stable_both-edges]'
)}
>
<div
className={cn(
'flex min-h-0 flex-col',
docked ? 'flex-1' : 'min-h-full items-center justify-center px-6 pt-[2vh] pb-[22vh]'
)}
>
<div
className={cn(
'shrink-0',
docked
? cn(PAGE_COLUMN_CLASS, SIDEBAR_DIVIDER_PAD_ABOVE_CLASS, 'pt-8')
: 'w-full max-w-chat'
)}
>
{!docked && (
<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]'>
Search {organization.name}
</h1>
)}
<SearchField
key={query}
initialValue={query}
onSubmit={onSubmit}
docked={docked}
focusOnMount
/>
</div>
<div
ref={scrollContainerRef}
className={cn(
SIDEBAR_DIVIDER_PAD_BELOW_CLASS,
SIDEBAR_DIVIDER_PAD_ABOVE_CLASS,
scrollFadeClass,
'min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable_both-edges]'
docked
? cn(
SIDEBAR_DIVIDER_PAD_BELOW_CLASS,
SIDEBAR_DIVIDER_PAD_ABOVE_CLASS,
scrollFadeClass,
'min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable_both-edges]'
)
: 'w-full max-w-chat'
)}
{...scrollFadeAttributes(scrollEdges)}
>
{/* The rows carry their own `px-2`; this gutter brings each row's mark under the
field's own search glyph, so results read as a column hanging from the field. */}
<div ref={scrollContentRef} className={cn(PAGE_COLUMN_CLASS, 'px-8')}>
<KnowledgeSearchResults scope={scope} query={query} onSummarize={summarize} />
</div>
</div>
</>
) : (
<div className='min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable_both-edges]'>
{/* Asymmetric padding biases the group up so heading and field sit at the optical center, as on Home */}
<div className='flex min-h-full flex-col items-center justify-center px-6 pt-[2vh] pb-[22vh]'>
<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]'>
Search {organization.name}
</h1>
<div className='w-full max-w-chat'>
<SearchField key={q} initialValue={q} onSubmit={submit} focusOnMount />
<div ref={scrollContentRef} className={docked ? cn(PAGE_COLUMN_CLASS, 'px-8') : 'px-2'}>
{children}
</div>
</div>
</div>
)}
</div>
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ describe('incomplete search coverage', () => {
it.each([false, true])(
'distinguishes incomplete retrieval and permits retry (hasResults=%s)',
async (hasResults) => {
mocks.overview.mockReturnValue({
data: { providers: [{ connectorType: 'gmail', isSyncing: true }] },
})
mocks.search.mockReturnValue({
data: {
query: 'launch',
Expand Down Expand Up @@ -118,9 +121,10 @@ describe('incomplete search coverage', () => {
expect(container.textContent).not.toContain('Search couldn’t run')
expect(container.textContent).not.toContain('No documents')
expect(container.textContent).toContain(
hasResults ? '1 document · some results may be missing.' : 'Search didn’t finish.'
hasResults ? '1 document · some results may be missing.' : 'Search timed out.'
)
expect(container.textContent).not.toContain('Search found no results.')
expect(container.textContent).not.toContain('Still indexing')
if (hasResults) expect(container.textContent).toContain('Release plan')
const retry = [...container.querySelectorAll('button')].find(
(button) => button.textContent === 'Try again'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { useState } from 'react'
import { type ReactNode, useState } from 'react'
import { Chip, ChipLink, cn } from '@sim/emcn'
import { useQueryStates } from 'nuqs'
import { ActivityStatus } from '@/components/ui/activity-status'
Expand Down Expand Up @@ -94,6 +94,8 @@ type KnowledgeSearchResultsProps = (
| { scope: ResourceScope; workspaceId?: never }
) & {
query: string
/** Lets the page dock its header after this query has displayed results. */
renderLayout?: (results: ReactNode, hasDisplayedResults: boolean) => ReactNode
/** Binds the Assistant turn to the selected canonical document. */
onSummarize: (prompt: string, filters: WorkspaceSearchFilters) => void
}
Expand All @@ -104,6 +106,7 @@ export function KnowledgeSearchResults({
scope: suppliedScope,
query,
onSummarize,
renderLayout,
}: KnowledgeSearchResultsProps) {
const scope: ResourceScope = suppliedScope ?? { kind: 'workspace', workspaceId: workspaceId! }
const { data: session } = useSession()
Expand All @@ -114,6 +117,7 @@ export function KnowledgeSearchResults({
scope={scope}
query={trimmed}
onSummarize={onSummarize}
renderLayout={renderLayout}
/>
)
}
Expand All @@ -122,9 +126,11 @@ interface SearchResultsProps {
scope: ResourceScope
query: string
onSummarize: KnowledgeSearchResultsProps['onSummarize']
renderLayout: KnowledgeSearchResultsProps['renderLayout']
}

function SearchResults({ scope, query, onSummarize }: SearchResultsProps) {
function SearchResults({ scope, query, onSummarize, renderLayout }: SearchResultsProps) {
const [hasDisplayedResults, setHasDisplayedResults] = useState(false)
const [searchedAt] = useState(Date.now)
const {
data: index,
Expand Down Expand Up @@ -168,28 +174,28 @@ function SearchResults({ scope, query, onSummarize }: SearchResultsProps) {
const partial = search?.retrieval.status === 'partial'
const documentCount = documents.length === 1 ? '1 document' : `${documents.length} documents`

if (noSources) {
return (
<div className='flex items-center gap-2 px-2 py-2'>
<p className='text-[var(--text-muted)] text-caption'>No sources are set up yet.</p>
<ChipLink
href={
scope.kind === 'organization'
? `/o/${scope.organizationId}/integrations`
: `/workspace/${scope.workspaceId}/knowledge`
}
>
View sources
</ChipLink>
</div>
)
}
const indexingNote =
indexing.length > 0
? `Still indexing ${indexing.join(', ')}; results grow as documents land.`
: null

return (
const showResults = !noSources && !failed && !basesPending && documents.length > 0
if (showResults && !hasDisplayedResults) setHasDisplayedResults(true)

const content = noSources ? (
<div className='flex items-center gap-2 px-2 py-2'>
<p className='text-[var(--text-muted)] text-caption'>No sources are set up yet.</p>
<ChipLink
href={
scope.kind === 'organization'
? `/o/${scope.organizationId}/integrations`
: `/workspace/${scope.workspaceId}/knowledge`
}
>
View sources
</ChipLink>
</div>
) : (
<div className='flex flex-col'>
<div className='flex items-center gap-2 px-2 py-2'>
<div className='min-w-0 flex-1'>
Expand All @@ -201,14 +207,14 @@ function SearchResults({ scope, query, onSummarize }: SearchResultsProps) {
? 'Search couldn’t run.'
: partial
? documents.length === 0
? 'Search didn’t finish.'
? 'Search timed out.'
: `${documentCount} · some results may be missing.`
: documents.length === 0
? 'Search found no results.'
: `${documentCount} · searched as you`}
</p>
)}
{indexingNote && !failed && (
{indexingNote && !failed && !partial && (
<p className='text-[var(--text-muted)] text-caption'>{indexingNote}</p>
)}
</div>
Expand Down Expand Up @@ -259,7 +265,7 @@ function SearchResults({ scope, query, onSummarize }: SearchResultsProps) {
</Chip>
))}
</div>
{!failed && !basesPending && documents.length > 0 && (
{showResults && (
<div
role='region'
aria-label='Search results'
Expand Down Expand Up @@ -290,4 +296,5 @@ function SearchResults({ scope, query, onSummarize }: SearchResultsProps) {
)}
</div>
)
return renderLayout ? renderLayout(content, hasDisplayedResults || showResults) : content
}
Loading
Loading