From 4651063b3e5a70575e0b38069d9e384f0c6c3357 Mon Sep 17 00:00:00 2001 From: Denys Date: Sun, 5 Apr 2026 20:00:13 +0200 Subject: [PATCH 01/14] Update mobile adb pair endpoint --- core/constants/aiIndex.ts | 10 +- docs/ai/design/feature-ai-index-page.md | 28 +++ .../implementation/feature-ai-index-page.md | 3 + docs/ai/planning/feature-ai-index-page.md | 13 ++ docs/ai/requirements/feature-ai-index-page.md | 15 +- docs/ai/testing/feature-ai-index-page.md | 8 + .../components/settings/AIIndexPanel.tsx | 102 ++++++++++- ui/mobile/package.json | 2 +- .../tests/component/aiIndexPanel.test.tsx | 45 ++++- .../features/settings/AIIndexTab.tsx | 142 +++++++++++++-- .../tests/unit/components/aiIndexTab.test.tsx | 165 ++++++++++++++++++ 11 files changed, 515 insertions(+), 18 deletions(-) diff --git a/core/constants/aiIndex.ts b/core/constants/aiIndex.ts index badfb82722a..1ce4c9a959e 100644 --- a/core/constants/aiIndex.ts +++ b/core/constants/aiIndex.ts @@ -1,4 +1,4 @@ -import type { AIIndexStatus, AIIndexMutationResult } from "@core/types/aiIndex" +import type { AIIndexStatus, AIIndexMutationResult, AIIndexNoteRow } from "@core/types/aiIndex" export const AI_INDEX_STATUS_LABELS: Record = { indexed: "Indexed", @@ -49,3 +49,11 @@ export function getAIIndexActionPresentation(status: AIIndexStatus): AIIndexActi buttonVariant: "default", } } + +export function isAIIndexStatusActionable(status: AIIndexStatus) { + return status !== "indexed" +} + +export function getAIIndexActionableNotes(notes: AIIndexNoteRow[]) { + return notes.filter((note) => isAIIndexStatusActionable(note.status)) +} diff --git a/docs/ai/design/feature-ai-index-page.md b/docs/ai/design/feature-ai-index-page.md index 66da145afc6..7f78dd50f14 100644 --- a/docs/ai/design/feature-ai-index-page.md +++ b/docs/ai/design/feature-ai-index-page.md @@ -13,6 +13,7 @@ graph TD SP["SettingsPage"] --> TAB["AIIndexTab"] TAB --> FILTERS["StatusFilters"] TAB --> SEARCH["SearchRow"] + TAB --> BULK["Bulk Index Loaded Action"] TAB --> LIST["AIIndexList"] LIST --> ROW["AIIndexNoteRow"] TAB --> HOOK["useAIIndexNotes(status, search, page)"] @@ -28,6 +29,7 @@ graph TD - `SettingsPage` gets a new `AI Index` tab entry. - `AIIndexTab` is a standalone settings flow, not a reuse of the notes page controller. - `useAIIndexNotes` owns pagination, active filter, loading state, and cache invalidation for this page only. +- `AIIndexTab` also owns the summary-level bulk action state for the currently loaded result set. - `AIIndexList` reuses the virtualization pattern from `NoteList`, but renders dedicated AI-index rows/cards. - `AIIndexNoteRow` renders note metadata and per-row actions. - A dedicated server-side RPC returns notes together with aggregated index status so filtering and pagination remain correct. @@ -113,6 +115,7 @@ The `all` filter is the default page state so the Settings tab opens with the fu - Settings-specific container for: - heading / summary + - summary-level bulk action button for loaded notes - filter pills - ordinary search row below the pills - list body @@ -148,6 +151,19 @@ The `all` filter is the default page state so the Settings tab opens with the fu - last indexed timestamp - note-level action buttons +### Bulk action behavior + +- The summary/header area gets one bulk action button beside the existing reset/filter context. +- The bulk action scopes itself to the currently loaded client list after server-side filter and search have already been applied. +- It must not fetch additional pages or infer unseen matches from `totalCount`. +- Eligible rows: + - `not_indexed`: invoke `rag-index` with `action: "index"` + - `outdated`: invoke `rag-index` with `action: "reindex"` + - `indexed`: skip +- The implementation reuses the existing single-note `rag-index` contract in a client-side loop instead of adding a separate bulk RPC or Edge Function. +- On web, successful mutations should flow through the same optimistic row-update and exit-animation pipeline already used for per-row actions so filtered rows disappear gracefully. +- On mobile, successful mutations should reuse the same cache patch + invalidate path already used for per-row actions so cards disappear from filtered views consistently. + ## Design Decisions ### Dedicated fetch path instead of client-side joining @@ -165,6 +181,16 @@ The `all` filter is the default page state so the Settings tab opens with the fu - Decision: keep `rag-index` for write actions and add a dedicated RPC read path for AI index state. - Rationale: existing mutation behavior is already shipped and tested; the missing capability is efficient list aggregation and filtering. +### Bulk action targets loaded notes, not all matching notes + +- Decision: the bulk button processes only the currently loaded notes that remain visible after the active filter and active committed search query. +- Rationale: this matches the mental model already shown in the summary ("Showing X loaded notes out of Y"), avoids hidden work on notes the user cannot currently inspect, and keeps the feature aligned with the paginated/infinite-list architecture on both web and mobile. + +### Bulk action stays on the client and reuses the single-note mutation API + +- Decision: do not add a backend bulk-index endpoint for this refinement. +- Rationale: the existing `rag-index` function is explicitly single-note, the AI Index page already owns the loaded list on the client, and reusing the current mutation path is the smallest, safest change for web and mobile parity. + ### Reuse ordinary search semantics without reusing the notes controller - Decision: reuse the main note-search query construction and UX (`Search` input, debounce, 3-character threshold, FTS-first behavior) inside the dedicated AI Index flow. @@ -185,6 +211,7 @@ graph TD SS --> AIP["AIIndexPanel"] AIP --> CHIPS["Filter Chips (Pressable)"] AIP --> SINPUT["Search TextInput"] + AIP --> BCTA["Bulk Index Loaded Button"] AIP --> FL["FlatList"] FL --> CARD["AIIndexNoteCard (memo)"] AIP --> HOOK["useAIIndexNotes(filter, search)"] @@ -201,6 +228,7 @@ graph TD - Standalone panel component rendered inside a dedicated `flex: 1` AI Index viewport in `SettingsScreen` (not wrapped in the general settings `ScrollView`). - Filter row: one horizontally scrolling Pressable-chip rail matching `SettingsTabBar` interaction and sizing. - Search TextInput with clear button, debounced at 300ms. +- Summary row can expose one bulk button near the count/reset affordances when the current loaded list contains `not_indexed` or `outdated` cards. - FlatList with `onEndReached` for infinite scroll, `onRefresh` for pull-to-refresh. - Loading spinner, empty message, and error + retry states. - Summary text ("X notes" or "Showing X of Y notes"). diff --git a/docs/ai/implementation/feature-ai-index-page.md b/docs/ai/implementation/feature-ai-index-page.md index d7967b2414d..343516cd2ad 100644 --- a/docs/ai/implementation/feature-ai-index-page.md +++ b/docs/ai/implementation/feature-ai-index-page.md @@ -59,6 +59,7 @@ ui/web/components/features/settings/ - Reuse the shared search utilities (`SEARCH_CONFIG`, `buildTsQuery`, language mapping) so AI Index search behaves like the main notes search without reusing the notes controller. - Make each AI Index row open the corresponding note in the main notes shell while preserving the originating `Settings -> AI Index` view state for back navigation. - Refine the page so it surfaces the user's working context directly: visible count, loaded count, active scope, active search, reset affordances, and clearer empty-state recovery. +- Add a summary-level `Index loaded notes` action that only processes the currently loaded rows after active filter + active committed search have already narrowed the dataset. - Keep the summary chrome intentionally compact so the note list appears on the first screen without requiring an initial scroll. - Hide the redundant Settings-section hero for `AI Index` specifically; the left navigation already provides the tab label and description. @@ -73,6 +74,7 @@ ui/web/components/features/settings/ - Keep the row card-like rather than turning it into a rigid table. The title block should own most of the width, status should live as a compact badge in the metadata line, and action buttons can stay equal-width without stealing title space on desktop or mobile. - Because AI Index commonly opens notes in the main workspace after a cold `/settings` load, prefetch the `/` route and prewarm the first main-notes query page while the AI Index tab is already visible. - Treat row mutations as optimistic UI updates: if the current filter still includes the note, swap its status/button state immediately; if the mutation moves the note out of the active filter, keep it around just long enough for a short exit animation before removing it from the visible list. +- Treat the bulk summary action as an orchestrator over the existing row-mutation path rather than a separate backend workflow. It should derive its candidate set from the already loaded list, skip `indexed` rows, and feed each success back through the same row-update pipeline. - Keep the underlying `rag-index` source of truth aligned with the optimistic UI by writing a fresh `indexed_at` timestamp during reindex upserts; otherwise `Outdated` notes can stay stale even after a successful reindex. - Normalize `rag-index` payloads through `core/rag/indexResult.ts` so semantic non-success responses such as `reason: "too_short"` do not show a false success state in AI Index. @@ -121,6 +123,7 @@ ui/mobile/app/(tabs)/ - AI Index filter tabs intentionally match the top `SettingsTabBar` interaction model: one horizontal scroll rail, no wrapping, and no extra boxed card around them. - The filter rail is visually separated with a divider rather than another surface, so the first useful content stays closer to the top of the screen. - Mobile action buttons are stacked vertically inside each AI Index card to keep long labels readable on narrow screens. +- The mobile bulk action should live in the same summary area as the count/reset affordances so it mirrors the web placement and inherits the same "loaded notes only" mental model. ## Integration Points diff --git a/docs/ai/planning/feature-ai-index-page.md b/docs/ai/planning/feature-ai-index-page.md index be423d9f080..800f0b3c8ad 100644 --- a/docs/ai/planning/feature-ai-index-page.md +++ b/docs/ai/planning/feature-ai-index-page.md @@ -13,6 +13,7 @@ description: Task breakdown for the Settings AI index management page - [x] Milestone 3: Row actions refresh list state correctly and tests cover the new flow - [x] Milestone 4: AI Index supports ordinary note search semantics below the status tabs - [x] Milestone 5: Mobile AI Index page with filters, search, actions, and tests +- [x] Milestone 6: AI Index supports a summary-level `Index loaded notes` action on web and mobile that respects the current loaded search/filter scope ## Task Breakdown @@ -60,6 +61,16 @@ description: Task breakdown for the Settings AI index management page - [x] Task 4.6: Write unit tests for hook, card, and panel (21 tests) - [x] Task 4.7: Pass type-check and lint with zero errors +### Phase 5: Loaded-scope bulk indexing refinement + +- [x] Task 5.1: Update feature docs to define the loaded-only bulk indexing semantics +- [x] Task 5.2: Add a summary-level bulk button to the web AI Index tab in the header area +- [x] Task 5.3: Reuse existing row mutation semantics so bulk successes update/animate rows correctly on web +- [x] Task 5.4: Add a matching summary-level bulk button to the mobile AI Index panel +- [x] Task 5.5: Ensure the bulk action uses only the currently loaded notes after active filter + committed search query +- [x] Task 5.6: Add web and mobile unit tests for loaded-only bulk indexing behavior +- [x] Task 5.7: Re-run targeted validation and record outcomes in implementation/testing docs + ## Dependencies - Existing `rag-index` Edge Function remains the mutation layer. @@ -84,6 +95,8 @@ Total expected effort: 1 focused implementation cycle with follow-up testing and - Mitigation: mirror `NoteList` sizing and scrolling patterns in a dedicated component. - Query refresh race after row mutations - Mitigation: centralize invalidation in the AI index hook/query key. +- User confusion about bulk scope when pagination and search are active + - Mitigation: label the action as `Index loaded notes`, place it beside the existing loaded-count summary, and keep the implementation scoped to the current loaded list only. ## Resources Needed diff --git a/docs/ai/requirements/feature-ai-index-page.md b/docs/ai/requirements/feature-ai-index-page.md index 893da81becd..1ad7106acab 100644 --- a/docs/ai/requirements/feature-ai-index-page.md +++ b/docs/ai/requirements/feature-ai-index-page.md @@ -32,6 +32,7 @@ Users can currently index or delete the AI/RAG index only from inside an individ - `Index` - `Reindex` - `Remove from index` +- Add a bulk action button near the list summary that indexes only the current loaded result set for the active filter and active search. ### Secondary goals @@ -46,7 +47,7 @@ Users can currently index or delete the AI/RAG index only from inside an individ ### Non-goals - Replacing the existing per-note `RagIndexPanel` entry points. -- Bulk actions on multiple notes in MVP. +- A whole-workspace "index every note" action that ignores pagination, loaded state, or active search results. - Editing note content from the AI Index page. - ~~Mobile-specific AI Index screen beyond preserving responsive Settings behavior.~~ (Resolved: mobile AI Index screen is now in scope and implemented.) - Automatic reindex on note save. @@ -58,6 +59,7 @@ Users can currently index or delete the AI/RAG index only from inside an individ - As a user, I want to filter to `Not indexed` notes so I can decide what should enter the AI index. - As a user, I want to reindex an outdated or already indexed note without opening it first. - As a user, I want to remove a note from the AI index from the same list view. +- As a user, I want one bulk button that indexes only the notes currently loaded in the visible AI Index list, so search and filters naturally limit its scope. ### Status rules @@ -81,6 +83,12 @@ Users can currently index or delete the AI/RAG index only from inside an individ - search starts after 3 characters - matching should follow the ordinary note search path (`FTS` first, then substring fallback) - The default sort order is descending by note `updated_at` so the most recently changed notes are reviewed first. +- The bulk action must use the same narrowed list the user is looking at: + - only notes from the active filter and active committed search query are eligible + - only notes already loaded into the current infinite list are eligible + - notes on later, not-yet-loaded pages must not be indexed + - notes excluded by search must not be indexed + - already indexed notes may stay visible in `All notes`, but the bulk action should skip them rather than reprocessing them unless they are `Outdated` ## Success Criteria @@ -91,6 +99,8 @@ Users can currently index or delete the AI/RAG index only from inside an individ - [ ] Search query changes reload AI index data without breaking pagination or virtualization. - [ ] Each row shows `title`, computed status, and `last indexed at` when available. - [ ] `Index`, `Reindex`, and `Remove from index` actions are available according to row state. +- [ ] A bulk button is available in the AI Index summary/header area on web and mobile when the current loaded result set contains actionable notes. +- [ ] The bulk button affects only the currently loaded notes that match the active filter and active committed search query. - [ ] After an action completes, the affected row reflects the new status without leaving the page. - [ ] `Outdated` status is computed from actual note update time versus latest index time. - [ ] The implementation does not directly reuse `NoteCard` or the existing notes/search controller flow. @@ -103,6 +113,7 @@ Users can currently index or delete the AI/RAG index only from inside an individ - Existing `rag-index` Edge Function remains the source of truth for index/delete/reindex actions. - Browser-side reads of `note_embeddings` are limited by RLS and are awkward for server-side status filtering at scale. - Status filtering must remain performant for large note sets and should not require fetching the full workspace into the browser first. +- The bulk action should reuse the existing single-note `rag-index` mutation path rather than introducing a whole new backend bulk API for this refinement. ### Assumptions @@ -121,6 +132,7 @@ The web AI Index page solved the desktop experience. Mobile users on the React N - Add an `AI Index` tab to the mobile Settings screen (`ui/mobile/app/(tabs)/settings.tsx`). - Reuse the same backend RPC (`get_ai_index_notes`) and Edge Function (`rag-index`) — no new server work. - Provide the same filter chips (All / Indexed / Not indexed / Outdated), search input, and per-note actions (Index / Reindex / Update index / Remove index). +- Provide the same summary-area bulk action on mobile, with identical "loaded notes only" and "respect active search/filter" semantics. - Use `FlatList` with pull-to-refresh and infinite scroll (not `react-window` — React Native). - Follow mobile UI patterns: `StyleSheet.create`, `useTheme()`, `memo()`, `Toast.show()`. @@ -144,6 +156,7 @@ The web AI Index page solved the desktop experience. Mobile users on the React N - [x] Search input filters notes with debounce (300ms, 3-char minimum). - [x] Each card shows title, status badge, status description, and action buttons. - [x] Actions call `rag-index` and show toast on success/error. +- [x] A bulk button appears in the summary area and processes only the currently loaded, search-filtered mobile cards that still need indexing. - [x] Pull-to-refresh and infinite scroll work correctly. - [x] Loading, empty, and error states are handled. - [x] 21 unit tests cover the hook, card, and panel components. diff --git a/docs/ai/testing/feature-ai-index-page.md b/docs/ai/testing/feature-ai-index-page.md index aaca9b876bf..e8c1a7d9f2e 100644 --- a/docs/ai/testing/feature-ai-index-page.md +++ b/docs/ai/testing/feature-ai-index-page.md @@ -43,6 +43,9 @@ description: Test strategy for the Settings AI index page and its dedicated data - [x] Updates row status/buttons optimistically on `All notes` immediately after a successful AI-index mutation - [x] Animates rows out of filtered views when a successful mutation changes them out of the active status bucket - [x] Treats semantic `rag-index` skips such as `outcome: "skipped", reason: "too_short"` as honest failures and restores `not_indexed` +- [x] Exposes a summary-level `Index loaded notes` button only when the current loaded result set contains actionable rows +- [x] Ensures the web bulk action processes only the currently loaded notes from the active filter and active committed search query +- [x] Ensures the mobile bulk action uses the same loaded-only scope and skips already indexed rows ## Integration Tests @@ -77,9 +80,12 @@ description: Test strategy for the Settings AI index page and its dedicated data - `npm run test:unit:core -- --runTestsByPath core/tests/unit/core-rag-indexResult.test.ts` - `npm run test:component -- --spec "cypress/component/features/settings/AIIndexNoteRow.cy.tsx"` - Completed: + - `npm run test:unit:web -- --runTestsByPath ui/web/tests/unit/components/aiIndexTab.test.tsx` (bulk button visibility, loaded-only scope, search-limited scope) + - `cd ui/mobile && npx jest tests/component/aiIndexPanel.test.tsx --no-coverage` (mobile bulk button visibility and loaded-only scope) - `npm run type-check` - `npm run type-check:tests` - `npx eslint ui/web/components/features/settings/AIIndexTab.tsx ui/web/components/features/settings/AIIndexList.tsx ui/web/components/features/settings/AIIndexNoteRow.tsx ui/web/tests/unit/components/aiIndexNoteRow.test.tsx ui/web/tests/unit/components/aiIndexTab.test.tsx` + - `cd ui/mobile && npm run validate` - Browser QA in local Next dev server via Playwright: desktop auth-skip flow, `Settings -> AI Index`, empty-state flow, row open, browser back return, and mobile viewport snapshot review ## Manual Testing @@ -89,6 +95,8 @@ description: Test strategy for the Settings AI index page and its dedicated data - Verify timestamps and status badges are understandable - Verify opening a note from `Settings -> AI Index`, then using browser back and mobile in-note back to return to the same filtered/scrolled AI Index view - Verify the page still feels usable when there are only a few notes and when the current filter/search returns nothing +- Verify the bulk button does not appear for fully indexed loaded results +- Verify search limits the bulk action scope so notes outside the committed query are not indexed ## Performance Testing diff --git a/ui/mobile/components/settings/AIIndexPanel.tsx b/ui/mobile/components/settings/AIIndexPanel.tsx index 8e77bf33f9e..9e37c1b27fa 100644 --- a/ui/mobile/components/settings/AIIndexPanel.tsx +++ b/ui/mobile/components/settings/AIIndexPanel.tsx @@ -10,9 +10,12 @@ import { View, } from 'react-native' import { Search, X } from 'lucide-react-native' +import Toast from 'react-native-toast-message' import { useQueryClient, type InfiniteData } from '@tanstack/react-query' +import { getAIIndexActionPresentation, getAIIndexActionableNotes } from '@core/constants/aiIndex' import { SEARCH_CONFIG } from '@core/constants/search' +import { parseRagIndexResult } from '@core/rag/indexResult' import type { AIIndexFilter, AIIndexMutationResult, AIIndexNoteRow, AIIndexNotesPage } from '@core/types/aiIndex' import { Button } from '@ui/mobile/components/ui/Button' import { AIIndexNoteCard } from '@ui/mobile/components/settings/AIIndexNoteCard' @@ -55,6 +58,14 @@ function getSummaryText(loadedCount: number, totalCount: number) { return `${totalCount} note${totalCount === 1 ? '' : 's'}` } +function getBulkSummaryText(successCount: number, skippedCount: number, errorCount: number) { + return [ + successCount > 0 ? `${successCount} indexed` : null, + skippedCount > 0 ? `${skippedCount} skipped` : null, + errorCount > 0 ? `${errorCount} failed` : null, + ].filter(Boolean).join(' • ') +} + function patchNoteStatus(note: AIIndexNoteRow, result: AIIndexMutationResult): AIIndexNoteRow { if (note.id !== result.noteId) return note return { @@ -71,7 +82,7 @@ function shouldKeepNote(note: AIIndexNoteRow, result: AIIndexMutationResult, fil export function AIIndexPanel() { const { colors } = useTheme() - const { user } = useSupabase() + const { client: supabase, user } = useSupabase() const queryClient = useQueryClient() const styles = useMemo(() => createStyles(colors), [colors]) const listRef = useRef | null>(null) @@ -80,6 +91,7 @@ export function AIIndexPanel() { const [filter, setFilter] = useState('all') const [searchDraft, setSearchDraft] = useState('') const [searchQuery, setSearchQuery] = useState('') + const [bulkIndexProgress, setBulkIndexProgress] = useState<{ completed: number; total: number } | null>(null) const normalizedSearchDraft = searchDraft.trim() const isSearchHintVisible = @@ -113,9 +125,10 @@ export function AIIndexPanel() { const totalCount = queryResult.data?.pages[0]?.totalCount ?? 0 const summaryText = getSummaryText(notes.length, totalCount) const emptyMessage = getEmptyMessage(filter, searchQuery) + const actionableLoadedNotes = useMemo(() => getAIIndexActionableNotes(notes), [notes]) - const handleMutated = useCallback( + const applyMutationResult = useCallback( (result: AIIndexMutationResult) => { queryClient.setQueriesData>( { queryKey: getAIIndexNotesQueryPrefix(user?.id) }, @@ -132,11 +145,18 @@ export function AIIndexPanel() { } }, ) + }, + [filter, queryClient, user?.id], + ) + + const handleMutated = useCallback( + (result: AIIndexMutationResult) => { + applyMutationResult(result) void queryClient.invalidateQueries({ queryKey: getAIIndexNotesQueryPrefix(user?.id), }) }, - [filter, queryClient, user?.id], + [applyMutationResult, queryClient, user?.id], ) const handleLoadMore = useCallback(() => { @@ -159,6 +179,69 @@ export function AIIndexPanel() { setFilter('all') }, []) + const handleBulkIndexLoaded = useCallback(async () => { + if (bulkIndexProgress || actionableLoadedNotes.length === 0) return + + const notesToProcess = actionableLoadedNotes + let successCount = 0 + let skippedCount = 0 + let errorCount = 0 + + setBulkIndexProgress({ completed: 0, total: notesToProcess.length }) + + try { + for (const [index, note] of notesToProcess.entries()) { + const actionPresentation = getAIIndexActionPresentation(note.status) + try { + const { data, error } = await supabase.functions.invoke('rag-index', { + body: { noteId: note.id, action: actionPresentation.action }, + }) + if (error) throw error + + const result = parseRagIndexResult(data) + if (result.outcome === 'indexed') { + successCount += 1 + applyMutationResult({ + noteId: note.id, + previousStatus: note.status, + nextStatus: actionPresentation.successStatus, + }) + } else if (result.outcome === 'skipped') { + skippedCount += 1 + if (result.reason === 'too_short') { + applyMutationResult({ + noteId: note.id, + previousStatus: note.status, + nextStatus: 'not_indexed', + }) + } + } else { + errorCount += 1 + } + } catch { + errorCount += 1 + } finally { + setBulkIndexProgress({ + completed: index + 1, + total: notesToProcess.length, + }) + } + } + + const summary = getBulkSummaryText(successCount, skippedCount, errorCount) + if (successCount > 0 && errorCount === 0) { + Toast.show({ type: 'success', text1: summary || 'Loaded notes indexed' }) + } else if (successCount > 0 || skippedCount > 0 || errorCount > 0) { + Toast.show({ type: 'info', text1: summary || 'Bulk indexing finished' }) + } + } finally { + setBulkIndexProgress(null) + void queryClient.invalidateQueries({ + queryKey: getAIIndexNotesQueryPrefix(user?.id), + }) + } + }, [actionableLoadedNotes, applyMutationResult, bulkIndexProgress, queryClient, supabase.functions, user?.id]) + const renderItem = useCallback( ({ item }: { item: AIIndexNoteRow }) => ( @@ -304,8 +387,19 @@ export function AIIndexPanel() { {summaryText ? ( {summaryText} - {(hasActiveSearch || hasActiveFilter) ? ( + {(actionableLoadedNotes.length > 0 || bulkIndexProgress || hasActiveSearch || hasActiveFilter) ? ( + {(actionableLoadedNotes.length > 0 || bulkIndexProgress) ? ( + + ) : null} {hasActiveSearch ? ( + ) : null + return (
({ + toast: { + error: (...args: unknown[]) => mockToastError(...args), + message: (...args: unknown[]) => mockToastMessage(...args), + success: (...args: unknown[]) => mockToastSuccess(...args), + }, +})) jest.mock("next/navigation", () => ({ useRouter: () => ({ @@ -55,6 +66,9 @@ describe("AIIndexTab", () => { mockPush.mockReset() mockPrefetch.mockReset() mockAIIndexList.mockReset() + mockToastError.mockReset() + mockToastMessage.mockReset() + mockToastSuccess.mockReset() jest.mocked(consumeAIIndexViewState).mockReturnValue(null) jest.mocked(clearActiveSettingsNoteReturnPath).mockReset() jest.mocked(saveAIIndexViewState).mockReset() @@ -558,4 +572,155 @@ describe("AIIndexTab", () => { expect(screen.getByText("Showing 1 note")).toBeTruthy() }) + + it("shows the bulk button only when the loaded list contains actionable notes", () => { + jest.spyOn(aiIndexHooks, "useFlattenedAIIndexNotes").mockReturnValue([ + { + id: "note-indexed", + title: "Indexed note", + updatedAt: "2026-03-29T10:00:00Z", + lastIndexedAt: "2026-03-29T10:00:00Z", + status: "indexed", + }, + ]) + jest.spyOn(aiIndexHooks, "useAIIndexNotes").mockReturnValue({ + ...mockQuery, + data: { pages: [{ totalCount: 1, notes: [], hasMore: false }] }, + } as never) + + const view = render( + + + + ) + + expect(screen.queryByRole("button", { name: "Index loaded notes" })).toBeNull() + + jest.spyOn(aiIndexHooks, "useFlattenedAIIndexNotes").mockReturnValue([ + { + id: "note-not-indexed", + title: "Need index", + updatedAt: "2026-03-29T11:00:00Z", + lastIndexedAt: null, + status: "not_indexed", + }, + ]) + + view.rerender( + + + + ) + + expect(screen.getByRole("button", { name: "Index loaded notes" })).toBeTruthy() + }) + + it("bulk-indexes only loaded actionable notes and skips indexed rows", async () => { + const invoke = jest.fn() + .mockResolvedValueOnce({ data: { outcome: "indexed", chunkCount: 1 }, error: null }) + .mockResolvedValueOnce({ data: { outcome: "indexed", chunkCount: 2 }, error: null }) + + jest.spyOn(aiIndexHooks, "useFlattenedAIIndexNotes").mockReturnValue([ + { + id: "note-indexed", + title: "Indexed note", + updatedAt: "2026-03-29T10:00:00Z", + lastIndexedAt: "2026-03-29T10:00:00Z", + status: "indexed", + }, + { + id: "note-not-indexed", + title: "Need index", + updatedAt: "2026-03-29T11:00:00Z", + lastIndexedAt: null, + status: "not_indexed", + }, + { + id: "note-outdated", + title: "Need reindex", + updatedAt: "2026-03-29T12:00:00Z", + lastIndexedAt: "2026-03-29T11:30:00Z", + status: "outdated", + }, + ]) + jest.spyOn(aiIndexHooks, "useAIIndexNotes").mockReturnValue({ + ...mockQuery, + data: { pages: [{ totalCount: 3, notes: [], hasMore: false }] }, + } as never) + + render( + + + + ) + + fireEvent.click(screen.getByRole("button", { name: "Index loaded notes" })) + + await waitFor(() => { + expect(invoke).toHaveBeenCalledTimes(2) + }) + + expect(invoke).toHaveBeenNthCalledWith(1, "rag-index", { + body: { noteId: "note-not-indexed", action: "index" }, + }) + expect(invoke).toHaveBeenNthCalledWith(2, "rag-index", { + body: { noteId: "note-outdated", action: "reindex" }, + }) + }) + + it("keeps the bulk action scoped to the committed search results", async () => { + const invoke = jest.fn().mockResolvedValue({ data: { outcome: "indexed", chunkCount: 1 }, error: null }) + + jest.spyOn(aiIndexHooks, "useFlattenedAIIndexNotes").mockReturnValue([ + { + id: "note-search-hit", + title: "Matched note", + updatedAt: "2026-03-29T11:00:00Z", + lastIndexedAt: null, + status: "not_indexed", + }, + ]) + jest.spyOn(aiIndexHooks, "useAIIndexNotes").mockReturnValue({ + ...mockQuery, + data: { pages: [{ totalCount: 1, notes: [], hasMore: false }] }, + } as never) + + render( + + + + ) + + fireEvent.change(screen.getByLabelText("Search AI index notes"), { target: { value: "match" } }) + + act(() => { + jest.advanceTimersByTime(300) + }) + + await waitFor(() => { + expect(aiIndexHooks.useAIIndexNotes).toHaveBeenLastCalledWith("all", "match") + }) + + fireEvent.click(screen.getByRole("button", { name: "Index loaded notes" })) + + await waitFor(() => { + expect(invoke).toHaveBeenCalledTimes(1) + }) + + expect(invoke).toHaveBeenCalledWith("rag-index", { + body: { noteId: "note-search-hit", action: "index" }, + }) + }) }) From b40e099ec69af1a5a95589cbb3a1ddfaca14f543 Mon Sep 17 00:00:00 2001 From: Denys Date: Sun, 5 Apr 2026 20:16:38 +0200 Subject: [PATCH 02/14] Refine AI index bulk action review fixes --- .../components/settings/AIIndexPanel.tsx | 535 ++++++++++++------ .../tests/component/aiIndexPanel.test.tsx | 1 + .../features/settings/AIIndexTab.tsx | 151 +++-- 3 files changed, 452 insertions(+), 235 deletions(-) diff --git a/ui/mobile/components/settings/AIIndexPanel.tsx b/ui/mobile/components/settings/AIIndexPanel.tsx index 9e37c1b27fa..67798783799 100644 --- a/ui/mobile/components/settings/AIIndexPanel.tsx +++ b/ui/mobile/components/settings/AIIndexPanel.tsx @@ -1,3 +1,4 @@ +import type { MutableRefObject, ReactElement } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ActivityIndicator, @@ -20,6 +21,7 @@ import type { AIIndexFilter, AIIndexMutationResult, AIIndexNoteRow, AIIndexNotes import { Button } from '@ui/mobile/components/ui/Button' import { AIIndexNoteCard } from '@ui/mobile/components/settings/AIIndexNoteCard' import { + getAIIndexNotesQueryKey, getAIIndexNotesQueryPrefix, useAIIndexNotes, useFlattenedAIIndexNotes, @@ -31,6 +33,18 @@ type FilterOption = Readonly<{ label: string }> +type BulkIndexProgress = { + completed: number + total: number +} + +type BulkIndexOutcome = 'indexed' | 'skipped' | 'failed' + +type BulkIndexInvoke = ( + name: string, + options: { body: { noteId: string; action: 'index' | 'reindex' } } +) => Promise<{ data: unknown; error: unknown }> + const FILTER_OPTIONS: readonly FilterOption[] = [ { value: 'all', label: 'All notes' }, { value: 'indexed', label: 'Indexed' }, @@ -47,6 +61,12 @@ const FILTER_EMPTY_MESSAGES: Record = { const DEBOUNCE_MS = 300 +function runAsyncTask(task: Promise | unknown) { + Promise.resolve(task).catch(() => { + // Best-effort background work should not break the settings UI. + }) +} + function getEmptyMessage(filter: AIIndexFilter, searchQuery: string) { if (searchQuery.length > 0) return `No notes match "${searchQuery}".` return FILTER_EMPTY_MESSAGES[filter] @@ -80,6 +100,244 @@ function shouldKeepNote(note: AIIndexNoteRow, result: AIIndexMutationResult, fil return note.id !== result.noteId || note.status === filter } +function incrementBulkCounts( + counts: Readonly<{ successCount: number; skippedCount: number; errorCount: number }>, + outcome: BulkIndexOutcome, +) { + if (outcome === 'indexed') { + return { ...counts, successCount: counts.successCount + 1 } + } + if (outcome === 'skipped') { + return { ...counts, skippedCount: counts.skippedCount + 1 } + } + return { ...counts, errorCount: counts.errorCount + 1 } +} + +function showBulkIndexToast(successCount: number, skippedCount: number, errorCount: number) { + const summary = getBulkSummaryText(successCount, skippedCount, errorCount) + + if (successCount > 0 && errorCount === 0) { + Toast.show({ type: 'success', text1: summary || 'Loaded notes indexed' }) + return + } + + if (successCount > 0 || skippedCount > 0 || errorCount > 0) { + Toast.show({ type: 'info', text1: summary || 'Bulk indexing finished' }) + } +} + +function updateBulkProgress( + index: number, + total: number, + setBulkIndexProgress: (progress: BulkIndexProgress) => void, +) { + setBulkIndexProgress({ + completed: index + 1, + total, + }) +} + +async function processBulkIndexNote({ + applyMutationResult, + invoke, + note, +}: Readonly<{ + applyMutationResult: (result: AIIndexMutationResult) => void + invoke: BulkIndexInvoke + note: AIIndexNoteRow +}>): Promise { + const actionPresentation = getAIIndexActionPresentation(note.status) + + try { + const { data, error } = await invoke('rag-index', { + body: { noteId: note.id, action: actionPresentation.action }, + }) + if (error) throw error + + const result = parseRagIndexResult(data) + if (result.outcome === 'indexed') { + applyMutationResult({ + noteId: note.id, + previousStatus: note.status, + nextStatus: actionPresentation.successStatus, + }) + return 'indexed' + } + + if (result.outcome === 'skipped') { + if (result.reason === 'too_short') { + applyMutationResult({ + noteId: note.id, + previousStatus: note.status, + nextStatus: 'not_indexed', + }) + } + return 'skipped' + } + + return 'failed' + } catch { + return 'failed' + } +} + +function AIIndexSummary({ + actionableLoadedCount, + bulkIndexProgress, + hasActiveFilter, + hasActiveSearch, + onBulkIndexPress, + onClearSearch, + onResetFilter, + styles, + summaryText, +}: Readonly<{ + actionableLoadedCount: number + bulkIndexProgress: BulkIndexProgress | null + hasActiveFilter: boolean + hasActiveSearch: boolean + onBulkIndexPress: () => void + onClearSearch: () => void + onResetFilter: () => void + styles: ReturnType + summaryText: string | null +}>) { + if (!summaryText) return null + + const showBulkButton = actionableLoadedCount > 0 || bulkIndexProgress !== null + const showSummaryActions = showBulkButton || hasActiveSearch || hasActiveFilter + + return ( + + {summaryText} + {showSummaryActions ? ( + + {showBulkButton ? ( + + ) : null} + {hasActiveSearch ? ( + + ) : null} + {hasActiveFilter ? ( + + ) : null} + + ) : null} + + ) +} + +function AIIndexContent({ + colors, + emptyMessage, + errorMessage, + hasActiveFilter, + hasActiveSearch, + keyExtractor, + listRef, + notes, + onClearSearch, + onLoadMore, + onRefresh, + onResetFilter, + queryResult, + renderItem, + styles, +}: Readonly<{ + colors: ReturnType['colors'] + emptyMessage: string + errorMessage: string + hasActiveFilter: boolean + hasActiveSearch: boolean + keyExtractor: (item: AIIndexNoteRow) => string + listRef: MutableRefObject | null> + notes: AIIndexNoteRow[] + onClearSearch: () => void + onLoadMore: () => void + onRefresh: () => void + onResetFilter: () => void + queryResult: ReturnType + renderItem: ({ item }: { item: AIIndexNoteRow }) => ReactElement + styles: ReturnType +}>) { + if (queryResult.isLoading) { + return ( + + + Loading AI index notes... + + ) + } + + if (queryResult.isError) { + return ( + + AI Index is unavailable + {errorMessage} + + + ) + } + + if (notes.length === 0) { + return ( + + {emptyMessage} + {(hasActiveSearch || hasActiveFilter) ? ( + + {hasActiveSearch ? ( + + ) : null} + {hasActiveFilter ? ( + + ) : null} + + ) : null} + + ) + } + + return ( + + ) : null + } + /> + ) +} + export function AIIndexPanel() { const { colors } = useTheme() const { client: supabase, user } = useSupabase() @@ -91,7 +349,7 @@ export function AIIndexPanel() { const [filter, setFilter] = useState('all') const [searchDraft, setSearchDraft] = useState('') const [searchQuery, setSearchQuery] = useState('') - const [bulkIndexProgress, setBulkIndexProgress] = useState<{ completed: number; total: number } | null>(null) + const [bulkIndexProgress, setBulkIndexProgress] = useState(null) const normalizedSearchDraft = searchDraft.trim() const isSearchHintVisible = @@ -122,51 +380,65 @@ export function AIIndexPanel() { const queryResult = useAIIndexNotes(filter, searchQuery) const notes = useFlattenedAIIndexNotes(queryResult) + const activeQueryKey = useMemo( + () => getAIIndexNotesQueryKey(user?.id, filter, searchQuery), + [filter, searchQuery, user?.id], + ) const totalCount = queryResult.data?.pages[0]?.totalCount ?? 0 const summaryText = getSummaryText(notes.length, totalCount) const emptyMessage = getEmptyMessage(filter, searchQuery) const actionableLoadedNotes = useMemo(() => getAIIndexActionableNotes(notes), [notes]) + const applyMutationResultToQuery = useCallback(( + queryKey: readonly unknown[], + result: AIIndexMutationResult, + ) => { + queryClient.setQueryData>(queryKey, (old) => { + if (!old) return old + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + notes: page.notes + .map((note) => patchNoteStatus(note, result)) + .filter((note) => shouldKeepNote(note, result, filter)), + })), + } + }) + }, [filter, queryClient]) + + const applyMutationResult = useCallback((result: AIIndexMutationResult) => { + queryClient.setQueriesData>( + { queryKey: getAIIndexNotesQueryPrefix(user?.id) }, + (old) => { + if (!old) return old + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + notes: page.notes + .map((note) => patchNoteStatus(note, result)) + .filter((note) => shouldKeepNote(note, result, filter)), + })), + } + }, + ) + }, [filter, queryClient, user?.id]) - const applyMutationResult = useCallback( - (result: AIIndexMutationResult) => { - queryClient.setQueriesData>( - { queryKey: getAIIndexNotesQueryPrefix(user?.id) }, - (old) => { - if (!old) return old - return { - ...old, - pages: old.pages.map((page) => ({ - ...page, - notes: page.notes - .map((note) => patchNoteStatus(note, result)) - .filter((note) => shouldKeepNote(note, result, filter)), - })), - } - }, - ) - }, - [filter, queryClient, user?.id], - ) - - const handleMutated = useCallback( - (result: AIIndexMutationResult) => { - applyMutationResult(result) - void queryClient.invalidateQueries({ - queryKey: getAIIndexNotesQueryPrefix(user?.id), - }) - }, - [applyMutationResult, queryClient, user?.id], - ) + const handleMutated = useCallback((result: AIIndexMutationResult) => { + applyMutationResult(result) + runAsyncTask(queryClient.invalidateQueries({ + queryKey: getAIIndexNotesQueryPrefix(user?.id), + })) + }, [applyMutationResult, queryClient, user?.id]) const handleLoadMore = useCallback(() => { - if (queryResult.hasNextPage && !queryResult.isFetchingNextPage) { - void queryResult.fetchNextPage() - } + if (!queryResult.hasNextPage || queryResult.isFetchingNextPage) return + runAsyncTask(queryResult.fetchNextPage()) }, [queryResult]) const handleRefresh = useCallback(() => { - void queryResult.refetch() + runAsyncTask(queryResult.refetch()) }, [queryResult]) const handleClearSearch = useCallback(() => { @@ -182,65 +454,32 @@ export function AIIndexPanel() { const handleBulkIndexLoaded = useCallback(async () => { if (bulkIndexProgress || actionableLoadedNotes.length === 0) return - const notesToProcess = actionableLoadedNotes - let successCount = 0 - let skippedCount = 0 - let errorCount = 0 - - setBulkIndexProgress({ completed: 0, total: notesToProcess.length }) + let counts = { successCount: 0, skippedCount: 0, errorCount: 0 } + setBulkIndexProgress({ completed: 0, total: actionableLoadedNotes.length }) try { - for (const [index, note] of notesToProcess.entries()) { - const actionPresentation = getAIIndexActionPresentation(note.status) - try { - const { data, error } = await supabase.functions.invoke('rag-index', { - body: { noteId: note.id, action: actionPresentation.action }, - }) - if (error) throw error - - const result = parseRagIndexResult(data) - if (result.outcome === 'indexed') { - successCount += 1 - applyMutationResult({ - noteId: note.id, - previousStatus: note.status, - nextStatus: actionPresentation.successStatus, - }) - } else if (result.outcome === 'skipped') { - skippedCount += 1 - if (result.reason === 'too_short') { - applyMutationResult({ - noteId: note.id, - previousStatus: note.status, - nextStatus: 'not_indexed', - }) - } - } else { - errorCount += 1 - } - } catch { - errorCount += 1 - } finally { - setBulkIndexProgress({ - completed: index + 1, - total: notesToProcess.length, - }) - } + for (const [index, note] of actionableLoadedNotes.entries()) { + const outcome = await processBulkIndexNote({ + applyMutationResult: (result) => applyMutationResultToQuery(activeQueryKey, result), + invoke: supabase.functions.invoke as BulkIndexInvoke, + note, + }) + counts = incrementBulkCounts(counts, outcome) + updateBulkProgress(index, actionableLoadedNotes.length, setBulkIndexProgress) } - const summary = getBulkSummaryText(successCount, skippedCount, errorCount) - if (successCount > 0 && errorCount === 0) { - Toast.show({ type: 'success', text1: summary || 'Loaded notes indexed' }) - } else if (successCount > 0 || skippedCount > 0 || errorCount > 0) { - Toast.show({ type: 'info', text1: summary || 'Bulk indexing finished' }) - } + showBulkIndexToast(counts.successCount, counts.skippedCount, counts.errorCount) } finally { setBulkIndexProgress(null) - void queryClient.invalidateQueries({ + await queryClient.invalidateQueries({ queryKey: getAIIndexNotesQueryPrefix(user?.id), }) } - }, [actionableLoadedNotes, applyMutationResult, bulkIndexProgress, queryClient, supabase.functions, user?.id]) + }, [actionableLoadedNotes, activeQueryKey, applyMutationResultToQuery, bulkIndexProgress, queryClient, supabase.functions.invoke, user?.id]) + + const handleBulkIndexPress = useCallback(() => { + runAsyncTask(handleBulkIndexLoaded()) + }, [handleBulkIndexLoaded]) const renderItem = useCallback( ({ item }: { item: AIIndexNoteRow }) => ( @@ -257,73 +496,6 @@ export function AIIndexPanel() { ? queryResult.error.message : 'Failed to load AI index notes.' - const renderContent = () => { - if (queryResult.isLoading) { - return ( - - - Loading AI index notes... - - ) - } - - if (queryResult.isError) { - return ( - - AI Index is unavailable - {errorMessage} - - - ) - } - - if (notes.length === 0) { - return ( - - {emptyMessage} - {(hasActiveSearch || hasActiveFilter) ? ( - - {hasActiveSearch ? ( - - ) : null} - {hasActiveFilter ? ( - - ) : null} - - ) : null} - - ) - } - - return ( - - ) : null - } - /> - ) - } - return ( @@ -384,39 +556,36 @@ export function AIIndexPanel() { ) : null} - {summaryText ? ( - - {summaryText} - {(actionableLoadedNotes.length > 0 || bulkIndexProgress || hasActiveSearch || hasActiveFilter) ? ( - - {(actionableLoadedNotes.length > 0 || bulkIndexProgress) ? ( - - ) : null} - {hasActiveSearch ? ( - - ) : null} - {hasActiveFilter ? ( - - ) : null} - - ) : null} - - ) : null} + - {renderContent()} + ) } diff --git a/ui/mobile/tests/component/aiIndexPanel.test.tsx b/ui/mobile/tests/component/aiIndexPanel.test.tsx index 6ca078e0900..1403c331292 100644 --- a/ui/mobile/tests/component/aiIndexPanel.test.tsx +++ b/ui/mobile/tests/component/aiIndexPanel.test.tsx @@ -49,6 +49,7 @@ jest.mock('@tanstack/react-query', () => { jest.mock('@ui/mobile/hooks/useAIIndexNotes', () => ({ useAIIndexNotes: (...args: unknown[]) => mockUseAIIndexNotes(...args), useFlattenedAIIndexNotes: (...args: unknown[]) => mockUseFlattenedAIIndexNotes(...args), + getAIIndexNotesQueryKey: jest.fn((userId: string | undefined, filter: string, searchQuery = '') => ['ai-index-notes', userId ?? null, filter, searchQuery.trim()]), getAIIndexNotesQueryPrefix: jest.fn((userId: string | undefined) => ['ai-index-notes', userId ?? null]), })) diff --git a/ui/web/components/features/settings/AIIndexTab.tsx b/ui/web/components/features/settings/AIIndexTab.tsx index 9e549f79399..54a308f25dc 100644 --- a/ui/web/components/features/settings/AIIndexTab.tsx +++ b/ui/web/components/features/settings/AIIndexTab.tsx @@ -73,12 +73,38 @@ type OptimisticMutationState = AIIndexMutationResult & { sourceIndex: number } +type BulkIndexOutcome = "indexed" | "skipped" | "failed" + +type BulkIndexCounters = { + successCount: number + skippedCount: number + errorCount: number +} + +type BulkIndexInvoke = ( + name: string, + options: { body: { noteId: string; action: "index" | "reindex" } } +) => Promise<{ data: unknown; error: unknown }> + function runBackgroundTask(task: Promise) { task.catch(() => { // Best-effort background work should not break the settings UI. }) } +function incrementBulkIndexCounters( + counters: BulkIndexCounters, + outcome: BulkIndexOutcome +): BulkIndexCounters { + if (outcome === "indexed") { + return { ...counters, successCount: counters.successCount + 1 } + } + if (outcome === "skipped") { + return { ...counters, skippedCount: counters.skippedCount + 1 } + } + return { ...counters, errorCount: counters.errorCount + 1 } +} + function formatBulkIndexSummary(successCount: number, skippedCount: number, errorCount: number) { const parts = [ successCount > 0 ? `${successCount} indexed` : null, @@ -89,6 +115,53 @@ function formatBulkIndexSummary(successCount: number, skippedCount: number, erro return parts.join(" • ") } +async function processBulkIndexNote({ + applyMutationResult, + invoke, + note, +}: Readonly<{ + applyMutationResult: (mutationResult: AIIndexMutationResult, options?: { invalidate?: boolean }) => void + invoke: BulkIndexInvoke + note: AIIndexNoteRowData +}>): Promise { + const actionPresentation = getAIIndexActionPresentation(note.status) + + try { + const { data, error } = await invoke("rag-index", { + body: { + noteId: note.id, + action: actionPresentation.action, + }, + }) + if (error) throw error + + const result = parseRagIndexResult(data) + if (result.outcome === "indexed") { + applyMutationResult({ + noteId: note.id, + previousStatus: note.status, + nextStatus: actionPresentation.successStatus, + }, { invalidate: false }) + return "indexed" + } + + if (result.outcome === "skipped") { + if (result.reason === "too_short") { + applyMutationResult({ + noteId: note.id, + previousStatus: note.status, + nextStatus: "not_indexed", + }, { invalidate: false }) + } + return "skipped" + } + + return "failed" + } catch { + return "failed" + } +} + function matchesAIIndexFilter(filter: AIIndexFilter, status: AIIndexNoteRowData["status"]) { return filter === "all" || filter === status } @@ -665,59 +738,32 @@ export function AIIndexTab() { const handleBulkIndexLoaded = React.useCallback(async () => { if (bulkIndexProgress || actionableLoadedNotes.length === 0) return - const notesToProcess = actionableLoadedNotes - let successCount = 0 - let skippedCount = 0 - let errorCount = 0 + let counters: BulkIndexCounters = { + successCount: 0, + skippedCount: 0, + errorCount: 0, + } - setBulkIndexProgress({ completed: 0, total: notesToProcess.length }) + setBulkIndexProgress({ completed: 0, total: actionableLoadedNotes.length }) try { - for (const [index, note] of notesToProcess.entries()) { - const actionPresentation = getAIIndexActionPresentation(note.status) - try { - const { data, error } = await supabase.functions.invoke("rag-index", { - body: { - noteId: note.id, - action: actionPresentation.action, - }, - }) - if (error) throw error - - const result = parseRagIndexResult(data) - if (result.outcome === "indexed") { - successCount += 1 - applyMutationResult({ - noteId: note.id, - previousStatus: note.status, - nextStatus: actionPresentation.successStatus, - }, { invalidate: false }) - } else if (result.outcome === "skipped") { - skippedCount += 1 - if (result.reason === "too_short") { - applyMutationResult({ - noteId: note.id, - previousStatus: note.status, - nextStatus: "not_indexed", - }, { invalidate: false }) - } - } else { - errorCount += 1 - } - } catch { - errorCount += 1 - } finally { - setBulkIndexProgress({ - completed: index + 1, - total: notesToProcess.length, - }) - } + for (const [index, note] of actionableLoadedNotes.entries()) { + const outcome = await processBulkIndexNote({ + applyMutationResult, + invoke: supabase.functions.invoke as BulkIndexInvoke, + note, + }) + counters = incrementBulkIndexCounters(counters, outcome) + setBulkIndexProgress({ + completed: index + 1, + total: actionableLoadedNotes.length, + }) } - const summary = formatBulkIndexSummary(successCount, skippedCount, errorCount) - if (successCount > 0 && errorCount === 0) { + const summary = formatBulkIndexSummary(counters.successCount, counters.skippedCount, counters.errorCount) + if (counters.successCount > 0 && counters.errorCount === 0) { toast.success(summary || "Loaded notes indexed") - } else if (successCount > 0 || skippedCount > 0 || errorCount > 0) { + } else if (counters.successCount > 0 || counters.skippedCount > 0 || counters.errorCount > 0) { toast.message(summary || "Bulk indexing finished") } } finally { @@ -725,6 +771,11 @@ export function AIIndexTab() { runBackgroundTask(queryClient.invalidateQueries({ queryKey: getAIIndexNotesQueryPrefix(user?.id) })) } }, [actionableLoadedNotes, applyMutationResult, bulkIndexProgress, queryClient, supabase.functions, user?.id]) + const handleBulkIndexClick = React.useCallback(() => { + handleBulkIndexLoaded().catch(() => { + toast.error("Bulk indexing failed") + }) + }, [handleBulkIndexLoaded]) const emptyState = ( 0 || bulkIndexProgress ? ( - ) : null} {hasActiveSearch ? ( ) })} + {bulkAction}
@@ -433,7 +436,6 @@ function AIIndexToolbar({ function AIIndexResultsHeader({ activeSearchQuery, - bulkAction, filter, hasActiveFilter, hasActiveSearch, @@ -444,7 +446,6 @@ function AIIndexResultsHeader({ summaryText, }: Readonly<{ activeSearchQuery: string - bulkAction?: React.ReactNode filter: AIIndexFilter hasActiveFilter: boolean hasActiveSearch: boolean @@ -480,7 +481,6 @@ function AIIndexResultsHeader({
- {bulkAction} ((name, options) => { + return supabase.functions.invoke(name, options) + }, [supabase.functions]) const handleBulkIndexLoaded = React.useCallback(async () => { if (bulkIndexProgress || actionableLoadedNotes.length === 0) return @@ -750,7 +753,7 @@ export function AIIndexTab() { for (const [index, note] of actionableLoadedNotes.entries()) { const outcome = await processBulkIndexNote({ applyMutationResult, - invoke: supabase.functions.invoke as BulkIndexInvoke, + invoke: invokeBulkIndex, note, }) counters = incrementBulkIndexCounters(counters, outcome) @@ -770,7 +773,7 @@ export function AIIndexTab() { setBulkIndexProgress(null) runBackgroundTask(queryClient.invalidateQueries({ queryKey: getAIIndexNotesQueryPrefix(user?.id) })) } - }, [actionableLoadedNotes, applyMutationResult, bulkIndexProgress, queryClient, supabase.functions, user?.id]) + }, [actionableLoadedNotes, applyMutationResult, bulkIndexProgress, invokeBulkIndex, queryClient, user?.id]) const handleBulkIndexClick = React.useCallback(() => { handleBulkIndexLoaded().catch(() => { toast.error("Bulk indexing failed") @@ -796,26 +799,37 @@ export function AIIndexTab() { } const bulkAction = actionableLoadedNotes.length > 0 || bulkIndexProgress ? ( - + > + + {bulkIndexProgress ? ( + + ) : ( + + )} + + {bulkIndexProgress + ? `${Math.min(bulkIndexProgress.total, bulkIndexProgress.completed + 1)}/${bulkIndexProgress.total}` + : "Index loaded"} + + + ) : null return (
{ }) }) + it("keeps the supabase function context during bulk indexing", async () => { + const note = { + id: "note-not-indexed", + title: "Need index", + updatedAt: "2026-03-29T11:00:00Z", + lastIndexedAt: null, + status: "not_indexed" as const, + } + const functions = { + calls: [] as Array<[string, { body: { noteId: string; action: string } }]>, + invoke(this: typeof functions, name: string, options: { body: { noteId: string; action: string } }) { + if (this !== functions) { + return Promise.reject(new Error("lost functions context")) + } + + this.calls.push([name, options]) + return Promise.resolve({ data: { outcome: "indexed", chunkCount: 1 }, error: null }) + }, + } + + jest.spyOn(aiIndexHooks, "useFlattenedAIIndexNotes").mockReturnValue([note]) + jest.spyOn(aiIndexHooks, "useAIIndexNotes").mockReturnValue({ + ...mockQuery, + data: { pages: [{ totalCount: 1, notes: [], hasMore: false }] }, + } as never) + + render( + + + + ) + + fireEvent.click(screen.getByRole("button", { name: "Index loaded notes" })) + + await waitFor(() => { + expect(functions.calls).toHaveLength(1) + }) + + expect(functions.calls[0]).toEqual([ + "rag-index", + { body: { noteId: "note-not-indexed", action: "index" } }, + ]) + expect(mockToastError).not.toHaveBeenCalledWith("Bulk indexing failed") + }) + it("keeps the bulk action scoped to the committed search results", async () => { const invoke = jest.fn().mockResolvedValue({ data: { outcome: "indexed", chunkCount: 1 }, error: null }) From 552078d175ec0d7523040406e6d7fbd60748b1ee Mon Sep 17 00:00:00 2001 From: Denys Date: Sun, 5 Apr 2026 21:41:46 +0200 Subject: [PATCH 04/14] Show bulk indexing progress in AI index actions --- .../components/settings/AIIndexPanel.tsx | 9 +++- .../tests/component/aiIndexPanel.test.tsx | 27 +++++++++++ .../features/settings/AIIndexTab.tsx | 1 + .../tests/unit/components/aiIndexTab.test.tsx | 45 +++++++++++++++++++ 4 files changed, 81 insertions(+), 1 deletion(-) diff --git a/ui/mobile/components/settings/AIIndexPanel.tsx b/ui/mobile/components/settings/AIIndexPanel.tsx index 8872284ad4c..49ce672239f 100644 --- a/ui/mobile/components/settings/AIIndexPanel.tsx +++ b/ui/mobile/components/settings/AIIndexPanel.tsx @@ -530,7 +530,11 @@ export function AIIndexPanel() { ]} > - + {bulkIndexProgress ? ( + + ) : ( + + )} {bulkActionLabel} @@ -636,6 +640,9 @@ const createStyles = (colors: ReturnType['colors']) => alignItems: 'center', gap: 4, }, + actionChipSpinner: { + transform: [{ scale: 0.7 }], + }, actionChipLabel: { color: colors.primary, fontFamily: 'Inter_500Medium', diff --git a/ui/mobile/tests/component/aiIndexPanel.test.tsx b/ui/mobile/tests/component/aiIndexPanel.test.tsx index 0e301f3fd52..0db972aa833 100644 --- a/ui/mobile/tests/component/aiIndexPanel.test.tsx +++ b/ui/mobile/tests/component/aiIndexPanel.test.tsx @@ -248,4 +248,31 @@ describe('AIIndexPanel', () => { body: { noteId: 'n3', action: 'reindex' }, }) }) + + it('shows an active loading state on the bulk action while indexing runs', async () => { + let resolveInvoke: ((value: { data: { outcome: string; chunkCount: number }; error: null }) => void) | null = null + setupMocks({}, [ + { id: 'n2', title: 'Need Index', updatedAt: '2025-06-01', lastIndexedAt: null, status: 'not_indexed' }, + ]) + mockInvoke.mockImplementation(() => new Promise((resolve) => { + resolveInvoke = resolve + })) + + render() + + fireEvent.press(screen.getByLabelText('Index loaded notes')) + + await waitFor(() => { + expect(screen.getByLabelText('Indexing loaded notes')).toBeTruthy() + expect(screen.getByText('1/1')).toBeTruthy() + }) + + await act(async () => { + resolveInvoke?.({ data: { outcome: 'indexed', chunkCount: 1 }, error: null }) + }) + + await waitFor(() => { + expect(screen.getByLabelText('Index loaded notes')).toBeTruthy() + }) + }) }) diff --git a/ui/web/components/features/settings/AIIndexTab.tsx b/ui/web/components/features/settings/AIIndexTab.tsx index 6fe2d8556fa..f538e241d80 100644 --- a/ui/web/components/features/settings/AIIndexTab.tsx +++ b/ui/web/components/features/settings/AIIndexTab.tsx @@ -802,6 +802,7 @@ export function AIIndexTab() { ) : null} @@ -498,6 +501,29 @@ export function AIIndexPanel() { const bulkActionLabel = bulkIndexProgress ? `${bulkIndexProgress.completed}/${bulkIndexProgress.total}` : 'Index loaded' + const bulkAction = showBulkAction ? ( + [ + styles.actionChip, + bulkIndexProgress !== null && styles.actionChipDisabled, + pressed && bulkIndexProgress === null && styles.actionChipPressed, + ]} + > + + {bulkIndexProgress ? ( + + ) : ( + + )} + {bulkActionLabel} + + + ) : null return ( @@ -529,29 +555,6 @@ export function AIIndexPanel() { ) })} - {showBulkAction ? ( - [ - styles.actionChip, - bulkIndexProgress !== null && styles.actionChipDisabled, - pressed && bulkIndexProgress === null && styles.actionChipPressed, - ]} - > - - {bulkIndexProgress ? ( - - ) : ( - - )} - {bulkActionLabel} - - - ) : null} @@ -582,6 +585,7 @@ export function AIIndexPanel() { ) : null} ['colors']) => color: colors.mutedForeground, }, summaryRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: 10, - flexWrap: 'wrap', + gap: 8, }, summaryText: { fontSize: 12, @@ -715,6 +715,7 @@ const createStyles = (colors: ReturnType['colors']) => flexDirection: 'row', gap: 6, flexWrap: 'wrap', + alignItems: 'center', }, list: { flex: 1, diff --git a/ui/web/components/features/settings/AIIndexTab.tsx b/ui/web/components/features/settings/AIIndexTab.tsx index 25017b51326..88479f9f03b 100644 --- a/ui/web/components/features/settings/AIIndexTab.tsx +++ b/ui/web/components/features/settings/AIIndexTab.tsx @@ -345,7 +345,6 @@ function AIIndexEmptyState({ } function AIIndexToolbar({ - bulkAction, filter, filterOptions, isSearchHintVisible, @@ -355,7 +354,6 @@ function AIIndexToolbar({ onSearchKeyDown, searchDraft, }: Readonly<{ - bulkAction?: React.ReactNode filter: AIIndexFilter filterOptions: Array<{ value: AIIndexFilter; label: string }> isSearchHintVisible: boolean @@ -395,7 +393,6 @@ function AIIndexToolbar({ ) })} - {bulkAction}
@@ -436,6 +433,7 @@ function AIIndexToolbar({ function AIIndexResultsHeader({ activeSearchQuery, + bulkAction, filter, hasActiveFilter, hasActiveSearch, @@ -446,6 +444,7 @@ function AIIndexResultsHeader({ summaryText, }: Readonly<{ activeSearchQuery: string + bulkAction?: React.ReactNode filter: AIIndexFilter hasActiveFilter: boolean hasActiveSearch: boolean @@ -455,8 +454,10 @@ function AIIndexResultsHeader({ onResetFilter: () => void summaryText: string }>) { + const hasDetailRow = Boolean(bulkAction) || hasActiveFilter || hasActiveSearch || isFetching || isFetchingNextPage + return ( -
+

{summaryText}

@@ -480,7 +481,8 @@ function AIIndexResultsHeader({ ) : null}
-
+
+ {bulkAction} Date: Sun, 5 Apr 2026 22:18:27 +0200 Subject: [PATCH 07/14] Refine AI index summary actions layout --- .../components/settings/AIIndexPanel.tsx | 53 ++++++++++++++++++- .../features/settings/AIIndexTab.tsx | 16 +++--- 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/ui/mobile/components/settings/AIIndexPanel.tsx b/ui/mobile/components/settings/AIIndexPanel.tsx index 86861e73f47..df4f80dbf6f 100644 --- a/ui/mobile/components/settings/AIIndexPanel.tsx +++ b/ui/mobile/components/settings/AIIndexPanel.tsx @@ -193,16 +193,22 @@ async function processBulkIndexNote({ function AIIndexSummary({ bulkAction, + filterLabel, hasActiveFilter, hasActiveSearch, + isFetching, + isFetchingNextPage, onClearSearch, onResetFilter, styles, summaryText, }: Readonly<{ bulkAction?: ReactElement | null + filterLabel: string hasActiveFilter: boolean hasActiveSearch: boolean + isFetching: boolean + isFetchingNextPage: boolean onClearSearch: () => void onResetFilter: () => void styles: ReturnType @@ -215,6 +221,22 @@ function AIIndexSummary({ return ( {summaryText} + + + {filterLabel} + + {hasActiveSearch ? ( + + Search active + + ) : null} + {isFetching && !isFetchingNextPage ? ( + Refreshing... + ) : null} + {isFetchingNextPage ? ( + Loading more + ) : null} + {showSummaryActions ? ( {bulkAction} @@ -586,8 +608,11 @@ export function AIIndexPanel() { option.value === filter)?.label ?? 'All notes'} hasActiveFilter={hasActiveFilter} hasActiveSearch={hasActiveSearch} + isFetching={queryResult.isRefetching} + isFetchingNextPage={queryResult.isFetchingNextPage} onClearSearch={handleClearSearch} onResetFilter={handleResetFilter} styles={styles} @@ -706,14 +731,38 @@ const createStyles = (colors: ReturnType['colors']) => summaryRow: { gap: 8, }, - summaryText: { + summaryMeta: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + flexWrap: 'wrap', + }, + summaryBadge: { + borderRadius: 999, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.background, + paddingHorizontal: 8, + paddingVertical: 3, + }, + summaryBadgeLabel: { + fontSize: 12, + fontFamily: 'Inter_500Medium', + color: colors.foreground, + }, + summaryMetaText: { fontSize: 12, fontFamily: 'Inter_400Regular', color: colors.mutedForeground, }, + summaryText: { + fontSize: 13, + fontFamily: 'Inter_600SemiBold', + color: colors.foreground, + }, summaryActions: { flexDirection: 'row', - gap: 6, + gap: 8, flexWrap: 'wrap', alignItems: 'center', }, diff --git a/ui/web/components/features/settings/AIIndexTab.tsx b/ui/web/components/features/settings/AIIndexTab.tsx index 88479f9f03b..2eb5125eca8 100644 --- a/ui/web/components/features/settings/AIIndexTab.tsx +++ b/ui/web/components/features/settings/AIIndexTab.tsx @@ -295,12 +295,12 @@ function AIIndexResetActions({ return (
{hasActiveSearch ? ( - ) : null} {hasActiveFilter ? ( - ) : null} @@ -454,12 +454,10 @@ function AIIndexResultsHeader({ onResetFilter: () => void summaryText: string }>) { - const hasDetailRow = Boolean(bulkAction) || hasActiveFilter || hasActiveSearch || isFetching || isFetchingNextPage - return ( -
-
-

{summaryText}

+
+
+

{summaryText}

{FILTER_LABELS[filter]} {hasActiveSearch ? ( @@ -481,7 +479,7 @@ function AIIndexResultsHeader({ ) : null}
-
+
{bulkAction} Date: Sun, 5 Apr 2026 22:30:43 +0200 Subject: [PATCH 08/14] Align AI index bulk action with note actions --- .../components/settings/AIIndexPanel.tsx | 28 ++++++------------- .../features/settings/AIIndexTab.tsx | 21 ++------------ 2 files changed, 10 insertions(+), 39 deletions(-) diff --git a/ui/mobile/components/settings/AIIndexPanel.tsx b/ui/mobile/components/settings/AIIndexPanel.tsx index df4f80dbf6f..4c0ef3da4e9 100644 --- a/ui/mobile/components/settings/AIIndexPanel.tsx +++ b/ui/mobile/components/settings/AIIndexPanel.tsx @@ -194,29 +194,23 @@ async function processBulkIndexNote({ function AIIndexSummary({ bulkAction, filterLabel, - hasActiveFilter, hasActiveSearch, isFetching, isFetchingNextPage, - onClearSearch, - onResetFilter, styles, summaryText, }: Readonly<{ bulkAction?: ReactElement | null filterLabel: string - hasActiveFilter: boolean hasActiveSearch: boolean isFetching: boolean isFetchingNextPage: boolean - onClearSearch: () => void - onResetFilter: () => void styles: ReturnType summaryText: string | null }>) { if (!summaryText) return null - const showSummaryActions = Boolean(bulkAction) || hasActiveSearch || hasActiveFilter + const showSummaryActions = Boolean(bulkAction) return ( @@ -240,16 +234,6 @@ function AIIndexSummary({ {showSummaryActions ? ( {bulkAction} - {hasActiveSearch ? ( - - ) : null} - {hasActiveFilter ? ( - - ) : null} ) : null} @@ -532,6 +516,7 @@ export function AIIndexPanel() { onPress={handleBulkIndexPress} style={({ pressed }) => [ styles.actionChip, + styles.actionButton, bulkIndexProgress !== null && styles.actionChipDisabled, pressed && bulkIndexProgress === null && styles.actionChipPressed, ]} @@ -609,12 +594,9 @@ export function AIIndexPanel() { option.value === filter)?.label ?? 'All notes'} - hasActiveFilter={hasActiveFilter} hasActiveSearch={hasActiveSearch} isFetching={queryResult.isRefetching} isFetchingNextPage={queryResult.isFetchingNextPage} - onClearSearch={handleClearSearch} - onResetFilter={handleResetFilter} styles={styles} summaryText={summaryText} /> @@ -671,6 +653,11 @@ const createStyles = (colors: ReturnType['colors']) => paddingHorizontal: 10, paddingVertical: 5, }, + actionButton: { + minHeight: 36, + alignSelf: 'stretch', + justifyContent: 'center', + }, actionChipDisabled: { opacity: 0.7, }, @@ -765,6 +752,7 @@ const createStyles = (colors: ReturnType['colors']) => gap: 8, flexWrap: 'wrap', alignItems: 'center', + width: '100%', }, list: { flex: 1, diff --git a/ui/web/components/features/settings/AIIndexTab.tsx b/ui/web/components/features/settings/AIIndexTab.tsx index 2eb5125eca8..7cfdfe5cde3 100644 --- a/ui/web/components/features/settings/AIIndexTab.tsx +++ b/ui/web/components/features/settings/AIIndexTab.tsx @@ -435,23 +435,17 @@ function AIIndexResultsHeader({ activeSearchQuery, bulkAction, filter, - hasActiveFilter, hasActiveSearch, isFetching, isFetchingNextPage, - onClearSearch, - onResetFilter, summaryText, }: Readonly<{ activeSearchQuery: string bulkAction?: React.ReactNode filter: AIIndexFilter - hasActiveFilter: boolean hasActiveSearch: boolean isFetching: boolean isFetchingNextPage: boolean - onClearSearch: () => void - onResetFilter: () => void summaryText: string }>) { return ( @@ -479,16 +473,8 @@ function AIIndexResultsHeader({ ) : null}
-
+
{bulkAction} -
) @@ -806,7 +792,7 @@ export function AIIndexTab() { onClick={handleBulkIndexClick} disabled={bulkIndexProgress !== null} className={cn( - "inline-flex h-8 shrink-0 items-center rounded-lg border px-3 text-sm transition-colors", + "inline-flex h-9 w-full items-center justify-center rounded-md border px-3 text-sm whitespace-nowrap transition-colors", bulkIndexProgress ? "cursor-default border-primary/20 bg-primary/10 text-primary/75" : "border-primary/25 bg-primary/10 font-medium text-primary hover:border-primary/35 hover:bg-primary/15" @@ -845,12 +831,9 @@ export function AIIndexTab() { activeSearchQuery={activeSearchQuery} bulkAction={bulkAction} filter={filter} - hasActiveFilter={hasActiveFilter} hasActiveSearch={hasActiveSearch} isFetching={query.isFetching} isFetchingNextPage={query.isFetchingNextPage} - onClearSearch={handleClearSearch} - onResetFilter={handleResetFilter} summaryText={summaryText} />
From a2dd6e5b753e4d336ee13c071cb395238ce8334c Mon Sep 17 00:00:00 2001 From: Denys Date: Sun, 5 Apr 2026 22:37:29 +0200 Subject: [PATCH 09/14] Normalize AI index bulk action layout --- .../components/settings/AIIndexPanel.tsx | 44 ++++++------------- .../features/settings/AIIndexTab.tsx | 13 +++--- 2 files changed, 20 insertions(+), 37 deletions(-) diff --git a/ui/mobile/components/settings/AIIndexPanel.tsx b/ui/mobile/components/settings/AIIndexPanel.tsx index 4c0ef3da4e9..c0c1612754c 100644 --- a/ui/mobile/components/settings/AIIndexPanel.tsx +++ b/ui/mobile/components/settings/AIIndexPanel.tsx @@ -508,28 +508,26 @@ export function AIIndexPanel() { ? `${bulkIndexProgress.completed}/${bulkIndexProgress.total}` : 'Index loaded' const bulkAction = showBulkAction ? ( - [ + style={[ styles.actionChip, styles.actionButton, - bulkIndexProgress !== null && styles.actionChipDisabled, - pressed && bulkIndexProgress === null && styles.actionChipPressed, ]} > {bulkIndexProgress ? ( - + ) : ( - + )} - {bulkActionLabel} + {bulkActionLabel} - + ) : null return ( @@ -646,36 +644,20 @@ const createStyles = (colors: ReturnType['colors']) => paddingVertical: 4, }, actionChip: { - borderRadius: 999, - borderWidth: 1, - borderColor: colors.primary, - backgroundColor: colors.selectionBackground, - paddingHorizontal: 10, - paddingVertical: 5, + backgroundColor: colors.primary, }, actionButton: { - minHeight: 36, - alignSelf: 'stretch', - justifyContent: 'center', - }, - actionChipDisabled: { - opacity: 0.7, - }, - actionChipPressed: { - opacity: 0.85, + flex: 1, }, actionChipContent: { flexDirection: 'row', alignItems: 'center', gap: 4, }, - actionChipSpinner: { - transform: [{ scale: 0.7 }], - }, - actionChipLabel: { - color: colors.primary, + actionButtonLabel: { + color: colors.primaryForeground, fontFamily: 'Inter_500Medium', - fontSize: 12, + fontSize: 13, }, chipPressed: { opacity: 0.6, diff --git a/ui/web/components/features/settings/AIIndexTab.tsx b/ui/web/components/features/settings/AIIndexTab.tsx index 7cfdfe5cde3..942d0b79966 100644 --- a/ui/web/components/features/settings/AIIndexTab.tsx +++ b/ui/web/components/features/settings/AIIndexTab.tsx @@ -449,7 +449,7 @@ function AIIndexResultsHeader({ summaryText: string }>) { return ( -
+

{summaryText}

@@ -473,7 +473,7 @@ function AIIndexResultsHeader({ ) : null}
-
+
{bulkAction}
@@ -785,17 +785,18 @@ export function AIIndexTab() { } const bulkAction = actionableLoadedNotes.length > 0 || bulkIndexProgress ? ( - + ) : null return ( From 53ea39cf931db7acc7adfb545f05710bf3ee6be4 Mon Sep 17 00:00:00 2001 From: Denys Date: Sun, 5 Apr 2026 22:42:18 +0200 Subject: [PATCH 10/14] Align AI index header action column --- ui/web/components/features/settings/AIIndexTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/web/components/features/settings/AIIndexTab.tsx b/ui/web/components/features/settings/AIIndexTab.tsx index 942d0b79966..027a13e4c9e 100644 --- a/ui/web/components/features/settings/AIIndexTab.tsx +++ b/ui/web/components/features/settings/AIIndexTab.tsx @@ -449,7 +449,7 @@ function AIIndexResultsHeader({ summaryText: string }>) { return ( -
+

{summaryText}

From 47fab25f448fdd165ab0ffbaadaf9b79c794b37b Mon Sep 17 00:00:00 2001 From: Denys Date: Sun, 5 Apr 2026 22:53:01 +0200 Subject: [PATCH 11/14] Fix AI index bulk action alignment --- ui/mobile/components/settings/AIIndexPanel.tsx | 12 +++++------- ui/web/components/features/settings/AIIndexTab.tsx | 2 +- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/ui/mobile/components/settings/AIIndexPanel.tsx b/ui/mobile/components/settings/AIIndexPanel.tsx index c0c1612754c..8e5e5ac4f7e 100644 --- a/ui/mobile/components/settings/AIIndexPanel.tsx +++ b/ui/mobile/components/settings/AIIndexPanel.tsx @@ -514,10 +514,7 @@ export function AIIndexPanel() { accessibilityLabel={bulkIndexProgress ? 'Indexing loaded notes' : 'Index loaded notes'} disabled={bulkIndexProgress !== null} onPress={handleBulkIndexPress} - style={[ - styles.actionChip, - styles.actionButton, - ]} + style={styles.actionButton} > {bulkIndexProgress ? ( @@ -643,10 +640,8 @@ const createStyles = (colors: ReturnType['colors']) => chip: { paddingVertical: 4, }, - actionChip: { - backgroundColor: colors.primary, - }, actionButton: { + backgroundColor: colors.primary, flex: 1, }, actionChipContent: { @@ -654,6 +649,9 @@ const createStyles = (colors: ReturnType['colors']) => alignItems: 'center', gap: 4, }, + actionChipSpinner: { + transform: [{ scale: 0.8 }], + }, actionButtonLabel: { color: colors.primaryForeground, fontFamily: 'Inter_500Medium', diff --git a/ui/web/components/features/settings/AIIndexTab.tsx b/ui/web/components/features/settings/AIIndexTab.tsx index 027a13e4c9e..6ce0d22d17c 100644 --- a/ui/web/components/features/settings/AIIndexTab.tsx +++ b/ui/web/components/features/settings/AIIndexTab.tsx @@ -449,7 +449,7 @@ function AIIndexResultsHeader({ summaryText: string }>) { return ( -
+

{summaryText}

From 02c26e61309cea4a691a3455d013eb92c20fafae Mon Sep 17 00:00:00 2001 From: Denys Date: Fri, 10 Apr 2026 19:51:45 +0200 Subject: [PATCH 12/14] claude fix --- ui/mobile/components/settings/AIIndexPanel.tsx | 10 ++++++---- ui/web/components/features/settings/AIIndexList.tsx | 2 +- ui/web/components/features/settings/AIIndexTab.tsx | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/ui/mobile/components/settings/AIIndexPanel.tsx b/ui/mobile/components/settings/AIIndexPanel.tsx index 8e5e5ac4f7e..b59b15e2291 100644 --- a/ui/mobile/components/settings/AIIndexPanel.tsx +++ b/ui/mobile/components/settings/AIIndexPanel.tsx @@ -518,9 +518,9 @@ export function AIIndexPanel() { > {bulkIndexProgress ? ( - + ) : ( - + )} {bulkActionLabel} @@ -641,7 +641,9 @@ const createStyles = (colors: ReturnType['colors']) => paddingVertical: 4, }, actionButton: { - backgroundColor: colors.primary, + backgroundColor: `${colors.primary}1A`, + borderWidth: 1, + borderColor: `${colors.primary}4D`, flex: 1, }, actionChipContent: { @@ -653,7 +655,7 @@ const createStyles = (colors: ReturnType['colors']) => transform: [{ scale: 0.8 }], }, actionButtonLabel: { - color: colors.primaryForeground, + color: colors.primary, fontFamily: 'Inter_500Medium', fontSize: 13, }, diff --git a/ui/web/components/features/settings/AIIndexList.tsx b/ui/web/components/features/settings/AIIndexList.tsx index c9dd4e29838..eee1c74eaab 100644 --- a/ui/web/components/features/settings/AIIndexList.tsx +++ b/ui/web/components/features/settings/AIIndexList.tsx @@ -189,7 +189,7 @@ export const AIIndexList = memo(function AIIndexList({ } return ( -
+
{({ height: listHeight, width: listWidth }) => ( ) { return ( -
+

{summaryText}

From 538f3af1140cd79681927fe99473935531b4467e Mon Sep 17 00:00:00 2001 From: Denys Date: Fri, 10 Apr 2026 21:09:01 +0200 Subject: [PATCH 13/14] revie fixes --- core/bulkIndex.ts | 86 +++++++++++++++++ .../components/settings/AIIndexPanel.tsx | 93 +++--------------- ui/mobile/package.json | 2 +- .../features/settings/AIIndexTab.tsx | 95 ++----------------- 4 files changed, 110 insertions(+), 166 deletions(-) create mode 100644 core/bulkIndex.ts diff --git a/core/bulkIndex.ts b/core/bulkIndex.ts new file mode 100644 index 00000000000..75c3826ad31 --- /dev/null +++ b/core/bulkIndex.ts @@ -0,0 +1,86 @@ +import { getAIIndexActionPresentation } from "@core/constants/aiIndex" +import { parseRagIndexResult } from "@core/rag/indexResult" +import type { AIIndexMutationResult, AIIndexNoteRow } from "@core/types/aiIndex" + +export type BulkIndexOutcome = "indexed" | "skipped" | "failed" + +export type BulkIndexCounters = { + successCount: number + skippedCount: number + errorCount: number +} + +export type BulkIndexInvoke = ( + name: string, + options: { body: { noteId: string; action: "index" | "reindex" } } +) => Promise<{ data: unknown; error: unknown }> + +export function incrementBulkIndexCounters( + counters: BulkIndexCounters, + outcome: BulkIndexOutcome +): BulkIndexCounters { + if (outcome === "indexed") { + return { ...counters, successCount: counters.successCount + 1 } + } + if (outcome === "skipped") { + return { ...counters, skippedCount: counters.skippedCount + 1 } + } + return { ...counters, errorCount: counters.errorCount + 1 } +} + +export function formatBulkIndexSummary(successCount: number, skippedCount: number, errorCount: number) { + const parts = [ + successCount > 0 ? `${successCount} indexed` : null, + skippedCount > 0 ? `${skippedCount} skipped` : null, + errorCount > 0 ? `${errorCount} failed` : null, + ].filter(Boolean) + + return parts.join(" • ") +} + +export async function processBulkIndexNote({ + applyMutationResult, + invoke, + note, +}: Readonly<{ + applyMutationResult: (result: AIIndexMutationResult) => void + invoke: BulkIndexInvoke + note: AIIndexNoteRow +}>): Promise { + const actionPresentation = getAIIndexActionPresentation(note.status) + + try { + const { data, error } = await invoke("rag-index", { + body: { + noteId: note.id, + action: actionPresentation.action, + }, + }) + if (error) throw error + + const result = parseRagIndexResult(data) + if (result.outcome === "indexed") { + applyMutationResult({ + noteId: note.id, + previousStatus: note.status, + nextStatus: actionPresentation.successStatus, + }) + return "indexed" + } + + if (result.outcome === "skipped") { + if (result.reason === "too_short") { + applyMutationResult({ + noteId: note.id, + previousStatus: note.status, + nextStatus: "not_indexed", + }) + } + return "skipped" + } + + return "failed" + } catch { + return "failed" + } +} diff --git a/ui/mobile/components/settings/AIIndexPanel.tsx b/ui/mobile/components/settings/AIIndexPanel.tsx index b59b15e2291..1e88d3bf06d 100644 --- a/ui/mobile/components/settings/AIIndexPanel.tsx +++ b/ui/mobile/components/settings/AIIndexPanel.tsx @@ -14,9 +14,15 @@ import { Database, Search, X } from 'lucide-react-native' import Toast from 'react-native-toast-message' import { useQueryClient, type InfiniteData } from '@tanstack/react-query' -import { getAIIndexActionPresentation, getAIIndexActionableNotes } from '@core/constants/aiIndex' +import { + type BulkIndexCounters, + type BulkIndexInvoke, + formatBulkIndexSummary, + incrementBulkIndexCounters, + processBulkIndexNote, +} from '@core/bulkIndex' +import { getAIIndexActionableNotes } from '@core/constants/aiIndex' import { SEARCH_CONFIG } from '@core/constants/search' -import { parseRagIndexResult } from '@core/rag/indexResult' import type { AIIndexFilter, AIIndexMutationResult, AIIndexNoteRow, AIIndexNotesPage } from '@core/types/aiIndex' import { Button } from '@ui/mobile/components/ui/Button' import { AIIndexNoteCard } from '@ui/mobile/components/settings/AIIndexNoteCard' @@ -38,13 +44,6 @@ type BulkIndexProgress = { total: number } -type BulkIndexOutcome = 'indexed' | 'skipped' | 'failed' - -type BulkIndexInvoke = ( - name: string, - options: { body: { noteId: string; action: 'index' | 'reindex' } } -) => Promise<{ data: unknown; error: unknown }> - const FILTER_OPTIONS: readonly FilterOption[] = [ { value: 'all', label: 'All notes' }, { value: 'indexed', label: 'Indexed' }, @@ -78,14 +77,6 @@ function getSummaryText(loadedCount: number, totalCount: number) { return `${totalCount} note${totalCount === 1 ? '' : 's'}` } -function getBulkSummaryText(successCount: number, skippedCount: number, errorCount: number) { - return [ - successCount > 0 ? `${successCount} indexed` : null, - skippedCount > 0 ? `${skippedCount} skipped` : null, - errorCount > 0 ? `${errorCount} failed` : null, - ].filter(Boolean).join(' • ') -} - function patchNoteStatus(note: AIIndexNoteRow, result: AIIndexMutationResult): AIIndexNoteRow { if (note.id !== result.noteId) return note return { @@ -110,21 +101,8 @@ function getFilterFromAIIndexQueryKey(queryKey: readonly unknown[]): AIIndexFilt : 'all' } -function incrementBulkCounts( - counts: Readonly<{ successCount: number; skippedCount: number; errorCount: number }>, - outcome: BulkIndexOutcome, -) { - if (outcome === 'indexed') { - return { ...counts, successCount: counts.successCount + 1 } - } - if (outcome === 'skipped') { - return { ...counts, skippedCount: counts.skippedCount + 1 } - } - return { ...counts, errorCount: counts.errorCount + 1 } -} - function showBulkIndexToast(successCount: number, skippedCount: number, errorCount: number) { - const summary = getBulkSummaryText(successCount, skippedCount, errorCount) + const summary = formatBulkIndexSummary(successCount, skippedCount, errorCount) if (successCount > 0 && errorCount === 0) { Toast.show({ type: 'success', text1: summary || 'Loaded notes indexed' }) @@ -147,50 +125,6 @@ function updateBulkProgress( }) } -async function processBulkIndexNote({ - applyMutationResult, - invoke, - note, -}: Readonly<{ - applyMutationResult: (result: AIIndexMutationResult) => void - invoke: BulkIndexInvoke - note: AIIndexNoteRow -}>): Promise { - const actionPresentation = getAIIndexActionPresentation(note.status) - - try { - const { data, error } = await invoke('rag-index', { - body: { noteId: note.id, action: actionPresentation.action }, - }) - if (error) throw error - - const result = parseRagIndexResult(data) - if (result.outcome === 'indexed') { - applyMutationResult({ - noteId: note.id, - previousStatus: note.status, - nextStatus: actionPresentation.successStatus, - }) - return 'indexed' - } - - if (result.outcome === 'skipped') { - if (result.reason === 'too_short') { - applyMutationResult({ - noteId: note.id, - previousStatus: note.status, - nextStatus: 'not_indexed', - }) - } - return 'skipped' - } - - return 'failed' - } catch { - return 'failed' - } -} - function AIIndexSummary({ bulkAction, filterLabel, @@ -394,6 +328,7 @@ export function AIIndexPanel() { queryKey: readonly unknown[], result: AIIndexMutationResult, ) => { + const queryFilter = getFilterFromAIIndexQueryKey(queryKey) queryClient.setQueryData>(queryKey, (old) => { if (!old) return old return { @@ -402,11 +337,11 @@ export function AIIndexPanel() { ...page, notes: page.notes .map((note) => patchNoteStatus(note, result)) - .filter((note) => shouldKeepNote(note, result, filter)), + .filter((note) => shouldKeepNote(note, result, queryFilter)), })), } }) - }, [filter, queryClient]) + }, [queryClient]) const applyMutationResult = useCallback((result: AIIndexMutationResult) => { const cachedQueries = queryClient.getQueriesData>({ @@ -462,7 +397,7 @@ export function AIIndexPanel() { const handleBulkIndexLoaded = useCallback(async () => { if (bulkIndexProgress || actionableLoadedNotes.length === 0) return - let counts = { successCount: 0, skippedCount: 0, errorCount: 0 } + let counts: BulkIndexCounters = { successCount: 0, skippedCount: 0, errorCount: 0 } setBulkIndexProgress({ completed: 0, total: actionableLoadedNotes.length }) try { @@ -472,7 +407,7 @@ export function AIIndexPanel() { invoke: invokeBulkIndex, note, }) - counts = incrementBulkCounts(counts, outcome) + counts = incrementBulkIndexCounters(counts, outcome) updateBulkProgress(index, actionableLoadedNotes.length, setBulkIndexProgress) } diff --git a/ui/mobile/package.json b/ui/mobile/package.json index 6a7181fbb84..7f04592ebc9 100644 --- a/ui/mobile/package.json +++ b/ui/mobile/package.json @@ -18,7 +18,7 @@ "android:stage:release": "cross-env APP_VARIANT=stage EXPO_PUBLIC_APP_VARIANT=stage expo run:android --variant stageRelease --app-id com.everfreenote.app.stage", "android:prod": "cross-env APP_VARIANT=prod EXPO_PUBLIC_APP_VARIANT=prod expo run:android --variant prodDebug --app-id com.everfreenote.app", "android:prod:release": "cross-env APP_VARIANT=prod EXPO_PUBLIC_APP_VARIANT=prod expo run:android --variant prodRelease --app-id com.everfreenote.app", - "adb:connect": "adb connect 192.168.0.12:34491", + "adb:connect": "adb connect 192.168.0.12:39689", "adb:pair": "adb pair 192.168.0.12:42649", "adb:kill": "adb kill-server", "adb:start": "adb start-server", diff --git a/ui/web/components/features/settings/AIIndexTab.tsx b/ui/web/components/features/settings/AIIndexTab.tsx index 726dff8b220..1a807e9cb7c 100644 --- a/ui/web/components/features/settings/AIIndexTab.tsx +++ b/ui/web/components/features/settings/AIIndexTab.tsx @@ -9,15 +9,21 @@ import { toast } from "sonner" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" -import { getAIIndexActionPresentation, getAIIndexActionableNotes } from "@core/constants/aiIndex" +import { getAIIndexActionableNotes } from "@core/constants/aiIndex" import { SEARCH_CONFIG } from "@core/constants/search" -import { parseRagIndexResult } from "@core/rag/indexResult" import { NoteService } from "@core/services/notes" import type { AIIndexFilter, AIIndexMutationResult, AIIndexNoteRow as AIIndexNoteRowData, } from "@core/types/aiIndex" +import { + type BulkIndexCounters, + type BulkIndexInvoke, + formatBulkIndexSummary, + incrementBulkIndexCounters, + processBulkIndexNote, +} from "@core/bulkIndex" import { cn } from "@ui/web/lib/utils" import { getAIIndexNotesQueryPrefix, @@ -73,95 +79,12 @@ type OptimisticMutationState = AIIndexMutationResult & { sourceIndex: number } -type BulkIndexOutcome = "indexed" | "skipped" | "failed" - -type BulkIndexCounters = { - successCount: number - skippedCount: number - errorCount: number -} - -type BulkIndexInvoke = ( - name: string, - options: { body: { noteId: string; action: "index" | "reindex" } } -) => Promise<{ data: unknown; error: unknown }> - function runBackgroundTask(task: Promise) { task.catch(() => { // Best-effort background work should not break the settings UI. }) } -function incrementBulkIndexCounters( - counters: BulkIndexCounters, - outcome: BulkIndexOutcome -): BulkIndexCounters { - if (outcome === "indexed") { - return { ...counters, successCount: counters.successCount + 1 } - } - if (outcome === "skipped") { - return { ...counters, skippedCount: counters.skippedCount + 1 } - } - return { ...counters, errorCount: counters.errorCount + 1 } -} - -function formatBulkIndexSummary(successCount: number, skippedCount: number, errorCount: number) { - const parts = [ - successCount > 0 ? `${successCount} indexed` : null, - skippedCount > 0 ? `${skippedCount} skipped` : null, - errorCount > 0 ? `${errorCount} failed` : null, - ].filter(Boolean) - - return parts.join(" • ") -} - -async function processBulkIndexNote({ - applyMutationResult, - invoke, - note, -}: Readonly<{ - applyMutationResult: (mutationResult: AIIndexMutationResult, options?: { invalidate?: boolean }) => void - invoke: BulkIndexInvoke - note: AIIndexNoteRowData -}>): Promise { - const actionPresentation = getAIIndexActionPresentation(note.status) - - try { - const { data, error } = await invoke("rag-index", { - body: { - noteId: note.id, - action: actionPresentation.action, - }, - }) - if (error) throw error - - const result = parseRagIndexResult(data) - if (result.outcome === "indexed") { - applyMutationResult({ - noteId: note.id, - previousStatus: note.status, - nextStatus: actionPresentation.successStatus, - }, { invalidate: false }) - return "indexed" - } - - if (result.outcome === "skipped") { - if (result.reason === "too_short") { - applyMutationResult({ - noteId: note.id, - previousStatus: note.status, - nextStatus: "not_indexed", - }, { invalidate: false }) - } - return "skipped" - } - - return "failed" - } catch { - return "failed" - } -} - function matchesAIIndexFilter(filter: AIIndexFilter, status: AIIndexNoteRowData["status"]) { return filter === "all" || filter === status } @@ -738,7 +661,7 @@ export function AIIndexTab() { try { for (const [index, note] of actionableLoadedNotes.entries()) { const outcome = await processBulkIndexNote({ - applyMutationResult, + applyMutationResult: (result) => applyMutationResult(result, { invalidate: false }), invoke: invokeBulkIndex, note, }) From b7242f3f073b7b1e0000464947b1bebaed196161 Mon Sep 17 00:00:00 2001 From: Denys Date: Fri, 10 Apr 2026 21:28:11 +0200 Subject: [PATCH 14/14] Address remaining PR review comments (#7-#11) - Reword user story for clarity (docs) - Sync totalCount on optimistic removals in mobile - Guard bulk indexing with useRef to prevent race conditions - Add test coverage for skipped/failed/too_short bulk outcomes - Fix existing test assertion for totalCount after filter-aware updates Co-Authored-By: Claude Opus 4.6 --- docs/ai/requirements/feature-ai-index-page.md | 2 +- .../components/settings/AIIndexPanel.tsx | 19 +++- .../tests/component/aiIndexPanel.test.tsx | 52 ++++++++++- .../features/settings/AIIndexTab.tsx | 7 +- .../tests/unit/components/aiIndexTab.test.tsx | 88 +++++++++++++++++++ 5 files changed, 162 insertions(+), 6 deletions(-) diff --git a/docs/ai/requirements/feature-ai-index-page.md b/docs/ai/requirements/feature-ai-index-page.md index 1ad7106acab..8dddc3da5e8 100644 --- a/docs/ai/requirements/feature-ai-index-page.md +++ b/docs/ai/requirements/feature-ai-index-page.md @@ -59,7 +59,7 @@ Users can currently index or delete the AI/RAG index only from inside an individ - As a user, I want to filter to `Not indexed` notes so I can decide what should enter the AI index. - As a user, I want to reindex an outdated or already indexed note without opening it first. - As a user, I want to remove a note from the AI index from the same list view. -- As a user, I want one bulk button that indexes only the notes currently loaded in the visible AI Index list, so search and filters naturally limit its scope. +- As a user, I want a single bulk button that indexes only currently loaded notes in the visible AI Index list, so active search and filters naturally limit scope. ### Status rules diff --git a/ui/mobile/components/settings/AIIndexPanel.tsx b/ui/mobile/components/settings/AIIndexPanel.tsx index 1e88d3bf06d..383e69e08ba 100644 --- a/ui/mobile/components/settings/AIIndexPanel.tsx +++ b/ui/mobile/components/settings/AIIndexPanel.tsx @@ -91,6 +91,14 @@ function shouldKeepNote(note: AIIndexNoteRow, result: AIIndexMutationResult, fil return note.id !== result.noteId || note.status === filter } +function getTotalCountDelta(queryFilter: AIIndexFilter, result: AIIndexMutationResult): number { + if (queryFilter === 'all') return 0 + const matchedBefore = result.previousStatus === queryFilter + const matchedAfter = result.nextStatus === queryFilter + if (matchedBefore === matchedAfter) return 0 + return matchedAfter ? 1 : -1 +} + function getFilterFromAIIndexQueryKey(queryKey: readonly unknown[]): AIIndexFilter { const rawFilter = Array.isArray(queryKey) ? queryKey[2] : null return rawFilter === 'indexed' @@ -280,6 +288,7 @@ export function AIIndexPanel() { const styles = useMemo(() => createStyles(colors), [colors]) const listRef = useRef | null>(null) const debounceRef = useRef | null>(null) + const bulkIndexInFlightRef = useRef(false) const [filter, setFilter] = useState('all') const [searchDraft, setSearchDraft] = useState('') @@ -329,12 +338,14 @@ export function AIIndexPanel() { result: AIIndexMutationResult, ) => { const queryFilter = getFilterFromAIIndexQueryKey(queryKey) + const delta = getTotalCountDelta(queryFilter, result) queryClient.setQueryData>(queryKey, (old) => { if (!old) return old return { ...old, pages: old.pages.map((page) => ({ ...page, + totalCount: Math.max(0, page.totalCount + delta), notes: page.notes .map((note) => patchNoteStatus(note, result)) .filter((note) => shouldKeepNote(note, result, queryFilter)), @@ -352,10 +363,12 @@ export function AIIndexPanel() { if (!queryData) continue const queryFilter = getFilterFromAIIndexQueryKey(queryKey) + const delta = getTotalCountDelta(queryFilter, result) queryClient.setQueryData>(queryKey, { ...queryData, pages: queryData.pages.map((page) => ({ ...page, + totalCount: Math.max(0, page.totalCount + delta), notes: page.notes .map((note) => patchNoteStatus(note, result)) .filter((note) => shouldKeepNote(note, result, queryFilter)), @@ -395,7 +408,8 @@ export function AIIndexPanel() { }, []) const handleBulkIndexLoaded = useCallback(async () => { - if (bulkIndexProgress || actionableLoadedNotes.length === 0) return + if (bulkIndexInFlightRef.current || actionableLoadedNotes.length === 0) return + bulkIndexInFlightRef.current = true let counts: BulkIndexCounters = { successCount: 0, skippedCount: 0, errorCount: 0 } setBulkIndexProgress({ completed: 0, total: actionableLoadedNotes.length }) @@ -413,12 +427,13 @@ export function AIIndexPanel() { showBulkIndexToast(counts.successCount, counts.skippedCount, counts.errorCount) } finally { + bulkIndexInFlightRef.current = false setBulkIndexProgress(null) await queryClient.invalidateQueries({ queryKey: getAIIndexNotesQueryPrefix(user?.id), }) } - }, [actionableLoadedNotes, activeQueryKey, applyMutationResultToQuery, bulkIndexProgress, invokeBulkIndex, queryClient, user?.id]) + }, [actionableLoadedNotes, activeQueryKey, applyMutationResultToQuery, invokeBulkIndex, queryClient, user?.id]) const handleBulkIndexPress = useCallback(() => { runAsyncTask(handleBulkIndexLoaded()) diff --git a/ui/mobile/tests/component/aiIndexPanel.test.tsx b/ui/mobile/tests/component/aiIndexPanel.test.tsx index b9ccb92de67..c439e6009b3 100644 --- a/ui/mobile/tests/component/aiIndexPanel.test.tsx +++ b/ui/mobile/tests/component/aiIndexPanel.test.tsx @@ -12,6 +12,12 @@ const mockUseAIIndexNotes = jest.fn() const mockUseFlattenedAIIndexNotes = jest.fn() const mockNoteCard = jest.fn() const mockInvoke = jest.fn() +const mockToastShow = jest.fn() + +jest.mock('react-native-toast-message', () => ({ + __esModule: true, + default: { show: (...args: unknown[]) => mockToastShow(...args) }, +})) jest.mock('@ui/mobile/providers', () => ({ useTheme: () => ({ @@ -98,6 +104,7 @@ describe('AIIndexPanel', () => { jest.clearAllMocks() jest.useFakeTimers() mockInvoke.mockReset() + mockToastShow.mockReset() mockGetQueriesData.mockReturnValue([]) setupMocks() }) @@ -335,9 +342,52 @@ describe('AIIndexPanel', () => { expect(mockSetQueryData).toHaveBeenNthCalledWith(2, ['ai-index-notes', 'test-user-id', 'indexed', ''], { pages: [{ notes: [], - totalCount: 1, + totalCount: 0, hasMore: false, }], }) }) + + it('reports skipped and failed outcomes in the bulk summary toast', async () => { + setupMocks({}, [ + { id: 'n1', title: 'OK note', updatedAt: '2025-06-01', lastIndexedAt: null, status: 'not_indexed' }, + { id: 'n2', title: 'Short note', updatedAt: '2025-06-01', lastIndexedAt: null, status: 'not_indexed' }, + { id: 'n3', title: 'Broken note', updatedAt: '2025-06-01', lastIndexedAt: null, status: 'not_indexed' }, + ]) + mockInvoke + .mockResolvedValueOnce({ data: { outcome: 'indexed', chunkCount: 1 }, error: null }) + .mockResolvedValueOnce({ data: { outcome: 'skipped', reason: 'too_short' }, error: null }) + .mockResolvedValueOnce({ data: null, error: new Error('edge fn error') }) + + render() + + fireEvent.press(screen.getByLabelText('Index loaded notes')) + + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledTimes(3) + }) + + await waitFor(() => { + expect(mockToastShow).toHaveBeenCalledWith( + expect.objectContaining({ type: 'info', text1: '1 indexed • 1 skipped • 1 failed' }) + ) + }) + }) + + it('shows success toast when all notes are indexed without errors', async () => { + setupMocks({}, [ + { id: 'n1', title: 'Need Index', updatedAt: '2025-06-01', lastIndexedAt: null, status: 'not_indexed' }, + ]) + mockInvoke.mockResolvedValue({ data: { outcome: 'indexed', chunkCount: 1 }, error: null }) + + render() + + fireEvent.press(screen.getByLabelText('Index loaded notes')) + + await waitFor(() => { + expect(mockToastShow).toHaveBeenCalledWith( + expect.objectContaining({ type: 'success', text1: '1 indexed' }) + ) + }) + }) }) diff --git a/ui/web/components/features/settings/AIIndexTab.tsx b/ui/web/components/features/settings/AIIndexTab.tsx index 1a807e9cb7c..ddac4080c14 100644 --- a/ui/web/components/features/settings/AIIndexTab.tsx +++ b/ui/web/components/features/settings/AIIndexTab.tsx @@ -437,6 +437,7 @@ export function AIIndexTab() { const [optimisticMutations, setOptimisticMutations] = React.useState>({}) const restoredStateRef = React.useRef(false) const exitTimeoutsRef = React.useRef>>({}) + const bulkIndexInFlightRef = React.useRef(false) const normalizedSearchDraft = searchDraft.trim() const persistedSearchQuery = getPersistedSearchQuery(normalizedSearchDraft) const isSearchHintVisible = @@ -648,7 +649,8 @@ export function AIIndexTab() { return supabase.functions.invoke(name, options) }, [supabase.functions]) const handleBulkIndexLoaded = React.useCallback(async () => { - if (bulkIndexProgress || actionableLoadedNotes.length === 0) return + if (bulkIndexInFlightRef.current || actionableLoadedNotes.length === 0) return + bulkIndexInFlightRef.current = true let counters: BulkIndexCounters = { successCount: 0, @@ -679,10 +681,11 @@ export function AIIndexTab() { toast.message(summary || "Bulk indexing finished") } } finally { + bulkIndexInFlightRef.current = false setBulkIndexProgress(null) runBackgroundTask(queryClient.invalidateQueries({ queryKey: getAIIndexNotesQueryPrefix(user?.id) })) } - }, [actionableLoadedNotes, applyMutationResult, bulkIndexProgress, invokeBulkIndex, queryClient, user?.id]) + }, [actionableLoadedNotes, applyMutationResult, invokeBulkIndex, queryClient, user?.id]) const handleBulkIndexClick = React.useCallback(() => { handleBulkIndexLoaded().catch(() => { toast.error("Bulk indexing failed") diff --git a/ui/web/tests/unit/components/aiIndexTab.test.tsx b/ui/web/tests/unit/components/aiIndexTab.test.tsx index 449feea5408..bf6c9d0f025 100644 --- a/ui/web/tests/unit/components/aiIndexTab.test.tsx +++ b/ui/web/tests/unit/components/aiIndexTab.test.tsx @@ -825,4 +825,92 @@ describe("AIIndexTab", () => { body: { noteId: "note-search-hit", action: "index" }, }) }) + + it("reports skipped and failed outcomes in the bulk summary toast", async () => { + const invoke = jest.fn() + .mockResolvedValueOnce({ data: { outcome: "indexed", chunkCount: 1 }, error: null }) + .mockResolvedValueOnce({ data: { outcome: "skipped", reason: "too_short" }, error: null }) + .mockResolvedValueOnce({ data: null, error: new Error("edge fn error") }) + + jest.spyOn(aiIndexHooks, "useFlattenedAIIndexNotes").mockReturnValue([ + { + id: "note-1", + title: "OK note", + updatedAt: "2026-03-29T11:00:00Z", + lastIndexedAt: null, + status: "not_indexed", + }, + { + id: "note-2", + title: "Short note", + updatedAt: "2026-03-29T12:00:00Z", + lastIndexedAt: null, + status: "not_indexed", + }, + { + id: "note-3", + title: "Broken note", + updatedAt: "2026-03-29T13:00:00Z", + lastIndexedAt: null, + status: "not_indexed", + }, + ]) + jest.spyOn(aiIndexHooks, "useAIIndexNotes").mockReturnValue({ + ...mockQuery, + data: { pages: [{ totalCount: 3, notes: [], hasMore: false }] }, + } as never) + + render( + + + + ) + + fireEvent.click(screen.getByRole("button", { name: "Index loaded notes" })) + + await waitFor(() => { + expect(invoke).toHaveBeenCalledTimes(3) + }) + + await waitFor(() => { + expect(mockToastMessage).toHaveBeenCalledWith("1 indexed • 1 skipped • 1 failed") + }) + }) + + it("shows success toast when all notes are indexed without errors", async () => { + const invoke = jest.fn() + .mockResolvedValue({ data: { outcome: "indexed", chunkCount: 1 }, error: null }) + + jest.spyOn(aiIndexHooks, "useFlattenedAIIndexNotes").mockReturnValue([ + { + id: "note-1", + title: "Note one", + updatedAt: "2026-03-29T11:00:00Z", + lastIndexedAt: null, + status: "not_indexed", + }, + ]) + jest.spyOn(aiIndexHooks, "useAIIndexNotes").mockReturnValue({ + ...mockQuery, + data: { pages: [{ totalCount: 1, notes: [], hasMore: false }] }, + } as never) + + render( + + + + ) + + fireEvent.click(screen.getByRole("button", { name: "Index loaded notes" })) + + await waitFor(() => { + expect(mockToastSuccess).toHaveBeenCalledWith("1 indexed") + }) + }) })