fix(search): open matched records and deduplicate sources (#1345)
Some checks failed
Development Build / extract-version (push) Has been cancelled
Development Build / changes (push) Has been cancelled
Tests / Backend Tests (push) Has been cancelled
Tests / Backend Lint (push) Has been cancelled
Tests / Backend Typecheck (push) Has been cancelled
Tests / Frontend Tests (push) Has been cancelled
Tests / Frontend Lint (push) Has been cancelled
Tests / Frontend Build (push) Has been cancelled
Development Build / build-regular (push) Has been cancelled
Development Build / build-single (push) Has been cancelled
Development Build / summary (push) Has been cancelled

This commit is contained in:
Aditya Raj Singh 2026-09-11 06:54:00 +05:30 • committed by GitHub
parent 83edd89356
commit ae7b96d376
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 169 additions and 10 deletions

View file

@ -0,0 +1,89 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import SearchPage from './page'
import { useSearch } from '@/lib/hooks/use-search'
import { useModalManager } from '@/lib/hooks/use-modal-manager'
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
usePathname: () => '/search',
useSearchParams: () => new URLSearchParams('mode=search'),
}))
vi.mock('@/components/layout/AppShell', () => ({
AppShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/components/search/NotebookScopeSelector', () => ({
NotebookScopeSelector: () => null,
}))
vi.mock('@/components/search/StreamingResponse', () => ({
StreamingResponse: () => null,
}))
vi.mock('@/components/search/AdvancedModelsDialog', () => ({
AdvancedModelsDialog: () => null,
}))
vi.mock('@/components/search/SaveToNotebooksDialog', () => ({
SaveToNotebooksDialog: () => null,
}))
vi.mock('@/lib/hooks/use-search', () => ({
useSearch: vi.fn(),
}))
vi.mock('@/lib/hooks/use-ask', () => ({
useAsk: () => ({
sendAsk: vi.fn(),
isStreaming: false,
strategy: null,
answers: [],
finalAnswer: '',
}),
}))
vi.mock('@/lib/hooks/use-models', () => ({
useModelDefaults: () => ({ data: null, isLoading: false }),
useModels: () => ({ data: [], isLoading: false }),
}))
vi.mock('@/lib/hooks/use-modal-manager', () => ({
useModalManager: vi.fn(),
}))
const mockUseSearch = vi.mocked(useSearch)
const mockUseModalManager = vi.mocked(useModalManager)
const openModal = vi.fn()
describe('SearchPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseSearch.mockReturnValue({
data: {
results: [{
id: 'source_insight:matched-insight',
parent_id: 'source:parent-source',
title: 'Matched insight',
final_score: 0.9,
created: '2026-01-01T00:00:00Z',
updated: '2026-01-01T00:00:00Z',
}],
total_count: 1,
search_type: 'text',
},
isPending: false,
mutate: vi.fn(),
} as unknown as ReturnType<typeof useSearch>)
mockUseModalManager.mockReturnValue({ openModal } as unknown as ReturnType<typeof useModalManager>)
})
it('opens the record that matched instead of its parent source', () => {
render(<SearchPage />)
fireEvent.click(screen.getByRole('button', { name: 'Matched insight' }))
expect(openModal).toHaveBeenCalledWith('insight', 'matched-insight')
})
})

View file

@ -466,13 +466,7 @@ export default function SearchPage() {
) : (
<div className="space-y-2">
{searchMutation.data.results.map((result, index) => {
// Parse type from parent_id (format: "source:id" or "note:id" or "source_insight:id")
// Handle null parent_id gracefully (orphaned records)
if (!result.parent_id) {
console.warn('Search result with null parent_id:', result)
return null
}
const [type, id] = result.parent_id.split(':')
const [type, id] = result.id.split(':')
const modalType = type === 'source_insight' ? 'insight' : type as 'source' | 'note' | 'insight'
return (

View file

@ -0,0 +1,73 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AddExistingSourceDialog } from './AddExistingSourceDialog'
import { searchApi } from '@/lib/api/search'
import { sourcesApi } from '@/lib/api/sources'
vi.mock('use-debounce', () => ({
useDebounce: (value: string) => [value],
}))
vi.mock('@/lib/api/search', () => ({
searchApi: { search: vi.fn() },
}))
vi.mock('@/lib/api/sources', () => ({
sourcesApi: { list: vi.fn() },
}))
const mutateAsync = vi.fn()
vi.mock('@/lib/hooks/use-sources', () => ({
useSources: () => ({ data: [] }),
useAddSourcesToNotebook: () => ({ mutateAsync, isPending: false }),
}))
const mockSearch = vi.mocked(searchApi.search)
const mockList = vi.mocked(sourcesApi.list)
describe('AddExistingSourceDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockList.mockResolvedValue([])
mockSearch.mockResolvedValue({
results: [
{
id: 'source:shared',
parent_id: 'source:shared',
title: 'Direct source match',
final_score: 0.9,
created: '2026-01-01T00:00:00Z',
updated: '2026-01-01T00:00:00Z',
},
{
id: 'source_insight:child',
parent_id: 'source:shared',
title: 'Insight from same source',
final_score: 0.8,
created: '2026-01-01T00:00:00Z',
updated: '2026-01-01T00:00:00Z',
},
],
total_count: 2,
search_type: 'text',
})
})
it('shows a source only once when several search hits have the same parent', async () => {
render(
<AddExistingSourceDialog
open={true}
onOpenChange={vi.fn()}
notebookId="notebook:1"
/>
)
fireEvent.change(screen.getByPlaceholderText('sources.searchPlaceholder'), {
target: { value: 'shared' },
})
await waitFor(() => expect(mockSearch).toHaveBeenCalled())
expect(screen.getByText('Direct source match')).toBeInTheDocument()
expect(screen.queryByText('Insight from same source')).not.toBeInTheDocument()
})
})

View file

@ -91,9 +91,12 @@ export function AddExistingSourceDialog({
minimum_score: 0.01,
})
// Since we set search_sources=true and search_notes=false,
// the API only returns sources, no need to filter
const sources = response.results.map(r => ({
const sourceIds = new Set<string>()
const sources = response.results.filter(r => {
if (sourceIds.has(r.parent_id)) return false
sourceIds.add(r.parent_id)
return true
}).map(r => ({
id: r.parent_id,
title: r.title || 'Untitled',
topics: [],