diff --git a/apps/sim/app/o/[organizationId]/search/search.test.tsx b/apps/sim/app/o/[organizationId]/search/search.test.tsx index f6c9d62e804..9d21df13fc8 100644 --- a/apps/sim/app/o/[organizationId]/search/search.test.tsx +++ b/apps/sim/app/o/[organizationId]/search/search.test.tsx @@ -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') + }) +}) diff --git a/apps/sim/app/o/[organizationId]/search/search.tsx b/apps/sim/app/o/[organizationId]/search/search.tsx index 7ab8ac1173d..0485bbb70ec 100644 --- a/apps/sim/app/o/[organizationId]/search/search.tsx +++ b/apps/sim/app/o/[organizationId]/search/search.tsx @@ -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' @@ -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. @@ -141,10 +141,6 @@ function OrganizationSearchContent() { const query = q.trim() const scope: ResourceScope = { kind: 'organization', organizationId: organization.id } - const scrollContainerRef = useRef(null) - const scrollContentRef = useRef(null) - const scrollEdges = useScrollEdges(scrollContainerRef, { contentRef: scrollContentRef }) - const summarize = (message: string, assistantSearch: WorkspaceSearchFilters) => { MothershipHandoffStorage.store( { message, assistantSearch }, @@ -159,49 +155,95 @@ function OrganizationSearchContent() { void setParams({ q: next }) } - const searching = query.length > 0 + const renderLayout = (results: ReactNode, docked: boolean) => ( + + {results} + + ) + + return query ? ( + + ) : ( + 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(null) + const scrollContentRef = useRef(null) + const scrollEdges = useScrollEdges(scrollContainerRef, { contentRef: scrollContentRef }) return (
- {/* Reserved even while empty so the field docks where the page header sits. */}
- {searching ? ( - <> -
- +
+
+
+ {!docked && ( +

+ Search {organization.name} +

+ )} +
- {/* 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. */} -
- -
-
- - ) : ( -
- {/* Asymmetric padding biases the group up so heading and field sit at the optical center, as on Home */} -
-

- Search {organization.name} -

-
- +
+ {children}
- )} +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx index 2264db73f56..c4a857f3c7d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx @@ -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', @@ -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' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx index b4bff7f98dd..dfce603fb86 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx @@ -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' @@ -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 } @@ -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() @@ -114,6 +117,7 @@ export function KnowledgeSearchResults({ scope={scope} query={trimmed} onSummarize={onSummarize} + renderLayout={renderLayout} /> ) } @@ -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, @@ -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 ( -
-

No sources are set up yet.

- - View sources - -
- ) - } 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 ? ( +
+

No sources are set up yet.

+ + View sources + +
+ ) : (
@@ -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`}

)} - {indexingNote && !failed && ( + {indexingNote && !failed && !partial && (

{indexingNote}

)}
@@ -259,7 +265,7 @@ function SearchResults({ scope, query, onSummarize }: SearchResultsProps) { ))}
- {!failed && !basesPending && documents.length > 0 && ( + {showResults && (
) + return renderLayout ? renderLayout(content, hasDisplayedResults || showResults) : content } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx index f6f22427549..d5c2d33f965 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx @@ -15,6 +15,19 @@ vi.mock('@/lib/auth/auth-client', () => ({ useSession: () => ({ data: { user: { id: mocks.userId } } }), })) vi.mock('@/lib/api/client/request', () => ({ requestJson: mocks.request })) +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), + usePathname: () => '/o/organization/search', +})) +vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({ + useOrganizationContext: () => ({ + organization: { id: 'organization', name: 'Acme' }, + searchAccess: { memberScoped: true }, + }), +})) +vi.mock('@/hooks/use-speech-to-text', () => ({ + useSpeechToText: () => ({ isSupported: false }), +})) vi.mock('@/hooks/queries/kb/connectors', () => ({ useSearchIndex: () => ({ data: { knowledgeBaseId: 'index' }, isPending: false }), useSearchSourceOverview: () => ({ @@ -56,6 +69,7 @@ import type { WorkspaceKnowledgeSearchData, } from '@/lib/api/contracts/knowledge' import type { ResourceScope } from '@/lib/core/resource-scope' +import { OrganizationSearch } from '@/app/o/[organizationId]/search/search' import { KnowledgeSearchResults } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results' import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' @@ -102,16 +116,22 @@ async function render({ scope = { kind: 'organization', organizationId: 'organization' }, query = 'launch', params = '', + organizationPage = false, }: { scope?: ResourceScope query?: string params?: string + organizationPage?: boolean } = {}) { await act(async () => { root.render( - + {organizationPage ? ( + + ) : ( + + )} ) @@ -170,6 +190,69 @@ async function complete( } describe('search refinement with the real query cache and URL state', () => { + it('keeps the organization header docked while source and date changes run filtered searches', async () => { + await render({ organizationPage: true, params: '?q=launch' }) + expect(container.querySelector('h1')?.textContent).toBe('Search Acme') + await complete(0) + expect(container.querySelector('h1')).toBeNull() + const input = container.querySelector('input') + const filters = container.querySelector('[aria-label="Search filters"]') + + for (const [label, expectedFilters] of [ + ['Gmail', { source: 'gmail' }], + ['Past week', { source: 'gmail', modifiedAfter: '2026-01-08T12:00:00.000Z' }], + ['Past month', { source: 'gmail', modifiedAfter: '2025-12-16T12:00:00.000Z' }], + ] as const) { + const previousRequests = requests.length + const control = button(label) + await click(label) + expect(requests).toHaveLength(previousRequests + 1) + expect(requests.at(-1)?.body).toEqual({ + organizationId: 'organization', + query: 'launch', + filters: expectedFilters, + }) + expect(container.querySelector('h1')).toBeNull() + expect(container.querySelector('input')).toBe(input) + expect(container.querySelector('[aria-label="Search filters"]')).toBe(filters) + expect(document.activeElement).toBe(control) + expect(container.textContent).toContain('Updating results…') + expect( + container.querySelector('[aria-label="Search results"]')?.getAttribute('aria-busy') + ).toBe('true') + expect(container.querySelector('a[data-source-link]')).not.toBeNull() + await complete(previousRequests, { title: `${label} result` }) + expect(container.querySelector('a[data-source-link]')?.textContent).toBe(`${label} result`) + expect(document.activeElement).toBe(control) + expect(container.querySelector('h1')).toBeNull() + } + }) + + it.each(['empty', 'timeout', 'error'] as const)( + 'keeps the organization header docked when a refinement returns %s', + async (outcome) => { + await render({ organizationPage: true, params: '?q=launch' }) + await complete(0) + const gmail = button('Gmail') + await click('Gmail') + if (outcome === 'error') { + await act(async () => { + requests[1].reject(new Error('Search failed')) + await vi.advanceTimersByTimeAsync(1) + }) + } else { + await complete(1, { empty: true, partial: outcome === 'timeout' }) + } + expect(container.querySelector('h1')).toBeNull() + expect(button('Gmail')).toBe(gmail) + expect(document.activeElement).toBe(gmail) + expect(container.textContent).not.toContain('Release plan') + await click('All sources') + expect(container.textContent).toContain('Release plan') + expect(container.querySelector('h1')).toBeNull() + } + ) + it('replaces filter URL state while preserving unrelated parameters', async () => { await render({ params: '?q=launch&panel=details' }) await click('Gmail') @@ -302,7 +385,7 @@ describe('search refinement with the real query cache and URL state', () => { await render() await complete(0, { partial: true, empty }) expect(container.textContent).toContain( - empty ? 'Search didn’t finish.' : 'some results may be missing.' + empty ? 'Search timed out.' : 'some results may be missing.' ) expect(container.textContent).not.toContain('Search found no results.') const gmail = button('Gmail')