diff --git a/frontend/messages/de.json b/frontend/messages/de.json
index 704425d5..7969df2a 100644
--- a/frontend/messages/de.json
+++ b/frontend/messages/de.json
@@ -301,6 +301,7 @@
"comp_multiselect_placeholder": "Namen tippen...",
"comp_multiselect_remove": "Entfernen",
"comp_multiselect_loading": "Suche...",
+ "comp_typeahead_error": "Suche fehlgeschlagen. Bitte versuchen Sie es erneut.",
"comp_taginput_placeholder_create": "Schlagworte hinzufügen...",
"comp_taginput_placeholder_filter": "Nach Schlagworten filtern...",
"comp_taginput_remove": "Schlagwort entfernen",
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 24856bd1..b45711e6 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -301,6 +301,7 @@
"comp_multiselect_placeholder": "Type a name...",
"comp_multiselect_remove": "Remove",
"comp_multiselect_loading": "Searching...",
+ "comp_typeahead_error": "Search failed. Please try again.",
"comp_taginput_placeholder_create": "Add tags...",
"comp_taginput_placeholder_filter": "Filter by tags...",
"comp_taginput_remove": "Remove tag",
diff --git a/frontend/messages/es.json b/frontend/messages/es.json
index 74b45118..9821ad93 100644
--- a/frontend/messages/es.json
+++ b/frontend/messages/es.json
@@ -301,6 +301,7 @@
"comp_multiselect_placeholder": "Escriba un nombre...",
"comp_multiselect_remove": "Eliminar",
"comp_multiselect_loading": "Buscando...",
+ "comp_typeahead_error": "La búsqueda falló. Inténtelo de nuevo.",
"comp_taginput_placeholder_create": "Añadir etiquetas...",
"comp_taginput_placeholder_filter": "Filtrar por etiquetas...",
"comp_taginput_remove": "Eliminar etiqueta",
diff --git a/frontend/src/lib/document/DocumentPickerDropdown.svelte b/frontend/src/lib/document/DocumentPickerDropdown.svelte
index e7d30706..951f79b9 100644
--- a/frontend/src/lib/document/DocumentPickerDropdown.svelte
+++ b/frontend/src/lib/document/DocumentPickerDropdown.svelte
@@ -62,13 +62,15 @@ function handleSelect(doc: DocumentOption) {
class="block w-full rounded border border-line bg-surface px-3 py-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
- {#if picker.isOpen && (picker.results.length > 0 || picker.loading)}
+ {#if picker.isOpen && (picker.results.length > 0 || picker.loading || picker.error)}
{#if picker.loading}
- {m.comp_multiselect_loading()}
+ {:else if picker.error}
+ - {m.comp_typeahead_error()}
{:else}
{#each picker.results as doc (doc.id)}
{@const disabled = alreadyAddedIds.has(doc.id!)}
diff --git a/frontend/src/lib/document/DocumentPickerDropdown.svelte.spec.ts b/frontend/src/lib/document/DocumentPickerDropdown.svelte.spec.ts
index 48df16dc..67ebdaef 100644
--- a/frontend/src/lib/document/DocumentPickerDropdown.svelte.spec.ts
+++ b/frontend/src/lib/document/DocumentPickerDropdown.svelte.spec.ts
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import DocumentPickerDropdown from './DocumentPickerDropdown.svelte';
+import { m } from '$lib/paraglide/messages.js';
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
@@ -93,3 +94,24 @@ describe('DocumentPickerDropdown — selection', () => {
expect(onSelect).not.toHaveBeenCalled();
});
});
+
+describe('DocumentPickerDropdown — search failure', () => {
+ it('shows an error message when the search request fails instead of vanishing', async () => {
+ // 500 from /api/documents/search — must surface, not render as "no results"
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ json: vi.fn().mockResolvedValue({ code: 'INTERNAL_ERROR' })
+ })
+ );
+
+ render(DocumentPickerDropdown, { onSelect: vi.fn() });
+
+ await userEvent.fill(page.getByRole('combobox'), 'Brief');
+ await waitForDebounce();
+
+ await expect.element(page.getByText(m.comp_typeahead_error())).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/lib/document/documentTypeahead.ts b/frontend/src/lib/document/documentTypeahead.ts
index 4853298a..1f244d28 100644
--- a/frontend/src/lib/document/documentTypeahead.ts
+++ b/frontend/src/lib/document/documentTypeahead.ts
@@ -14,7 +14,12 @@ export function createDocumentTypeahead() {
return createTypeahead({
fetchUrl: (q) =>
fetch(`/api/documents/search?q=${encodeURIComponent(q)}&size=10`)
- .then((r) => r.json())
+ .then((r) => {
+ // Without this check a 401/500 parses as JSON without `items` and
+ // renders as "no results" — errors must reach the hook's error state.
+ if (!r.ok) throw new Error(`document search failed: ${r.status}`);
+ return r.json();
+ })
.then((b: { items: DocumentListItem[] }) =>
b.items.map((it) => ({
id: it.id,
diff --git a/frontend/src/lib/shared/hooks/useTypeahead.svelte.test.ts b/frontend/src/lib/shared/hooks/useTypeahead.svelte.test.ts
index e2983188..7cdd05e9 100644
--- a/frontend/src/lib/shared/hooks/useTypeahead.svelte.test.ts
+++ b/frontend/src/lib/shared/hooks/useTypeahead.svelte.test.ts
@@ -78,6 +78,34 @@ describe('createTypeahead', () => {
errorSpy.mockRestore();
});
+ it('fetch error sets the error flag so callers can render a failure state', async () => {
+ const fetchUrl = vi.fn().mockRejectedValue(new Error('500'));
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const ta = createTypeahead({ fetchUrl, debounceMs: 0 });
+ expect(ta.error).toBe(false);
+ ta.setQuery('foo');
+ await vi.advanceTimersByTimeAsync(0);
+ expect(ta.error).toBe(true);
+ errorSpy.mockRestore();
+ });
+
+ it('error flag clears on the next successful fetch', async () => {
+ const fetchUrl = vi
+ .fn()
+ .mockRejectedValueOnce(new Error('500'))
+ .mockResolvedValueOnce([{ id: '1' }]);
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const ta = createTypeahead({ fetchUrl, debounceMs: 0 });
+ ta.setQuery('foo');
+ await vi.advanceTimersByTimeAsync(0);
+ expect(ta.error).toBe(true);
+ ta.setQuery('foob');
+ await vi.advanceTimersByTimeAsync(0);
+ expect(ta.error).toBe(false);
+ expect(ta.results).toEqual([{ id: '1' }]);
+ errorSpy.mockRestore();
+ });
+
it('setActiveIndex updates activeIndex', () => {
const ta = createTypeahead({ fetchUrl: vi.fn().mockResolvedValue([]) });
expect(ta.activeIndex).toBe(-1);
diff --git a/frontend/src/lib/shared/hooks/useTypeahead.svelte.ts b/frontend/src/lib/shared/hooks/useTypeahead.svelte.ts
index f0ac30d7..b132da2b 100644
--- a/frontend/src/lib/shared/hooks/useTypeahead.svelte.ts
+++ b/frontend/src/lib/shared/hooks/useTypeahead.svelte.ts
@@ -11,6 +11,7 @@ export function createTypeahead(options: Options) {
let results: T[] = $state([]);
let isOpen = $state(false);
let loading = $state(false);
+ let error = $state(false);
let activeIndex = $state(-1);
let debounceTimer: ReturnType | undefined;
@@ -21,11 +22,13 @@ export function createTypeahead(options: Options) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
loading = true;
+ error = false;
try {
results = await fetchUrl(q);
} catch (e) {
console.error('typeahead fetch error', e);
results = [];
+ error = true;
} finally {
loading = false;
}
@@ -65,6 +68,9 @@ export function createTypeahead(options: Options) {
get loading() {
return loading;
},
+ get error() {
+ return error;
+ },
get activeIndex() {
return activeIndex;
},