diff --git a/cypress/component/features/notes/NoteEditor.cy.tsx b/cypress/component/features/notes/NoteEditor.cy.tsx index 8305e5f26cd..fa58b975ff7 100644 --- a/cypress/component/features/notes/NoteEditor.cy.tsx +++ b/cypress/component/features/notes/NoteEditor.cy.tsx @@ -2,48 +2,27 @@ import React from 'react' import type { SupabaseClient } from '@supabase/supabase-js' import { NoteEditor } from '../../../../ui/web/components/features/notes/NoteEditor' import { SupabaseTestProvider } from '../../../../ui/web/providers/SupabaseProvider' +import { createSupabaseForExportDialog } from './noteTestHelpers' -const createSupabaseForExportDialog = () => { - const invoke = cy.stub().callsFake((name: string, params: { body: { action?: string } }) => { - if (name === 'wordpress-settings-status') { - return Promise.resolve({ - data: { - configured: true, - integration: { - siteUrl: 'https://stage.dkoreiba.com/', - wpUsername: 'editor', - enabled: true, - hasPassword: true, - }, - }, - error: null, - }) - } - if (name === 'wordpress-bridge' && params.body.action === 'get_categories') { - return Promise.resolve({ - data: { - categories: [{ id: 1, name: 'Tech' }], - rememberedCategoryIds: [], - }, - error: null, - }) - } - return Promise.resolve({ data: null, error: null }) - }) - - const supabase = { - functions: { invoke }, +/** Minimal Supabase stub for tests that open the "..." menu but don't need real WP/RAG responses. + * With user=null (SupabaseTestProvider default), useRagStatus returns early without querying. */ +function createMinimalSupabase(): SupabaseClient { + return { + functions: { invoke: cy.stub().resolves({ data: null, error: null }) }, auth: { - getUser: cy.stub().resolves({ data: { user: { id: 'user-1' } } }), + getUser: cy.stub().resolves({ data: { user: null }, error: null }), + getSession: cy.stub().resolves({ data: { session: null }, error: null }), + onAuthStateChange: cy.stub().returns({ data: { subscription: { unsubscribe: cy.stub() } } }), + signOut: cy.stub().resolves({ error: null }), }, from: cy.stub().returns({ - upsert: cy.stub().resolves({ error: null }), - update: cy.stub().returnsThis(), - eq: cy.stub().resolves({ error: null }), + select: cy.stub().returns({ + eq: cy.stub().returns({ + eq: cy.stub().resolves({ data: [], error: null }), + }), + }), }), } as unknown as SupabaseClient - - return { supabase, invoke } } describe('NoteEditor Component', () => { @@ -396,36 +375,37 @@ describe('NoteEditor Component', () => { cy.get('[data-cy="redo-button"]').should('be.disabled') }) - it('shows export button when WordPress is configured and note has id', () => { - const props = { - ...getDefaultProps(), - noteId: 'note-1', - wordpressConfigured: true, - } - + it('shows more actions menu when note has id', () => { + const props = { ...getDefaultProps(), noteId: 'note-1' } cy.mount() - cy.contains('button', 'Export to WP').should('be.visible') + cy.get('button[aria-label="More actions"]').should('be.visible') }) - it('shows mobile more-actions menu instead of visible export button', () => { - cy.viewport(390, 844) + it('does not show more actions menu for new notes without id', () => { + cy.mount() + cy.get('button[aria-label="More actions"]').should('not.exist') + }) + it('shows WordPress export inside the more actions menu when configured', () => { + const { supabase } = createSupabaseForExportDialog() const props = { ...getDefaultProps(), noteId: 'note-1', wordpressConfigured: true, } - - cy.mount() - cy.contains('button', 'Export to WP') - .should('have.class', 'hidden') - .and('have.class', 'md:inline-flex') - cy.get('button[aria-label="More actions"]').should('be.visible') + cy.mount( + + + + ) + // WP export is no longer an inline header button + cy.contains('button', 'Export to WP').should('not.exist') + // It lives in the "..." menu + cy.get('button[aria-label="More actions"]').click() + cy.contains('[role="menuitem"]', 'Export to WP').should('be.visible') }) - it('opens export dialog from mobile menu and closes menu content', () => { - cy.viewport(390, 844) - + it('opens export dialog from the more actions menu', () => { const props = { ...getDefaultProps(), noteId: 'note-1', @@ -449,14 +429,54 @@ describe('NoteEditor Component', () => { }) }) - it('hides export button for new notes without id', () => { + it('does not show export button when WordPress is not configured or note has no id', () => { + // With wordpressConfigured=true but no noteId — no menu at all + cy.mount() + cy.contains('button', 'Export to WP').should('not.exist') + cy.get('button[aria-label="More actions"]').should('not.exist') + }) + + it('shows delete note option in the more actions menu when onDelete is provided', () => { const props = { ...getDefaultProps(), - wordpressConfigured: true, + noteId: 'note-1', + onDelete: cy.stub(), } + cy.mount( + + + + ) + cy.get('button[aria-label="More actions"]').click() + cy.contains('[role="menuitem"]', 'Delete note').should('be.visible') + }) - cy.mount() - cy.contains('button', 'Export to WP').should('not.exist') + it('calls onDelete when delete note is clicked from the more actions menu', () => { + const onDelete = cy.stub().as('onDelete') + const props = { + ...getDefaultProps(), + noteId: 'note-1', + onDelete, + } + cy.mount( + + + + ) + cy.get('button[aria-label="More actions"]').click() + cy.contains('[role="menuitem"]', 'Delete note').click() + cy.get('@onDelete').should('have.been.calledOnce') + }) + + it('does not show delete note option when onDelete is not provided', () => { + const props = { ...getDefaultProps(), noteId: 'note-1' } + cy.mount( + + + + ) + cy.get('button[aria-label="More actions"]').click() + cy.contains('[role="menuitem"]', 'Delete note').should('not.exist') }) }) diff --git a/cypress/component/features/notes/NoteView.cy.tsx b/cypress/component/features/notes/NoteView.cy.tsx index 858532dfea4..6ac6f0cd57b 100644 --- a/cypress/component/features/notes/NoteView.cy.tsx +++ b/cypress/component/features/notes/NoteView.cy.tsx @@ -1,51 +1,8 @@ import React from 'react' -import type { SupabaseClient } from '@supabase/supabase-js' import { NoteView } from '../../../../ui/web/components/features/notes/NoteView' import type { Note } from '../../../../core/types/domain' import { SupabaseTestProvider } from '../../../../ui/web/providers/SupabaseProvider' - -const createSupabaseForExportDialog = () => { - const invoke = cy.stub().callsFake((name: string, params: { body: { action?: string } }) => { - if (name === 'wordpress-settings-status') { - return Promise.resolve({ - data: { - configured: true, - integration: { - siteUrl: 'https://stage.dkoreiba.com/', - wpUsername: 'editor', - enabled: true, - hasPassword: true, - }, - }, - error: null, - }) - } - if (name === 'wordpress-bridge' && params.body.action === 'get_categories') { - return Promise.resolve({ - data: { - categories: [{ id: 1, name: 'Tech' }], - rememberedCategoryIds: [], - }, - error: null, - }) - } - return Promise.resolve({ data: null, error: null }) - }) - - const supabase = { - functions: { invoke }, - auth: { - getUser: cy.stub().resolves({ data: { user: { id: 'user-1' } } }), - }, - from: cy.stub().returns({ - upsert: cy.stub().resolves({ error: null }), - update: cy.stub().returnsThis(), - eq: cy.stub().resolves({ error: null }), - }), - } as unknown as SupabaseClient - - return { supabase, invoke } -} +import { createSupabaseForExportDialog } from './noteTestHelpers' describe('NoteView Component', () => { const mockNote: Note & { content?: string | null } = { @@ -129,42 +86,76 @@ describe('NoteView Component', () => { cy.get('.prose script').should('not.exist') }) - it('shows export button when WordPress is configured', () => { + it('more actions menu is always visible', () => { + // The "..." button is always present — it holds RAG controls (and optionally WP export) const props = { note: mockNote, onEdit: cy.stub(), onDelete: cy.stub(), onTagClick: cy.stub(), onRemoveTag: cy.stub(), - wordpressConfigured: true, + wordpressConfigured: false, } - cy.mount() - cy.contains('button', 'Export to WP').should('be.visible') + cy.get('button[aria-label="More actions"]').should('be.visible') }) - it('shows mobile more-actions menu instead of visible export button', () => { + it('more actions menu is visible on mobile too', () => { cy.viewport(390, 844) - const props = { note: mockNote, onEdit: cy.stub(), onDelete: cy.stub(), onTagClick: cy.stub(), onRemoveTag: cy.stub(), - wordpressConfigured: true, } - cy.mount() - cy.contains('button', 'Export to WP') - .should('have.class', 'hidden') - .and('have.class', 'md:inline-flex') cy.get('button[aria-label="More actions"]').should('be.visible') }) - it('opens export dialog from mobile menu and closes menu content', () => { - cy.viewport(390, 844) + it('shows RAG index controls inside the more actions menu', () => { + const { supabase } = createSupabaseForExportDialog() + const props = { + note: mockNote, + onEdit: cy.stub(), + onDelete: cy.stub(), + onTagClick: cy.stub(), + onRemoveTag: cy.stub(), + } + cy.mount( + + + + ) + cy.get('button[aria-label="More actions"]').click() + // RAG items always present in the menu + cy.contains('[role="menuitem"]', 'Index note').should('be.visible') + cy.get('[data-cy="note-delete-index-button"]').should('be.visible') + }) + it('shows WordPress export inside the more actions menu when configured', () => { + const { supabase } = createSupabaseForExportDialog() + const props = { + note: mockNote, + onEdit: cy.stub(), + onDelete: cy.stub(), + onTagClick: cy.stub(), + onRemoveTag: cy.stub(), + wordpressConfigured: true, + } + cy.mount( + + + + ) + // WP export is no longer an inline header button + cy.contains('button', 'Export to WP').should('not.exist') + // It lives in the "..." menu + cy.get('button[aria-label="More actions"]').click() + cy.contains('[role="menuitem"]', 'Export to WP').should('be.visible') + }) + + it('opens export dialog from the more actions menu', () => { const props = { note: mockNote, onEdit: cy.stub(), @@ -192,7 +183,8 @@ describe('NoteView Component', () => { }) }) - it('hides export button when WordPress is not configured', () => { + it('does not show WordPress export in menu when not configured', () => { + const { supabase } = createSupabaseForExportDialog() const props = { note: mockNote, onEdit: cy.stub(), @@ -201,8 +193,12 @@ describe('NoteView Component', () => { onRemoveTag: cy.stub(), wordpressConfigured: false, } - - cy.mount() - cy.contains('button', 'Export to WP').should('not.exist') + cy.mount( + + + + ) + cy.get('button[aria-label="More actions"]').click() + cy.contains('[role="menuitem"]', 'Export to WP').should('not.exist') }) }) diff --git a/cypress/component/features/notes/RagIndexPanel.cy.tsx b/cypress/component/features/notes/RagIndexPanel.cy.tsx index 311785cd534..b5e2bb9d6c4 100644 --- a/cypress/component/features/notes/RagIndexPanel.cy.tsx +++ b/cypress/component/features/notes/RagIndexPanel.cy.tsx @@ -2,6 +2,11 @@ import React from 'react' import type { SupabaseClient, User } from '@supabase/supabase-js' import { RagIndexPanel } from '../../../../ui/web/components/features/notes/RagIndexPanel' import { SupabaseTestProvider } from '../../../../ui/web/providers/SupabaseProvider' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '../../../../ui/web/components/ui/dropdown-menu' type EmbeddingRow = { chunk_index: number @@ -168,3 +173,121 @@ describe('RagIndexPanel Component', () => { }) }) +describe('RagIndexPanel (variant=menu)', () => { + const testUser = { id: 'user-1' } as User + + function mountMenuVariant({ + rows = [] as EmbeddingRow[], + onMenuClose = cy.stub() as Cypress.Agent, + invokeImpl, + }: { + rows?: EmbeddingRow[] + onMenuClose?: Cypress.Agent + invokeImpl?: (name: string, params: unknown) => Promise + } = {}) { + const { supabase } = createSupabaseForRag(rows, invokeImpl) + cy.mount( + + {/* defaultOpen keeps DropdownMenuContent mounted for the duration of the test */} + + + + + + + + + + ) + return { supabase } + } + + it('renders status label and action items when not indexed', () => { + mountMenuVariant({ rows: [] }) + cy.contains('AI index: Not indexed').should('be.visible') + cy.contains('[role="menuitem"]', 'Index note').should('be.visible') + cy.get('[data-cy="note-delete-index-button"]').should('have.attr', 'data-disabled') + }) + + it('renders indexed state with chunk count', () => { + const rows: EmbeddingRow[] = [ + { chunk_index: 0, indexed_at: '2026-03-02T20:00:00.000Z' }, + { chunk_index: 1, indexed_at: '2026-03-02T20:00:00.000Z' }, + ] + mountMenuVariant({ rows }) + cy.contains('2 chunks').should('be.visible') + cy.contains('[role="menuitem"]', 'Re-index').should('be.visible') + cy.get('[data-cy="note-delete-index-button"]').should('not.be.disabled') + }) + + it('keeps dropdown mounted while AlertDialog is shown (onSelect preventDefault)', () => { + // This is the core regression test for the bug where the confirm dialog + // would flash and disappear because DropdownMenuContent was unmounting before + // the AlertDialog could render. + const rows: EmbeddingRow[] = [{ chunk_index: 0, indexed_at: '2026-03-02T20:00:00.000Z' }] + const onMenuClose = cy.stub().as('onMenuClose') + mountMenuVariant({ rows, onMenuClose }) + + cy.get('[data-cy="note-delete-index-button"]').click() + + // Dropdown must still be mounted — the item still exists in the DOM + cy.get('[data-cy="note-delete-index-button"]').should('exist') + // AlertDialog must be visible + cy.contains('Remove from AI index?').should('be.visible') + // onMenuClose must NOT have fired yet + cy.get('@onMenuClose').should('not.have.been.called') + }) + + it('calls onMenuClose when confirmation dialog is cancelled', () => { + const rows: EmbeddingRow[] = [{ chunk_index: 0, indexed_at: '2026-03-02T20:00:00.000Z' }] + const onMenuClose = cy.stub().as('onMenuClose') + mountMenuVariant({ rows, onMenuClose }) + + cy.get('[data-cy="note-delete-index-button"]').click() + cy.contains('Remove from AI index?').should('be.visible') + cy.contains('button', 'Cancel').click() + + cy.get('@onMenuClose').should('have.been.calledOnce') + }) + + it('calls onMenuClose after confirmed delete', () => { + const rows: EmbeddingRow[] = [{ chunk_index: 0, indexed_at: '2026-03-02T20:00:00.000Z' }] + const onMenuClose = cy.stub().as('onMenuClose') + mountMenuVariant({ + rows, + onMenuClose, + invokeImpl: async () => ({ data: { deleted: true }, error: null }), + }) + + cy.get('[data-cy="note-delete-index-button"]').click() + cy.get('[data-cy="note-delete-index-confirm"]').click() + + cy.get('@onMenuClose').should('have.been.calledOnce') + }) + + it('invokes rag-index with action=index from menu item', () => { + const { supabase } = createSupabaseForRag([], async (name, params) => { + expect(name).to.eq('rag-index') + expect(params).to.deep.eq({ body: { noteId: 'note-1', action: 'index' } }) + return { data: { chunkCount: 2 }, error: null } + }) + const onMenuClose = cy.stub().as('onMenuClose') + cy.mount( + + + + + + + + + ) + cy.contains('[role="menuitem"]', 'Index note').click() + cy.wrap(supabase.functions.invoke).should('have.been.calledWith', 'rag-index', { + body: { noteId: 'note-1', action: 'index' }, + }) + // onMenuClose called after operation settles — dropdown closes only then + cy.get('@onMenuClose').should('have.been.calledOnce') + }) +}) + diff --git a/cypress/component/features/notes/noteTestHelpers.ts b/cypress/component/features/notes/noteTestHelpers.ts new file mode 100644 index 00000000000..339d0afbf39 --- /dev/null +++ b/cypress/component/features/notes/noteTestHelpers.ts @@ -0,0 +1,44 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +export function createSupabaseForExportDialog() { + const invoke = cy.stub().callsFake((name: string, params: { body: { action?: string } }) => { + if (name === 'wordpress-settings-status') { + return Promise.resolve({ + data: { + configured: true, + integration: { + siteUrl: 'https://stage.dkoreiba.com/', + wpUsername: 'editor', + enabled: true, + hasPassword: true, + }, + }, + error: null, + }) + } + if (name === 'wordpress-bridge' && params.body.action === 'get_categories') { + return Promise.resolve({ + data: { + categories: [{ id: 1, name: 'Tech' }], + rememberedCategoryIds: [], + }, + error: null, + }) + } + return Promise.resolve({ data: null, error: null }) + }) + + const supabase = { + functions: { invoke }, + auth: { + getUser: cy.stub().resolves({ data: { user: { id: 'user-1' } } }), + }, + from: cy.stub().returns({ + upsert: cy.stub().resolves({ error: null }), + update: cy.stub().returnsThis(), + eq: cy.stub().resolves({ error: null }), + }), + } as unknown as SupabaseClient + + return { supabase, invoke } +} diff --git a/ui/web/components/features/notes/MoreActionsMenu.tsx b/ui/web/components/features/notes/MoreActionsMenu.tsx new file mode 100644 index 00000000000..97bb69b39d3 --- /dev/null +++ b/ui/web/components/features/notes/MoreActionsMenu.tsx @@ -0,0 +1,81 @@ +"use client" + +import * as React from 'react' +import { MoreHorizontal, Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { + ExportToWordPressButton, + type ExportableWordPressNote, +} from '@/components/features/wordpress/ExportToWordPressButton' +import { WordPressExportDialog } from '@/components/features/wordpress/WordPressExportDialog' +import { RagIndexPanel } from '@/components/features/notes/RagIndexPanel' + +interface MoreActionsMenuProps { + noteId: string + wordpressConfigured?: boolean + getExportNote: () => ExportableWordPressNote | null + onDelete?: () => void +} + +export function MoreActionsMenu({ + noteId, + wordpressConfigured = false, + getExportNote, + onDelete, +}: MoreActionsMenuProps) { + const [moreMenuOpen, setMoreMenuOpen] = React.useState(false) + const [exportDialogOpen, setExportDialogOpen] = React.useState(false) + const [exportDialogNote, setExportDialogNote] = React.useState(null) + + const handleExportRequest = React.useCallback((exportNote: ExportableWordPressNote) => { + setExportDialogNote(exportNote) + setExportDialogOpen(true) + }, []) + + return ( + <> + + + + + + setMoreMenuOpen(false)} /> + {wordpressConfigured && ( + <> + + + + )} + {onDelete && ( + <> + + + + Delete note + + + )} + + + {exportDialogNote ? ( + + ) : null} + + ) +} diff --git a/ui/web/components/features/notes/NoteEditor.tsx b/ui/web/components/features/notes/NoteEditor.tsx index 5a8a49f8936..40f71b598d1 100644 --- a/ui/web/components/features/notes/NoteEditor.tsx +++ b/ui/web/components/features/notes/NoteEditor.tsx @@ -1,22 +1,15 @@ "use client" import * as React from "react" -import { MoreHorizontal } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import RichTextEditor, { type RichTextEditorHandle } from "@/components/RichTextEditor" import { useDebouncedCallback } from "@ui/web/hooks/useDebouncedCallback" import { TagInput } from "@/components/TagInput" -import { - ExportToWordPressButton, - type ExportableWordPressNote, -} from "@/components/features/wordpress/ExportToWordPressButton" -import { WordPressExportDialog } from "@/components/features/wordpress/WordPressExportDialog" -import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import { MoreActionsMenu } from "@/components/features/notes/MoreActionsMenu" import { buildTagString, normalizeTag, normalizeTagList, parseTagString } from "@ui/web/lib/tags" import { useTagSuggestions } from "@ui/web/hooks/useTagSuggestions" import { useNoteEditorAutoSave } from "@ui/web/hooks/useNoteEditorAutoSave" -import { RagIndexPanel } from "@/components/features/notes/RagIndexPanel" const DEFAULT_AUTOSAVE_DELAY_MS = 500 @@ -38,6 +31,7 @@ interface NoteEditorProps { autosaveDelayMs?: number lastSavedAt?: string | null wordpressConfigured?: boolean + onDelete?: () => void } export const NoteEditor = React.memo(React.forwardRef(function NoteEditor({ @@ -54,12 +48,11 @@ export const NoteEditor = React.memo(React.forwardRef(() => parseTagString(initialTags)) const [tagQuery, setTagQuery] = React.useState("") - const [exportDialogOpen, setExportDialogOpen] = React.useState(false) - const [exportDialogNote, setExportDialogNote] = React.useState(null) const titleInputRef = React.useRef(null) const editorRef = React.useRef(null) @@ -127,11 +120,6 @@ export const NoteEditor = React.memo(React.forwardRef { - setExportDialogNote(exportNote) - setExportDialogOpen(true) - }, []) - const suggestions = useTagSuggestions({ allTags: availableTags, selectedTags, @@ -165,43 +153,31 @@ export const NoteEditor = React.memo(React.forwardRef ({ flushPendingSave }), [flushPendingSave]) + // Show the "..." menu for existing notes (RAG + delete) + const showMoreMenu = !!noteId + return (
{/* Editor Header */}

Editing

-
- {noteId ? : null} - {wordpressConfigured && noteId ? ( - - ) : null} +
- {wordpressConfigured && noteId ? ( - - - - - - - - - ) : null} + {/* More actions menu — RAG controls, delete note, WordPress export */} + {showMoreMenu && ( + + )}
{(showSaving || isSaving) ? (
Saving...
@@ -245,9 +221,6 @@ export const NoteEditor = React.memo(React.forwardRef
- {exportDialogNote ? ( - - ) : null}
) })) diff --git a/ui/web/components/features/notes/NoteView.tsx b/ui/web/components/features/notes/NoteView.tsx index dd731edb83c..7d5df3b0c6a 100644 --- a/ui/web/components/features/notes/NoteView.tsx +++ b/ui/web/components/features/notes/NoteView.tsx @@ -1,18 +1,11 @@ "use client" import * as React from "react" -import { Edit2, Trash2, ChevronLeft, MoreHorizontal } from "lucide-react" +import { Edit2, Trash2, ChevronLeft } from "lucide-react" import { Button } from "@/components/ui/button" -import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import InteractiveTag from "@/components/InteractiveTag" import { HorizontalTagScroll } from "@/components/HorizontalTagScroll" -import { - ExportToWordPressButton, - type ExportableWordPressNote, -} from "@/components/features/wordpress/ExportToWordPressButton" -import { WordPressExportDialog } from "@/components/features/wordpress/WordPressExportDialog" - -import { RagIndexPanel } from "@/components/features/notes/RagIndexPanel" +import { MoreActionsMenu } from "@/components/features/notes/MoreActionsMenu" import { SanitizationService } from "@core/services/sanitizer" import { NOTE_CONTENT_CLASS } from "@core/constants/typography" import type { Note } from "@core/types/domain" @@ -62,13 +55,6 @@ export const NoteView = React.memo(function NoteView({ tags: note.tags ?? [], }), [note.content, note.description, note.id, note.tags, note.title]) - const [exportDialogOpen, setExportDialogOpen] = React.useState(false) - const [exportDialogNote, setExportDialogNote] = React.useState(null) - - const handleExportRequest = React.useCallback((exportNote: ExportableWordPressNote) => { - setExportDialogNote(exportNote) - setExportDialogOpen(true) - }, []) return (
@@ -87,15 +73,7 @@ export const NoteView = React.memo(function NoteView({ )}

Reading

-
- - {wordpressConfigured ? ( - - ) : null} +
- {wordpressConfigured ? ( - - - - - - - - - ) : null} + {/* More actions menu — always visible, contains RAG index controls + optional WP export */} +
@@ -139,7 +108,7 @@ export const NoteView = React.memo(function NoteView({

{note.title}

- + {note.tags && note.tags.length > 0 && (
@@ -155,21 +124,18 @@ export const NoteView = React.memo(function NoteView({
)} - +
- +

Created: {formattedDates.created}

Updated: {formattedDates.updated}

- {exportDialogNote ? ( - - ) : null} ) }) diff --git a/ui/web/components/features/notes/NotesShell.tsx b/ui/web/components/features/notes/NotesShell.tsx index 91725b85700..298467dd51d 100644 --- a/ui/web/components/features/notes/NotesShell.tsx +++ b/ui/web/components/features/notes/NotesShell.tsx @@ -245,6 +245,7 @@ function EditorPane({ isAutoSaving={autoSaving} lastSavedAt={lastSavedAt} wordpressConfigured={wordpressConfigured} + onDelete={selectedNote ? () => handleDeleteNote(selectedNote) : undefined} /> ) } diff --git a/ui/web/components/features/notes/RagIndexPanel.tsx b/ui/web/components/features/notes/RagIndexPanel.tsx index 683f61e439c..a21cc167ab4 100644 --- a/ui/web/components/features/notes/RagIndexPanel.tsx +++ b/ui/web/components/features/notes/RagIndexPanel.tsx @@ -3,6 +3,10 @@ import { useState } from 'react' import { Database, Trash2, Loader2 } from 'lucide-react' import { Button } from '@/components/ui/button' +import { + DropdownMenuItem, + DropdownMenuLabel, +} from '@/components/ui/dropdown-menu' import { AlertDialog, AlertDialogAction, @@ -32,6 +36,9 @@ async function extractErrorMessage(err: unknown, fallback: string): Promise void } type Operation = 'indexing' | 'deleting' | null @@ -42,7 +49,7 @@ function parseChunkCount(data: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null } -export function RagIndexPanel({ noteId }: RagIndexPanelProps) { +export function RagIndexPanel({ noteId, variant = 'inline', onMenuClose }: RagIndexPanelProps) { const { supabase } = useSupabase() const { chunkCount, indexedAt, isLoading, refresh } = useRagStatus(noteId) const [operation, setOperation] = useState(null) @@ -100,13 +107,90 @@ export function RagIndexPanel({ noteId }: RagIndexPanelProps) { return 'Not indexed' } + const confirmDialog = ( + { + setDeleteConfirmOpen(open) + // After dialog closes (confirm or cancel), let the parent close the dropdown too + if (!open) onMenuClose?.() + }} + > + + + Remove from AI index? + + This will remove all embeddings for this note. You can re-index it at any time. + + + + Cancel + { setDeleteConfirmOpen(false); void handleDelete() }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Remove + + + + + ) + + if (variant === 'menu') { + return ( + <> + + AI index: {statusText()} + + {/* + onSelect preventDefault keeps the dropdown mounted for the duration of the + indexing request so operation state survives. onMenuClose is called in finally + to close the menu once the request settles, preventing concurrent requests. + */} + { e.preventDefault(); void handleIndex().finally(() => onMenuClose?.()) }} + disabled={isBusy} + title={isIndexed ? 'Re-index this note' : 'Index this note for AI search'} + > + {operation === 'indexing' ? ( + + ) : ( + + )} + {isIndexed ? 'Re-index' : 'Index note'} + + {/* + onSelect preventDefault keeps the dropdown mounted while the AlertDialog is open. + Without this, DropdownMenuContent unmounts before the dialog can render, + losing the deleteConfirmOpen state. + */} + { e.preventDefault(); setDeleteConfirmOpen(true) }} + disabled={isBusy || !isIndexed} + data-cy="note-delete-index-button" + className="text-red-600 focus:text-red-600 focus:bg-red-50 dark:focus:bg-red-950/20" + title="Remove this note from the AI index" + > + {operation === 'deleting' ? ( + + ) : ( + + )} + Delete index + + {confirmDialog} + + ) + } + return ( <>
- - - - Remove from AI index? - - This will remove all embeddings for this note. You can re-index it at any time. - - - - Cancel - { setDeleteConfirmOpen(false); void handleDelete() }} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - Remove - - - - + {confirmDialog} ) }