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
54 changes: 47 additions & 7 deletions apps/sim/app/o/[organizationId]/search/search.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,31 @@ describe('organization Search query navigation', () => {
})

describe('organization Search header placement', () => {
it('tracks result scroll edges after submitting from the centered layout', async () => {
await render()
await editDraft('Orion')
await act(async () =>
searchInput().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
)
const results = container.querySelector('[aria-label="Search results"]')!
const scroller = results.closest<HTMLDivElement>('.overflow-y-auto')!
Object.defineProperties(scroller, {
scrollHeight: { value: 1000 },
clientHeight: { value: 400 },
})
await act(async () => {
scroller.scrollTop = 100
scroller.dispatchEvent(new Event('scroll'))
})
expect(scroller.getAttribute('data-scroll-fade-top')).toBe('true')
expect(scroller.getAttribute('data-scroll-fade-bottom')).toBe('true')
await act(async () => {
scroller.scrollTop = 600
scroller.dispatchEvent(new Event('scroll'))
})
expect(scroller.getAttribute('data-scroll-fade-bottom')).toBeNull()
})

it.each([
['pending', { isPending: true, isFetching: true }],
['failed', { isError: true, isPending: false }],
Expand All @@ -230,38 +255,53 @@ describe('organization Search header placement', () => {
'timed out',
{ data: { results: [], retrieval: { status: 'partial', timedOutLegs: ['vector'] } } },
],
])('keeps the initial %s search in the centered layout', async (_state, response) => {
])('keeps a submitted %s search at the top', async (_state, response) => {
mocks.search.mockReturnValue(response)
await render('?q=Orion')
expect(container.querySelector('h1')?.textContent).toBe('Search Acme')
expect(container.querySelector('h1')).toBeNull()
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 () => {
it('moves to the top on submit and reveals filters after results without losing a draft', async () => {
const completed = mocks.search(scope, 'Orion')
mocks.search.mockReturnValue({ isPending: true, isFetching: true })
await render('?q=Orion')
await render()
expect(container.querySelector('h1')?.textContent).toBe('Search Acme')
await editDraft('Orion')
await act(async () =>
searchInput().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
)
expect(container.querySelector('h1')).toBeNull()
expect(container.textContent).toContain('Searching…')
expect(container.querySelector('[aria-label="Search filters"]')).toBeNull()
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)
const filters = container.querySelector('[aria-label="Search filters"]')
expect(filters).not.toBeNull()

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

mocks.search.mockReturnValue({ isPending: true, isFetching: true })
await render('?q=Vega')
expect(container.querySelector('h1')?.textContent).toBe('Search Acme')
expect(container.querySelector('h1')).toBeNull()
expect(container.querySelector('[aria-label="Search filters"]')).toBeNull()
expect(searchInput().value).toBe('Vega')

await render()
expect(container.querySelector('h1')?.textContent).toBe('Search Acme')
expect(container.querySelector('[aria-label="Search filters"]')).toBeNull()
})
})
107 changes: 32 additions & 75 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 { type ReactNode, useEffect, useRef, useState } from 'react'
import { 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 results arrive the field docks at
* query field, centered like Home; once a query is submitted 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,6 +141,13 @@ 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,
enabled: query.length > 0,
})

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

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

return (
<div className='flex h-full min-h-0 flex-col bg-[var(--bg)]'>
<div className={PAGE_HEADER_BAR}>
<div className={HEADER_ACTION_CLUSTER} />
</div>
<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
/>
{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>
<div
ref={scrollContainerRef}
className={cn(
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'
SIDEBAR_DIVIDER_PAD_BELOW_CLASS,
SIDEBAR_DIVIDER_PAD_ABOVE_CLASS,
scrollFadeClass,
'min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable_both-edges]'
)}
{...scrollFadeAttributes(scrollEdges)}
>
<div ref={scrollContentRef} className={docked ? cn(PAGE_COLUMN_CLASS, 'px-8') : 'px-2'}>
{children}
<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]'>
<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 text-center 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>
</div>
</div>
</div>
)}
</div>
)
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { type ReactNode, useState } from 'react'
import { 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,8 +94,6 @@ 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 @@ -106,7 +104,6 @@ export function KnowledgeSearchResults({
scope: suppliedScope,
query,
onSummarize,
renderLayout,
}: KnowledgeSearchResultsProps) {
const scope: ResourceScope = suppliedScope ?? { kind: 'workspace', workspaceId: workspaceId! }
const { data: session } = useSession()
Expand All @@ -117,7 +114,6 @@ export function KnowledgeSearchResults({
scope={scope}
query={trimmed}
onSummarize={onSummarize}
renderLayout={renderLayout}
/>
)
}
Expand All @@ -126,11 +122,10 @@ interface SearchResultsProps {
scope: ResourceScope
query: string
onSummarize: KnowledgeSearchResultsProps['onSummarize']
renderLayout: KnowledgeSearchResultsProps['renderLayout']
}

function SearchResults({ scope, query, onSummarize, renderLayout }: SearchResultsProps) {
const [hasDisplayedResults, setHasDisplayedResults] = useState(false)
function SearchResults({ scope, query, onSummarize }: SearchResultsProps) {
const [hasShownFilters, setHasShownFilters] = useState(false)
const [searchedAt] = useState(Date.now)
const {
data: index,
Expand Down Expand Up @@ -180,9 +175,11 @@ function SearchResults({ scope, query, onSummarize, renderLayout }: SearchResult
: null

const showResults = !noSources && !failed && !basesPending && documents.length > 0
if (showResults && !hasDisplayedResults) setHasDisplayedResults(true)
const showFilters =
hasShownFilters || showResults || (!noSources && !pending && !failed && !!search && !partial)
if (showFilters && !hasShownFilters) setHasShownFilters(true)

const content = noSources ? (
return 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
Expand Down Expand Up @@ -228,43 +225,45 @@ function SearchResults({ scope, query, onSummarize, renderLayout }: SearchResult
</Chip>
)}
</div>
<div
role='group'
aria-label='Search filters'
className='flex flex-wrap items-center gap-1.5 px-2 pb-2'
>
<Chip
shape='round'
active={filters.source === null}
aria-pressed={filters.source === null}
onClick={() => setFilters({ source: null })}
{showFilters && (
<div
role='group'
aria-label='Search filters'
className='flex flex-wrap items-center gap-1.5 px-2 pb-2'
>
All sources
</Chip>
{sourceTypes.map((type) => (
<Chip
key={type}
shape='round'
active={filters.source === type}
aria-pressed={filters.source === type}
onClick={() => setFilters({ source: filters.source === type ? null : type })}
active={filters.source === null}
aria-pressed={filters.source === null}
onClick={() => setFilters({ source: null })}
>
{type === UPLOAD_SOURCE ? 'Uploads' : connectorDisplayName(type)}
All sources
</Chip>
))}
<span aria-hidden className='mx-0.5 h-[16px] w-px bg-[var(--border)]' />
{UPDATED_WINDOWS.map((window) => (
<Chip
key={window.id}
shape='round'
active={filters.updated === window.id}
aria-pressed={filters.updated === window.id}
onClick={() => setFilters({ updated: window.id })}
>
{window.label}
</Chip>
))}
</div>
{sourceTypes.map((type) => (
<Chip
key={type}
shape='round'
active={filters.source === type}
aria-pressed={filters.source === type}
onClick={() => setFilters({ source: filters.source === type ? null : type })}
>
{type === UPLOAD_SOURCE ? 'Uploads' : connectorDisplayName(type)}
</Chip>
))}
<span aria-hidden className='mx-0.5 h-[16px] w-px bg-[var(--border)]' />
{UPDATED_WINDOWS.map((window) => (
<Chip
key={window.id}
shape='round'
active={filters.updated === window.id}
aria-pressed={filters.updated === window.id}
onClick={() => setFilters({ updated: window.id })}
>
{window.label}
</Chip>
))}
</div>
)}
{showResults && (
<div
role='region'
Expand Down Expand Up @@ -296,5 +295,4 @@ function SearchResults({ scope, query, onSummarize, renderLayout }: SearchResult
)}
</div>
)
return renderLayout ? renderLayout(content, hasDisplayedResults || showResults) : content
}
Loading
Loading