diff --git a/apps/sim/app/o/[organizationId]/search/search.test.tsx b/apps/sim/app/o/[organizationId]/search/search.test.tsx index 9d21df13fc8..ddf950eb08b 100644 --- a/apps/sim/app/o/[organizationId]/search/search.test.tsx +++ b/apps/sim/app/o/[organizationId]/search/search.test.tsx @@ -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('.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 }], @@ -230,20 +255,27 @@ 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') @@ -251,7 +283,8 @@ describe('organization Search header placement', () => { 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: [] } }, @@ -259,9 +292,16 @@ describe('organization Search header placement', () => { 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() }) }) diff --git a/apps/sim/app/o/[organizationId]/search/search.tsx b/apps/sim/app/o/[organizationId]/search/search.tsx index 0485bbb70ec..fb92898c7e2 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 { 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' @@ -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. @@ -141,6 +141,13 @@ 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, + enabled: query.length > 0, + }) + const summarize = (message: string, assistantSearch: WorkspaceSearchFilters) => { MothershipHandoffStorage.store( { message, assistantSearch }, @@ -155,95 +162,45 @@ function OrganizationSearchContent() { void setParams({ q: next }) } - 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 }) + const searching = query.length > 0 return (
-
-
-
- {!docked && ( -

- Search {organization.name} -

- )} - + {searching ? ( + <> +
+
-
- {children} +
+ +
+
+ + ) : ( +
+
+

+ Search {organization.name} +

+
+
-
+ )}
) } 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 dfce603fb86..8ac809b11ec 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 { 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' @@ -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 } @@ -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() @@ -117,7 +114,6 @@ export function KnowledgeSearchResults({ scope={scope} query={trimmed} onSummarize={onSummarize} - renderLayout={renderLayout} /> ) } @@ -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, @@ -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 ? (

No sources are set up yet.

)}
-
- setFilters({ source: null })} + {showFilters && ( +
- All sources - - {sourceTypes.map((type) => ( 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 - ))} - - {UPDATED_WINDOWS.map((window) => ( - setFilters({ updated: window.id })} - > - {window.label} - - ))} -
+ {sourceTypes.map((type) => ( + setFilters({ source: filters.source === type ? null : type })} + > + {type === UPLOAD_SOURCE ? 'Uploads' : connectorDisplayName(type)} + + ))} + + {UPDATED_WINDOWS.map((window) => ( + setFilters({ updated: window.id })} + > + {window.label} + + ))} +
+ )} {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 d5c2d33f965..6b435b90369 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 @@ -192,10 +192,12 @@ 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() + expect(container.querySelector('[aria-label="Search filters"]')).toBeNull() const input = container.querySelector('input') + await complete(0) + expect(container.querySelector('h1')).toBeNull() + expect(container.querySelector('input')).toBe(input) const filters = container.querySelector('[aria-label="Search filters"]') for (const [label, expectedFilters] of [ @@ -255,6 +257,7 @@ describe('search refinement with the real query cache and URL state', () => { it('replaces filter URL state while preserving unrelated parameters', async () => { await render({ params: '?q=launch&panel=details' }) + await complete(0) await click('Gmail') await click('Past week') expect(mocks.urlUpdate).toHaveBeenLastCalledWith( @@ -272,7 +275,7 @@ describe('search refinement with the real query cache and URL state', () => { it('keeps controls and focus while retaining only the preceding refinement results', async () => { await render() - expect(button('Gmail')).toBeDefined() + expect(container.querySelector('[aria-label="Search filters"]')).toBeNull() expect(container.textContent).toContain('Searching…') await complete(0) const gmail = button('Gmail') @@ -341,6 +344,7 @@ describe('search refinement with the real query cache and URL state', () => { }) expect(container.textContent).not.toContain('Release plan') expect(container.textContent).toContain('Searching…') + expect(container.querySelector('[aria-label="Search filters"]')).toBeNull() expect(requests).toHaveLength(2) } ) @@ -380,7 +384,7 @@ describe('search refinement with the real query cache and URL state', () => { }) it.each([true, false])( - 'keeps filters and useful matches for partial results, then retries (empty=%s)', + 'reveals filters only with useful partial results, then retries (empty=%s)', async (empty) => { await render() await complete(0, { partial: true, empty }) @@ -388,16 +392,51 @@ describe('search refinement with the real query cache and URL state', () => { empty ? 'Search timed out.' : 'some results may be missing.' ) expect(container.textContent).not.toContain('Search found no results.') - const gmail = button('Gmail') + const filters = container.querySelector('[aria-label="Search filters"]') + expect(filters === null).toBe(empty) await click('Try again') expect(button('Retrying…').disabled).toBe(true) - expect(button('Gmail')).toBe(gmail) + expect(container.querySelector('[aria-label="Search filters"]')).toBe(filters) await complete(1, { empty: true }) expect(container.textContent).toContain('Search found no results.') expect(container.textContent).not.toContain('Try again') + expect(container.querySelector('[aria-label="Search filters"]')).not.toBeNull() } ) + it('lets an empty completed search broaden filters supplied in a shared link', async () => { + await render({ params: '?source=gmail&updated=7d' }) + expect(container.querySelector('[aria-label="Search filters"]')).toBeNull() + expect(requests[0].body.filters).toEqual({ + source: 'gmail', + modifiedAfter: '2026-01-08T12:00:00.000Z', + }) + await complete(0, { empty: true }) + expect(container.textContent).toContain('Search found no results.') + expect(button('Gmail').getAttribute('aria-pressed')).toBe('true') + const filters = container.querySelector('[aria-label="Search filters"]') + await click('All sources') + await click('Any time') + expect(requests.at(-1)?.body.filters).toEqual({}) + expect(container.querySelector('[aria-label="Search filters"]')).toBe(filters) + await complete(requests.length - 1) + expect(container.textContent).toContain('Release plan') + }) + + it('keeps filters hidden after an initial failure and reveals them on a successful retry', async () => { + await render() + await act(async () => { + requests[0].reject(new Error('Search failed')) + await vi.advanceTimersByTimeAsync(1) + }) + expect(container.textContent).toContain('Search couldn’t run.') + expect(container.querySelector('[aria-label="Search filters"]')).toBeNull() + await click('Try again') + expect(container.querySelector('[aria-label="Search filters"]')).toBeNull() + await complete(1) + expect(container.querySelector('[aria-label="Search filters"]')).not.toBeNull() + }) + it('preserves filter focus and permits recovery after a failed refinement', async () => { await render() await complete(0)