From 92a45c8d7cfaeffbfb0ea5ab972f2dc57d64b3cc Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 15 Sep 2026 11:41:00 -0700 Subject: [PATCH] fix(search): preserve filters and report incomplete results --- .../o/[organizationId]/search/search.test.tsx | 6 +- .../knowledge-search-results.test.tsx | 18 +- .../knowledge-search-results.tsx | 223 ++++++------ .../search-transitions.test.tsx | 342 ++++++++++++++++++ apps/sim/hooks/queries/kb/knowledge.test.ts | 19 +- apps/sim/hooks/queries/kb/knowledge.ts | 18 +- .../sim/hooks/queries/utils/knowledge-keys.ts | 10 +- .../server/knowledge/workspace-search.test.ts | 17 +- .../search-latency.integration.ts | 9 +- .../lib/knowledge/search/diagnostics.test.ts | 33 ++ apps/sim/lib/knowledge/search/diagnostics.ts | 4 +- 11 files changed, 572 insertions(+), 127 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx diff --git a/apps/sim/app/o/[organizationId]/search/search.test.tsx b/apps/sim/app/o/[organizationId]/search/search.test.tsx index 9b9a6f78714..f6c9d62e804 100644 --- a/apps/sim/app/o/[organizationId]/search/search.test.tsx +++ b/apps/sim/app/o/[organizationId]/search/search.test.tsx @@ -17,6 +17,9 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('@/hooks/use-speech-to-text', () => ({ useSpeechToText: mocks.speech })) +vi.mock('@/lib/auth/auth-client', () => ({ + useSession: () => ({ data: { user: { id: 'reader' } } }), +})) vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mocks.push }), usePathname: () => '/o/organization-a/search', @@ -32,9 +35,6 @@ vi.mock('@/hooks/queries/kb/connectors', () => ({ useSearchIndex: () => ({ data: { knowledgeBaseId: 'index-a' }, isPending: false }), useSearchSourceOverview: () => ({ data: { providers: [], hasSearchableDocuments: true } }), })) -vi.mock('@/app/workspace/[workspaceId]/home/components/search-sources', () => ({ - isIndexing: () => false, -})) vi.mock( '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags', () => ({ 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 6b9617c5d38..2264db73f56 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 @@ -10,6 +10,9 @@ const mocks = vi.hoisted(() => ({ search: vi.fn(), retry: vi.fn(), })) +vi.mock('@/lib/auth/auth-client', () => ({ + useSession: () => ({ data: { user: { id: 'reader' } } }), +})) vi.mock('@/hooks/queries/kb/connectors', () => ({ useSearchIndex: mocks.index, useSearchSourceOverview: mocks.overview, @@ -69,7 +72,8 @@ describe('source indexing context in search results', () => { await render() expect(mocks.overview).toHaveBeenCalledWith({ kind: 'workspace', workspaceId: 'workspace' }) expect(container.textContent).toContain('Google Drive') - expect(container.textContent).not.toContain('Slack') + expect(container.textContent).toContain('Slack') + expect(container.textContent).toContain('Still indexing Google Drive;') }) it('does not invent indexing progress while the overview is unavailable', async () => { mocks.overview.mockReturnValue({ data: undefined }) @@ -81,7 +85,7 @@ describe('source indexing context in search results', () => { describe('incomplete search coverage', () => { it.each([false, true])( - 'shows matches without timeout copy or retry controls (hasResults=%s)', + 'distinguishes incomplete retrieval and permits retry (hasResults=%s)', async (hasResults) => { mocks.search.mockReturnValue({ data: { @@ -113,17 +117,17 @@ describe('incomplete search coverage', () => { await render() expect(container.textContent).not.toContain('Search couldn’t run') expect(container.textContent).not.toContain('No documents') - expect(container.textContent).not.toContain('Some results may be missing.') - expect(container.textContent).not.toContain('Search is incomplete.') expect(container.textContent).toContain( - hasResults ? '1 document' : 'Search found no results.' + hasResults ? '1 document · some results may be missing.' : 'Search didn’t finish.' ) + expect(container.textContent).not.toContain('Search found no results.') if (hasResults) expect(container.textContent).toContain('Release plan') const retry = [...container.querySelectorAll('button')].find( (button) => button.textContent === 'Try again' ) - expect(retry).toBeUndefined() - expect(mocks.retry).not.toHaveBeenCalled() + expect(retry).toBeDefined() + await act(async () => retry?.click()) + expect(mocks.retry).toHaveBeenCalledOnce() } ) }) 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 3c92726ee09..b4bff7f98dd 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,14 +1,15 @@ 'use client' -import { useMemo } from 'react' -import { Chip, ChipLink } from '@sim/emcn' +import { useState } from 'react' +import { Chip, ChipLink, cn } from '@sim/emcn' import { useQueryStates } from 'nuqs' import { ActivityStatus } from '@/components/ui/activity-status' import type { WorkspaceKnowledgeSearchResult, WorkspaceSearchFilters, } from '@/lib/api/contracts/knowledge' -import type { ResourceScope } from '@/lib/core/resource-scope' +import { useSession } from '@/lib/auth/auth-client' +import { type ResourceScope, resourceScopeKey } from '@/lib/core/resource-scope' import { getBaseUrl } from '@/lib/core/utils/urls' import { matchSnippet } from '@/lib/knowledge/search/snippet' import { connectorDisplayName } from '@/lib/sim-search/connectors' @@ -25,8 +26,6 @@ import { import { useSearchIndex, useSearchSourceOverview } from '@/hooks/queries/kb/connectors' import { useWorkspaceKnowledgeSearch } from '@/hooks/queries/kb/knowledge' -/** Filters appear only once a list is long and mixed enough for them to help. */ -const FILTERS_MIN_RESULTS = 10 const DAY_MS = 24 * 60 * 60 * 1000 /** Every result without a connector is an upload; the filter names them so. */ const UPLOAD_SOURCE = 'upload' @@ -82,6 +81,7 @@ function handleResultsKeyDown(event: React.KeyboardEvent) { const links = [...event.currentTarget.querySelectorAll('a[data-source-link]')] if (links.length === 0) return const index = links.findIndex((link) => link === document.activeElement) + if (index < 0) return const next = event.key === 'ArrowDown' ? Math.min(index + 1, links.length - 1) : Math.max(index - 1, 0) if (next === index) return @@ -98,15 +98,7 @@ type KnowledgeSearchResultsProps = ( onSummarize: (prompt: string, filters: WorkspaceSearchFilters) => void } -/** - * Search results include documents the signed-in person may read that - * match their query in the canonical Enterprise Search index, as rows - * that open the source. A header says how many and that the search ran as - * them; while a connected source is still indexing it says so, and the list - * grows as documents land. Filters by source and recency appear only once the - * list is long and mixed enough to need them, and live in the URL beside the - * query so a filtered search is a shareable link. - */ +/** A new query or access scope starts a fresh search and rolling-date anchor. */ export function KnowledgeSearchResults({ workspaceId, scope: suppliedScope, @@ -114,6 +106,26 @@ export function KnowledgeSearchResults({ onSummarize, }: KnowledgeSearchResultsProps) { const scope: ResourceScope = suppliedScope ?? { kind: 'workspace', workspaceId: workspaceId! } + const { data: session } = useSession() + const trimmed = query.trim() + return ( + + ) +} + +interface SearchResultsProps { + scope: ResourceScope + query: string + onSummarize: KnowledgeSearchResultsProps['onSummarize'] +} + +function SearchResults({ scope, query, onSummarize }: SearchResultsProps) { + const [searchedAt] = useState(Date.now) const { data: index, isPending: basesPending, @@ -121,21 +133,19 @@ export function KnowledgeSearchResults({ isFetching: basesFetching, refetch: refetchIndex, } = useSearchIndex(scope) - const knowledgeBaseIds = index?.knowledgeBaseId ? [index.knowledgeBaseId] : [] const [filters, setFilters] = useQueryStates(searchFilterParsers, resourceUrlKeys) - const searchFilters = useMemo(() => { - const window = UPDATED_WINDOWS.find((entry) => entry.id === filters.updated) - return { - ...(filters.source ? { source: filters.source } : {}), - ...(window?.days - ? { modifiedAfter: new Date(Date.now() - window.days * DAY_MS).toISOString() } - : {}), - } - }, [filters.source, filters.updated]) + const window = UPDATED_WINDOWS.find((entry) => entry.id === filters.updated) + const searchFilters: WorkspaceSearchFilters = { + ...(filters.source ? { source: filters.source } : {}), + ...(window?.days + ? { modifiedAfter: new Date(searchedAt - window.days * DAY_MS).toISOString() } + : {}), + } const { data: search, isPending, isFetching, + isPlaceholderData, isError: searchFailed, refetch: refetchSearch, } = useWorkspaceKnowledgeSearch(scope, query, searchFilters) @@ -143,35 +153,22 @@ export function KnowledgeSearchResults({ const indexing = (overview?.providers ?? []) .filter((provider) => provider.isSyncing) .map((provider) => connectorDisplayName(provider.connectorType)) - const documents = useMemo(() => groupResultsByDocument(search?.results ?? []), [search?.results]) + const documents = groupResultsByDocument(search?.results ?? []) const sourceTypes = [ ...new Set([ ...(filters.source ? [filters.source] : []), - ...documents.map((result) => result.connectorType ?? UPLOAD_SOURCE), + ...(overview?.providers.map((provider) => provider.connectorType) ?? []), + UPLOAD_SOURCE, ]), - ] - const filtersActive = filters.source !== null || filters.updated !== 'any' - /** The controls appear once the list is long and mixed, and stay while a filter from the link is active. */ - const showFilters = - filtersActive || (documents.length >= FILTERS_MIN_RESULTS && sourceTypes.length > 1) + ].sort((left, right) => connectorDisplayName(left).localeCompare(connectorDisplayName(right))) + const failed = basesFailed || searchFailed + const pending = basesPending || isPending + const fetching = basesFetching || isFetching + const noSources = !basesPending && !basesFailed && !index?.knowledgeBaseId + const partial = search?.retrieval.status === 'partial' + const documentCount = documents.length === 1 ? '1 document' : `${documents.length} documents` - /** A failed search offers a retry; server diagnostics carry the cause. */ - if (basesFailed || searchFailed) { - const retrying = basesFetching || isFetching - return ( -
-

Search couldn’t run.

- void (basesFailed ? refetchIndex() : refetchSearch())} - > - {retrying ? 'Retrying…' : 'Try again'} - -
- ) - } - if (!basesPending && knowledgeBaseIds.length === 0) { + if (noSources) { return (

No sources are set up yet.

@@ -187,14 +184,6 @@ export function KnowledgeSearchResults({
) } - if (isPending || (isFetching && !search)) { - return ( -
- -
- ) - } - const indexingNote = indexing.length > 0 ? `Still indexing ${indexing.join(', ')}; results grow as documents land.` @@ -203,54 +192,81 @@ export function KnowledgeSearchResults({ return (
- - {documents.length === 0 ? ( - 'Search found no results.' +
+ {fetching || (pending && !failed) ? ( + ) : ( - <> - - {documents.length === 1 ? '1 document' : `${documents.length} documents`} - - {' · searched as you'} - +

+ {failed + ? 'Search couldn’t run.' + : partial + ? documents.length === 0 + ? 'Search didn’t finish.' + : `${documentCount} · some results may be missing.` + : documents.length === 0 + ? 'Search found no results.' + : `${documentCount} · searched as you`} +

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

{indexingNote}

)} - {indexingNote && {indexingNote}} - +
+ {(failed || partial) && ( + void (basesFailed ? refetchIndex() : refetchSearch())} + > + {fetching ? 'Retrying…' : 'Try again'} + + )}
- {showFilters && ( -
+
+ setFilters({ source: null })} + > + All sources + + {sourceTypes.map((type) => ( setFilters({ source: null })} + active={filters.source === type} + aria-pressed={filters.source === type} + onClick={() => setFilters({ source: filters.source === type ? null : type })} > - All sources + {type === UPLOAD_SOURCE ? 'Uploads' : connectorDisplayName(type)} - {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} - - ))} -
- )} - {documents.length > 0 && ( -
+ ))} + + {UPDATED_WINDOWS.map((window) => ( + setFilters({ updated: window.id })} + > + {window.label} + + ))} +
+ {!failed && !basesPending && documents.length > 0 && ( +
{documents.map((result) => { const source = toSource(result, query, scope) return ( @@ -258,11 +274,14 @@ export function KnowledgeSearchResults({ key={result.documentId} source={source} query={query} - onSummarize={(cited) => - onSummarize(`Summarize "${cited.title ?? cited.url}"`, { - ...searchFilters, - documentIds: [result.documentId], - }) + onSummarize={ + isPlaceholderData + ? undefined + : (cited) => + onSummarize(`Summarize "${cited.title ?? cited.url}"`, { + ...searchFilters, + documentIds: [result.documentId], + }) } /> ) 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 new file mode 100644 index 00000000000..f6f22427549 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx @@ -0,0 +1,342 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + request: vi.fn(), + userId: 'reader', + summarize: vi.fn(), + urlUpdate: vi.fn(), +})) +vi.mock('@/lib/auth/auth-client', () => ({ + useSession: () => ({ data: { user: { id: mocks.userId } } }), +})) +vi.mock('@/lib/api/client/request', () => ({ requestJson: mocks.request })) +vi.mock('@/hooks/queries/kb/connectors', () => ({ + useSearchIndex: () => ({ data: { knowledgeBaseId: 'index' }, isPending: false }), + useSearchSourceOverview: () => ({ + data: { + providers: [ + { connectorType: 'slack', isSyncing: false }, + { connectorType: 'gmail', isSyncing: false }, + ], + }, + }), +})) +vi.mock( + '@/app/workspace/[workspaceId]/home/components/message-content/components/source-card', + () => ({ + SourceCard: ({ + source, + onSummarize, + }: { + source: { title: string; url: string } + onSummarize?: (source: { title: string; url: string }) => void + }) => ( +
+ + {source.title} + + + {onSummarize && ( + + )} +
+ ), + }) +) + +import type { + WorkspaceKnowledgeSearchBody, + WorkspaceKnowledgeSearchData, +} from '@/lib/api/contracts/knowledge' +import type { ResourceScope } from '@/lib/core/resource-scope' +import { KnowledgeSearchResults } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results' +import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' + +interface PendingSearch { + body: WorkspaceKnowledgeSearchBody + signal: AbortSignal + resolve: (data: { data: WorkspaceKnowledgeSearchData }) => void + reject: (error: Error) => void +} + +let root: Root +let container: HTMLDivElement +let client: QueryClient +let requests: PendingSearch[] + +beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-15T12:00:00Z')) + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + mocks.userId = 'reader' + requests = [] + mocks.request.mockImplementation( + (_contract, input) => + new Promise((resolve, reject) => { + requests.push({ ...input, resolve, reject }) + }) + ) + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + client.clear() + container.remove() + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +async function render({ + scope = { kind: 'organization', organizationId: 'organization' }, + query = 'launch', + params = '', +}: { + scope?: ResourceScope + query?: string + params?: string +} = {}) { + await act(async () => { + root.render( + + + + + + ) + }) +} + +function button(label: string) { + const result = [...container.querySelectorAll('button')].find( + (item) => item.textContent === label + ) + if (!result) throw new Error(`Missing button: ${label}`) + return result +} + +async function click(label: string) { + await act(async () => { + button(label).focus() + button(label).click() + await vi.advanceTimersByTimeAsync(1) + }) +} + +async function complete( + index: number, + { title = 'Release plan', partial = false, empty = false } = {} +) { + await act(async () => { + requests[index].resolve({ + data: { + query: requests[index].body.query, + results: empty + ? [] + : [ + { + documentId: title, + knowledgeBaseId: 'index', + knowledgeBaseName: 'Search index', + documentName: title, + sourceUrl: 'https://example.com/release', + connectorType: requests[index].body.filters?.source ?? 'slack', + sourceModifiedAt: null, + author: null, + content: 'launch details', + chunkIndex: 0, + similarity: 0.9, + }, + ], + retrieval: { + status: partial ? 'partial' : 'complete', + timedOutLegs: partial ? ['vector'] : [], + }, + }, + }) + await vi.advanceTimersByTimeAsync(1) + }) +} + +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 click('Gmail') + await click('Past week') + expect(mocks.urlUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ + queryString: '?q=launch&panel=details&source=gmail&updated=7d', + options: expect.objectContaining({ history: 'replace' }), + }) + ) + await click('All sources') + await click('Any time') + expect(mocks.urlUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ queryString: '?q=launch&panel=details' }) + ) + }) + + it('keeps controls and focus while retaining only the preceding refinement results', async () => { + await render() + expect(button('Gmail')).toBeDefined() + expect(container.textContent).toContain('Searching…') + await complete(0) + const gmail = button('Gmail') + await click('Gmail') + expect(button('Gmail')).toBe(gmail) + expect(document.activeElement).toBe(gmail) + expect(gmail.getAttribute('aria-pressed')).toBe('true') + expect(container.textContent).toContain('Updating results…') + expect(container.textContent).toContain('Release plan') + expect(container.textContent).not.toContain('Summarize') + expect(requests[1].body.filters).toMatchObject({ source: 'gmail' }) + await complete(1, { title: 'Email plan' }) + expect(document.activeElement).toBe(gmail) + expect(container.textContent).not.toContain('Release plan') + await click('Summarize') + expect(mocks.summarize).toHaveBeenCalledWith(expect.any(String), { + source: 'gmail', + documentIds: ['Email plan'], + }) + }) + + it('cancels an abandoned refinement and reuses a fresh cached result', async () => { + await render() + await complete(0) + await click('Gmail') + await click('All sources') + expect(requests).toHaveLength(2) + expect(requests[1].signal.aborted).toBe(true) + expect(container.textContent).toContain('Release plan') + expect(container.textContent).not.toContain('Updating results…') + await complete(1, { title: 'Abandoned result' }) + expect(container.textContent).not.toContain('Abandoned result') + }) + + it('keeps one rolling cutoff across source and date refinements, then resets for a new query', async () => { + await render({ params: '?updated=7d' }) + const cutoff = requests[0].body.filters?.modifiedAfter + expect(cutoff).toBe('2026-01-08T12:00:00.000Z') + await complete(0) + vi.setSystemTime(new Date('2026-01-15T12:00:20Z')) + await click('Gmail') + expect(requests[1].body.filters?.modifiedAfter).toBe(cutoff) + await click('Past month') + expect(requests[2].body.filters?.modifiedAfter).toBe('2025-12-16T12:00:00.000Z') + await click('Past week') + expect(requests[3].body.filters?.modifiedAfter).toBe(cutoff) + vi.setSystemTime(new Date('2026-01-15T12:00:30Z')) + await render({ query: 'another question', params: '?updated=7d' }) + expect(requests.at(-1)?.body.filters?.modifiedAfter).toBe('2026-01-08T12:00:30.000Z') + }) + + it.each(['query', 'organization', 'workspace', 'reader'])( + 'clears prior results when the %s changes', + async (change) => { + await render() + await complete(0) + if (change === 'reader') mocks.userId = 'another-reader' + await render({ + query: change === 'query' ? 'another question' : 'launch', + scope: + change === 'organization' + ? { kind: 'organization', organizationId: 'another-org' } + : change === 'workspace' + ? { kind: 'workspace', workspaceId: 'another-workspace' } + : { kind: 'organization', organizationId: 'organization' }, + }) + expect(container.textContent).not.toContain('Release plan') + expect(container.textContent).toContain('Searching…') + expect(requests).toHaveLength(2) + } + ) + + it('does not restore cleared access data as a placeholder', async () => { + await render() + await complete(0) + await act(async () => { + void client.resetQueries({ queryKey: knowledgeKeys.searches() }) + await vi.advanceTimersByTimeAsync(1) + }) + expect(container.textContent).not.toContain('Release plan') + await click('Gmail') + expect(container.textContent).not.toContain('Release plan') + }) + + it('clears displayed placeholder data when access is reset during a refinement', async () => { + await render() + await complete(0) + await click('Gmail') + expect(container.textContent).toContain('Release plan') + await act(async () => { + void client.resetQueries({ queryKey: knowledgeKeys.searches() }) + await vi.advanceTimersByTimeAsync(1) + }) + expect(container.textContent).not.toContain('Release plan') + }) + + it('does not retain invalidated results during a refinement', async () => { + await render() + await complete(0) + await act(async () => { + await client.invalidateQueries({ queryKey: knowledgeKeys.searches(), refetchType: 'none' }) + }) + await click('Gmail') + expect(container.textContent).not.toContain('Release plan') + }) + + it.each([true, false])( + 'keeps filters and useful matches for partial results, then retries (empty=%s)', + async (empty) => { + await render() + await complete(0, { partial: true, empty }) + expect(container.textContent).toContain( + empty ? 'Search didn’t finish.' : 'some results may be missing.' + ) + expect(container.textContent).not.toContain('Search found no results.') + const gmail = button('Gmail') + await click('Try again') + expect(button('Retrying…').disabled).toBe(true) + expect(button('Gmail')).toBe(gmail) + await complete(1, { empty: true }) + expect(container.textContent).toContain('Search found no results.') + expect(container.textContent).not.toContain('Try again') + } + ) + + it('preserves filter focus and permits recovery after a failed refinement', async () => { + await render() + await complete(0) + const gmail = button('Gmail') + await click('Gmail') + await act(async () => { + requests[1].reject(new Error('Search failed')) + await vi.advanceTimersByTimeAsync(1) + }) + expect(document.activeElement).toBe(gmail) + expect(container.textContent).toContain('Search couldn’t run.') + expect(container.textContent).not.toContain('Release plan') + await click('All sources') + expect(container.textContent).toContain('Release plan') + }) + + it('does not redirect arrow keys from row actions to the first result', async () => { + await render() + await complete(0) + const copy = button('Copy link') + copy.focus() + act(() => copy.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))) + expect(document.activeElement).toBe(copy) + }) +}) diff --git a/apps/sim/hooks/queries/kb/knowledge.test.ts b/apps/sim/hooks/queries/kb/knowledge.test.ts index 71729ec1bde..07e2c6ca6e5 100644 --- a/apps/sim/hooks/queries/kb/knowledge.test.ts +++ b/apps/sim/hooks/queries/kb/knowledge.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ useMutation: vi.fn(), useQuery: vi.fn(), invalidateQueries: vi.fn(), + getQueryData: vi.fn(), })) vi.mock('@tanstack/react-query', () => ({ @@ -16,7 +17,14 @@ vi.mock('@tanstack/react-query', () => ({ useInfiniteQuery: vi.fn(), useMutation: mocks.useMutation, useQuery: mocks.useQuery, - useQueryClient: vi.fn(() => ({ invalidateQueries: mocks.invalidateQueries })), + useQueryClient: vi.fn(() => ({ + invalidateQueries: mocks.invalidateQueries, + getQueryData: mocks.getQueryData, + })), +})) + +vi.mock('@/lib/auth/auth-client', () => ({ + useSession: () => ({ data: { user: { id: 'reader' } } }), })) vi.mock('@sim/emcn', () => ({ @@ -202,13 +210,18 @@ describe('knowledge query placeholder scope', () => { ).toBeUndefined() }) - it('does not reuse results from a different query, workspace, or filter', () => { + it('partitions search cache entries by filter and reader', () => { const query = captureQuery(() => useWorkspaceKnowledgeSearch('workspace-1', 'new query', { source: 'slack' }) ) - expect(query.placeholderData).toBeUndefined() + expect(query.queryKey).toEqual( + knowledgeKeys.search('workspace-1', 'new query', { source: 'slack' }, 'reader') + ) expect(knowledgeKeys.search('workspace-1', 'query', { source: 'slack' })).not.toEqual( knowledgeKeys.search('workspace-1', 'query', { source: 'gitlab' }) ) + expect(knowledgeKeys.search('workspace-1', 'query', {}, 'reader')).not.toEqual( + knowledgeKeys.search('workspace-1', 'query', {}, 'another-reader') + ) }) }) diff --git a/apps/sim/hooks/queries/kb/knowledge.ts b/apps/sim/hooks/queries/kb/knowledge.ts index bb0a023bcda..5af98a7da79 100644 --- a/apps/sim/hooks/queries/kb/knowledge.ts +++ b/apps/sim/hooks/queries/kb/knowledge.ts @@ -55,6 +55,7 @@ import { type WorkspaceKnowledgeSearchData, } from '@/lib/api/contracts/knowledge' import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge/search' +import { useSession } from '@/lib/auth/auth-client' import type { ChunkingStrategy, StrategyOptions } from '@/lib/chunkers/types' import { type ResourceScope, @@ -1208,6 +1209,9 @@ export function useWorkspaceKnowledgeSearch( query: string, filters?: WorkspaceSearchFilters ) { + const { data: session } = useSession() + const queryClient = useQueryClient() + const userId = session?.user?.id const trimmed = query.trim() const scope = typeof owner === 'string' @@ -1218,7 +1222,7 @@ export function useWorkspaceKnowledgeSearch( const scopeKey = scope?.kind === 'workspace' ? scope.workspaceId : scope ? resourceScopeKey(scope) : undefined return useQuery({ - queryKey: knowledgeKeys.search(scopeKey, trimmed, filters), + queryKey: knowledgeKeys.search(scopeKey, trimmed, filters, userId), queryFn: ({ signal }) => searchWorkspaceKnowledge( { @@ -1228,8 +1232,18 @@ export function useWorkspaceKnowledgeSearch( }, signal ), - enabled: Boolean(scope) && trimmed.length > 0, + enabled: Boolean(scope && userId) && trimmed.length > 0, staleTime: WORKSPACE_KNOWLEDGE_SEARCH_STALE_TIME, retry: false, + placeholderData: (previous, previousQuery) => + userId && + previousQuery?.state.status === 'success' && + !previousQuery.state.isInvalidated && + knowledgeKeys + .searchQuery(scopeKey, trimmed, userId) + .every((part, index) => previousQuery.queryKey[index] === part) && + queryClient.getQueryData(previousQuery.queryKey) === previous + ? previous + : undefined, }) } diff --git a/apps/sim/hooks/queries/utils/knowledge-keys.ts b/apps/sim/hooks/queries/utils/knowledge-keys.ts index 493d6758bbf..8ad0ae229a6 100644 --- a/apps/sim/hooks/queries/utils/knowledge-keys.ts +++ b/apps/sim/hooks/queries/utils/knowledge-keys.ts @@ -32,8 +32,14 @@ export const knowledgeKeys = { detail: (knowledgeBaseId?: string) => [...knowledgeKeys.details(), knowledgeBaseId ?? ''] as const, searches: () => [...knowledgeKeys.all, 'search'] as const, - search: (workspaceId: string | undefined, query: string, filters?: WorkspaceSearchFilters) => - [...knowledgeKeys.searches(), workspaceId ?? '', query, filters ?? {}] as const, + searchQuery: (scopeKey: string | undefined, query: string, userId?: string) => + [...knowledgeKeys.searches(), scopeKey ?? '', userId ?? '', query] as const, + search: ( + scopeKey: string | undefined, + query: string, + filters?: WorkspaceSearchFilters, + userId?: string + ) => [...knowledgeKeys.searchQuery(scopeKey, query, userId), filters ?? {}] as const, tagDefinitions: (knowledgeBaseId: string) => [...knowledgeKeys.detail(knowledgeBaseId), 'tagDefinitions'] as const, tagUsage: (knowledgeBaseId: string) => diff --git a/apps/sim/lib/copilot/tools/server/knowledge/workspace-search.test.ts b/apps/sim/lib/copilot/tools/server/knowledge/workspace-search.test.ts index f14efc854e8..0ef7d3df4bb 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/workspace-search.test.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/workspace-search.test.ts @@ -41,6 +41,7 @@ import { searchWorkspaceServerTool, } from '@/lib/copilot/tools/server/knowledge/workspace-search' import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { annotateSearchDiagnostics } from '@/lib/knowledge/search/diagnostics' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const context = { @@ -85,10 +86,16 @@ describe('Assistant retrieval tools', () => { }) }) it('returns empty incomplete retrieval as a recoverable search outcome and logs coverage', async () => { - mocks.search.mockResolvedValue({ - retrieval: { status: 'partial', timedOutLegs: ['vector', 'keyword'] }, - knowledgeBases: [{ id: 'index', name: 'Enterprise Search' }], - results: [], + mocks.search.mockImplementation(async () => { + annotateSearchDiagnostics({ + retrievalStatus: 'partial', + timedOutLegs: ['vector', 'keyword'], + }) + return { + retrieval: { status: 'partial', timedOutLegs: ['vector', 'keyword'] }, + knowledgeBases: [{ id: 'index', name: 'Enterprise Search' }], + results: [], + } }) const result = await searchWorkspaceServerTool.execute({ query: 'canaries' }, context) @@ -104,7 +111,7 @@ describe('Assistant retrieval tools', () => { expect(result).not.toHaveProperty('error') expect(mocks.info).toHaveBeenCalledWith( 'Knowledge search completed', - expect.objectContaining({ passageBytes: 0, originalPassageBytes: 0, outcome: 'success' }) + expect.objectContaining({ passageBytes: 0, originalPassageBytes: 0, outcome: 'partial' }) ) }) it('pins organization and private chat while reusing the canonical search index and citations', async () => { diff --git a/apps/sim/lib/knowledge/__integration__/search-latency.integration.ts b/apps/sim/lib/knowledge/__integration__/search-latency.integration.ts index 200952ca7cf..5d03eab792b 100644 --- a/apps/sim/lib/knowledge/__integration__/search-latency.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/search-latency.integration.ts @@ -171,7 +171,7 @@ function saveReport() { const diagnosticSchema = z .object({ surface: z.enum(['dashboard', 'copilot']), - outcome: z.literal('success'), + outcome: z.enum(['success', 'partial']), elapsedMs: z.number(), vectorBudgetMs: z.number().positive(), retrievalStatus: z.enum(['complete', 'partial']), @@ -259,6 +259,7 @@ async function searchDashboard(query = 'Orion deployment') { function expectCompleteVectorSearch(diagnostics: z.infer) { const budget = diagnostics.surface === 'dashboard' ? 3000 : 8000 expect(diagnostics).toMatchObject({ + outcome: 'success', vectorBudgetMs: budget, retrievalStatus: 'complete', timedOutLegs: [], @@ -635,7 +636,10 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu const completed = diagnosticLog?.mock.calls.find( ([message]) => message === 'Knowledge search completed' ) - expect(diagnosticSchema.parse(completed?.[1]).vectorBudgetMs).toBe(8000) + expect(diagnosticSchema.parse(completed?.[1])).toMatchObject({ + vectorBudgetMs: 8000, + outcome: delayedLegs === 'vector' ? 'success' : 'partial', + }) if (delayedLegs === 'both') { expect(result).toMatchObject({ success: true, @@ -690,6 +694,7 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu ([message]) => message === 'Knowledge search completed' ) const diagnostics = diagnosticSchema.parse(completed?.[1]) + expect(diagnostics.outcome).toBe('partial') expect(diagnostics.vectorBudgetMs).toBe(3000) expect(diagnostics.stages.vector.totalMs).toBeGreaterThan(2500) expect(diagnostics.stages.vector.totalMs).toBeLessThan(4000) diff --git a/apps/sim/lib/knowledge/search/diagnostics.test.ts b/apps/sim/lib/knowledge/search/diagnostics.test.ts index 21cf17c8e07..0d49ef060a2 100644 --- a/apps/sim/lib/knowledge/search/diagnostics.test.ts +++ b/apps/sim/lib/knowledge/search/diagnostics.test.ts @@ -123,4 +123,37 @@ describe('search pipeline diagnostics', () => { expect(logs.info).not.toHaveBeenCalled() expect(vi.getTimerCount()).toBe(0) }) + + it.each([false, true])('counts partial completion separately (empty=%s)', async (empty) => { + const result = { results: empty ? [] : ['private result'] } + expect( + await withSearchDiagnostics({ surface: 'dashboard' }, async () => { + annotateSearchDiagnostics({ + retrievalStatus: 'partial', + timedOutLegs: ['vector'], + resultCount: result.results.length, + }) + return result + }) + ).toBe(result) + expect(logs.info).toHaveBeenCalledWith( + 'Knowledge search completed', + expect.objectContaining({ + outcome: 'partial', + retrievalStatus: 'partial', + timedOutLegs: ['vector'], + }) + ) + expect(JSON.stringify(logs.info.mock.calls)).not.toContain('private result') + }) + + it('keeps a later failure distinct from partial retrieval', async () => { + await expect( + withSearchDiagnostics({}, async () => { + annotateSearchDiagnostics({ retrievalStatus: 'partial' }) + throw new Error('metadata failed') + }) + ).rejects.toThrow('metadata failed') + expect(logs.info.mock.calls[0][1].outcome).toBe('error') + }) }) diff --git a/apps/sim/lib/knowledge/search/diagnostics.ts b/apps/sim/lib/knowledge/search/diagnostics.ts index fe30814a125..9f5f3726575 100644 --- a/apps/sim/lib/knowledge/search/diagnostics.ts +++ b/apps/sim/lib/knowledge/search/diagnostics.ts @@ -180,7 +180,9 @@ export async function withSearchDiagnostics( outcome = result && typeof result === 'object' && 'success' in result && result.success === false ? 'error' - : 'success' + : trace.metadata.retrievalStatus === 'partial' + ? 'partial' + : 'success' return result } finally { clearInterval(timer)