From b446d62c753196b7fe05a45ec816ec9f700d779b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 01:31:15 +0000 Subject: [PATCH 1/2] Replace the tag filter chip row with an autocomplete The row showed only the first 12 tags, so any tag past the cut could not be filtered on by clicking. The autocomplete searches the whole catalogue and only commits existing tags, since a filter on an unknown name shows nothing. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01CEfKJF2fPYpsaS6fF14Fqs --- frontend/src/components/FilterBar.test.tsx | 35 +++++- frontend/src/components/FilterBar.tsx | 19 +--- frontend/src/components/TagFilterInput.tsx | 121 +++++++++++++++++++++ 3 files changed, 155 insertions(+), 20 deletions(-) create mode 100644 frontend/src/components/TagFilterInput.tsx diff --git a/frontend/src/components/FilterBar.test.tsx b/frontend/src/components/FilterBar.test.tsx index df8f8e3..11fe302 100644 --- a/frontend/src/components/FilterBar.test.tsx +++ b/frontend/src/components/FilterBar.test.tsx @@ -34,6 +34,12 @@ function lastParams(): ListAssetsParams { return calls.length > 0 ? (calls[calls.length - 1][0] ?? {}) : {} } +/** Pick a tag through the autocomplete, the only way to add a tag filter. */ +async function addTag(user: ReturnType, name: string) { + await user.click(await screen.findByRole('combobox', { name: 'Filter by tag' })) + await user.click(await screen.findByRole('option', { name: new RegExp(name) })) +} + beforeEach(() => { list = vi .spyOn(assetsApi, 'list') @@ -64,19 +70,40 @@ describe('FilterBar', () => { const user = userEvent.setup() render() - await user.click(await screen.findByRole('button', { name: /NATO/ })) + await addTag(user, 'NATO') await waitFor(() => expect(lastParams().tag).toEqual(['NATO'])) - await user.click(screen.getByRole('button', { name: /archive/ })) + await addTag(user, 'archive') await waitFor(() => expect(lastParams().tag).toEqual(['NATO', 'archive'])) }) + it('narrows the tag menu as you type, and Enter picks the highlighted tag', async () => { + const user = userEvent.setup() + render() + + const input = await screen.findByRole('combobox', { name: 'Filter by tag' }) + await user.type(input, 'arch') + expect(screen.queryByRole('option', { name: /NATO/ })).not.toBeInTheDocument() + + await user.keyboard('{Enter}') + await waitFor(() => expect(lastParams().tag).toEqual(['archive'])) + }) + + it('never commits free text that is not an existing tag', async () => { + const user = userEvent.setup() + render() + + const input = await screen.findByRole('combobox', { name: 'Filter by tag' }) + await user.type(input, 'nonexistent{Enter}') + expect(lastParams().tag).toBeUndefined() + }) + it('clears one filter from its chip without touching the others', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: 'Video' })) - await user.click(await screen.findByRole('button', { name: /NATO/ })) + await addTag(user, 'NATO') await waitFor(() => expect(lastParams().tag).toEqual(['NATO'])) await user.click(screen.getByRole('button', { name: 'Clear NATO' })) @@ -90,7 +117,7 @@ describe('FilterBar', () => { render() await user.click(screen.getByRole('button', { name: 'Video' })) - await user.click(await screen.findByRole('button', { name: /NATO/ })) + await addTag(user, 'NATO') await waitFor(() => expect(lastParams().tag).toEqual(['NATO'])) await user.click(screen.getByRole('button', { name: 'Clear all' })) diff --git a/frontend/src/components/FilterBar.tsx b/frontend/src/components/FilterBar.tsx index 8630375..26165f9 100644 --- a/frontend/src/components/FilterBar.tsx +++ b/frontend/src/components/FilterBar.tsx @@ -1,7 +1,8 @@ import { useEffect, useMemo, useState } from 'react' import { Search, SlidersHorizontal, X } from 'lucide-react' import { buildCategoryTree, type CategoryNode } from '@/api/tags' -import TypeFilterChips, { Chip } from '@/components/TypeFilterChips' +import TagFilterInput from '@/components/TagFilterInput' +import TypeFilterChips from '@/components/TypeFilterChips' import { useLibraryStore } from '@/stores/library' import { useTagStore } from '@/stores/tags' import { formatDuration } from '@/utils/format' @@ -188,21 +189,7 @@ export default function FilterBar() { {tags.length > 0 && ( -
- Tags - {tags.slice(0, 12).map((tag) => ( - t.toLowerCase() === tag.name.toLowerCase())} - onClick={() => toggleTag(tag.name)} - > - {tag.name} - {tag.asset_count !== undefined && ( - {tag.asset_count} - )} - - ))} -
+ )} {showMore && ( diff --git a/frontend/src/components/TagFilterInput.tsx b/frontend/src/components/TagFilterInput.tsx new file mode 100644 index 0000000..29e0e22 --- /dev/null +++ b/frontend/src/components/TagFilterInput.tsx @@ -0,0 +1,121 @@ +import { useMemo, useState } from 'react' +import type { Tag } from '@/api/tags' + +/** + * An autocomplete for the tag *filter*. + * + * This replaced a row of chips cut off at the first dozen tags, which made every tag + * past the cut unfilterable-by-click. It differs from `TagInput` on purpose: that one + * creates tags by name, while a filter on a name that does not exist can only ever show + * an empty grid — so here Enter picks from the menu and free text is never committed. + * Chosen tags are shown by FilterBar's active-chip row, so they are only hidden from + * the menu rather than rendered again here. + */ + +interface Props { + tags: Tag[] + /** Names currently filtered on; compared case-insensitively, as the server does. */ + selected: string[] + onPick: (name: string) => void +} + +// The menu scrolls, so this is a guard against rendering thousands of rows, not the +// limit the old chip row was. +const MAX_OPTIONS = 50 + +const LISTBOX_ID = 'tag-filter-suggestions' + +export default function TagFilterInput({ tags, selected, onPick }: Props) { + const [draft, setDraft] = useState('') + const [open, setOpen] = useState(false) + const [active, setActive] = useState(0) + + const matches = useMemo(() => { + const term = draft.trim().toLowerCase() + const chosen = new Set(selected.map((name) => name.toLowerCase())) + return tags + .filter((tag) => !chosen.has(tag.name.toLowerCase())) + .filter((tag) => !term || tag.name.toLowerCase().includes(term)) + .slice(0, MAX_OPTIONS) + }, [tags, selected, draft]) + + const pick = (name: string) => { + onPick(name) + setDraft('') + setActive(0) + } + + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'ArrowDown') { + event.preventDefault() + setOpen(true) + setActive((i) => Math.min(i + 1, matches.length - 1)) + } else if (event.key === 'ArrowUp') { + event.preventDefault() + setActive((i) => Math.max(i - 1, 0)) + } else if (event.key === 'Enter') { + event.preventDefault() + const match = matches[active] + if (open && match) pick(match.name) + } else if (event.key === 'Escape') { + setOpen(false) + } + } + + return ( +
+ 0} + aria-controls={LISTBOX_ID} + aria-autocomplete="list" + aria-label="Filter by tag" + className="input py-1 text-xs" + placeholder="Filter by tag…" + value={draft} + onChange={(e) => { + setDraft(e.target.value) + setOpen(true) + setActive(0) + }} + onFocus={() => setOpen(true)} + // A timeout, not a plain close: clicking an option blurs the input first, and + // unmounting the menu synchronously would eat the click. + onBlur={() => window.setTimeout(() => setOpen(false), 120)} + onKeyDown={onKeyDown} + /> + + {open && matches.length > 0 && ( +
    + {matches.map((tag, index) => ( +
  • + +
  • + ))} +
+ )} +
+ ) +} From a5d7ab8d000d8fd7e49b07e3c980d39a501544ce Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 01:43:58 +0000 Subject: [PATCH 2/2] Fix the library never loading past its first page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The infinite-scroll observer was attached once on mount, when the grid is still showing 'Loading…' and the sentinel element does not exist, so it never attached. Re-attach after the first load and after each page; a fresh observer also reports a sentinel that is still on screen. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01CEfKJF2fPYpsaS6fF14Fqs --- frontend/src/views/LibraryView.test.tsx | 42 +++++++++++++++++++++++++ frontend/src/views/LibraryView.tsx | 11 ++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/frontend/src/views/LibraryView.test.tsx b/frontend/src/views/LibraryView.test.tsx index 31ade18..fa30352 100644 --- a/frontend/src/views/LibraryView.test.tsx +++ b/frontend/src/views/LibraryView.test.tsx @@ -633,3 +633,45 @@ describe('LibraryView generations (M8)', () => { expect(screen.getByRole('form', { name: 'Generate an image' })).toBeVisible() }) }) + +describe('LibraryView paging', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('fetches the next page once the first has rendered', async () => { + /** + * The sentinel is not in the DOM while the first page loads, so an observer attached + * only on mount never saw it and the library stopped at page one. This observer + * reports intersecting as soon as it is given something to watch, which is what a + * real one does for an element already on screen. + */ + class VisibleObserver { + constructor(private readonly callback: IntersectionObserverCallback) {} + observe() { + this.callback( + [{ isIntersecting: true } as IntersectionObserverEntry], + this as unknown as IntersectionObserver + ) + } + unobserve() {} + disconnect() {} + } + vi.stubGlobal('IntersectionObserver', VisibleObserver) + + const list = vi.spyOn(assetsApi, 'list').mockImplementation(async (params) => { + const offset = params?.offset ?? 0 + return { + data: offset === 0 ? ASSETS.slice(0, 2) : ASSETS.slice(2), + total: ASSETS.length, + limit: 60, + offset, + } + }) + + render() + + expect(await screen.findByText('Third')).toBeInTheDocument() + expect(list).toHaveBeenCalledWith(expect.objectContaining({ offset: 2 })) + }) +}) diff --git a/frontend/src/views/LibraryView.tsx b/frontend/src/views/LibraryView.tsx index 9c8c3b8..9a1a487 100644 --- a/frontend/src/views/LibraryView.tsx +++ b/frontend/src/views/LibraryView.tsx @@ -68,6 +68,15 @@ export default function LibraryView() { // Infinite scroll. An observer rather than a scroll handler: it does not fire on // every pixel, and it keeps working if the grid is put in a different container. + // + // Keyed on `loading` and the loaded count, not just `loadMore`. The sentinel only + // exists once the first page is in, so an effect that ran once on mount found no + // element and never attached — the grid stopped at page one for good. Re-attaching + // after each page matters for the same reason: an observer reports on *changes* in + // visibility, so a sentinel that is still on screen after a page lands (a tall + // window, small cards) would never ask for the next one. A fresh observer always + // reports its initial state. + const loadedCount = assets.length useEffect(() => { const sentinel = sentinelRef.current if (!sentinel) return @@ -82,7 +91,7 @@ export default function LibraryView() { ) observer.observe(sentinel) return () => observer.disconnect() - }, [loadMore]) + }, [loadMore, loading, loadedCount]) // A URL import or a generation finishes in the background, so nothing else would put // what it made in the grid. Keyed on seeing a job go from running to finished rather