diff --git a/.tmp/proof/document-search-desktop.png b/.tmp/proof/document-search-desktop.png new file mode 100644 index 0000000..1eaeb6d Binary files /dev/null and b/.tmp/proof/document-search-desktop.png differ diff --git a/.tmp/proof/document-search-mobile.png b/.tmp/proof/document-search-mobile.png new file mode 100644 index 0000000..799318e Binary files /dev/null and b/.tmp/proof/document-search-mobile.png differ diff --git a/docs/local-development.md b/docs/local-development.md index fce7541..11deb61 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -299,8 +299,13 @@ curl -sS --get http://localhost:8000/api/documents \ Search and source filters combine before counting and pagination. The response keeps the existing `items`, `total`, `limit` and `offset` fields and newest-first ordering. A search with no matches returns an empty page with `total` set to zero. This searches -saved PostgreSQL records and does not call Slack, embeddings or the model. The -dashboard does not expose the search input yet. +saved PostgreSQL records and does not call Slack, embeddings or the model. + +In the dashboard, open **Documents**, enter text in **Search documents** and +submit **Search**. Results are paginated in groups of 20; **Previous** and +**Next** keep the submitted search. **Clear search** returns to the first page +of all saved documents. Typing does not send a request until the form is +submitted. Selecting a result displays its full saved content. ## Next Steps diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index a35bd80..298aaae 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,79 +1,16 @@ 'use client' -import { useEffect, useState } from 'react' +import { useState } from 'react' import ConversationsPanel from '@/components/conversations-panel' import RetrievalPanel from '@/components/retrieval-panel' -import { - askQuestion, - getDocument, - listDocuments, - type Document, - type DocumentDetail, -} from '@/lib/api' - -function formatDate(value?: string | null) { - if (!value) return '' - const date = new Date(value) - if (Number.isNaN(date.getTime())) return '' - return date.toLocaleDateString('en-AU', { - day: 'numeric', - month: 'short', - year: 'numeric', - }) -} +import DocumentsPanel from '@/components/documents-panel' +import { askQuestion } from '@/lib/api' export default function Home() { const [question, setQuestion] = useState('') const [answer, setAnswer] = useState('') const [loading, setLoading] = useState(false) const [activeTab, setActiveTab] = useState<'ask' | 'documents' | 'conversations' | 'retrieval'>('ask') - const [documents, setDocuments] = useState([]) - const [documentsTotal, setDocumentsTotal] = useState(0) - const [documentsLoading, setDocumentsLoading] = useState(false) - const [documentsError, setDocumentsError] = useState('') - const [selectedDocument, setSelectedDocument] = useState(null) - const [detailLoading, setDetailLoading] = useState(false) - const [detailError, setDetailError] = useState('') - - useEffect(() => { - if (activeTab !== 'documents') return - let cancelled = false - setDocumentsLoading(true) - setDocumentsError('') - listDocuments() - .then((data) => { - if (cancelled) return - setDocuments(data.items) - setDocumentsTotal(data.total) - }) - .catch(() => { - if (cancelled) return - setDocuments([]) - setDocumentsTotal(0) - setDocumentsError('Could not load documents. Check the backend is running and try again.') - }) - .finally(() => { - if (!cancelled) setDocumentsLoading(false) - }) - return () => { - cancelled = true - } - }, [activeTab]) - - const handleSelectDocument = async (doc: Document) => { - setDetailLoading(true) - setDetailError('') - try { - const detail = await getDocument(doc.id) - setSelectedDocument(detail) - } catch { - setSelectedDocument(null) - setDetailError('Could not load this document. Try again.') - } finally { - setDetailLoading(false) - } - } - const handleAsk = async () => { if (!question.trim()) return @@ -206,98 +143,7 @@ export default function Home() { )} - {/* Documents Tab */} - {activeTab === 'documents' && ( -
-
-

- Knowledge Base Documents -

-

- Indexed documents used for retrieval-augmented generation. Select one to read its full content. -

- - {documentsLoading && ( -

Loading documents...

- )} - - {!documentsLoading && documentsError && ( -
-

{documentsError}

-
- )} - - {!documentsLoading && !documentsError && documents.length === 0 && ( -

- No documents yet. Add one with POST /api/documents and it will appear here. -

- )} - - {!documentsLoading && !documentsError && documents.length > 0 && ( - <> -

- {documentsTotal} {documentsTotal === 1 ? 'document' : 'documents'} -

-
    - {documents.map((doc) => ( -
  • - -
  • - ))} -
- - )} - - {detailLoading && ( -

Loading document...

- )} - - {!detailLoading && detailError && ( -
-

{detailError}

-
- )} - - {!detailLoading && !detailError && selectedDocument && ( -
-

- {selectedDocument.title} -

-

- {selectedDocument.source} - {formatDate(selectedDocument.created_at) && - ` - ${formatDate(selectedDocument.created_at)}`} -

-

{selectedDocument.content}

-
- )} -
-
- )} + {activeTab === 'documents' && } {activeTab === 'conversations' && } {activeTab === 'retrieval' && } diff --git a/frontend/components/documents-panel.tsx b/frontend/components/documents-panel.tsx new file mode 100644 index 0000000..d62adf9 --- /dev/null +++ b/frontend/components/documents-panel.tsx @@ -0,0 +1,135 @@ +'use client' + +import { useEffect, useState } from 'react' +import { getDocument, listDocuments, type DocumentDetail, type DocumentListResponse } from '@/lib/api' + +const PAGE_SIZE = 20 +const buttonClass = 'min-h-11 rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-40 disabled:cursor-not-allowed' + +function formatDate(value?: string | null) { + if (!value || Number.isNaN(new Date(value).getTime())) return '' + return new Date(value).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }) +} + +export default function DocumentsPanel() { + const [draft, setDraft] = useState('') + const [request, setRequest] = useState({ query: '', offset: 0 }) + const [list, setList] = useState(null) + const [listError, setListError] = useState(false) + const [selection, setSelection] = useState<{ id: number } | null>(null) + const [detail, setDetail] = useState(null) + const [detailError, setDetailError] = useState(false) + + useEffect(() => { + let cancelled = false + const controller = new AbortController() + listDocuments(PAGE_SIZE, request.offset, undefined, request.query, controller.signal) + .then((data) => { if (!cancelled) setList(data) }) + .catch(() => { if (!cancelled) setListError(true) }) + return () => { cancelled = true; controller.abort() } + }, [request]) + + useEffect(() => { + if (!selection) return + let cancelled = false + const controller = new AbortController() + getDocument(selection.id, controller.signal) + .then((data) => { if (!cancelled) setDetail(data) }) + .catch(() => { if (!cancelled) setDetailError(true) }) + return () => { cancelled = true; controller.abort() } + }, [selection]) + + function load(query: string, offset = 0) { + setList(null) + setListError(false) + setSelection(null) + setDetail(null) + setDetailError(false) + setRequest({ query, offset }) + } + + function select(id: number) { + setDetail(null) + setDetailError(false) + setSelection({ id }) + } + + return ( +
+

Knowledge Base Documents

+

+ Indexed documents used for retrieval-augmented generation. Select one to read its full content. +

+
{ event.preventDefault(); load(draft.trim()) }} className="mb-6"> + +

Search titles and full content for matching text.

+
+ setDraft(event.target.value)} + aria-describedby="document-search-help" + className="min-w-0 w-full sm:w-auto sm:flex-1 min-h-11 rounded-lg border border-gray-300 px-3 py-2 text-gray-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" /> + + +
+
+ + {listError ? ( +
+

Could not load documents. Check the backend is running and try again.

+ +
+ ) : !list ?

Loading documents...

: ( + <> +

+ {list.total} {request.query ? 'matching ' : ''}{list.total === 1 ? 'document' : 'documents'} + {request.query && <> for “{request.query}”} +

+ {list.items.length === 0 && ( +

{request.query ? 'No documents match this search. Try different text or clear the search.' : 'No saved documents on this page.'}

+ )} +
    + {list.items.map((doc) => ( +
  • + +
  • + ))} +
+ + + )} + + {selection && ( +
+ {detailError ? ( +
+

Could not load this document. It may no longer be available.

+ +
+ ) : !detail ?

Loading document...

: ( +
+

{detail.title}

+

{detail.source}{formatDate(detail.created_at) && ` - ${formatDate(detail.created_at)}`}

+

{detail.content}

+
+ )} +
+ )} +
+ ) +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 4c05401..3ab34c4 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -150,15 +150,18 @@ export async function askQuestion( export async function listDocuments( limit?: number, offset?: number, - source?: string + source?: string, + query?: string, + signal?: AbortSignal ): Promise { const params = new URLSearchParams() if (limit !== undefined) params.append('limit', limit.toString()) if (offset !== undefined) params.append('offset', offset.toString()) if (source) params.append('source', source) + if (query?.trim()) params.append('q', query.trim()) const url = `${API_BASE_URL}/api/documents${params.toString() ? `?${params}` : ''}` - const response = await fetch(url) + const response = await fetch(url, { signal }) if (!response.ok) { throw new Error(`API error: ${response.status}`) @@ -167,8 +170,8 @@ export async function listDocuments( return response.json() } -export async function getDocument(id: number): Promise { - const response = await fetch(`${API_BASE_URL}/api/documents/${id}`) +export async function getDocument(id: number, signal?: AbortSignal): Promise { + const response = await fetch(`${API_BASE_URL}/api/documents/${id}`, { signal }) if (!response.ok) { throw new Error(`API error: ${response.status}`) diff --git a/frontend/tests/documents.test.tsx b/frontend/tests/documents.test.tsx new file mode 100644 index 0000000..8c0dacc --- /dev/null +++ b/frontend/tests/documents.test.tsx @@ -0,0 +1,205 @@ +import { afterEach, expect, test } from 'bun:test' +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import Home from '../app/page' +import { listDocuments } from '../lib/api' + +const originalFetch = globalThis.fetch +const requests: Array<{ url: URL; signal?: AbortSignal | null }> = [] + +function doc(id: number, title = `Guide ${id}`) { + return { id, title, source: 'docs', created_at: '2026-11-14T09:00:00Z', content_preview: `Preview ${id}` } +} + +function page(items = [doc(1)], total = items.length, offset = 0) { + return { items, total, limit: 20, offset } +} + +function json(data: unknown, status = 200) { + return new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } }) +} + +function mockApi(handler: (url: URL) => Response | Promise) { + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(String(input)) + expect(url.pathname.startsWith('/api/documents')).toBe(true) + expect(init?.method).toBeUndefined() + requests.push({ url, signal: init?.signal }) + return handler(url) + }) as typeof fetch +} + +function openDocuments() { + render() + fireEvent.click(screen.getByRole('button', { name: 'Documents' })) +} + +function draft(value: string) { + fireEvent.change(screen.getByLabelText('Search documents'), { target: { value } }) +} + +function search(value: string) { + draft(value) + fireEvent.submit(screen.getByLabelText('Search documents').closest('form')!) +} + +afterEach(() => { + cleanup() + globalThis.fetch = originalFetch + requests.length = 0 +}) + +test('documents load on opening the tab, search only on submit, and retain the applied query while paging', async () => { + mockApi((url) => { + const offset = Number(url.searchParams.get('offset')) + return json(page(offset ? [doc(21)] : Array.from({ length: 20 }, (_, i) => doc(i + 1)), 21, offset)) + }) + render() + expect(requests).toEqual([]) + fireEvent.click(screen.getByRole('button', { name: 'Documents' })) + await screen.findByText('Preview 1') + expect((screen.getByRole('button', { name: 'Previous documents' }) as HTMLButtonElement).disabled).toBe(true) + fireEvent.click(screen.getByRole('button', { name: 'Next documents' })) + await screen.findByText('Preview 21') + expect((screen.getByRole('button', { name: 'Next documents' }) as HTMLButtonElement).disabled).toBe(true) + const before = requests.length + draft(' VPN ') + expect(requests.length).toBe(before) + fireEvent.click(screen.getByRole('button', { name: 'Search' })) + await screen.findByText('21 matching documents for “VPN”') + expect(requests.at(-1)?.url.searchParams.get('offset')).toBe('0') + draft('unsubmitted edit') + fireEvent.click(screen.getByRole('button', { name: 'Next documents' })) + await screen.findByText('Preview 21') + expect(requests.at(-1)?.url.searchParams.get('q')).toBe('VPN') + expect(requests.at(-1)?.url.searchParams.get('offset')).toBe('20') + fireEvent.click(screen.getByRole('button', { name: 'Previous documents' })) + await screen.findByText('Preview 1') + expect(requests.at(-1)?.url.searchParams.get('q')).toBe('VPN') + expect(requests.at(-1)?.url.searchParams.get('offset')).toBe('0') + fireEvent.click(screen.getByRole('button', { name: 'Clear search' })) + await screen.findByText('21 documents') + expect((screen.getByLabelText('Search documents') as HTMLInputElement).value).toBe('') + expect(requests.at(-1)?.url.searchParams.has('q')).toBe(false) + expect(requests.at(-1)?.url.searchParams.get('offset')).toBe('0') +}) + +test('document client encodes literal search text, keeps source filters, and omits blank queries', async () => { + mockApi(() => json(page())) + const query = 'VPN & 100%_setup/a?b=1+#' + await listDocuments(20, 40, 'internal docs', ` ${query} \n`) + expect(requests[0].url.searchParams.get('q')).toBe(query) + expect(requests[0].url.searchParams.get('source')).toBe('internal docs') + expect(requests[0].url.searchParams.get('offset')).toBe('40') + expect(requests[0].url.searchParams.has('b')).toBe(false) + await listDocuments(20, 0, undefined, ' \t ') + expect(requests[1].url.searchParams.has('q')).toBe(false) +}) + +test('documents show loading, retry the submitted query after a list error, and distinguish empty searches', async () => { + let resolve!: (response: Response) => void + mockApi(() => new Promise((done) => { resolve = done })) + openDocuments() + expect(screen.getByRole('status').textContent).toBe('Loading documents...') + await act(async () => { resolve(json(page([], 0))) }) + expect(screen.getByText('No saved documents on this page.')).toBeTruthy() + mockApi(() => json({ detail: 'internal database details' }, 503)) + search('missing') + await screen.findByRole('alert') + expect(screen.queryByText('internal database details')).toBeNull() + draft('new unsent text') + mockApi(() => json(page([], 0))) + fireEvent.click(screen.getByRole('button', { name: 'Retry documents' })) + await screen.findByText(/No documents match this search/) + expect(requests.at(-1)?.url.searchParams.get('q')).toBe('missing') + expect(screen.queryByRole('alert')).toBeNull() + expect((screen.getByRole('button', { name: 'Next documents' }) as HTMLButtonElement).disabled).toBe(true) +}) + +test('documents render saved content as text and retry detail failures without losing results', async () => { + let fail = true + const markup = '' + mockApi((url) => url.pathname === '/api/documents' + ? json(page([doc(1, 'VPN guide')])) + : fail ? json({}, 404) : json({ ...doc(1, 'VPN guide'), content: markup })) + openDocuments() + await screen.findByText('Preview 1') + fireEvent.click(screen.getByRole('button', { name: /^VPN guide/ })) + await screen.findByRole('alert') + expect(screen.getByText('Preview 1')).toBeTruthy() + fail = false + fireEvent.click(screen.getByRole('button', { name: 'Retry document' })) + await screen.findByText(markup) + const detail = within(screen.getByRole('region', { name: 'Document content' })) + expect(detail.getByRole('heading', { name: 'VPN guide' })).toBeTruthy() + expect(document.querySelector('script')).toBeNull() + search('new query') + expect(screen.queryByRole('region', { name: 'Document content' })).toBeNull() + await screen.findByText('1 matching document for “new query”') +}) + +test('documents ignore stale successful and failed list responses after a newer search', async () => { + let resolveOld!: (response: Response) => void + let rejectOld!: (error: Error) => void + mockApi((url) => { + if (!url.searchParams.has('q')) return new Promise((done) => { resolveOld = done }) + if (url.searchParams.get('q') === 'old') return new Promise((_, reject) => { rejectOld = reject }) + return json(page([doc(2, 'Current result')])) + }) + openDocuments() + search('old') + expect(requests[0].signal?.aborted).toBe(true) + search('current') + await screen.findByText('Current result') + await act(async () => { + resolveOld(json(page([doc(1, 'Stale result')]))) + rejectOld(new Error('late failure')) + }) + expect(screen.queryByText('Stale result')).toBeNull() + expect(screen.queryByRole('alert')).toBeNull() + expect(screen.getByText('Current result')).toBeTruthy() +}) + +test('documents ignore late details after another selection, a page change, and clearing a search', async () => { + const pending: Array<(response: Response) => void> = [] + mockApi((url) => url.pathname === '/api/documents' + ? json(page([doc(1), doc(2)], 21, Number(url.searchParams.get('offset')))) + : new Promise((done) => { pending.push(done) })) + openDocuments() + await screen.findByText('Preview 1') + fireEvent.click(screen.getByRole('button', { name: /^Guide 1 / })) + expect(screen.getAllByRole('status').some((node) => node.textContent === 'Loading document...')).toBe(true) + fireEvent.click(screen.getByRole('button', { name: /^Guide 2 / })) + await act(async () => { + pending[1](json({ ...doc(2), content: 'Current full content' })) + pending[0](json({ ...doc(1), content: 'Stale full content' })) + }) + expect(screen.getByText('Current full content')).toBeTruthy() + expect(screen.queryByText('Stale full content')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: /^Guide 1 / })) + const detailRequest = requests.at(-1)! + fireEvent.click(screen.getByRole('button', { name: 'Next documents' })) + expect(detailRequest.signal?.aborted).toBe(true) + await screen.findByText('Preview 1') + await act(async () => { pending[2](json({ ...doc(1), content: 'Stale page detail' })) }) + expect(screen.queryByRole('region', { name: 'Document content' })).toBeNull() + search('vpn') + await screen.findByText('21 matching documents for “vpn”') + fireEvent.click(screen.getByRole('button', { name: /^Guide 1 / })) + fireEvent.click(screen.getByRole('button', { name: 'Clear search' })) + await act(async () => { pending[3](json({ ...doc(1), content: 'Stale search detail' })) }) + await screen.findByText('21 documents') + expect(screen.queryByRole('region', { name: 'Document content' })).toBeNull() +}) + +test('documents abort requests on leaving the tab and ignore late responses when reopened', async () => { + let resolveOld!: (response: Response) => void + mockApi(() => new Promise((done) => { resolveOld = done })) + openDocuments() + fireEvent.click(screen.getByRole('button', { name: 'Ask Question' })) + expect(requests[0].signal?.aborted).toBe(true) + mockApi(() => json(page([doc(2, 'Reopened result')]))) + fireEvent.click(screen.getByRole('button', { name: 'Documents' })) + await screen.findByText('Reopened result') + await act(async () => { resolveOld(json(page([doc(1, 'Unmounted result')]))) }) + expect(screen.queryByText('Unmounted result')).toBeNull() +})