From c2fccf74d32321146d8cf5fa0a2acd5d9b19c7d9 Mon Sep 17 00:00:00 2001 From: Denys Date: Wed, 1 Apr 2026 22:23:25 +0200 Subject: [PATCH 1/5] Normalize RAG index outcomes across AI index and note actions --- core/rag/indexResult.ts | 127 ++++++++++++ core/tests/unit/core-rag-indexResult.test.ts | 62 ++++++ core/types/aiIndex.ts | 6 + .../features/settings/AIIndexList.cy.tsx | 99 +++++++++ .../features/settings/AIIndexNoteRow.cy.tsx | 118 +++++++++++ .../ai/design/feature-rag-note-indexing-ui.md | 7 +- .../implementation/feature-ai-index-page.md | 3 + .../feature-rag-note-indexing-ui.md | 13 +- docs/ai/planning/feature-ai-index-page.md | 4 + docs/ai/testing/feature-ai-index-page.md | 7 + supabase/functions/rag-index/index.ts | 8 +- ui/mobile/components/NoteIndexMenu.tsx | 42 ++-- .../features/notes/RagIndexPanel.tsx | 51 ++--- .../features/settings/AIIndexList.tsx | 22 +- .../features/settings/AIIndexNoteRow.tsx | 102 +++++----- .../features/settings/AIIndexTab.tsx | 189 ++++++++++++++++-- .../unit/components/aiIndexList.test.tsx | 24 ++- .../unit/components/aiIndexNoteRow.test.tsx | 69 ++++++- .../tests/unit/components/aiIndexTab.test.tsx | 130 +++++++++++- 19 files changed, 940 insertions(+), 143 deletions(-) create mode 100644 core/rag/indexResult.ts create mode 100644 core/tests/unit/core-rag-indexResult.test.ts create mode 100644 cypress/component/features/settings/AIIndexList.cy.tsx create mode 100644 cypress/component/features/settings/AIIndexNoteRow.cy.tsx diff --git a/core/rag/indexResult.ts b/core/rag/indexResult.ts new file mode 100644 index 00000000000..1c3d1e65ceb --- /dev/null +++ b/core/rag/indexResult.ts @@ -0,0 +1,127 @@ +export type RagIndexDebugChunkPayload = { + chunkIndex: number + charOffset: number + sectionHeading: string | null + title: string | null + content: string +} + +export type RagIndexSkippedReason = "too_short" + +export type RagIndexIndexedResult = { + outcome: "indexed" + chunkCount: number + droppedChunks: number + message: null + debugChunks: RagIndexDebugChunkPayload[] +} + +export type RagIndexDeletedResult = { + outcome: "deleted" + message: null + debugChunks: [] +} + +export type RagIndexSkippedResult = { + outcome: "skipped" + reason: RagIndexSkippedReason | null + chunkCount: 0 + message: string + debugChunks: RagIndexDebugChunkPayload[] +} + +export type RagIndexUnknownResult = { + outcome: "unknown" + message: string + debugChunks: RagIndexDebugChunkPayload[] +} + +export type NormalizedRagIndexResult = + | RagIndexIndexedResult + | RagIndexDeletedResult + | RagIndexSkippedResult + | RagIndexUnknownResult + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function parseFiniteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null +} + +function normalizeMessage(value: unknown, fallback: string) { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback +} + +export function parseRagIndexDebugChunks(data: unknown): RagIndexDebugChunkPayload[] { + if (!isRecord(data)) return [] + + const value = data.debugChunks + if (!Array.isArray(value)) return [] + + return value.filter((chunk): chunk is RagIndexDebugChunkPayload => { + if (!isRecord(chunk)) return false + return typeof chunk.chunkIndex === "number" + && typeof chunk.charOffset === "number" + && typeof chunk.content === "string" + && (typeof chunk.sectionHeading === "string" || chunk.sectionHeading === null) + && (typeof chunk.title === "string" || chunk.title === null) + }) +} + +export function parseRagIndexResult(data: unknown): NormalizedRagIndexResult { + if (!isRecord(data)) { + return { + outcome: "unknown", + message: "Indexing returned an empty response.", + debugChunks: [], + } + } + + if (data.outcome === "deleted" || data.deleted === true) { + return { outcome: "deleted", message: null, debugChunks: [] } + } + + const debugChunks = parseRagIndexDebugChunks(data) + const rawReason = typeof data.reason === "string" + ? data.reason + : typeof data.skipped === "string" + ? data.skipped + : null + + if (data.outcome === "skipped" || rawReason !== null) { + return { + outcome: "skipped", + reason: rawReason === "too_short" ? "too_short" : null, + chunkCount: 0, + message: normalizeMessage(data.message, "Indexing was skipped."), + debugChunks, + } + } + + const chunkCount = parseFiniteNumber(data.chunkCount) + if ((data.outcome === "indexed" || chunkCount !== null) && (chunkCount ?? 0) > 0) { + return { + outcome: "indexed", + chunkCount: chunkCount ?? 0, + droppedChunks: Math.max(0, parseFiniteNumber(data.droppedChunks) ?? 0), + message: null, + debugChunks, + } + } + + if (chunkCount === 0) { + return { + outcome: "unknown", + message: normalizeMessage(data.message, "Indexing completed without creating any chunks."), + debugChunks, + } + } + + return { + outcome: "unknown", + message: normalizeMessage(data.message, "Indexing returned an unexpected response."), + debugChunks, + } +} diff --git a/core/tests/unit/core-rag-indexResult.test.ts b/core/tests/unit/core-rag-indexResult.test.ts new file mode 100644 index 00000000000..993a7fe9a4a --- /dev/null +++ b/core/tests/unit/core-rag-indexResult.test.ts @@ -0,0 +1,62 @@ +import { parseRagIndexResult } from "@core/rag/indexResult" + +describe("parseRagIndexResult", () => { + it("normalizes successful index payloads", () => { + expect(parseRagIndexResult({ + outcome: "indexed", + chunkCount: 3, + droppedChunks: 1, + })).toEqual({ + outcome: "indexed", + chunkCount: 3, + droppedChunks: 1, + message: null, + debugChunks: [], + }) + }) + + it("normalizes skipped too-short payloads", () => { + expect(parseRagIndexResult({ + outcome: "skipped", + reason: "too_short", + chunkCount: 0, + message: "Note is too short for indexing", + })).toEqual({ + outcome: "skipped", + reason: "too_short", + chunkCount: 0, + message: "Note is too short for indexing", + debugChunks: [], + }) + }) + + it("supports legacy skipped payloads", () => { + expect(parseRagIndexResult({ + chunkCount: 0, + skipped: "too_short", + message: "Too short", + })).toEqual({ + outcome: "skipped", + reason: "too_short", + chunkCount: 0, + message: "Too short", + debugChunks: [], + }) + }) + + it("supports delete payloads", () => { + expect(parseRagIndexResult({ deleted: true })).toEqual({ + outcome: "deleted", + message: null, + debugChunks: [], + }) + }) + + it("treats zero chunks without skipped reason as unknown", () => { + expect(parseRagIndexResult({ chunkCount: 0, message: "No chunks" })).toEqual({ + outcome: "unknown", + message: "No chunks", + debugChunks: [], + }) + }) +}) diff --git a/core/types/aiIndex.ts b/core/types/aiIndex.ts index 5380649d5a4..12cbbd45c3d 100644 --- a/core/types/aiIndex.ts +++ b/core/types/aiIndex.ts @@ -2,6 +2,12 @@ export type AIIndexStatus = "indexed" | "not_indexed" | "outdated" export type AIIndexFilter = "all" | AIIndexStatus +export interface AIIndexMutationResult { + noteId: string + previousStatus: AIIndexStatus + nextStatus: AIIndexStatus +} + export interface AIIndexNoteRow { id: string title: string diff --git a/cypress/component/features/settings/AIIndexList.cy.tsx b/cypress/component/features/settings/AIIndexList.cy.tsx new file mode 100644 index 00000000000..cc72e2ffefd --- /dev/null +++ b/cypress/component/features/settings/AIIndexList.cy.tsx @@ -0,0 +1,99 @@ +import React from 'react' +import type { SupabaseClient } from '@supabase/supabase-js' + +import { AIIndexList } from '../../../../ui/web/components/features/settings/AIIndexList' +import type { AIIndexMutationResult, AIIndexNoteRow } from '../../../../core/types/aiIndex' +import { SupabaseTestProvider } from '../../../../ui/web/providers/SupabaseProvider' + +const notes: AIIndexNoteRow[] = [ + { + id: 'note-1', + title: 'Outdated note', + updatedAt: '2026-03-29T10:00:00Z', + lastIndexedAt: '2026-03-29T09:00:00Z', + status: 'outdated', + }, + { + id: 'note-2', + title: 'Indexed note', + updatedAt: '2026-03-29T11:00:00Z', + lastIndexedAt: '2026-03-29T11:05:00Z', + status: 'indexed', + }, +] + +describe('features/settings/AIIndexList', () => { + function mountList({ + exitingNoteIds = [], + onMutated = cy.stub().as('onMutated'), + }: { + exitingNoteIds?: string[] + onMutated?: Cypress.Agent + } = {}) { + const invoke = cy.stub().callsFake((name: string) => { + if (name === 'rag-index') { + return Promise.resolve({ data: { deleted: true }, error: null }) + } + return Promise.resolve({ data: null, error: null }) + }).as('invoke') + + const supabase = { + functions: { invoke }, + } as unknown as SupabaseClient + + cy.mount( +
+ + Nothing to review here yet
} + height={480} + width={720} + /> + + + ) + + return { invoke, onMutated } + } + + it('passes row mutations through the list without extra confirmation UI', () => { + mountList() + + cy.contains('Indexed note').should('be.visible') + cy.contains('Indexed note') + .parents('article') + .first() + .within(() => { + cy.contains('button', 'Remove index').click() + }) + + cy.get('@invoke').should('have.been.calledWith', 'rag-index', { + body: { + noteId: 'note-2', + action: 'delete', + }, + }) + cy.get('@onMutated').should('have.been.calledWith', { + noteId: 'note-2', + previousStatus: 'indexed', + nextStatus: 'not_indexed', + } satisfies AIIndexMutationResult) + }) + + it('marks exiting rows as hidden so filtered removals can animate out', () => { + mountList({ exitingNoteIds: ['note-1'] }) + + cy.contains('Outdated note') + .parents('article') + .first() + .should('have.attr', 'aria-hidden', 'true') + }) +}) diff --git a/cypress/component/features/settings/AIIndexNoteRow.cy.tsx b/cypress/component/features/settings/AIIndexNoteRow.cy.tsx new file mode 100644 index 00000000000..7bc328957ff --- /dev/null +++ b/cypress/component/features/settings/AIIndexNoteRow.cy.tsx @@ -0,0 +1,118 @@ +import React from 'react' +import type { SupabaseClient } from '@supabase/supabase-js' + +import { AIIndexNoteRow } from '../../../../ui/web/components/features/settings/AIIndexNoteRow' +import { SupabaseTestProvider } from '../../../../ui/web/providers/SupabaseProvider' + +describe('features/settings/AIIndexNoteRow', () => { + function mountRow({ + note, + invokeImpl, + onMutated = cy.stub().as('onMutated'), + }: { + note: Parameters[0]['note'] + invokeImpl?: (name: string, params: unknown) => Promise + onMutated?: Cypress.Agent + }) { + const invoke = cy.stub().callsFake((name: string, params: unknown) => { + if (invokeImpl) return invokeImpl(name, params) + return Promise.resolve({ data: { deleted: true }, error: null }) + }).as('invoke') + + const supabase = { + functions: { invoke }, + } as unknown as SupabaseClient + + cy.mount( + + + + ) + + return { invoke, onMutated } + } + + it('removes an indexed note immediately without a confirmation dialog', () => { + mountRow({ + note: { + id: 'note-1', + title: 'Indexed note', + updatedAt: '2026-03-29T10:00:00Z', + lastIndexedAt: '2026-03-29T10:05:00Z', + status: 'indexed', + }, + invokeImpl: async (name: string, params: unknown) => { + expect(name).to.eq('rag-index') + expect(params).to.deep.eq({ + body: { + noteId: 'note-1', + action: 'delete', + }, + }) + return { data: { deleted: true }, error: null } + }, + }) + + cy.contains('button', 'Remove index').click() + + cy.contains('Remove note from AI index?').should('not.exist') + cy.get('@onMutated').should('have.been.calledWith', { + noteId: 'note-1', + previousStatus: 'indexed', + nextStatus: 'not_indexed', + }) + }) + + it('shows the status-driven primary action for outdated notes', () => { + mountRow({ + note: { + id: 'note-outdated', + title: 'Outdated note', + updatedAt: '2026-03-29T10:00:00Z', + lastIndexedAt: '2026-03-29T09:00:00Z', + status: 'outdated', + }, + }) + + cy.contains('button', 'Update index').should('be.visible') + cy.contains('Changed after the last successful index.').should('be.visible') + }) + + it('maps too-short index responses back to not indexed instead of pretending success', () => { + mountRow({ + note: { + id: 'note-short', + title: 'Too short note', + updatedAt: '2026-03-29T10:00:00Z', + lastIndexedAt: '2026-03-29T09:00:00Z', + status: 'outdated', + }, + invokeImpl: async (name: string, params: unknown) => { + expect(name).to.eq('rag-index') + expect(params).to.deep.eq({ + body: { + noteId: 'note-short', + action: 'reindex', + }, + }) + return { + data: { + outcome: 'skipped', + reason: 'too_short', + chunkCount: 0, + message: 'Note is too short for indexing (minimum: 250 characters)', + }, + error: null, + } + }, + }) + + cy.contains('button', 'Update index').click() + + cy.get('@onMutated').should('have.been.calledWith', { + noteId: 'note-short', + previousStatus: 'outdated', + nextStatus: 'not_indexed', + }) + }) +}) diff --git a/docs/ai/design/feature-rag-note-indexing-ui.md b/docs/ai/design/feature-rag-note-indexing-ui.md index e9f613c8cdd..c3d166b6939 100644 --- a/docs/ai/design/feature-rag-note-indexing-ui.md +++ b/docs/ai/design/feature-rag-note-indexing-ui.md @@ -81,12 +81,14 @@ interface RagStatus { 4. `batchEmbedContents` → Gemini REST API (`output_dimensionality: 1536`) 5. Upsert chunks into `note_embeddings` by `(note_id, chunk_index)` 6. Delete stale tail chunks where `chunk_index >= newChunkCount` -7. Return `{ chunkCount: N }` +7. Return an explicit semantic outcome payload: + - success: `{ outcome: "indexed", chunkCount: N }` + - skipped-too-short: `{ outcome: "skipped", reason: "too_short", chunkCount: 0, message }` **action: 'delete'** flow: 1. Validate JWT → get userId 2. `DELETE FROM note_embeddings WHERE note_id = ? AND user_id = ?` -3. Return `{ deleted: true }` +3. Return `{ outcome: "deleted", deleted: true }` **Errors:** `400` bad input, `401` unauthorized, `404` note not found, `500` Gemini/DB error @@ -108,6 +110,7 @@ RLS policy on `note_embeddings` enforces per-user isolation (`auth.uid() = user_ - Uses `useSupabase()` to get the browser client for `functions.invoke()` - Uses `useRagStatus(noteId)` for live status (polling) - Renders: Index/Re-index button, Delete Index button, status text +- Normalizes `rag-index` payloads through a shared parser so semantic skips are not shown as success **UI states:** diff --git a/docs/ai/implementation/feature-ai-index-page.md b/docs/ai/implementation/feature-ai-index-page.md index ea84876eaa5..befc67df239 100644 --- a/docs/ai/implementation/feature-ai-index-page.md +++ b/docs/ai/implementation/feature-ai-index-page.md @@ -72,6 +72,9 @@ ui/web/components/features/settings/ - Prefer compact rows over metadata-heavy cards. The title should get more room (including two-line truncation), while date details should not dominate the row when status already communicates the main state. - 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. +- 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. ## Integration Points diff --git a/docs/ai/implementation/feature-rag-note-indexing-ui.md b/docs/ai/implementation/feature-rag-note-indexing-ui.md index b75bf4d8d61..958d26ea845 100644 --- a/docs/ai/implementation/feature-rag-note-indexing-ui.md +++ b/docs/ai/implementation/feature-rag-note-indexing-ui.md @@ -72,13 +72,15 @@ File: `supabase/functions/rag-index/index.ts` 4. Call Gemini `batchEmbedContents` 5. Upsert new chunks by `(note_id, chunk_index)` 6. Delete stale tail chunks (`chunk_index >= newChunkCount`) -7. Return `{ chunkCount }` +7. Return an explicit semantic result: + - `{ outcome: "indexed", chunkCount, droppedChunks?, debugChunks? }` + - `{ outcome: "skipped", reason: "too_short", chunkCount: 0, message }` when the note is too short and embeddings are cleared ### action = delete 1. Validate JWT and resolve `userId` 2. Delete rows from `note_embeddings` by `note_id` and `user_id` -3. Return `{ deleted: true }` +3. Return `{ outcome: "deleted", deleted: true }` ## Client Integration @@ -110,6 +112,13 @@ File: `supabase/functions/rag-index/index.ts` - Indexing - Indexed - Deleting +- Uses the shared parser in `core/rag/indexResult.ts` so `200 OK` semantic skips are surfaced honestly instead of being treated as success + +### `AIIndexNoteRow.tsx` and `NoteIndexMenu.tsx` + +- Reuse the same normalized `rag-index` outcome contract +- Only apply optimistic/status success states when the function explicitly reports `outcome: "indexed"` +- Treat `reason: "too_short"` as a semantic non-success and keep the note in `not_indexed` ### `NoteEditor.tsx` and `NoteView.tsx` diff --git a/docs/ai/planning/feature-ai-index-page.md b/docs/ai/planning/feature-ai-index-page.md index 6fda3c99a34..1990b9d1132 100644 --- a/docs/ai/planning/feature-ai-index-page.md +++ b/docs/ai/planning/feature-ai-index-page.md @@ -44,6 +44,10 @@ description: Task breakdown for the Settings AI index management page - [x] Task 3.8: Refine row-level UX with clearer status meaning, stronger primary actions, and less disabled-button noise - Follow-up refinement kept the compact header but reverted a too-rigid desktop table rhythm back to a more flexible card layout so titles stay readable on desktop and mobile. - Follow-up performance refinement prewarms the main workspace route and the first notes page so the first AI Index -> note transition after a hard reload is less cold. +- [x] Task 3.9: Make AI Index mutations feel immediate and trustworthy + - Successful row actions now update the visible row state optimistically. + - Rows that leave the active filter because of a successful mutation animate out instead of disappearing abruptly. + - The `rag-index` source of truth now writes a fresh `indexed_at` timestamp during upserts so `Outdated` notes truly become `Indexed`. ## Dependencies diff --git a/docs/ai/testing/feature-ai-index-page.md b/docs/ai/testing/feature-ai-index-page.md index 5e9baa06173..e4fd527998b 100644 --- a/docs/ai/testing/feature-ai-index-page.md +++ b/docs/ai/testing/feature-ai-index-page.md @@ -40,6 +40,9 @@ description: Test strategy for the Settings AI index page and its dedicated data - [x] Restores saved AI Index filter/search state on mount and carries the current state into note navigation - [x] Renders the AI Index error state with a retry affordance - [x] Covers the direct `AIIndexList` empty-state passthrough branch +- [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` ## Integration Tests @@ -54,6 +57,7 @@ description: Test strategy for the Settings AI index page and its dedicated data - [ ] Scroll enough to trigger lazy loading - [ ] Reindex an outdated note and verify it leaves the `Outdated` filter - [ ] Remove an indexed note and verify it becomes `Not indexed` +- [ ] Reindex an outdated note from `All notes` and verify the primary action switches to the indexed-state button without a page refresh ## Test Data @@ -68,8 +72,11 @@ description: Test strategy for the Settings AI index page and its dedicated data - `npm run test:unit:web -- --runTestsByPath ui/web/tests/unit/hooks/useAIIndexNotes.test.tsx` - `npm run test:unit:web -- --runTestsByPath ui/web/tests/unit/components/aiIndexNoteRow.test.tsx` - `npm run test:unit:web -- --runTestsByPath ui/web/tests/unit/components/aiIndexTab.test.tsx` + - `npm run test:unit:web -- --runTestsByPath ui/web/tests/unit/components/aiIndexList.test.tsx` - `npm run test:unit:web -- --runTestsByPath ui/web/tests/unit/lib/aiIndexNavigationState.test.ts` - `npm run test:unit:web -- --runTestsByPath ui/web/tests/unit/components/settingsPage.aiIndex.test.tsx` + - `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 type-check` - `npm run type-check:tests` diff --git a/supabase/functions/rag-index/index.ts b/supabase/functions/rag-index/index.ts index 70718439815..30bdbcd1800 100644 --- a/supabase/functions/rag-index/index.ts +++ b/supabase/functions/rag-index/index.ts @@ -212,7 +212,7 @@ serve(async (req: Request) => { .eq("note_id", noteId) .eq("user_id", userId) if (error) throw error - return jsonResponse({ deleted: true }) + return jsonResponse({ outcome: "deleted", deleted: true }) } const { data: apiKeyRow, error: apiKeyError } = await supabaseAdmin @@ -280,6 +280,8 @@ serve(async (req: Request) => { .eq("user_id", userId) if (clearError) throw clearError return jsonResponse({ + outcome: "skipped", + reason: "too_short", chunkCount: 0, skipped: "too_short", message: `Note is too short for indexing (minimum: ${settings.min_chunk_size} characters)`, @@ -298,6 +300,8 @@ serve(async (req: Request) => { throw new Error(`Gemini returned ${vectors.length} vectors for ${chunksForIndexing.length} chunks`) } + const indexedAt = new Date().toISOString() + const rows = chunksForIndexing.map((chunk, index) => ({ note_id: noteId, user_id: userId, @@ -307,6 +311,7 @@ serve(async (req: Request) => { body_content: chunk.bodyContent, overlap_prefix: chunk.overlapPrefix, embedding: vectors[index], + indexed_at: indexedAt, })) const { error: upsertError } = await supabaseAdmin @@ -336,6 +341,7 @@ serve(async (req: Request) => { }) return jsonResponse({ + outcome: "indexed", chunkCount: chunksForIndexing.length, droppedChunks: droppedChunkCount > 0 ? droppedChunkCount : undefined, debugChunks: debugChunks diff --git a/ui/mobile/components/NoteIndexMenu.tsx b/ui/mobile/components/NoteIndexMenu.tsx index 2f194835996..89978f502cc 100644 --- a/ui/mobile/components/NoteIndexMenu.tsx +++ b/ui/mobile/components/NoteIndexMenu.tsx @@ -11,7 +11,8 @@ import { Database, Trash2 } from 'lucide-react-native' import { useTheme } from '@ui/mobile/providers' import { useSupabase } from '@ui/mobile/providers/SupabaseProvider' import { useRagStatus } from '@ui/mobile/hooks/useRagStatus' -import { logRagIndexDebugChunks, type RagIndexDebugChunk } from '@core/rag/debugLog' +import { logRagIndexDebugChunks } from '@core/rag/debugLog' +import { parseRagIndexResult } from '@core/rag/indexResult' interface NoteIndexMenuProps { noteId: string @@ -21,21 +22,6 @@ interface NoteIndexMenuProps { type Operation = 'indexing' | 'deleting' | null -function parseDebugChunks(data: unknown): RagIndexDebugChunk[] { - if (!data || typeof data !== 'object') return [] - const value = (data as { debugChunks?: unknown }).debugChunks - if (!Array.isArray(value)) return [] - return value.filter((chunk): chunk is RagIndexDebugChunk => { - if (!chunk || typeof chunk !== 'object') return false - const candidate = chunk as Partial - return typeof candidate.chunkIndex === 'number' - && typeof candidate.charOffset === 'number' - && typeof candidate.content === 'string' - && (typeof candidate.sectionHeading === 'string' || candidate.sectionHeading === null) - && (typeof candidate.title === 'string' || candidate.title === null) - }) -} - async function extractErrorMessage(err: unknown, fallback: string): Promise { if (!(err instanceof Error)) return fallback const ctx = (err as Error & { context?: unknown }).context @@ -98,14 +84,15 @@ export function NoteIndexMenu({ noteId, visible, onClose }: NoteIndexMenuProps) body: { noteId, action, debugChunks: true }, }) if (error) throw error - const debugChunks = parseDebugChunks(data) - if (debugChunks.length > 0) { - logRagIndexDebugChunks(noteId, debugChunks) + const result = parseRagIndexResult(data) + if (result.debugChunks.length > 0) { + logRagIndexDebugChunks(noteId, result.debugChunks) + } + if (result.outcome === 'indexed') { + showToast(`Indexed into ${result.chunkCount} chunks`) + } else { + showToast(result.message ?? 'Indexing returned an unexpected response.', true) } - const count = typeof (data as { chunkCount?: number })?.chunkCount === 'number' - ? (data as { chunkCount: number }).chunkCount - : null - showToast(count === null ? 'Indexed successfully' : `Indexed into ${count} chunks`) refresh() } catch (err) { showToast(await extractErrorMessage(err, 'Indexing failed'), true) @@ -118,11 +105,16 @@ export function NoteIndexMenu({ noteId, visible, onClose }: NoteIndexMenuProps) setOperation('deleting') setDeleteConfirmVisible(false) try { - const { error } = await client.functions.invoke('rag-index', { + const { data, error } = await client.functions.invoke('rag-index', { body: { noteId, action: 'delete' }, }) if (error) throw error - showToast('Removed from AI index') + const result = parseRagIndexResult(data) + if (result.outcome === 'deleted') { + showToast('Removed from AI index') + } else { + showToast(result.message ?? 'Delete returned an unexpected response.', true) + } refresh() } catch (err) { showToast(await extractErrorMessage(err, 'Delete failed'), true) diff --git a/ui/web/components/features/notes/RagIndexPanel.tsx b/ui/web/components/features/notes/RagIndexPanel.tsx index b5102e10094..4dec90e2b84 100644 --- a/ui/web/components/features/notes/RagIndexPanel.tsx +++ b/ui/web/components/features/notes/RagIndexPanel.tsx @@ -18,9 +18,10 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog' import { toast } from 'sonner' +import { parseRagIndexResult } from '@core/rag/indexResult' import { useSupabase } from '@ui/web/providers/SupabaseProvider' import { useRagStatus } from '@ui/web/hooks/useRagStatus' -import { logRagIndexDebugChunks, type RagIndexDebugChunk } from '@core/rag/debugLog' +import { logRagIndexDebugChunks } from '@core/rag/debugLog' import { isRagDebugChunksEnabled } from '@ui/web/components/features/settings/RagIndexingSettingsPanel' async function extractErrorMessage(err: unknown, fallback: string): Promise { @@ -45,27 +46,6 @@ interface RagIndexPanelProps { type Operation = 'indexing' | 'deleting' | null -function parseDebugChunks(data: unknown): RagIndexDebugChunk[] { - if (!data || typeof data !== 'object') return [] - const value = (data as { debugChunks?: unknown }).debugChunks - if (!Array.isArray(value)) return [] - return value.filter((chunk): chunk is RagIndexDebugChunk => { - if (!chunk || typeof chunk !== 'object') return false - const candidate = chunk as Partial - return typeof candidate.chunkIndex === 'number' - && typeof candidate.charOffset === 'number' - && typeof candidate.content === 'string' - && (typeof candidate.sectionHeading === 'string' || candidate.sectionHeading === null) - && (typeof candidate.title === 'string' || candidate.title === null) - }) -} - -function parseChunkCount(data: unknown): number | null { - if (!data || typeof data !== 'object') return null - const value = (data as { chunkCount?: unknown }).chunkCount - return typeof value === 'number' && Number.isFinite(value) ? value : null -} - export function RagIndexPanel({ noteId, variant = 'inline', onMenuClose }: RagIndexPanelProps) { const { supabase } = useSupabase() const { chunkCount, indexedAt, isLoading, refresh } = useRagStatus(noteId) @@ -83,16 +63,18 @@ export function RagIndexPanel({ noteId, variant = 'inline', onMenuClose }: RagIn body: { noteId, action: isIndexed ? 'reindex' : 'index', ...(debug ? { debugChunks: true } : {}) }, }) if (error) throw error - const debugChunks = parseDebugChunks(data) - if (debugChunks.length > 0) { - logRagIndexDebugChunks(noteId, debugChunks) + const result = parseRagIndexResult(data) + if (result.debugChunks.length > 0) { + logRagIndexDebugChunks(noteId, result.debugChunks) } - const count = parseChunkCount(data) - if (count === null) { - console.warn('[rag-index] Unexpected response payload for index action', data) - toast.success('Indexed successfully') + + if (result.outcome === 'indexed') { + toast.success(`Indexed into ${result.chunkCount} chunks`) + } else if (result.outcome === 'skipped') { + toast.error(result.message) } else { - toast.success(`Indexed into ${count} chunks`) + console.warn('[rag-index] Unexpected response payload for index action', data) + toast.error(result.message) } refresh() } catch (err) { @@ -105,11 +87,16 @@ export function RagIndexPanel({ noteId, variant = 'inline', onMenuClose }: RagIn const handleDelete = async () => { setOperation('deleting') try { - const { error } = await supabase.functions.invoke('rag-index', { + const { data, error } = await supabase.functions.invoke('rag-index', { body: { noteId, action: 'delete' }, }) if (error) throw error - toast.success('RAG index removed') + const result = parseRagIndexResult(data) + if (result.outcome === 'deleted') { + toast.success('RAG index removed') + } else { + toast.error(result.message) + } refresh() } catch (err) { toast.error(await extractErrorMessage(err, 'Delete failed')) diff --git a/ui/web/components/features/settings/AIIndexList.tsx b/ui/web/components/features/settings/AIIndexList.tsx index 6da05a4dc5b..c9dd4e29838 100644 --- a/ui/web/components/features/settings/AIIndexList.tsx +++ b/ui/web/components/features/settings/AIIndexList.tsx @@ -8,7 +8,7 @@ import * as ReactWindow from "react-window" import { Loader2 } from "lucide-react" import { Button } from "@/components/ui/button" -import type { AIIndexNoteRow as AIIndexNoteRowData } from "@core/types/aiIndex" +import type { AIIndexMutationResult, AIIndexNoteRow as AIIndexNoteRowData } from "@core/types/aiIndex" import { AIIndexNoteRow } from "@/components/features/settings/AIIndexNoteRow" // react-window v2 uses different API (rowCount, rowHeight, rowComponent, rowProps) @@ -33,15 +33,16 @@ type RowComponentProps = { type ItemData = { items: AIIndexNoteRowData[] + exitingNoteIds: Set hasMore: boolean isLoadingMore: boolean onLoadMore: () => void - onMutated: () => void + onMutated: (result: AIIndexMutationResult) => void onOpenNote: (noteId: string) => void } const AIIndexRowRenderer = memo(({ index, style, ...props }: RowComponentProps) => { - const { items, hasMore, isLoadingMore, onLoadMore, onMutated, onOpenNote } = props as unknown as ItemData + const { items, exitingNoteIds, hasMore, isLoadingMore, onLoadMore, onMutated, onOpenNote } = props as unknown as ItemData if (index === items.length) { let loadMoreContent: React.ReactNode = null @@ -67,7 +68,12 @@ const AIIndexRowRenderer = memo(({ index, style, ...props }: RowComponentProps - + ) }) @@ -117,6 +123,7 @@ function LoadingSkeleton() { export const AIIndexList = memo(function AIIndexList({ notes, + exitingNoteIds = [], isLoading, hasMore, isFetchingNextPage, @@ -130,11 +137,12 @@ export const AIIndexList = memo(function AIIndexList({ width, }: { notes: AIIndexNoteRowData[] + exitingNoteIds?: string[] isLoading: boolean hasMore: boolean isFetchingNextPage: boolean onLoadMore: () => void - onMutated: () => void + onMutated: (result: AIIndexMutationResult) => void onOpenNote: (noteId: string) => void emptyState: React.ReactNode initialScrollOffset?: number @@ -145,15 +153,17 @@ export const AIIndexList = memo(function AIIndexList({ const dynamicRowHeight = useDynamicRowHeight({ defaultRowHeight: 168 }) const listRef = useListRef() const restoredScrollOffsetRef = React.useRef(null) + const exitingNoteIdSet = useMemo(() => new Set(exitingNoteIds), [exitingNoteIds]) const itemData = useMemo(() => ({ items: notes, + exitingNoteIds: exitingNoteIdSet, hasMore, isLoadingMore: isFetchingNextPage, onLoadMore, onMutated, onOpenNote, - }), [hasMore, isFetchingNextPage, notes, onLoadMore, onMutated, onOpenNote]) + }), [exitingNoteIdSet, hasMore, isFetchingNextPage, notes, onLoadMore, onMutated, onOpenNote]) React.useLayoutEffect(() => { if (initialScrollOffset <= 0) return diff --git a/ui/web/components/features/settings/AIIndexNoteRow.tsx b/ui/web/components/features/settings/AIIndexNoteRow.tsx index f54b6829284..fbf041c0fe7 100644 --- a/ui/web/components/features/settings/AIIndexNoteRow.tsx +++ b/ui/web/components/features/settings/AIIndexNoteRow.tsx @@ -6,17 +6,8 @@ import { toast } from "sonner" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog" -import type { AIIndexNoteRow as AIIndexNoteRowData } from "@core/types/aiIndex" +import { parseRagIndexResult } from "@core/rag/indexResult" +import type { AIIndexMutationResult, AIIndexNoteRow as AIIndexNoteRowData } from "@core/types/aiIndex" import { cn } from "@ui/web/lib/utils" import { useSupabase } from "@ui/web/providers/SupabaseProvider" @@ -68,17 +59,18 @@ export function AIIndexNoteRow({ note, onMutated, onOpenNote, + isExiting = false, }: Readonly<{ note: AIIndexNoteRowData - onMutated: () => void + onMutated: (result: AIIndexMutationResult) => void onOpenNote: (noteId: string) => void + isExiting?: boolean }>) { const { supabase } = useSupabase() const [operation, setOperation] = React.useState(null) - const [deleteConfirmOpen, setDeleteConfirmOpen] = React.useState(false) const isIndexed = note.status !== "not_indexed" - const isBusy = operation !== null + const isBusy = operation !== null || isExiting let actionLabel = "Index note" if (note.status === "outdated") { actionLabel = "Update index" @@ -98,7 +90,7 @@ export function AIIndexNoteRow({ const handleIndex = React.useCallback(async () => { setOperation("indexing") try { - const { error } = await supabase.functions.invoke("rag-index", { + const { data, error } = await supabase.functions.invoke("rag-index", { body: { noteId: note.id, action: isIndexed ? "reindex" : "index", @@ -106,19 +98,41 @@ export function AIIndexNoteRow({ }) if (error) throw error - toast.success(`Note ${actionVerb}`) - onMutated() + const result = parseRagIndexResult(data) + if (result.outcome === "indexed") { + toast.success(`Note ${actionVerb}`) + onMutated({ + noteId: note.id, + previousStatus: note.status, + nextStatus: "indexed", + }) + return + } + + if (result.outcome === "skipped") { + toast.error(result.message) + if (result.reason === "too_short") { + onMutated({ + noteId: note.id, + previousStatus: note.status, + nextStatus: "not_indexed", + }) + } + return + } + + toast.error(result.message) } catch (error) { toast.error(await extractErrorMessage(error, `${actionLabel} failed`)) } finally { setOperation(null) } - }, [actionLabel, actionVerb, isIndexed, note.id, onMutated, supabase.functions]) + }, [actionLabel, actionVerb, isIndexed, note.id, note.status, onMutated, supabase.functions]) const handleDelete = React.useCallback(async () => { setOperation("deleting") try { - const { error } = await supabase.functions.invoke("rag-index", { + const { data, error } = await supabase.functions.invoke("rag-index", { body: { noteId: note.id, action: "delete", @@ -126,15 +140,24 @@ export function AIIndexNoteRow({ }) if (error) throw error - toast.success("Note removed from AI index") - onMutated() + const result = parseRagIndexResult(data) + if (result.outcome === "deleted") { + toast.success("Note removed from AI index") + onMutated({ + noteId: note.id, + previousStatus: note.status, + nextStatus: "not_indexed", + }) + return + } + + toast.error(result.message) } catch (error) { toast.error(await extractErrorMessage(error, "Remove from index failed")) } finally { setOperation(null) - setDeleteConfirmOpen(false) } - }, [note.id, onMutated, supabase.functions]) + }, [note.id, note.status, onMutated, supabase.functions]) const handleIndexClick = React.useCallback(() => { handleIndex().catch(() => { @@ -142,8 +165,7 @@ export function AIIndexNoteRow({ }) }, [handleIndex]) - const handleDeleteClick = React.useCallback((event: React.MouseEvent) => { - event.preventDefault() + const handleDeleteClick = React.useCallback(() => { handleDelete().catch(() => { // handleDelete already reports failures with a toast. }) @@ -151,7 +173,13 @@ export function AIIndexNoteRow({ return ( <> -
+
- - - - - Remove note from AI index? - - This deletes all stored embeddings for the note. You can index it again later from this page. - - - - Cancel - - {operation === "deleting" ? "Removing..." : "Remove index"} - - - - ) } diff --git a/ui/web/components/features/settings/AIIndexTab.tsx b/ui/web/components/features/settings/AIIndexTab.tsx index eb2bd1a26be..023ea783077 100644 --- a/ui/web/components/features/settings/AIIndexTab.tsx +++ b/ui/web/components/features/settings/AIIndexTab.tsx @@ -10,8 +10,13 @@ import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { SEARCH_CONFIG } from "@core/constants/search" import { NoteService } from "@core/services/notes" +import type { + AIIndexFilter, + AIIndexMutationResult, + AIIndexNoteRow as AIIndexNoteRowData, +} from "@core/types/aiIndex" +import { selectableSurfaceStateClasses } from "@ui/web/lib/selectableSurfaceStyles" import { cn } from "@ui/web/lib/utils" -import type { AIIndexFilter } from "@core/types/aiIndex" import { getAIIndexNotesQueryPrefix, useAIIndexNotes, @@ -57,12 +62,24 @@ const FILTER_SEARCH_LABELS: Record = { outdated: "outdated notes", } +const ROW_EXIT_DURATION_MS = 260 + +type OptimisticMutationState = AIIndexMutationResult & { + phase: "stable" | "leaving" | "hidden" + noteSnapshot: AIIndexNoteRowData + sourceIndex: number +} + function runBackgroundTask(task: Promise) { task.catch(() => { // Best-effort background work should not break the settings UI. }) } +function matchesAIIndexFilter(filter: AIIndexFilter, status: AIIndexNoteRowData["status"]) { + return filter === "all" || filter === status +} + function getPersistedSearchQuery(searchDraft: string) { return searchDraft.length >= SEARCH_CONFIG.MIN_QUERY_LENGTH ? searchDraft : "" } @@ -83,6 +100,72 @@ function getResultsSummaryText(loadedCount: number, totalCount: number) { return `Showing ${totalCount} ${totalCount === 1 ? "note" : "notes"}` } +function mergeOptimisticNotes( + notes: AIIndexNoteRowData[], + filter: AIIndexFilter, + optimisticMutations: Record +) { + const visibleEntries = notes.map((note, index) => { + const optimisticMutation = optimisticMutations[note.id] + if (!optimisticMutation) { + return { + note, + isExiting: false, + order: index, + } + } + + const projectedNote: AIIndexNoteRowData = { + ...note, + status: optimisticMutation.nextStatus, + lastIndexedAt: optimisticMutation.nextStatus === "not_indexed" ? null : note.lastIndexedAt, + } + const matchesFilter = matchesAIIndexFilter(filter, optimisticMutation.nextStatus) + + if (!matchesFilter && optimisticMutation.phase === "hidden") { + return null + } + + return { + note: projectedNote, + isExiting: optimisticMutation.phase === "leaving", + order: optimisticMutation.sourceIndex, + } + }).filter((entry): entry is { note: AIIndexNoteRowData; isExiting: boolean; order: number } => entry !== null) + + const visibleIds = new Set(visibleEntries.map((entry) => entry.note.id)) + const exitingEntries = Object.values(optimisticMutations) + .filter((mutation) => mutation.phase === "leaving" && !visibleIds.has(mutation.noteId)) + .map((mutation) => ({ + note: { + ...mutation.noteSnapshot, + status: mutation.nextStatus, + lastIndexedAt: mutation.nextStatus === "not_indexed" ? null : mutation.noteSnapshot.lastIndexedAt, + }, + isExiting: true, + order: mutation.sourceIndex + 0.5, + })) + + return [...visibleEntries, ...exitingEntries] + .sort((left, right) => left.order - right.order) +} + +function getOptimisticTotalCount( + filter: AIIndexFilter, + totalCount: number, + optimisticMutations: Record +) { + const delta = Object.values(optimisticMutations).reduce((sum, mutation) => { + const matchedBefore = matchesAIIndexFilter(filter, mutation.previousStatus) + const matchedAfter = matchesAIIndexFilter(filter, mutation.nextStatus) + + if (matchedBefore === matchedAfter) return sum + return sum + (matchedAfter ? 1 : -1) + }, 0) + + return Math.max(0, totalCount + delta) +} + function AIIndexResetActions({ hasActiveFilter, hasActiveSearch, @@ -187,7 +270,10 @@ function AIIndexToolbar({
-
+
{filterOptions.map((option) => { const isActive = option.value === filter return ( @@ -196,10 +282,8 @@ function AIIndexToolbar({ type="button" onClick={() => onFilterChange(option.value)} className={cn( - "rounded-xl border px-4 py-2 text-sm font-medium transition-all", - isActive - ? "border-foreground/15 bg-foreground text-background shadow-sm" - : "border-border/70 bg-background text-foreground hover:bg-muted/50" + "shrink-0 rounded-xl border px-4 py-2 text-sm font-medium transition-colors", + isActive ? selectableSurfaceStateClasses.active : selectableSurfaceStateClasses.idlePill )} > {option.label} @@ -350,7 +434,9 @@ export function AIIndexTab() { const [searchQuery, setSearchQuery] = React.useState("") const [initialScrollOffset, setInitialScrollOffset] = React.useState(0) const [scrollOffset, setScrollOffset] = React.useState(0) + const [optimisticMutations, setOptimisticMutations] = React.useState>({}) const restoredStateRef = React.useRef(false) + const exitTimeoutsRef = React.useRef>>({}) const normalizedSearchDraft = searchDraft.trim() const persistedSearchQuery = getPersistedSearchQuery(normalizedSearchDraft) const isSearchHintVisible = @@ -365,6 +451,34 @@ export function AIIndexTab() { const notes = useFlattenedAIIndexNotes(query) const totalCount = query.data?.pages[0]?.totalCount ?? 0 + React.useEffect(() => () => { + Object.values(exitTimeoutsRef.current).forEach((timeoutId) => clearTimeout(timeoutId)) + }, []) + + React.useEffect(() => { + setOptimisticMutations((previousState) => { + let hasChanges = false + const nextState = { ...previousState } + + for (const [noteId, optimisticMutation] of Object.entries(previousState)) { + const liveNote = notes.find((note) => note.id === noteId) + + if (liveNote && liveNote.status === optimisticMutation.nextStatus) { + delete nextState[noteId] + hasChanges = true + continue + } + + if (!liveNote && optimisticMutation.phase !== "leaving" && !matchesAIIndexFilter(filter, optimisticMutation.nextStatus)) { + delete nextState[noteId] + hasChanges = true + } + } + + return hasChanges ? nextState : previousState + }) + }, [filter, notes]) + React.useEffect(() => { if (restoredStateRef.current) return @@ -400,9 +514,49 @@ export function AIIndexTab() { })) }, [noteService, queryClient, router, user?.id]) - const handleMutated = React.useCallback(() => { + const handleMutated = React.useCallback((mutationResult: AIIndexMutationResult) => { + const sourceIndex = notes.findIndex((note) => note.id === mutationResult.noteId) + const sourceNote = sourceIndex >= 0 ? notes[sourceIndex] : null + const shouldExit = + sourceNote !== null && + filter !== "all" && + !matchesAIIndexFilter(filter, mutationResult.nextStatus) + + if (sourceNote) { + setOptimisticMutations((previousState) => ({ + ...previousState, + [mutationResult.noteId]: { + ...mutationResult, + phase: shouldExit ? "leaving" : "stable", + noteSnapshot: sourceNote, + sourceIndex, + }, + })) + } + + if (shouldExit) { + const existingTimeout = exitTimeoutsRef.current[mutationResult.noteId] + if (existingTimeout) clearTimeout(existingTimeout) + + exitTimeoutsRef.current[mutationResult.noteId] = setTimeout(() => { + setOptimisticMutations((previousState) => { + const currentMutation = previousState[mutationResult.noteId] + if (!currentMutation) return previousState + + return { + ...previousState, + [mutationResult.noteId]: { + ...currentMutation, + phase: "hidden", + }, + } + }) + delete exitTimeoutsRef.current[mutationResult.noteId] + }, ROW_EXIT_DURATION_MS) + } + runBackgroundTask(queryClient.invalidateQueries({ queryKey: getAIIndexNotesQueryPrefix(user?.id) })) - }, [queryClient, user?.id]) + }, [filter, notes, queryClient, user?.id]) const handleSearchChange = React.useCallback((value: string) => { setSearchDraft(value) @@ -445,11 +599,21 @@ export function AIIndexTab() { router.push("/") }, [currentViewState, router]) - const loadedCount = notes.length + const mergedNotes = React.useMemo(() => mergeOptimisticNotes(notes, filter, optimisticMutations), [filter, notes, optimisticMutations]) + const displayedNotes = React.useMemo(() => mergedNotes.map((entry) => entry.note), [mergedNotes]) + const exitingNoteIds = React.useMemo( + () => mergedNotes.filter((entry) => entry.isExiting).map((entry) => entry.note.id), + [mergedNotes] + ) + const optimisticTotalCount = React.useMemo( + () => getOptimisticTotalCount(filter, totalCount, optimisticMutations), + [filter, optimisticMutations, totalCount] + ) + const loadedCount = displayedNotes.length const hasActiveSearch = activeSearchQuery.length > 0 const hasActiveFilter = filter !== "all" const emptyMessage = getAIIndexEmptyMessage(filter, activeSearchQuery) - const summaryText = getResultsSummaryText(loadedCount, totalCount) + const summaryText = getResultsSummaryText(loadedCount, optimisticTotalCount) const handleResetFilter = React.useCallback(() => { setFilter("all") }, []) @@ -496,7 +660,7 @@ export function AIIndexTab() { onSearchChange={handleSearchChange} onSearchKeyDown={handleSearchKeyDown} searchDraft={searchDraft} - totalCount={totalCount} + totalCount={optimisticTotalCount} />
@@ -512,7 +676,8 @@ export function AIIndexTab() { />
({ note, onMutated, onOpenNote, + isExiting, }: { note: AIIndexNoteRowData - onMutated: () => void + onMutated: (result: AIIndexMutationResult) => void onOpenNote: (noteId: string) => void + isExiting?: boolean }) => ( -
+
{note.title} {note.status} -
@@ -208,4 +217,11 @@ describe("AIIndexList", () => { expect(onScrollOffsetChange).toHaveBeenCalledWith(72) }) + + it("marks exiting rows so filtered removals can animate out", () => { + renderAIIndexList({ exitingNoteIds: ["note-2"] }) + + expect(screen.getByTestId("note-row-note-1").getAttribute("data-exiting")).toBe("false") + expect(screen.getByTestId("note-row-note-2").getAttribute("data-exiting")).toBe("true") + }) }) diff --git a/ui/web/tests/unit/components/aiIndexNoteRow.test.tsx b/ui/web/tests/unit/components/aiIndexNoteRow.test.tsx index 6e7fef61d26..f35d9e5d9b4 100644 --- a/ui/web/tests/unit/components/aiIndexNoteRow.test.tsx +++ b/ui/web/tests/unit/components/aiIndexNoteRow.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor, within } from "@testing-library/react" +import { fireEvent, render, screen, waitFor } from "@testing-library/react" import type React from "react" import { toast } from "sonner" @@ -57,12 +57,16 @@ describe("AIIndexNoteRow", () => { action: "index", }, }) - expect(onMutated).toHaveBeenCalled() + expect(onMutated).toHaveBeenCalledWith({ + noteId: "note-1", + previousStatus: "not_indexed", + nextStatus: "indexed", + }) }) }) it("uses the update action for outdated notes and reindexes them", async () => { - const invoke = jest.fn().mockResolvedValue({ data: { chunkCount: 5 }, error: null }) + const invoke = jest.fn().mockResolvedValue({ data: { outcome: "indexed", chunkCount: 5 }, error: null }) const onMutated = jest.fn() renderWithSupabase( @@ -92,12 +96,16 @@ describe("AIIndexNoteRow", () => { action: "reindex", }, }) - expect(onMutated).toHaveBeenCalled() + expect(onMutated).toHaveBeenCalledWith({ + noteId: "note-outdated", + previousStatus: "outdated", + nextStatus: "indexed", + }) }) }) - it("removes an indexed note after confirmation", async () => { - const invoke = jest.fn().mockResolvedValue({ data: { deleted: true }, error: null }) + it("removes an indexed note immediately", async () => { + const invoke = jest.fn().mockResolvedValue({ data: { outcome: "deleted", deleted: true }, error: null }) const onMutated = jest.fn() const onOpenNote = jest.fn() @@ -118,9 +126,6 @@ describe("AIIndexNoteRow", () => { fireEvent.click(screen.getByRole("button", { name: "Remove index" })) - const dialog = await screen.findByRole("alertdialog") - fireEvent.click(within(dialog).getByRole("button", { name: "Remove index" })) - await waitFor(() => { expect(invoke).toHaveBeenCalledWith("rag-index", { body: { @@ -128,7 +133,51 @@ describe("AIIndexNoteRow", () => { action: "delete", }, }) - expect(onMutated).toHaveBeenCalled() + expect(onMutated).toHaveBeenCalledWith({ + noteId: "note-2", + previousStatus: "indexed", + nextStatus: "not_indexed", + }) + }) + }) + + it("treats too-short index responses as a semantic failure and restores not-indexed state", async () => { + const invoke = jest.fn().mockResolvedValue({ + data: { + outcome: "skipped", + reason: "too_short", + chunkCount: 0, + message: "Note is too short for indexing (minimum: 250 characters)", + }, + error: null, + }) + const onMutated = jest.fn() + + renderWithSupabase( + , + invoke + ) + + fireEvent.click(screen.getByRole("button", { name: "Update index" })) + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith("Note is too short for indexing (minimum: 250 characters)") + expect(toast.success).not.toHaveBeenCalled() + expect(onMutated).toHaveBeenCalledWith({ + noteId: "note-short", + previousStatus: "outdated", + nextStatus: "not_indexed", + }) }) }) diff --git a/ui/web/tests/unit/components/aiIndexTab.test.tsx b/ui/web/tests/unit/components/aiIndexTab.test.tsx index b30ce7ec364..ca5aaf6d1b5 100644 --- a/ui/web/tests/unit/components/aiIndexTab.test.tsx +++ b/ui/web/tests/unit/components/aiIndexTab.test.tsx @@ -1,6 +1,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" import { AIIndexTab } from "@/components/features/settings/AIIndexTab" +import type { AIIndexMutationResult } from "@core/types/aiIndex" import { SupabaseTestProvider } from "@ui/web/providers/SupabaseProvider" import * as aiIndexHooks from "@ui/web/hooks/useAIIndexNotes" import { @@ -44,8 +45,8 @@ describe("AIIndexTab", () => { isFetching: false, isError: false, error: null, - refetch: jest.fn(), - fetchNextPage: jest.fn(), + refetch: jest.fn().mockResolvedValue(undefined), + fetchNextPage: jest.fn().mockResolvedValue(undefined), } beforeEach(() => { @@ -250,4 +251,129 @@ describe("AIIndexTab", () => { expect(refetch).toHaveBeenCalled() }) + + it("updates row status immediately on the all-notes view after a successful mutation", async () => { + jest.spyOn(aiIndexHooks, "useFlattenedAIIndexNotes").mockReturnValue([ + { + id: "note-outdated", + title: "Outdated note", + updatedAt: "2026-03-29T10:00:00Z", + lastIndexedAt: "2026-03-29T09:00:00Z", + status: "outdated", + }, + ]) + jest.spyOn(aiIndexHooks, "useAIIndexNotes").mockReturnValue({ + ...mockQuery, + data: { pages: [{ totalCount: 1, notes: [], hasMore: false }] }, + } as never) + + render( + + + + ) + + const latestListProps = mockAIIndexList.mock.calls.at(-1)?.[0] as { + notes: Array<{ id: string; status: string }> + onMutated: (result: AIIndexMutationResult) => void + exitingNoteIds?: string[] + } + + act(() => { + latestListProps.onMutated({ + noteId: "note-outdated", + previousStatus: "outdated", + nextStatus: "indexed", + }) + }) + + const rerenderedListProps = mockAIIndexList.mock.calls.at(-1)?.[0] as { + notes: Array<{ id: string; status: string }> + exitingNoteIds?: string[] + } + + expect(rerenderedListProps.notes).toEqual([ + expect.objectContaining({ + id: "note-outdated", + status: "indexed", + }), + ]) + expect(rerenderedListProps.exitingNoteIds).toEqual([]) + }) + + it("animates outdated notes out of the filtered view after a successful reindex", async () => { + jest.spyOn(aiIndexHooks, "useFlattenedAIIndexNotes").mockReturnValue([ + { + id: "note-outdated", + title: "Outdated note", + updatedAt: "2026-03-29T10:00:00Z", + lastIndexedAt: "2026-03-29T09:00:00Z", + status: "outdated", + }, + { + id: "note-outdated-2", + title: "Another outdated note", + updatedAt: "2026-03-29T11:00:00Z", + lastIndexedAt: "2026-03-29T10:30:00Z", + status: "outdated", + }, + ]) + jest.spyOn(aiIndexHooks, "useAIIndexNotes").mockReturnValue({ + ...mockQuery, + data: { pages: [{ totalCount: 2, notes: [], hasMore: false }] }, + } as never) + + render( + + + + ) + + fireEvent.click(screen.getByRole("button", { name: "Outdated" })) + + const latestListProps = mockAIIndexList.mock.calls.at(-1)?.[0] as { + notes: Array<{ id: string; status: string }> + onMutated: (result: AIIndexMutationResult) => void + exitingNoteIds?: string[] + } + + act(() => { + latestListProps.onMutated({ + noteId: "note-outdated", + previousStatus: "outdated", + nextStatus: "indexed", + }) + }) + + const duringExitProps = mockAIIndexList.mock.calls.at(-1)?.[0] as { + notes: Array<{ id: string; status: string }> + exitingNoteIds?: string[] + } + + expect(duringExitProps.notes).toEqual([ + expect.objectContaining({ id: "note-outdated", status: "indexed" }), + expect.objectContaining({ id: "note-outdated-2", status: "outdated" }), + ]) + expect(duringExitProps.exitingNoteIds).toEqual(["note-outdated"]) + + act(() => { + jest.advanceTimersByTime(260) + }) + + const afterExitProps = mockAIIndexList.mock.calls.at(-1)?.[0] as { + notes: Array<{ id: string; status: string }> + exitingNoteIds?: string[] + } + + expect(afterExitProps.notes).toEqual([ + expect.objectContaining({ id: "note-outdated-2", status: "outdated" }), + ]) + expect(afterExitProps.exitingNoteIds).toEqual([]) + }) }) From cf89b1cd33a27e5887b78a869ad3163ddd0a4ad4 Mon Sep 17 00:00:00 2001 From: Denys Date: Wed, 1 Apr 2026 22:37:17 +0200 Subject: [PATCH 2/5] Preserve AI index debug chunks and optimistic filter state --- core/rag/indexResult.ts | 16 ++- core/tests/unit/core-rag-indexResult.test.ts | 30 ++++ docs/ai/testing/feature-ai-index-page.md | 1 - .../features/settings/AIIndexNoteRow.tsx | 134 +++++++++--------- .../features/settings/AIIndexTab.tsx | 10 +- .../unit/components/aiIndexList.test.tsx | 4 +- .../tests/unit/components/aiIndexTab.test.tsx | 47 ++++++ 7 files changed, 165 insertions(+), 77 deletions(-) diff --git a/core/rag/indexResult.ts b/core/rag/indexResult.ts index 1c3d1e65ceb..5f0c25a887a 100644 --- a/core/rag/indexResult.ts +++ b/core/rag/indexResult.ts @@ -4,6 +4,8 @@ export type RagIndexDebugChunkPayload = { sectionHeading: string | null title: string | null content: string + bodyContent: string + overlapPrefix: string | null } export type RagIndexSkippedReason = "too_short" @@ -54,6 +56,12 @@ function normalizeMessage(value: unknown, fallback: string) { return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback } +function parseSkippedReason(data: Record) { + if (typeof data.reason === "string") return data.reason + if (typeof data.skipped === "string") return data.skipped + return null +} + export function parseRagIndexDebugChunks(data: unknown): RagIndexDebugChunkPayload[] { if (!isRecord(data)) return [] @@ -65,6 +73,8 @@ export function parseRagIndexDebugChunks(data: unknown): RagIndexDebugChunkPaylo return typeof chunk.chunkIndex === "number" && typeof chunk.charOffset === "number" && typeof chunk.content === "string" + && typeof chunk.bodyContent === "string" + && (typeof chunk.overlapPrefix === "string" || chunk.overlapPrefix === null) && (typeof chunk.sectionHeading === "string" || chunk.sectionHeading === null) && (typeof chunk.title === "string" || chunk.title === null) }) @@ -84,11 +94,7 @@ export function parseRagIndexResult(data: unknown): NormalizedRagIndexResult { } const debugChunks = parseRagIndexDebugChunks(data) - const rawReason = typeof data.reason === "string" - ? data.reason - : typeof data.skipped === "string" - ? data.skipped - : null + const rawReason = parseSkippedReason(data) if (data.outcome === "skipped" || rawReason !== null) { return { diff --git a/core/tests/unit/core-rag-indexResult.test.ts b/core/tests/unit/core-rag-indexResult.test.ts index 993a7fe9a4a..163b3271324 100644 --- a/core/tests/unit/core-rag-indexResult.test.ts +++ b/core/tests/unit/core-rag-indexResult.test.ts @@ -44,6 +44,36 @@ describe("parseRagIndexResult", () => { }) }) + it("keeps the full debug chunk shape returned by the edge function", () => { + expect(parseRagIndexResult({ + outcome: "indexed", + chunkCount: 1, + debugChunks: [{ + chunkIndex: 0, + charOffset: 12, + sectionHeading: "Heading", + title: "Title", + content: "Chunk content", + bodyContent: "Body content", + overlapPrefix: "Overlap", + }], + })).toEqual({ + outcome: "indexed", + chunkCount: 1, + droppedChunks: 0, + message: null, + debugChunks: [{ + chunkIndex: 0, + charOffset: 12, + sectionHeading: "Heading", + title: "Title", + content: "Chunk content", + bodyContent: "Body content", + overlapPrefix: "Overlap", + }], + }) + }) + it("supports delete payloads", () => { expect(parseRagIndexResult({ deleted: true })).toEqual({ outcome: "deleted", diff --git a/docs/ai/testing/feature-ai-index-page.md b/docs/ai/testing/feature-ai-index-page.md index e4fd527998b..9fb744e6db4 100644 --- a/docs/ai/testing/feature-ai-index-page.md +++ b/docs/ai/testing/feature-ai-index-page.md @@ -72,7 +72,6 @@ description: Test strategy for the Settings AI index page and its dedicated data - `npm run test:unit:web -- --runTestsByPath ui/web/tests/unit/hooks/useAIIndexNotes.test.tsx` - `npm run test:unit:web -- --runTestsByPath ui/web/tests/unit/components/aiIndexNoteRow.test.tsx` - `npm run test:unit:web -- --runTestsByPath ui/web/tests/unit/components/aiIndexTab.test.tsx` - - `npm run test:unit:web -- --runTestsByPath ui/web/tests/unit/components/aiIndexList.test.tsx` - `npm run test:unit:web -- --runTestsByPath ui/web/tests/unit/lib/aiIndexNavigationState.test.ts` - `npm run test:unit:web -- --runTestsByPath ui/web/tests/unit/components/settingsPage.aiIndex.test.tsx` - `npm run test:unit:core -- --runTestsByPath core/tests/unit/core-rag-indexResult.test.ts` diff --git a/ui/web/components/features/settings/AIIndexNoteRow.tsx b/ui/web/components/features/settings/AIIndexNoteRow.tsx index fbf041c0fe7..75072601644 100644 --- a/ui/web/components/features/settings/AIIndexNoteRow.tsx +++ b/ui/web/components/features/settings/AIIndexNoteRow.tsx @@ -172,85 +172,83 @@ export function AIIndexNoteRow({ }, [handleDelete]) return ( - <> -
-
- +
+ -
+
+ + + {showRemoveAction ? ( - - {showRemoveAction ? ( - - ) : null} -
+ ) : null}
-
- +
+
) } diff --git a/ui/web/components/features/settings/AIIndexTab.tsx b/ui/web/components/features/settings/AIIndexTab.tsx index 023ea783077..c47e58d97f8 100644 --- a/ui/web/components/features/settings/AIIndexTab.tsx +++ b/ui/web/components/features/settings/AIIndexTab.tsx @@ -122,7 +122,15 @@ function mergeOptimisticNotes( } const matchesFilter = matchesAIIndexFilter(filter, optimisticMutation.nextStatus) - if (!matchesFilter && optimisticMutation.phase === "hidden") { + if (!matchesFilter) { + if (optimisticMutation.phase === "leaving") { + return { + note: projectedNote, + isExiting: true, + order: optimisticMutation.sourceIndex, + } + } + return null } diff --git a/ui/web/tests/unit/components/aiIndexList.test.tsx b/ui/web/tests/unit/components/aiIndexList.test.tsx index 2e6eb82cc31..879edfdc009 100644 --- a/ui/web/tests/unit/components/aiIndexList.test.tsx +++ b/ui/web/tests/unit/components/aiIndexList.test.tsx @@ -221,7 +221,7 @@ describe("AIIndexList", () => { it("marks exiting rows so filtered removals can animate out", () => { renderAIIndexList({ exitingNoteIds: ["note-2"] }) - expect(screen.getByTestId("note-row-note-1").getAttribute("data-exiting")).toBe("false") - expect(screen.getByTestId("note-row-note-2").getAttribute("data-exiting")).toBe("true") + expect((screen.getByTestId("note-row-note-1") as HTMLDivElement).dataset.exiting).toBe("false") + expect((screen.getByTestId("note-row-note-2") as HTMLDivElement).dataset.exiting).toBe("true") }) }) diff --git a/ui/web/tests/unit/components/aiIndexTab.test.tsx b/ui/web/tests/unit/components/aiIndexTab.test.tsx index ca5aaf6d1b5..f082002825c 100644 --- a/ui/web/tests/unit/components/aiIndexTab.test.tsx +++ b/ui/web/tests/unit/components/aiIndexTab.test.tsx @@ -376,4 +376,51 @@ describe("AIIndexTab", () => { ]) expect(afterExitProps.exitingNoteIds).toEqual([]) }) + + it("hides stable optimistic rows that stop matching after the user switches filters", async () => { + jest.spyOn(aiIndexHooks, "useFlattenedAIIndexNotes").mockReturnValue([ + { + id: "note-optimistic", + title: "Optimistic note", + updatedAt: "2026-03-29T10:00:00Z", + lastIndexedAt: null, + status: "not_indexed", + }, + ]) + jest.spyOn(aiIndexHooks, "useAIIndexNotes").mockReturnValue({ + ...mockQuery, + data: { pages: [{ totalCount: 1, notes: [], hasMore: false }] }, + } as never) + + render( + + + + ) + + const listProps = mockAIIndexList.mock.calls.at(-1)?.[0] as { + onMutated: (result: AIIndexMutationResult) => void + } + + act(() => { + listProps.onMutated({ + noteId: "note-optimistic", + previousStatus: "not_indexed", + nextStatus: "indexed", + }) + }) + + fireEvent.click(screen.getByRole("button", { name: "Not indexed" })) + + const rerenderedListProps = mockAIIndexList.mock.calls.at(-1)?.[0] as { + notes: Array<{ id: string; status: string }> + exitingNoteIds?: string[] + } + + expect(rerenderedListProps.notes).toEqual([]) + expect(rerenderedListProps.exitingNoteIds).toEqual([]) + }) }) From 39747e4d886f325f947ce490241fce25156ba2fd Mon Sep 17 00:00:00 2001 From: Denys Date: Wed, 1 Apr 2026 22:46:29 +0200 Subject: [PATCH 3/5] Address PR review follow-ups --- core/rag/indexResult.ts | 3 + core/tests/unit/core-rag-indexResult.test.ts | 59 +++++++++++++++++++ .../features/settings/AIIndexTab.tsx | 2 +- .../tests/unit/components/aiIndexTab.test.tsx | 6 +- 4 files changed, 67 insertions(+), 3 deletions(-) diff --git a/core/rag/indexResult.ts b/core/rag/indexResult.ts index 5f0c25a887a..8a1aa3e1f4b 100644 --- a/core/rag/indexResult.ts +++ b/core/rag/indexResult.ts @@ -99,6 +99,9 @@ export function parseRagIndexResult(data: unknown): NormalizedRagIndexResult { if (data.outcome === "skipped" || rawReason !== null) { return { outcome: "skipped", + // Preserve the skipped outcome and backend message even when the reason is + // not yet modeled locally. That lets the UI surface the semantic failure + // without pretending to understand a new discriminant. reason: rawReason === "too_short" ? "too_short" : null, chunkCount: 0, message: normalizeMessage(data.message, "Indexing was skipped."), diff --git a/core/tests/unit/core-rag-indexResult.test.ts b/core/tests/unit/core-rag-indexResult.test.ts index 163b3271324..bc0c9859e37 100644 --- a/core/tests/unit/core-rag-indexResult.test.ts +++ b/core/tests/unit/core-rag-indexResult.test.ts @@ -1,6 +1,14 @@ import { parseRagIndexResult } from "@core/rag/indexResult" describe("parseRagIndexResult", () => { + it("falls back to unknown for non-object payloads", () => { + expect(parseRagIndexResult(null)).toEqual({ + outcome: "unknown", + message: "Indexing returned an empty response.", + debugChunks: [], + }) + }) + it("normalizes successful index payloads", () => { expect(parseRagIndexResult({ outcome: "indexed", @@ -74,6 +82,45 @@ describe("parseRagIndexResult", () => { }) }) + it("drops invalid debug chunks while preserving valid ones", () => { + expect(parseRagIndexResult({ + outcome: "indexed", + chunkCount: 1, + debugChunks: [ + { + chunkIndex: 0, + charOffset: 12, + sectionHeading: "Heading", + title: "Title", + content: "Chunk content", + bodyContent: "Body content", + overlapPrefix: null, + }, + { + chunkIndex: 1, + charOffset: 18, + sectionHeading: "Heading", + title: "Title", + content: "Chunk content", + }, + ], + })).toEqual({ + outcome: "indexed", + chunkCount: 1, + droppedChunks: 0, + message: null, + debugChunks: [{ + chunkIndex: 0, + charOffset: 12, + sectionHeading: "Heading", + title: "Title", + content: "Chunk content", + bodyContent: "Body content", + overlapPrefix: null, + }], + }) + }) + it("supports delete payloads", () => { expect(parseRagIndexResult({ deleted: true })).toEqual({ outcome: "deleted", @@ -89,4 +136,16 @@ describe("parseRagIndexResult", () => { debugChunks: [], }) }) + + it("treats indexed payloads without chunks as unknown", () => { + expect(parseRagIndexResult({ + outcome: "indexed", + chunkCount: 0, + message: "No chunks created", + })).toEqual({ + outcome: "unknown", + message: "No chunks created", + debugChunks: [], + }) + }) }) diff --git a/ui/web/components/features/settings/AIIndexTab.tsx b/ui/web/components/features/settings/AIIndexTab.tsx index c47e58d97f8..23f73fc27bc 100644 --- a/ui/web/components/features/settings/AIIndexTab.tsx +++ b/ui/web/components/features/settings/AIIndexTab.tsx @@ -62,7 +62,7 @@ const FILTER_SEARCH_LABELS: Record = { outdated: "outdated notes", } -const ROW_EXIT_DURATION_MS = 260 +const ROW_EXIT_DURATION_MS = 300 type OptimisticMutationState = AIIndexMutationResult & { phase: "stable" | "leaving" | "hidden" diff --git a/ui/web/tests/unit/components/aiIndexTab.test.tsx b/ui/web/tests/unit/components/aiIndexTab.test.tsx index f082002825c..b3d69569e83 100644 --- a/ui/web/tests/unit/components/aiIndexTab.test.tsx +++ b/ui/web/tests/unit/components/aiIndexTab.test.tsx @@ -63,7 +63,9 @@ describe("AIIndexTab", () => { }) afterEach(() => { - jest.runOnlyPendingTimers() + act(() => { + jest.runOnlyPendingTimers() + }) jest.useRealTimers() jest.restoreAllMocks() }) @@ -363,7 +365,7 @@ describe("AIIndexTab", () => { expect(duringExitProps.exitingNoteIds).toEqual(["note-outdated"]) act(() => { - jest.advanceTimersByTime(260) + jest.advanceTimersByTime(300) }) const afterExitProps = mockAIIndexList.mock.calls.at(-1)?.[0] as { From 4904b9eb5937dbdd8b5ef1cdd98ced778ce663cf Mon Sep 17 00:00:00 2001 From: Denys Date: Wed, 1 Apr 2026 22:52:17 +0200 Subject: [PATCH 4/5] Handle dynamic mobile viewport height --- app/settings/page.tsx | 2 +- cypress/component/features/mobile/MobileLayout.cy.tsx | 5 +++++ ui/web/components/features/notes/NotesShell.tsx | 5 ++++- ui/web/components/features/settings/SettingsPage.tsx | 4 ++-- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/app/settings/page.tsx b/app/settings/page.tsx index f0900263d2f..6656288ad6c 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -12,7 +12,7 @@ export default function SettingsRoute() { function SettingsPageFallback() { return ( -
+

Loading settings...

) diff --git a/cypress/component/features/mobile/MobileLayout.cy.tsx b/cypress/component/features/mobile/MobileLayout.cy.tsx index 6e20b3d0816..bf213aa0137 100644 --- a/cypress/component/features/mobile/MobileLayout.cy.tsx +++ b/cypress/component/features/mobile/MobileLayout.cy.tsx @@ -144,6 +144,11 @@ describe('Mobile Layout Adaptation', () => { ) + cy.get('[data-testid=\'notes-shell\']') + .invoke('attr', 'class') + .should('include', 'h-[100dvh]') + .and('include', 'min-h-[100svh]') + // Sidebar content should be visible (not hidden) cy.get('[data-testid=\'sidebar-container\']').should('not.have.class', 'hidden') diff --git a/ui/web/components/features/notes/NotesShell.tsx b/ui/web/components/features/notes/NotesShell.tsx index 805ed7eaa2a..728c41aa16a 100644 --- a/ui/web/components/features/notes/NotesShell.tsx +++ b/ui/web/components/features/notes/NotesShell.tsx @@ -176,7 +176,10 @@ export function NotesShell({ controller }: NotesShellProps) { }, [handleSelectNote, router]) return ( -
+
+

Loading settings...

) } return ( -
+
From 1c401bae35a4323d5c6be6072f93eefeaad33a7c Mon Sep 17 00:00:00 2001 From: Denys Date: Wed, 1 Apr 2026 23:08:20 +0200 Subject: [PATCH 5/5] Fix remaining PR review issues --- .../features/notes/RagIndexPanel.tsx | 10 +- .../features/settings/AIIndexTab.tsx | 30 +++- .../tests/unit/components/aiIndexTab.test.tsx | 133 ++++++++++++++++++ 3 files changed, 164 insertions(+), 9 deletions(-) diff --git a/ui/web/components/features/notes/RagIndexPanel.tsx b/ui/web/components/features/notes/RagIndexPanel.tsx index 4dec90e2b84..a883833eb7c 100644 --- a/ui/web/components/features/notes/RagIndexPanel.tsx +++ b/ui/web/components/features/notes/RagIndexPanel.tsx @@ -37,6 +37,10 @@ async function extractErrorMessage(err: unknown, fallback: string): Promise = { const ROW_EXIT_DURATION_MS = 300 type OptimisticMutationState = AIIndexMutationResult & { + createdAt: number phase: "stable" | "leaving" | "hidden" noteSnapshot: AIIndexNoteRowData sourceIndex: number @@ -100,6 +101,17 @@ function getResultsSummaryText(loadedCount: number, totalCount: number) { return `Showing ${totalCount} ${totalCount === 1 ? "note" : "notes"}` } +function shouldExitInCurrentFilter( + filter: AIIndexFilter, + optimisticMutation: OptimisticMutationState +) { + // Exit animations only make sense while the note is leaving the *current* + // bucket. If the user switches into the destination filter, show the note + // normally instead of keeping it visually hidden. + return optimisticMutation.phase === "leaving" + && !matchesAIIndexFilter(filter, optimisticMutation.nextStatus) +} + function mergeOptimisticNotes( notes: AIIndexNoteRowData[], filter: AIIndexFilter, @@ -121,9 +133,10 @@ function mergeOptimisticNotes( lastIndexedAt: optimisticMutation.nextStatus === "not_indexed" ? null : note.lastIndexedAt, } const matchesFilter = matchesAIIndexFilter(filter, optimisticMutation.nextStatus) + const isExiting = shouldExitInCurrentFilter(filter, optimisticMutation) if (!matchesFilter) { - if (optimisticMutation.phase === "leaving") { + if (isExiting) { return { note: projectedNote, isExiting: true, @@ -136,14 +149,14 @@ function mergeOptimisticNotes( return { note: projectedNote, - isExiting: optimisticMutation.phase === "leaving", + isExiting, order: optimisticMutation.sourceIndex, } }).filter((entry): entry is { note: AIIndexNoteRowData; isExiting: boolean; order: number } => entry !== null) const visibleIds = new Set(visibleEntries.map((entry) => entry.note.id)) const exitingEntries = Object.values(optimisticMutations) - .filter((mutation) => mutation.phase === "leaving" && !visibleIds.has(mutation.noteId)) + .filter((mutation) => shouldExitInCurrentFilter(filter, mutation) && !visibleIds.has(mutation.noteId)) .map((mutation) => ({ note: { ...mutation.noteSnapshot, @@ -161,9 +174,12 @@ function mergeOptimisticNotes( function getOptimisticTotalCount( filter: AIIndexFilter, totalCount: number, - optimisticMutations: Record + optimisticMutations: Record, + lastServerSyncAt: number ) { const delta = Object.values(optimisticMutations).reduce((sum, mutation) => { + if (mutation.createdAt < lastServerSyncAt) return sum + const matchedBefore = matchesAIIndexFilter(filter, mutation.previousStatus) const matchedAfter = matchesAIIndexFilter(filter, mutation.nextStatus) @@ -458,6 +474,7 @@ export function AIIndexTab() { const query = useAIIndexNotes(filter, activeSearchQuery) const notes = useFlattenedAIIndexNotes(query) const totalCount = query.data?.pages[0]?.totalCount ?? 0 + const lastServerSyncAt = query.dataUpdatedAt ?? 0 React.useEffect(() => () => { Object.values(exitTimeoutsRef.current).forEach((timeoutId) => clearTimeout(timeoutId)) @@ -535,6 +552,7 @@ export function AIIndexTab() { ...previousState, [mutationResult.noteId]: { ...mutationResult, + createdAt: Date.now(), phase: shouldExit ? "leaving" : "stable", noteSnapshot: sourceNote, sourceIndex, @@ -614,8 +632,8 @@ export function AIIndexTab() { [mergedNotes] ) const optimisticTotalCount = React.useMemo( - () => getOptimisticTotalCount(filter, totalCount, optimisticMutations), - [filter, optimisticMutations, totalCount] + () => getOptimisticTotalCount(filter, totalCount, optimisticMutations, lastServerSyncAt), + [filter, lastServerSyncAt, optimisticMutations, totalCount] ) const loadedCount = displayedNotes.length const hasActiveSearch = activeSearchQuery.length > 0 diff --git a/ui/web/tests/unit/components/aiIndexTab.test.tsx b/ui/web/tests/unit/components/aiIndexTab.test.tsx index b3d69569e83..387c13d834a 100644 --- a/ui/web/tests/unit/components/aiIndexTab.test.tsx +++ b/ui/web/tests/unit/components/aiIndexTab.test.tsx @@ -39,6 +39,7 @@ jest.mock("@/components/features/settings/AIIndexList", () => ({ describe("AIIndexTab", () => { const mockQuery = { data: { pages: [{ totalCount: 0, notes: [], hasMore: false }] }, + dataUpdatedAt: 1, isLoading: false, hasNextPage: false, isFetchingNextPage: false, @@ -425,4 +426,136 @@ describe("AIIndexTab", () => { expect(rerenderedListProps.notes).toEqual([]) expect(rerenderedListProps.exitingNoteIds).toEqual([]) }) + + it("shows a moved note normally when the user switches into its destination filter", () => { + jest.spyOn(aiIndexHooks, "useFlattenedAIIndexNotes").mockReturnValue([ + { + id: "note-outdated", + title: "Outdated note", + updatedAt: "2026-03-29T10:00:00Z", + lastIndexedAt: "2026-03-29T09:00:00Z", + status: "outdated", + }, + ]) + jest.spyOn(aiIndexHooks, "useAIIndexNotes").mockReturnValue({ + ...mockQuery, + data: { pages: [{ totalCount: 1, notes: [], hasMore: false }] }, + dataUpdatedAt: 10, + } as never) + + render( + + + + ) + + fireEvent.click(screen.getByRole("button", { name: "Outdated" })) + + const listProps = mockAIIndexList.mock.calls.at(-1)?.[0] as { + onMutated: (result: AIIndexMutationResult) => void + } + + act(() => { + listProps.onMutated({ + noteId: "note-outdated", + previousStatus: "outdated", + nextStatus: "indexed", + }) + }) + + fireEvent.click(screen.getByRole("button", { name: "Indexed" })) + + const rerenderedListProps = mockAIIndexList.mock.calls.at(-1)?.[0] as { + notes: Array<{ id: string; status: string }> + exitingNoteIds?: string[] + } + + expect(rerenderedListProps.notes).toEqual([ + expect.objectContaining({ id: "note-outdated", status: "indexed" }), + ]) + expect(rerenderedListProps.exitingNoteIds).toEqual([]) + }) + + it("stops subtracting optimistic totals after the server refetch catches up", () => { + jest.spyOn(Date, "now").mockReturnValue(15) + + let currentNotes = [ + { + id: "note-outdated", + title: "Outdated note", + updatedAt: "2026-03-29T10:00:00Z", + lastIndexedAt: "2026-03-29T09:00:00Z", + status: "outdated" as const, + }, + { + id: "note-outdated-2", + title: "Another outdated note", + updatedAt: "2026-03-29T11:00:00Z", + lastIndexedAt: "2026-03-29T10:30:00Z", + status: "outdated" as const, + }, + ] + let currentQuery = { + ...mockQuery, + data: { pages: [{ totalCount: 2, notes: [], hasMore: false }] }, + dataUpdatedAt: 10, + } + + jest.spyOn(aiIndexHooks, "useFlattenedAIIndexNotes").mockImplementation(() => currentNotes) + jest.spyOn(aiIndexHooks, "useAIIndexNotes").mockImplementation(() => currentQuery as never) + + const view = render( + + + + ) + + fireEvent.click(screen.getByRole("button", { name: "Outdated" })) + + const listProps = mockAIIndexList.mock.calls.at(-1)?.[0] as { + onMutated: (result: AIIndexMutationResult) => void + } + + act(() => { + listProps.onMutated({ + noteId: "note-outdated", + previousStatus: "outdated", + nextStatus: "indexed", + }) + }) + + expect(screen.getByText("Showing 1 note")).toBeTruthy() + + currentNotes = [ + { + id: "note-outdated-2", + title: "Another outdated note", + updatedAt: "2026-03-29T11:00:00Z", + lastIndexedAt: "2026-03-29T10:30:00Z", + status: "outdated", + }, + ] + currentQuery = { + ...mockQuery, + data: { pages: [{ totalCount: 1, notes: [], hasMore: false }] }, + dataUpdatedAt: 20, + } + + view.rerender( + + + + ) + + expect(screen.getByText("Showing 1 note")).toBeTruthy() + }) })