From 0e0ed5ddaa573cbeaa3d15387b385e92c6e4bf21 Mon Sep 17 00:00:00 2001 From: Denys Date: Sat, 1 Aug 2026 19:56:19 +0200 Subject: [PATCH 01/23] feat(notes): add workspace note tabs --- core/services/noteWorkspaceTabs.ts | 314 +++++++++++ .../core-services-noteWorkspaceTabs.test.ts | 146 +++++ .../features/mobile/MobileLayout.cy.tsx | 20 + .../2026-08-01-feature-notes-tabs.md | 37 ++ .../design/2026-08-01-feature-notes-tabs.md | 165 ++++++ .../2026-08-01-feature-notes-tabs.md | 76 +++ .../2026-08-01-feature-notes-tabs.md | 37 ++ .../planning/2026-08-01-feature-notes-tabs.md | 94 ++++ .../2026-08-01-feature-notes-tabs.md | 84 +++ .../testing/2026-08-01-feature-notes-tabs.md | 86 +++ ui/web/components/RichTextEditor.tsx | 14 + .../features/notes/MobileNotesTabMenu.tsx | 99 ++++ .../components/features/notes/NoteEditor.tsx | 87 ++- ui/web/components/features/notes/NoteView.tsx | 19 +- .../components/features/notes/NotesShell.tsx | 97 ++-- .../features/notes/NotesTabStrip.tsx | 147 +++++ ui/web/hooks/useNoteAppController.ts | 506 ++++++++++++++---- ui/web/hooks/useNoteWorkspaceTabs.ts | 69 +++ ui/web/lib/noteWorkspaceStorage.ts | 56 ++ .../components/notesWorkspaceTabs.test.tsx | 103 ++++ ...useNoteAppController.remoteDelete.test.tsx | 1 + .../useNoteAppControllerAdditional.test.tsx | 7 +- .../unit/lib/noteWorkspaceStorage.test.ts | 68 +++ 23 files changed, 2180 insertions(+), 152 deletions(-) create mode 100644 core/services/noteWorkspaceTabs.ts create mode 100644 core/tests/unit/core-services-noteWorkspaceTabs.test.ts create mode 100644 docs/ai/deployment/2026-08-01-feature-notes-tabs.md create mode 100644 docs/ai/design/2026-08-01-feature-notes-tabs.md create mode 100644 docs/ai/implementation/2026-08-01-feature-notes-tabs.md create mode 100644 docs/ai/monitoring/2026-08-01-feature-notes-tabs.md create mode 100644 docs/ai/planning/2026-08-01-feature-notes-tabs.md create mode 100644 docs/ai/requirements/2026-08-01-feature-notes-tabs.md create mode 100644 docs/ai/testing/2026-08-01-feature-notes-tabs.md create mode 100644 ui/web/components/features/notes/MobileNotesTabMenu.tsx create mode 100644 ui/web/components/features/notes/NotesTabStrip.tsx create mode 100644 ui/web/hooks/useNoteWorkspaceTabs.ts create mode 100644 ui/web/lib/noteWorkspaceStorage.ts create mode 100644 ui/web/tests/unit/components/notesWorkspaceTabs.test.tsx create mode 100644 ui/web/tests/unit/lib/noteWorkspaceStorage.test.ts diff --git a/core/services/noteWorkspaceTabs.ts b/core/services/noteWorkspaceTabs.ts new file mode 100644 index 00000000000..d4c7d1ba50f --- /dev/null +++ b/core/services/noteWorkspaceTabs.ts @@ -0,0 +1,314 @@ +import type { NoteViewModel } from '@core/types/domain' + +export const NOTE_WORKSPACE_VERSION = 1 as const + +export type NoteWorkspaceMode = 'reading' | 'editing' +export type NoteWorkspaceSaveState = 'saved' | 'dirty' | 'saving' | 'error' + +export type NoteDraftSnapshot = { + title: string + description: string + tags: string +} + +export type NoteViewSession = { + scrollTop: number + titleSelection?: { start: number; end: number } + editorSelection?: { from: number; to: number } +} + +export type NoteWorkspaceTab = { + id: string + noteId: string | null + note: NoteViewModel | null + mode: NoteWorkspaceMode + draft: NoteDraftSnapshot + view: NoteViewSession + saveState: NoteWorkspaceSaveState + saveError: string | null +} + +export type NoteWorkspaceState = { + version: typeof NOTE_WORKSPACE_VERSION + tabs: NoteWorkspaceTab[] + activeTabId: string +} + +export type NoteWorkspaceTabPatch = { + note?: NoteViewModel | null + noteId?: string | null + mode?: NoteWorkspaceMode + draft?: Partial + view?: Partial + saveState?: NoteWorkspaceSaveState + saveError?: string | null +} + +export type NoteWorkspaceIdFactory = () => string + +const DEFAULT_DRAFT: NoteDraftSnapshot = { title: '', description: '', tags: '' } +const DEFAULT_VIEW: NoteViewSession = { scrollTop: 0 } + +const defaultIdFactory: NoteWorkspaceIdFactory = () => { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID() + } + return `note-tab-${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +const asString = (value: unknown, fallback: string): string => ( + typeof value === 'string' ? value : fallback +) + +const asNonNegativeFiniteNumber = (value: unknown, fallback: number): number => ( + typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback +) + +const asRange = (value: unknown, key: 'from' | 'to' | 'start' | 'end'): number | undefined => { + if (!value || typeof value !== 'object') return undefined + const candidate = (value as Record)[key] + return typeof candidate === 'number' && Number.isInteger(candidate) && candidate >= 0 + ? candidate + : undefined +} + +const normalizeRange = ( + value: unknown, + kind: T, +): NoteViewSession[T] | undefined => { + const from = asRange(value, kind === 'titleSelection' ? 'start' : 'from') + const to = asRange(value, kind === 'titleSelection' ? 'end' : 'to') + if (from === undefined || to === undefined || to < from) return undefined + return (kind === 'titleSelection' + ? { start: from, end: to } + : { from, to }) as NoteViewSession[T] +} + +const draftFromNote = (note: NoteViewModel | null): NoteDraftSnapshot => ({ + title: note?.title ?? '', + description: note?.description ?? note?.content ?? '', + tags: note?.tags?.join(', ') ?? '', +}) + +const createEmptyTab = (idFactory: NoteWorkspaceIdFactory): NoteWorkspaceTab => ({ + id: idFactory(), + noteId: null, + note: null, + // An empty workspace tab is a landing slot. Creating a note explicitly + // switches it to editing; opening a note switches it to reading. + mode: 'reading', + draft: { ...DEFAULT_DRAFT }, + view: { ...DEFAULT_VIEW }, + saveState: 'saved', + saveError: null, +}) + +const createNoteTab = (id: string, note: NoteViewModel): NoteWorkspaceTab => ({ + id, + noteId: note.id, + note, + mode: 'reading', + draft: draftFromNote(note), + view: { ...DEFAULT_VIEW }, + saveState: 'saved', + saveError: null, +}) + +export function createNoteWorkspaceState(idFactory: NoteWorkspaceIdFactory = defaultIdFactory): NoteWorkspaceState { + const tab = createEmptyTab(idFactory) + return { + version: NOTE_WORKSPACE_VERSION, + tabs: [tab], + activeTabId: tab.id, + } +} + +export function getActiveWorkspaceTab(state: NoteWorkspaceState): NoteWorkspaceTab { + return state.tabs.find((tab) => tab.id === state.activeTabId) ?? state.tabs[0]! +} + +export function findWorkspaceTabByNoteId( + state: NoteWorkspaceState, + noteId: string | null | undefined, +): NoteWorkspaceTab | null { + if (!noteId) return null + return state.tabs.find((tab) => tab.noteId === noteId) ?? null +} + +export function addWorkspaceTab( + state: NoteWorkspaceState, + idFactory: NoteWorkspaceIdFactory = defaultIdFactory, +): NoteWorkspaceState { + const tab = createEmptyTab(idFactory) + return { + ...state, + tabs: [...state.tabs, tab], + activeTabId: tab.id, + } +} + +export function activateWorkspaceTab(state: NoteWorkspaceState, tabId: string): NoteWorkspaceState { + if (!state.tabs.some((tab) => tab.id === tabId) || state.activeTabId === tabId) return state + return { ...state, activeTabId: tabId } +} + +export function openNoteInWorkspace( + state: NoteWorkspaceState, + note: NoteViewModel, + tabId: string = state.activeTabId, +): NoteWorkspaceState { + const existing = findWorkspaceTabByNoteId(state, note.id) + if (existing) return activateWorkspaceTab(state, existing.id) + + const index = state.tabs.findIndex((tab) => tab.id === tabId) + if (index < 0) return state + + const current = state.tabs[index] + const nextTab = createNoteTab(current.id, note) + const tabs = [...state.tabs] + tabs[index] = nextTab + return { ...state, tabs, activeTabId: nextTab.id } +} + +export function updateWorkspaceTab( + state: NoteWorkspaceState, + tabId: string, + patch: NoteWorkspaceTabPatch, +): NoteWorkspaceState { + const index = state.tabs.findIndex((tab) => tab.id === tabId) + if (index < 0) return state + + const current = state.tabs[index] + const nextNote = patch.note === undefined ? current.note : patch.note + const nextNoteId = patch.noteId === undefined ? (nextNote?.id ?? current.noteId) : patch.noteId + const normalizedNoteId = nextNote ? nextNote.id : nextNoteId + const nextTab: NoteWorkspaceTab = { + ...current, + ...patch, + note: nextNote, + noteId: normalizedNoteId ?? null, + draft: patch.draft ? { ...current.draft, ...patch.draft } : current.draft, + view: patch.view ? { ...current.view, ...patch.view } : current.view, + saveError: patch.saveError === undefined ? current.saveError : patch.saveError, + } + + if (!nextTab.noteId) nextTab.note = null + if (nextTab.saveState !== 'error' && patch.saveError === undefined && patch.saveState !== 'error') { + nextTab.saveError = null + } + + const tabs = [...state.tabs] + tabs[index] = nextTab + return { ...state, tabs } +} + +export function closeWorkspaceTab( + state: NoteWorkspaceState, + tabId: string, + idFactory: NoteWorkspaceIdFactory = defaultIdFactory, +): NoteWorkspaceState { + const index = state.tabs.findIndex((tab) => tab.id === tabId) + if (index < 0) return state + + if (state.tabs.length === 1) { + const replacement = createEmptyTab(idFactory) + return { ...state, tabs: [replacement], activeTabId: replacement.id } + } + + const wasActive = state.activeTabId === tabId + const tabs = state.tabs.filter((tab) => tab.id !== tabId) + if (!wasActive) return { ...state, tabs } + + const nextActive = state.tabs[index + 1] ?? state.tabs[index - 1] + return { ...state, tabs, activeTabId: nextActive.id } +} + +const normalizeNote = (value: unknown): NoteViewModel | null => { + if (!value || typeof value !== 'object') return null + const candidate = value as Record + return typeof candidate.id === 'string' && candidate.id.length > 0 + ? value as NoteViewModel + : null +} + +const normalizeTab = ( + value: unknown, + idFactory: NoteWorkspaceIdFactory, + usedTabIds: Set, +): NoteWorkspaceTab | null => { + if (!value || typeof value !== 'object') return null + const raw = value as Record + let id = typeof raw.id === 'string' && raw.id.length > 0 ? raw.id : idFactory() + while (usedTabIds.has(id)) id = idFactory() + usedTabIds.add(id) + + const note = normalizeNote(raw.note) + const rawNoteId = typeof raw.noteId === 'string' && raw.noteId.length > 0 ? raw.noteId : null + const noteId = note?.id ?? rawNoteId + const rawDraft = raw.draft && typeof raw.draft === 'object' ? raw.draft as Record : {} + const rawView = raw.view && typeof raw.view === 'object' ? raw.view as Record : {} + const titleSelection = normalizeRange(rawView.titleSelection, 'titleSelection') + const editorSelection = normalizeRange(rawView.editorSelection, 'editorSelection') + + return { + id, + noteId, + note: noteId && note?.id === noteId ? note : null, + mode: raw.mode === 'reading' ? 'reading' : 'editing', + draft: { + title: asString(rawDraft.title, note?.title ?? ''), + description: asString(rawDraft.description, note?.description ?? note?.content ?? ''), + tags: asString(rawDraft.tags, note?.tags?.join(', ') ?? ''), + }, + view: { + scrollTop: asNonNegativeFiniteNumber(rawView.scrollTop, 0), + ...(titleSelection ? { titleSelection } : {}), + ...(editorSelection ? { editorSelection } : {}), + }, + saveState: raw.saveState === 'dirty' || raw.saveState === 'saving' || raw.saveState === 'error' + ? raw.saveState + : 'saved', + saveError: typeof raw.saveError === 'string' ? raw.saveError : null, + } +} + +export function hydrateNoteWorkspaceState( + raw: unknown, + idFactory: NoteWorkspaceIdFactory = defaultIdFactory, +): NoteWorkspaceState { + let parsed: unknown = raw + if (typeof raw === 'string') { + try { + parsed = JSON.parse(raw) as unknown + } catch { + return createNoteWorkspaceState(idFactory) + } + } + + if (!parsed || typeof parsed !== 'object') return createNoteWorkspaceState(idFactory) + const source = parsed as Record + if (source.version !== NOTE_WORKSPACE_VERSION || !Array.isArray(source.tabs)) { + return createNoteWorkspaceState(idFactory) + } + + const usedTabIds = new Set() + const usedNoteIds = new Set() + const tabs = source.tabs + .map((tab) => normalizeTab(tab, idFactory, usedTabIds)) + .filter((tab): tab is NoteWorkspaceTab => tab !== null) + .filter((tab) => { + if (!tab.noteId) return true + if (usedNoteIds.has(tab.noteId)) return false + usedNoteIds.add(tab.noteId) + return true + }) + + if (tabs.length === 0) return createNoteWorkspaceState(idFactory) + const requestedActive = typeof source.activeTabId === 'string' ? source.activeTabId : '' + const activeTabId = tabs.some((tab) => tab.id === requestedActive) ? requestedActive : tabs[0].id + return { version: NOTE_WORKSPACE_VERSION, tabs, activeTabId } +} + +export function serializeNoteWorkspaceState(state: NoteWorkspaceState): string { + return JSON.stringify(state) +} diff --git a/core/tests/unit/core-services-noteWorkspaceTabs.test.ts b/core/tests/unit/core-services-noteWorkspaceTabs.test.ts new file mode 100644 index 00000000000..b4d5234d0e5 --- /dev/null +++ b/core/tests/unit/core-services-noteWorkspaceTabs.test.ts @@ -0,0 +1,146 @@ +import type { NoteViewModel } from '@core/types/domain' +import { + activateWorkspaceTab, + addWorkspaceTab, + closeWorkspaceTab, + createNoteWorkspaceState, + findWorkspaceTabByNoteId, + getActiveWorkspaceTab, + hydrateNoteWorkspaceState, + openNoteInWorkspace, + serializeNoteWorkspaceState, + updateWorkspaceTab, +} from '@core/services/noteWorkspaceTabs' + +const note = (id: string, title = id): NoteViewModel => ({ + id, + title, + description: `

${title}

`, + tags: ['work'], + created_at: '2026-08-01T00:00:00.000Z', + updated_at: '2026-08-01T00:00:00.000Z', + user_id: 'user-1', +}) + +const ids = (...values: string[]) => { + let index = 0 + return () => values[index++] ?? `generated-${index}` +} + +describe('note workspace tab state', () => { + it('creates one blank active landing tab', () => { + const state = createNoteWorkspaceState(ids('tab-1')) + + expect(state.tabs).toHaveLength(1) + expect(getActiveWorkspaceTab(state)).toMatchObject({ + id: 'tab-1', + noteId: null, + mode: 'reading', + saveState: 'saved', + }) + }) + + it('adds and activates a blank tab while preserving the first tab', () => { + const first = createNoteWorkspaceState(ids('tab-1')) + const state = addWorkspaceTab(first, ids('tab-2')) + + expect(state.tabs.map((tab) => tab.id)).toEqual(['tab-1', 'tab-2']) + expect(state.activeTabId).toBe('tab-2') + expect(state.tabs[0]).toBe(first.tabs[0]) + }) + + it('replaces the active tab when opening a new note', () => { + const first = createNoteWorkspaceState(ids('tab-1')) + const state = openNoteInWorkspace(first, note('note-1', 'First')) + + expect(state.tabs).toHaveLength(1) + expect(getActiveWorkspaceTab(state)).toMatchObject({ + id: 'tab-1', + noteId: 'note-1', + mode: 'reading', + draft: { title: 'First', description: '

First

', tags: 'work' }, + }) + }) + + it('activates an existing note tab instead of creating a duplicate', () => { + let state = createNoteWorkspaceState(ids('tab-1')) + state = openNoteInWorkspace(state, note('note-1')) + state = addWorkspaceTab(state, ids('tab-2')) + const deduplicated = openNoteInWorkspace(state, note('note-1')) + + expect(deduplicated.tabs).toHaveLength(2) + expect(deduplicated.activeTabId).toBe('tab-1') + expect(findWorkspaceTabByNoteId(deduplicated, 'note-1')?.id).toBe('tab-1') + }) + + it('updates draft, view, and failed-save state without changing tab identity', () => { + const state = createNoteWorkspaceState(ids('tab-1')) + const updated = updateWorkspaceTab(state, 'tab-1', { + draft: { title: 'Draft title' }, + view: { scrollTop: 240, editorSelection: { from: 3, to: 7 } }, + saveState: 'error', + saveError: 'network unavailable', + }) + + expect(getActiveWorkspaceTab(updated)).toMatchObject({ + id: 'tab-1', + draft: { title: 'Draft title', description: '', tags: '' }, + view: { scrollTop: 240, editorSelection: { from: 3, to: 7 } }, + saveState: 'error', + saveError: 'network unavailable', + }) + }) + + it('closes the active tab and prefers the right neighbor, then the left neighbor', () => { + let state = createNoteWorkspaceState(ids('tab-1')) + state = addWorkspaceTab(state, ids('tab-2')) + state = addWorkspaceTab(state, ids('tab-3')) + + const afterMiddleClose = closeWorkspaceTab(activateWorkspaceTab(state, 'tab-2'), 'tab-2', ids('replacement')) + expect(afterMiddleClose.tabs.map((tab) => tab.id)).toEqual(['tab-1', 'tab-3']) + expect(afterMiddleClose.activeTabId).toBe('tab-3') + + const afterRightmostClose = closeWorkspaceTab(activateWorkspaceTab(afterMiddleClose, 'tab-3'), 'tab-3', ids('replacement')) + expect(afterRightmostClose.activeTabId).toBe('tab-1') + }) + + it('keeps one blank active tab when the last tab closes', () => { + const state = createNoteWorkspaceState(ids('tab-1')) + const closed = closeWorkspaceTab(state, 'tab-1', ids('tab-replacement')) + + expect(closed.tabs).toHaveLength(1) + expect(getActiveWorkspaceTab(closed)).toMatchObject({ id: 'tab-replacement', noteId: null }) + }) + + it('round-trips valid state and rejects malformed or duplicate persisted tabs', () => { + let state = openNoteInWorkspace(createNoteWorkspaceState(ids('tab-1')), note('note-1')) + state = addWorkspaceTab(state, ids('tab-2')) + state = updateWorkspaceTab(state, 'tab-2', { note: note('note-2'), mode: 'editing' }) + + const restored = hydrateNoteWorkspaceState(serializeNoteWorkspaceState(state), ids('restored')) + expect(restored).toEqual(state) + + const duplicate = hydrateNoteWorkspaceState({ + version: 1, + activeTabId: 'tab-1', + tabs: [ + { ...state.tabs[0] }, + { ...state.tabs[1], id: 'tab-3', noteId: 'note-1' }, + ], + }, ids('restored')) + expect(duplicate.tabs.map((tab) => tab.noteId)).toEqual(['note-1', 'note-2']) + expect(duplicate.tabs.filter((tab) => tab.noteId === 'note-1')).toHaveLength(1) + + const malformed = hydrateNoteWorkspaceState('{not-json', ids('fallback')) + expect(malformed.tabs).toHaveLength(1) + expect(malformed.activeTabId).toBe('fallback') + }) + + it('ignores activation and updates for unknown tab IDs', () => { + const state = createNoteWorkspaceState(ids('tab-1')) + + expect(activateWorkspaceTab(state, 'missing')).toBe(state) + expect(updateWorkspaceTab(state, 'missing', { mode: 'reading' })).toBe(state) + expect(findWorkspaceTabByNoteId(state, null)).toBeNull() + }) +}) diff --git a/cypress/component/features/mobile/MobileLayout.cy.tsx b/cypress/component/features/mobile/MobileLayout.cy.tsx index 81a21c434ca..67260a29d7e 100644 --- a/cypress/component/features/mobile/MobileLayout.cy.tsx +++ b/cypress/component/features/mobile/MobileLayout.cy.tsx @@ -3,6 +3,7 @@ import { NotesShell } from '../../../../ui/web/components/features/notes/NotesSh import type { NoteAppController } from '../../../../ui/web/hooks/useNoteAppController' import { SupabaseTestProvider } from '../../../../ui/web/providers/SupabaseProvider' import type { SupabaseClient } from '@supabase/supabase-js' +import type { NoteWorkspaceTab } from '../../../../core/services/noteWorkspaceTabs' import { ThemeProvider } from '../../../../ui/web/components/theme-provider' describe('Mobile Layout Adaptation', () => { @@ -16,6 +17,16 @@ describe('Mobile Layout Adaptation', () => { createMockController = (overrides: Partial = {}): NoteAppController => { const handleSelectNote = cy.stub().as('handleSelectNote') handleSelectNote.resolves() + const activeTab: NoteWorkspaceTab = { + id: 'test-tab', + noteId: null, + note: null, + mode: 'reading', + draft: { title: '', description: '', tags: '' }, + view: { scrollTop: 0 }, + saveState: 'saved', + saveError: null, + } return ({ activeMainView: 'notes', @@ -31,6 +42,14 @@ describe('Mobile Layout Adaptation', () => { notesQuery: { isLoading: false, hasNextPage: false, isFetchingNextPage: false, fetchNextPage: cy.stub() } as any, selectedNote: null, isEditing: false, + tabs: [activeTab], + activeTabId: activeTab.id, + activeTab, + addTab: cy.stub().resolves(), + activateTab: cy.stub().resolves(), + closeTab: cy.stub().resolves(), + handleDraftChange: cy.stub(), + handleViewSessionChange: cy.stub(), setIsEditing: cy.stub(), isSearchPanelOpen: false, setIsSearchPanelOpen: cy.stub(), @@ -101,6 +120,7 @@ describe('Mobile Layout Adaptation', () => { ...overrides, // Required by controller type (used by NotesShell to register editor ref) + notePaneVisible: overrides.notePaneVisible ?? Boolean(overrides.selectedNote || overrides.isEditing), registerNoteEditorRef: overrides.registerNoteEditorRef ?? cy.stub(), resetFtsResults: overrides.resetFtsResults ?? cy.stub(), resetAIResults: overrides.resetAIResults ?? cy.stub(), diff --git a/docs/ai/deployment/2026-08-01-feature-notes-tabs.md b/docs/ai/deployment/2026-08-01-feature-notes-tabs.md new file mode 100644 index 00000000000..d4bfcb1f620 --- /dev/null +++ b/docs/ai/deployment/2026-08-01-feature-notes-tabs.md @@ -0,0 +1,37 @@ +--- +phase: deployment +title: Deployment Strategy +description: Define deployment process, infrastructure, and release procedures +--- + +# Deployment Strategy + +## Infrastructure + +No new infrastructure or database schema is required. The feature runs in the existing Next.js web bundle and existing native mobile bundle. + +## Deployment Pipeline + +- Run production type-check, ESLint, focused tests, and existing build checks in CI. +- No migration step is required because workspace state is client-side and versioned. +- The versioned storage key must support safe fallback when users load a new bundle over an older session snapshot. + +## Environment Configuration + +No new secrets or environment variables are required. + +## Deployment Steps + +1. Complete implementation/design/test review. +2. Run web and mobile validation independently. +3. Build the existing release artifacts. +4. Smoke-test Notes tab creation, switching, close, reload, and save failure. +5. Release through the existing pipeline. + +## Database Migrations + +None. + +## Rollback Plan + +Rollback is a normal application bundle rollback. A newer invalid workspace snapshot is ignored by version/schema validation, so it cannot prevent the Notes page from opening. diff --git a/docs/ai/design/2026-08-01-feature-notes-tabs.md b/docs/ai/design/2026-08-01-feature-notes-tabs.md new file mode 100644 index 00000000000..b7a416af799 --- /dev/null +++ b/docs/ai/design/2026-08-01-feature-notes-tabs.md @@ -0,0 +1,165 @@ +--- +phase: design +title: System Design & Architecture +description: Define the technical architecture, components, and data models +--- + +# System Design & Architecture + +## Architecture Overview + +```mermaid +graph TD + Controller[useNoteAppController] --> Workspace[useNoteWorkspaceTabs] + Workspace --> Reducer[Pure workspace reducer and invariants] + Workspace --> Storage[Session workspace storage adapter] + Controller --> Flush[Editor flush and session capture] + Flush --> Workspace + Controller --> TabStrip[Desktop NotesTabStrip] + Controller --> MobileTabs[Compact MobileTabsList] + Controller --> EditorPane[EditorPane] + EditorPane --> NoteEditor[NoteEditor] + EditorPane --> NoteView[NoteView] + NoteEditor --> Autosave[useNoteEditorAutoSave] + Autosave --> SaveHandlers[useNoteSaveHandlers] + SaveHandlers --> Offline[Offline cache and sync queue] + Controller --> Selection[Notes list, search, tags, internal-open flows] + Selection --> Controller +``` + +`useNoteWorkspaceTabs` owns tab identity, ordering, active-tab selection, session snapshots, deduplication, and storage hydration. `useNoteAppController` remains the only navigation/write integration point: it flushes/captures the old tab, applies the reducer transition, and synchronizes the existing selected-note/editor API for the rest of the UI. + +## Data Models + +```ts +type NoteWorkspaceMode = 'reading' | 'editing' +type NoteSaveState = 'saved' | 'dirty' | 'saving' | 'error' + +type NoteDraftSnapshot = { + title: string + description: string + tags: string +} + +type NoteViewSession = { + scrollTop: number + titleSelection?: { start: number; end: number } + editorSelection?: { from: number; to: number } +} + +type NoteWorkspaceTab = { + id: string + noteId: string | null + note: NoteViewModel | null + mode: NoteWorkspaceMode + draft: NoteDraftSnapshot + view: NoteViewSession + saveState: NoteSaveState + saveError: string | null +} + +type NoteWorkspaceState = { + version: 1 + tabs: NoteWorkspaceTab[] + activeTabId: string +} +``` + +The persisted JSON contains only serializable values. Runtime refs, promises, React elements, and editor instances are never persisted. A tab with `noteId: null` is a blank working slot; a saved note tab stores a lightweight note snapshot so a reload can render immediately while existing note-opening/revalidation logic remains authoritative. + +### Invariants + +- `tabs.length >= 1`. +- `activeTabId` always points to a tab. +- At most one tab has a given non-null `noteId`. +- A blank tab has `note === null` and `noteId === null`. +- Closing an active tab applies right-neighbor-then-left-neighbor selection. +- Invalid persisted records are discarded rather than partially applied. + +## API Design + +The pure reducer/service exposes deterministic operations that both web and native presentation layers can use: + +```ts +createWorkspaceState(idFactory?): NoteWorkspaceState +hydrateWorkspaceState(raw, idFactory?): NoteWorkspaceState +addWorkspaceTab(state, idFactory?): NoteWorkspaceState +activateWorkspaceTab(state, tabId): NoteWorkspaceState +openNoteInWorkspace(state, note, tabId?): NoteWorkspaceState +closeWorkspaceTab(state, tabId): NoteWorkspaceState +updateWorkspaceTab(state, tabId, patch): NoteWorkspaceState +findWorkspaceTabByNoteId(state, noteId): NoteWorkspaceTab | null +``` + +The React hook adds hydration/persistence and stable callbacks. Controller integration adds asynchronous operations around the reducer: + +1. `flushPendingEditorSave()` waits for the current autosave pipeline. +2. `captureActiveSession()` reads the current editor/view draft and writes it to the active tab. +3. The transition is applied (`activate`, `open`, `add`, or `close`). +4. The controller synchronizes `selectedNote`/`isEditing` for existing consumers. + +## Component Breakdown + +| Area | Component/module | Responsibility | +|---|---|---| +| Shared model | `core/services/noteWorkspaceTabs.ts` | Types, invariants, reducer operations, safe hydration | +| Web persistence | `ui/web/lib/noteWorkspaceStorage.ts` | `sessionStorage` adapter, version/key/quota handling | +| Web hook | `ui/web/hooks/useNoteWorkspaceTabs.ts` | React state, hydration, persistence, stable actions | +| Desktop UI | `ui/web/components/features/notes/NotesTabStrip.tsx` | Horizontal tabs, add/close, active and save indicators | +| Mobile web UI | `ui/web/components/features/notes/MobileTabsList.tsx` | Active-tab summary and compact switch/close sheet/list | +| Controller | `ui/web/hooks/useNoteAppController.ts` | Flush/capture/transition; routes every note-open path through tabs | +| Editor session | `NoteEditor.tsx`, `RichTextEditor.tsx` | Capture/restore draft, scroll, caret/selection | +| Reading session | `NoteView.tsx` | Capture/restore reading scroll | +| Layout | `NotesShell.tsx` | Places tab UI above Reading/Editing actions and keeps it mounted across Notes subviews | + +## Design Decisions + +### Active-slot replacement is the default + +`openNoteInWorkspace` updates the active tab. `addWorkspaceTab` is the only operation that increases tab count. This encodes the product's key rule in one reducer function instead of relying on individual click handlers. + +### Deduplicate before asynchronous fetch work + +The controller checks `findWorkspaceTabByNoteId` before replacing the active tab. If found, it flushes/captures the current tab and activates the existing tab. This prevents two concurrent sessions for one note and avoids unnecessary note-status checks. + +### Local drafts belong to tabs + +The existing editor remains mounted only for the active tab, so its DOM/TipTap instance is captured before unmount and restored from the tab's serializable draft/session. Existing autosave and offline queue behavior remains unchanged. + +### Session storage is the web boundary + +`sessionStorage` provides reload persistence while keeping separate external browser tabs isolated. Access is wrapped in try/catch and validated with a versioned schema. The in-memory reducer is always usable without storage. + +### Save errors are explicit + +The tab stores `saveState: 'error'` and `saveError`. Closing a failed tab requires an explicit confirmation from the user; an error is never silently discarded. Dirty/saving states are non-blocking indicators. + +### Split View remains possible + +The workspace is modeled as a list of independent tab sessions, while the controller exposes one active session today. A future split view can mount two active-session presenters without changing tab identity or persistence semantics. + +## Non-Functional Requirements + +- Switching tabs performs no new network request unless existing note revalidation is required; reducer transitions are synchronous after the current save flush. +- Storage writes are best-effort and serialized from a small, bounded state snapshot; storage failures never block editing. +- Tab buttons are keyboard reachable, have accessible names, and expose active/dirty/error state. +- Long titles are ellipsized; desktop overflow is horizontal and mobile uses a compact list. +- No secrets or auth tokens are added to workspace storage. + +## Design Review Resolution (2026-08-01) + +The design covers every requirements goal and transition: + +| Requirement | Design coverage | +|---|---| +| Active-slot replacement and explicit Add | `openNoteInWorkspace` and `addWorkspaceTab` reducer operations | +| Duplicate note prevention | `findWorkspaceTabByNoteId` before controller replacement | +| Per-tab mode/draft/save state | `NoteWorkspaceTab` data model and active-session capture | +| Autosave safety | flush/capture sequence before reducer transitions; existing save handlers remain authoritative | +| Reading/editor context | `NoteView` scroll capture plus `NoteEditor`/TipTap session capture | +| Reload and browser-tab isolation | versioned `sessionStorage` adapter with in-memory fallback | +| Tags/Search/Settings/internal opens | one controller boundary and a mounted workspace hook | +| Desktop/mobile views | separate presentation components consuming the same hook/model | +| Future Split View | independent tab sessions with one active presenter today | + +The only intentionally deferred behavior is native-mobile persistence policy; native UI can reuse the model without changing web `sessionStorage` semantics. No design gap blocks implementation. diff --git a/docs/ai/implementation/2026-08-01-feature-notes-tabs.md b/docs/ai/implementation/2026-08-01-feature-notes-tabs.md new file mode 100644 index 00000000000..3b58426ca17 --- /dev/null +++ b/docs/ai/implementation/2026-08-01-feature-notes-tabs.md @@ -0,0 +1,76 @@ +--- +phase: implementation +title: Implementation Guide +description: Technical implementation notes, patterns, and code guidelines +--- + +# Implementation Guide + +## Development Setup + +- Worktree: `.worktrees/codex-notes-tabs`, branch `codex/notes-tabs`. +- Root dependencies: `npm ci`. +- Mobile dependencies: `npm --prefix ui/mobile ci`. +- Fast validation: `npm run type-check` and `npx eslint . --max-warnings=0`. +- Focused test commands must use the project's Allure agent-mode wrapper when test evidence is collected. + +## Code Structure + +```text +core/services/noteWorkspaceTabs.ts +ui/web/lib/noteWorkspaceStorage.ts +ui/web/hooks/useNoteWorkspaceTabs.ts +ui/web/components/features/notes/NotesTabStrip.tsx +ui/web/components/features/notes/MobileNotesTabMenu.tsx +ui/web/hooks/useNoteAppController.ts +ui/web/components/features/notes/NotesShell.tsx +ui/web/components/features/notes/NoteEditor.tsx +ui/web/components/RichTextEditor.tsx +ui/web/components/features/notes/NoteView.tsx +``` + +## Implementation Notes + +### Core features + +- Keep reducer transitions pure and make invalid states impossible through normalization. +- Use an injected ID factory in reducer tests; production uses `crypto.randomUUID` with a safe fallback. +- Hydrate once, then persist state changes through a guarded storage adapter. +- Capture the current editor before unmount, flush autosave first, and then apply tab transitions. +- Use the active tab's draft as editor initial content; never use a server refresh to overwrite a dirty local field without existing reconciliation rules. +- Keep tab indicators derived from explicit per-tab save state rather than global UI assumptions. + +### Patterns & Best Practices + +- Preserve the existing `NoteAppController` public return shape where possible; add tab-specific fields instead of renaming existing handlers. +- Keep all open-note entry points routed through one controller function. +- Do not add direct Supabase calls to tab UI or storage. +- Use `sessionStorage` only through the adapter and never read/write it during server rendering. +- Keep accessibility labels stable so component tests can target behavior rather than CSS. + +## Integration Points + +- `useNoteEditorAutoSave` remains responsible for debouncing and flush; `useNoteSaveHandlers` remains responsible for online/offline writes. +- `NoteEditorHandle` gains capture/restore methods without exposing TipTap internals to the controller. +- `NoteView` reports reading scroll position to the active tab. +- `NotesShell` renders the tab UI above the existing editor action area and leaves the workspace mounted when `activeMainView` changes. + +## Error Handling + +- Storage parse/access/quota errors fall back to the in-memory state and optionally log a debug warning. +- Autosave errors set the active tab to `error`, retain the draft, and keep the existing toast/retry behavior. +- Failed-tab close requires explicit confirmation; canceling leaves the tab and draft intact. +- Invalid note snapshots are ignored or revalidated through existing note status logic. + +## Performance Considerations + +- Avoid network calls on tab activation when a valid local snapshot exists; use existing revalidation only where necessary. +- Throttle scroll persistence with `requestAnimationFrame` or a small debounce. +- Serialize only the workspace state, not React refs or query caches. +- Do not mount multiple editors in v1; only the active tab owns a live editor instance. + +## Security Notes + +- Workspace state contains note content in browser session storage, protected by the same-origin browser boundary; no auth/session tokens are stored. +- Validate all hydrated values and discard unknown versions/shapes. +- Existing HTML sanitization and editor input handling remain unchanged. diff --git a/docs/ai/monitoring/2026-08-01-feature-notes-tabs.md b/docs/ai/monitoring/2026-08-01-feature-notes-tabs.md new file mode 100644 index 00000000000..b662caffd78 --- /dev/null +++ b/docs/ai/monitoring/2026-08-01-feature-notes-tabs.md @@ -0,0 +1,37 @@ +--- +phase: monitoring +title: Monitoring & Observability +description: Define monitoring strategy, metrics, alerts, and incident response +--- + +# Monitoring & Observability + +## Key Metrics + +- Client-side autosave failures while a tab is dirty. +- Workspace storage read/write failures and rejected snapshot versions. +- Duplicate-note prevention and tab close-confirmation paths, if product analytics are enabled. +- UI errors during tab activation/restoration. + +## Logging Strategy + +- Keep storage and restoration diagnostics non-sensitive and bounded; never log note body contents or tokens. +- Reuse existing autosave/offline error reporting and toast behavior. +- Use warnings for recoverable storage failures and errors for unhandled activation/save failures. + +## Alerts & Notifications + +- Warning: elevated autosave failure rate or repeated workspace restore failures. +- Critical: a regression causes Notes to fail to render or silently discard dirty drafts. + +## Health Checks + +- Existing web/native smoke checks must open Notes and render one blank or restored tab. +- Focused regression tests cover the loss-safety paths before release. + +## Incident Response + +1. Triage whether the failure is storage-only, editor-session-only, or write-path-related. +2. Disable/roll back the UI bundle if dirty drafts can be lost. +3. Preserve existing offline queue data and use the reducer's safe fallback. +4. Add a regression test for the observed transition before re-release. diff --git a/docs/ai/planning/2026-08-01-feature-notes-tabs.md b/docs/ai/planning/2026-08-01-feature-notes-tabs.md new file mode 100644 index 00000000000..e87bb7c1f9e --- /dev/null +++ b/docs/ai/planning/2026-08-01-feature-notes-tabs.md @@ -0,0 +1,94 @@ +--- +phase: planning +title: Project Planning & Task Breakdown +description: Break down work into actionable tasks and estimate timeline +--- + +# Project Planning & Task Breakdown + +## Milestones + +- [x] M1: Shared tab model, safe persistence, and focused unit tests. +- [x] M2: Web controller integration and editor/reader session preservation. +- [x] M3: Desktop and mobile web tab presentations plus all open-note paths. +- [ ] M4: Native mobile presentation using the shared model semantics. +- [ ] M5: Full verification, documentation updates, and review. + +## Task Breakdown + +### Phase 1: Foundation + +- [x] T1.1: Add `core/services/noteWorkspaceTabs.ts` with types, state factory, invariant-preserving transitions, deduplication, close-neighbor behavior, and safe hydration. +- [x] T1.2: Add web `sessionStorage` adapter and `useNoteWorkspaceTabs` hook with one blank-tab fallback. +- [x] T1.3: Add unit tests for reducer transitions, malformed state, duplicate note IDs, persistence round-trip, and last-tab close. + +### Phase 2: Core Web Features + +- [x] T2.1: Integrate workspace state into `useNoteAppController` without breaking the existing `NoteAppController` consumers. +- [x] T2.2: Route list, FTS search, tag-only results, AI/internal note opens, edit, create, and settings return through active-tab transitions. +- [x] T2.3: Add active-tab flush/capture coordination and per-tab dirty/saving/error state. +- [x] T2.4: Extend `NoteEditor`, `RichTextEditor`, and `NoteView` to capture/restore draft, scroll, title selection, and best-effort editor selection. + +### Phase 3: Integration & Polish + +- [x] T3.1: Add desktop `NotesTabStrip` above the existing Reading/Editing header/actions. +- [x] T3.2: Add narrow/mobile web compact tab summary and list with accessible close/switch controls. +- [x] T3.3: Add focused component/controller tests for tab UX, duplicate selection, transitions, persistence, and save-error close confirmation. +- [x] T3.4: Verify Tags/Search/Settings navigation preserves the workspace. + +### Phase 4: Native Mobile + +- [ ] T4.1: Expose the shared model through the native mobile state/persistence adapter. +- [ ] T4.2: Adapt `useOpenNote`, Notes/Search result flows, and note route to activate existing sessions or replace the active session. +- [ ] T4.3: Add compact mobile tab list/sheet and native session tests. + +### Phase 5: Quality Gates + +- [x] T5.1: Update implementation/testing/deployment/monitoring docs with actual commands and evidence. +- [x] T5.2: Run focused unit/integration tests, production type-check, ESLint, and mobile checks independently. +- [x] T5.3: Run the project Allure agent-mode focused tests and inspect results/evidence. +- [ ] T5.4: Review diff/status for unrelated files and perform final code review. + +## Dependencies + +- T1.1 precedes T1.2 and all controller/UI work. +- T2.3/T2.4 must be complete before tab switching is considered loss-safe. +- T3.1/T3.2 depend on the controller's stable tab API. +- T4 can reuse T1.1 but must not change web persistence semantics. +- Verification depends on the final test plan and any new test fixtures. + +## Timeline & Estimates + +- Foundation: 0.5–1 day. +- Core web behavior: 1–2 days. +- UI and regression tests: 0.5–1 day. +- Native mobile adaptation: 1–2 days depending on route/store constraints. +- Quality gates and review: 0.5–1 day. + +These are engineering estimates only; autosave/editor lifecycle and native navigation are the main uncertainty buffers. + +## Risks & Mitigation + +- **Autosave race on tab switch:** flush and capture before every transition; retain the existing debounced-latest/reconciliation tests. +- **Large drafts exceed storage quota:** catch serialization/storage errors, keep memory state, and avoid blocking the editor. +- **Remote note changed while a tab is inactive:** revalidate on activation through existing `resolveOpenableNote` and reconcile local draft fields. +- **Duplicate sessions from different entry points:** centralize all open-note actions in controller/reducer and test each source. +- **Editor selection coordinates become stale:** treat restoration as best effort and validate bounds before applying. +- **Native/web behavior diverges:** share reducer/invariants and keep presentation-specific adapters thin. +- **Unrelated dirty files in the main checkout:** work only in `.worktrees/codex-notes-tabs` and stage task files explicitly. + +## Resources Needed + +- Existing React/Tiptap editor and autosave hooks. +- Existing offline cache/queue and note query/mutation services. +- Jest/Testing Library and the project's Allure agent-mode workflow. +- One bounded subagent for architecture/mobile investigation or a disjoint native implementation slice; close it when its report is integrated. + +## Execution Tracking + +- T2/T3 done (2026-08-01): controller, editor/reader session capture, desktop/mobile web controls, and navigation integration. Combined Allure run `C:\Users\DenysKoreiba\AppData\Local\Temp\allure-agent-58hLNC`; 34/34 passed, expectations matched, no findings. +- T5.2 done (2026-08-01): `npm run type-check` passed, including `ui/mobile`; focused ESLint passed for all changed source/test files. +- T5.4 pending final diff/status audit. Native mobile route/store adaptation remains a separately scoped follow-up; the delivered mobile presentation is the responsive web Notes UI. + +- T1.1 — done (2026-08-01): pure workspace model and 9 focused Jest tests. Allure agent output `C:\Users\DenysKoreiba\AppData\Local\Temp\allure-agent-QVm6RR`; 9/9 passed, expectations matched, no findings. +- T1.2/T1.3 — done (2026-08-01): web storage adapter, hook, and combined 13-test model/storage run. Allure agent output `C:\Users\DenysKoreiba\AppData\Local\Temp\allure-agent-Rc7Bph`; 13/13 passed, expectations matched, no findings. diff --git a/docs/ai/requirements/2026-08-01-feature-notes-tabs.md b/docs/ai/requirements/2026-08-01-feature-notes-tabs.md new file mode 100644 index 00000000000..a2632304616 --- /dev/null +++ b/docs/ai/requirements/2026-08-01-feature-notes-tabs.md @@ -0,0 +1,84 @@ +--- +phase: requirements +title: Requirements & Problem Understanding +description: Clarify the problem space, gather requirements, and define success criteria +--- + +# Requirements & Problem Understanding + +## Problem Statement + +Notes currently has one selected-note session. Selecting another note replaces the current view, so users lose the working context of the previous note: reading/editing mode, scroll position, editor caret/selection, and any local draft that is still in the autosave pipeline. Users work around this by opening several browser tabs, which duplicates application state and makes navigation/search/tag flows harder to reason about. + +The feature serves authenticated EverFreeNote users who work with several notes in one Notes workspace, on desktop and on narrow/mobile layouts. + +## Goals & Objectives + +- Add an in-app Notes workspace with at least one active tab at all times. +- Make ordinary note selection replace the active tab; only the explicit Add tab action creates a new tab. +- Deduplicate by note ID: selecting an already-open note activates its existing tab. +- Preserve per-tab note snapshot, Reading/Editing mode, local draft, save status, scroll position, and best-effort editor caret/text selection. +- Flush pending autosave work before leaving a tab and surface dirty/saving/error state on the tab. +- Restore the workspace after a page reload in the same browser tab, including order, active tab, note snapshots, drafts, mode, and view state. +- Keep the workspace alive while moving between Notes, Tags, Search, and Settings. +- Provide a desktop tab strip and a compact mobile presentation backed by the same state model. + +### Non-goals for the first version + +- Split View or displaying two note editors simultaneously. +- Drag-and-drop tab reordering. +- Close-all/close-right/close-others commands. +- Persisting the web workspace after a browser is fully closed; web persistence is scoped to `sessionStorage` and therefore to one external browser tab. +- New backend tables, Supabase endpoints, or a second concurrent editor session for the same note. + +## User Stories & Use Cases + +- As a note author, I can select a note from the normal list and it opens in the current active tab. +- As a note author, I can explicitly add a tab, then select a note and keep the first note open in its original tab. +- As a note author, I can switch tabs and return to the same Reading/Editing mode, scroll location, draft, and editor context. +- As a note author, I cannot open the same note in two tabs; selecting it activates the existing tab. +- As a note author, I can close any tab. Closing the active tab activates the tab to its right, or the left tab when there is no right neighbor. +- As a note author, closing the final tab leaves one empty active tab instead of removing the workspace. +- As a note author, I see a subtle dirty/saving/error marker and receive confirmation before closing a tab whose save failed. +- As a note author, note selection from the list, search results, tag results, and internal note-opening flows follows the same active-tab rule. +- As a note author, navigating to Tags, Search, or Settings does not destroy the Notes workspace; returning restores it. +- As a mobile user, I can see the active note and open-tab count and use a compact tab list to switch or close tabs. + +## Success Criteria + +- [ ] The Notes workspace always renders one active tab, including the initial and last-tab-closed states. +- [ ] List/search/tag/internal selection replaces the active tab unless the selected note is already open, in which case its existing tab activates. +- [ ] Add tab creates exactly one blank active tab and does not open a note implicitly. +- [ ] Active-tab close chooses right neighbor first, then left neighbor; closing the last tab creates one blank tab. +- [ ] Switching tabs does not lose a flushed or pending local draft; save failure remains visible and is not silently discarded. +- [ ] Reading and Editing modes are restored per tab; editing restoration includes title/body/tags draft values. +- [ ] Scroll position is restored for Reading and Editing views; caret/selection restoration is best effort and never blocks navigation. +- [ ] A valid `sessionStorage` snapshot restores tab order, active tab, note snapshots, drafts, and view state after reload; malformed or oversized state falls back safely to one blank tab. +- [ ] The tab model is shared by desktop and mobile presentation components. +- [ ] No new network request is required just to switch tabs, and no duplicate note editor session can be created for one note ID. +- [ ] Existing autosave, manual save, delete, search, tag, offline, and settings-return behavior remains passing. + +## Constraints & Assumptions + +- Existing `useNoteAppController` remains the navigation boundary; wrappers must flush the current editor before switching, closing, or leaving Notes. +- Existing offline cache/queue and `useNoteEditorAutoSave` remain the source of truth for writes. Tabs store a local working snapshot and status; they do not introduce a second persistence protocol. +- Web workspace persistence uses `sessionStorage`, which naturally separates same-origin browser tabs while surviving reloads. Storage access can fail and must be treated as best effort. +- Note content may be large. Serialization is bounded and failure-safe; the application must keep the in-memory workspace usable when storage quota is exceeded. +- Supabase data remains user-scoped. Persisted note snapshots are advisory UI state and are revalidated through existing note-opening logic when a note is selected. +- Editor caret/selection coordinates are ProseMirror-specific and can become invalid after remote content changes; restore only when the editor accepts the coordinates. +- The native mobile app may use its platform persistence adapter, but it must consume the same reducer/model semantics as the web tab strip. + +## Questions & Open Items + +The following review decisions resolve the v1 ambiguities without changing the requested behavior: + +- No hard tab-count cap is introduced. Desktop uses horizontal overflow and mobile uses a compact list. +- Web persistence is explicitly `sessionStorage` per external browser tab. Native mobile persistence is an adapter concern and is not allowed to change the web contract. +- If flushing the active editor fails during a normal switch, the transition is aborted so the user can retry and the failed state remains visible. Closing a failed tab is allowed only after an explicit discard confirmation. +- Caret/text-selection restoration is best effort and may be skipped after remote content reconciliation; draft and scroll restoration remain mandatory. +- The repository's current AI/search “open note in context” callbacks are the internal-open integration point. Any future note-link handler must call the same controller open-note function. +- Conflict-resolution UI is deferred; existing last-write-wins/autosave reconciliation remains authoritative. + +## Requirements Review Resolution (2026-08-01) + +All template sections are complete. The supplied concept provides the problem, target users, workflows, edge cases, mobile behavior, reload boundary, and explicit non-goals. The five decisions above are implementation defaults for unspecified details; none require additional backend or product clarification before Phase 4. diff --git a/docs/ai/testing/2026-08-01-feature-notes-tabs.md b/docs/ai/testing/2026-08-01-feature-notes-tabs.md new file mode 100644 index 00000000000..ceec2c2e65d --- /dev/null +++ b/docs/ai/testing/2026-08-01-feature-notes-tabs.md @@ -0,0 +1,86 @@ +--- +phase: testing +title: Testing Strategy +description: Define testing approach, test cases, and quality assurance +--- + +# Testing Strategy + +## Test Coverage Goals + +- 100% branch coverage for new pure workspace transitions and hydration guards. +- Component/controller coverage for every open-note source, tab lifecycle, persistence, and save-error path. +- Regression coverage for autosave flush/reconciliation, Reading/Editing restoration, Tags/Search/Settings navigation, and mobile presentation. +- Use Allure agent mode for focused runs and inspect report-backed evidence rather than relying on test counts alone. + +## Unit Tests + +### `core/services/noteWorkspaceTabs.ts` + +- [x] Creates one blank active tab. +- [x] Adds and activates one new blank tab. +- [x] Opens a note in the active tab without increasing tab count. +- [x] Activates an existing tab when the note ID is already open. +- [x] Preserves order and chooses the right/left neighbor on close. +- [x] Replaces the final closed tab with one blank active tab. +- [x] Applies tab patches without breaking invariants. +- [x] Rejects malformed, duplicate, unsupported-version, and oversized snapshots. + +### Web storage and hook + +- [x] Round-trips valid state through `sessionStorage`. +- [x] Handles blocked storage, invalid JSON, and quota errors without throwing. +- [x] Persists per-browser-tab state without using `localStorage`. + +## Integration Tests + +- [x] Controller flushes pending autosave and captures the outgoing tab before activation. +- [x] Selecting list/search/tag/internal result replaces the active tab. +- [x] Selecting a duplicate note activates the existing tab and does not create a second session. +- [x] Dirty draft and save-error state remain on the tab after switching. +- [x] Closing a failed tab requires explicit confirmation. +- [x] Editor draft, title selection, scroll, and best-effort ProseMirror selection restore after activation. +- [x] Reading scroll restores after activation. +- [x] Settings return preserves tabs and active tab; Tags/Search view changes do not clear them. + +## End-to-End Tests + +- [ ] Open note A, add tab, open note B, switch A/B, and verify both contexts. +- [ ] Open note A from normal list, search, tag-only results, and AI/internal flow and verify one tab. +- [ ] Reload the same browser tab and verify order, active tab, mode, draft, and scroll. +- [ ] Close active tabs in both neighbor directions and close the final tab. +- [x] Use the compact mobile tab list to switch and close tabs. + +## Test Data + +- Use deterministic `NoteViewModel` fixtures with distinct IDs, long titles, long HTML bodies, tags, and unsaved drafts. +- Mock storage only at the adapter boundary. +- Keep existing Supabase/offline mocks and test real reducer/controller behavior around them. + +## Test Reporting & Coverage + +- Focused web unit/component run: `npm run test:unit:web` (wrapped by Allure agent mode for evidence). +- Core reducer run: the focused Jest test path for the new core service (wrapped by Allure agent mode). +- Production type-check and lint run independently of test success. +- Record exact commands, pass/fail status, and any environment limitations here after implementation. + +## Manual Testing + +## Recorded Evidence + +- Allure Agent workspace/model/UI run: 34/34 passed, expectations matched, findings 0. Report: `C:\Users\DenysKoreiba\AppData\Local\Temp\allure-agent-58hLNC`. +- Existing controller regression run under Allure Agent: 18/18 passed, findings 0. Report: `C:\Users\DenysKoreiba\AppData\Local\Temp\allure-agent-yHYkoU`. +- `npm run type-check`: passed, including root, core, web tests, and `ui/mobile`. +- Focused ESLint for changed source/test files: passed. +- Native mobile route/store tab adaptation was not included; responsive web mobile controls are covered by component tests. + +- Desktop: keyboard tab navigation, ellipsis, horizontal overflow, dirty/error markers, close confirmation. +- Mobile viewport: active-note summary, tab count, compact list/sheet, touch targets, screen-reader labels. +- Reload: verify `sessionStorage` restore and separate browser-tab isolation. +- Network/offline: switch during autosave and after an induced save error. + +## Performance Testing + +- Verify switching does not mount more than one editor or trigger duplicate fetches. +- Verify rapid scroll/input changes do not write storage on every event without throttling. +- Verify long drafts remain responsive and storage failure does not block typing. diff --git a/ui/web/components/RichTextEditor.tsx b/ui/web/components/RichTextEditor.tsx index bfb0669dc0e..30a66e347c0 100644 --- a/ui/web/components/RichTextEditor.tsx +++ b/ui/web/components/RichTextEditor.tsx @@ -21,6 +21,8 @@ import { scrollEditorToChunk } from "./chunkFocusUtils" export type RichTextEditorHandle = { getHTML: () => string + getSelection?: () => { from: number; to: number } | undefined + setSelection?: (selection: { from: number; to: number }) => void setContent: (html: string) => void runCommand: (command: string, ...args: unknown[]) => void /** Scroll to and highlight the given plain-text chunk range. @@ -261,6 +263,18 @@ const RichTextEditor = React.forwardRef ({ getHTML: () => editor?.getHTML() ?? "", + getSelection: () => { + if (!editor) return undefined + const { from, to } = editor.state.selection + return { from, to } + }, + setSelection: ({ from, to }: { from: number; to: number }) => { + if (!editor) return + const maxPosition = editor.state.doc.content.size + const safeFrom = Math.min(Math.max(Math.trunc(from), 1), maxPosition) + const safeTo = Math.min(Math.max(Math.trunc(to), safeFrom), maxPosition) + editor.commands.setTextSelection({ from: safeFrom, to: safeTo }) + }, setContent: (html: string) => { if (!editor) return const document = createDocument(html, editor.schema, editor.options.parseOptions, { diff --git a/ui/web/components/features/notes/MobileNotesTabMenu.tsx b/ui/web/components/features/notes/MobileNotesTabMenu.tsx new file mode 100644 index 00000000000..dc31ab99ef3 --- /dev/null +++ b/ui/web/components/features/notes/MobileNotesTabMenu.tsx @@ -0,0 +1,99 @@ +"use client" + +import { useState } from "react" +import { List, Plus, X } from "lucide-react" +import type { NoteWorkspaceTab } from "@core/services/noteWorkspaceTabs" +import { Button } from "@/components/ui/button" +import { cn } from "@ui/web/lib/utils" +import { getTabLabel, SaveStateIndicator, type NotesTabStripProps } from "./NotesTabStrip" + +export type MobileNotesTabMenuProps = NotesTabStripProps + +export function MobileNotesTabMenu({ + tabs, + activeTabId, + onAddTab, + onActivateTab, + onCloseTab, +}: MobileNotesTabMenuProps) { + const [isOpen, setIsOpen] = useState(false) + const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0] + const activeLabel = activeTab ? getTabLabel(activeTab) : "No open notes" + + const handleActivate = (tabId: string) => { + onActivateTab(tabId) + setIsOpen(false) + } + + const handleClose = (tab: NoteWorkspaceTab) => { + onCloseTab(tab.id) + if (tab.id === activeTabId && tabs.length <= 1) setIsOpen(false) + } + + return ( +
+
+
+

{activeLabel}

+

{tabs.length} {tabs.length === 1 ? "tab" : "tabs"}

+
+ {activeTab && } + +
+ + {isOpen && ( +
+ {tabs.map((tab) => { + const label = getTabLabel(tab) + const isActive = tab.id === activeTabId + + return ( +
+ + +
+ ) + })} + +
+ )} +
+ ) +} diff --git a/ui/web/components/features/notes/NoteEditor.tsx b/ui/web/components/features/notes/NoteEditor.tsx index 4861985100c..22d493fc22b 100644 --- a/ui/web/components/features/notes/NoteEditor.tsx +++ b/ui/web/components/features/notes/NoteEditor.tsx @@ -12,15 +12,22 @@ 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 type { NoteDraftSnapshot, NoteViewSession } from "@core/services/noteWorkspaceTabs" const DEFAULT_AUTOSAVE_DELAY_MS = 500 const NOOP_CANCEL = () => {} export interface NoteEditorHandle { flushPendingSave: () => Promise + captureSession?: () => NoteEditorSession scrollToChunk: (charOffset: number, chunkLength: number) => void } +export type NoteEditorSession = { + draft: NoteDraftSnapshot + view: NoteViewSession +} + export type PendingChunkFocus = { requestId: string noteId: string @@ -47,6 +54,9 @@ interface NoteEditorProps { onBack?: () => void pendingChunkFocus?: PendingChunkFocus | null onPendingChunkFocusApplied?: (requestId: string) => void + initialSession?: NoteEditorSession + onDraftChange?: (draft: NoteDraftSnapshot) => void + onViewSessionChange?: (view: Partial) => void } export const NoteEditor = React.memo(React.forwardRef(function NoteEditor({ @@ -68,6 +78,9 @@ export const NoteEditor = React.memo(React.forwardRef(() => parseTagString(initialTags)) @@ -76,6 +89,8 @@ export const NoteEditor = React.memo(React.forwardRef NoteClipboardService.isBodyEmpty(initialDescription)) const titleInputRef = React.useRef(null) const editorRef = React.useRef(null) + const scrollContainerRef = React.useRef(null) + const initialSessionRef = React.useRef(initialSession) const previousNoteIdRef = React.useRef(noteId) const selectedTagsRef = React.useRef(selectedTags) @@ -93,6 +108,14 @@ export const NoteEditor = React.memo(React.forwardRef { + initialSessionRef.current = initialSession + }, [initialSession]) + + const notifyDraftChange = React.useCallback(() => { + onDraftChange?.(getFormData()) + }, [getFormData, onDraftChange]) + const applyExternalSnapshot = React.useCallback(( snapshot: { title: string; description: string; tags: string }, fieldDecisions: Record<'title' | 'description' | 'tags', 'accept-external' | 'acknowledge-local' | 'preserve-local'> @@ -152,7 +175,8 @@ export const NoteEditor = React.memo(React.forwardRef { handleContentChange() setIsBodyEmpty(NoteClipboardService.isBodyEmpty(editorRef.current?.getHTML() ?? "")) - }, [handleContentChange]) + notifyDraftChange() + }, [handleContentChange, notifyDraftChange]) const handleCopy = React.useCallback(() => { void copyNote(editorRef.current?.getHTML() ?? initialDescription) @@ -199,21 +223,65 @@ export const NoteEditor = React.memo(React.forwardRef { const next = selectedTagsRef.current.filter((tag) => tag !== tagToRemove) selectedTagsRef.current = next setSelectedTags(next) setTagQuery("") - }, []) + handleContentChange() + onDraftChange?.({ + ...getFormData(), + tags: buildTagString(next), + }) + }, [getFormData, handleContentChange, onDraftChange]) React.useImperativeHandle(ref, () => ({ flushPendingSave, + captureSession: () => ({ + draft: getFormData(), + view: { + scrollTop: scrollContainerRef.current?.scrollTop ?? 0, + ...(titleInputRef.current?.selectionStart !== null && titleInputRef.current?.selectionStart !== undefined && titleInputRef.current?.selectionEnd !== null && titleInputRef.current?.selectionEnd !== undefined + ? { + titleSelection: { + start: titleInputRef.current.selectionStart, + end: titleInputRef.current.selectionEnd, + }, + } + : {}), + ...(editorRef.current?.getSelection?.() ? { editorSelection: editorRef.current.getSelection?.() } : {}), + }, + }), scrollToChunk: (charOffset: number, chunkLength: number) => { editorRef.current?.scrollToChunk(charOffset, chunkLength) }, - }), [flushPendingSave]) + }), [flushPendingSave, getFormData]) + + React.useEffect(() => { + const session = initialSessionRef.current + if (!session) return + + const frame = window.requestAnimationFrame(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = session.view.scrollTop + } + if (titleInputRef.current && session.view.titleSelection) { + titleInputRef.current.setSelectionRange(session.view.titleSelection.start, session.view.titleSelection.end) + } + if (session.view.editorSelection) { + editorRef.current?.setSelection?.(session.view.editorSelection) + } + }) + + return () => window.cancelAnimationFrame(frame) + }, [editorSessionKey]) const effectivePendingChunkFocus = React.useMemo(() => { if (!pendingChunkFocus || !noteId) return null @@ -305,7 +373,11 @@ export const NoteEditor = React.memo(React.forwardRef {/* Editor Form */} -
+
onViewSessionChange?.({ scrollTop: event.currentTarget.scrollTop })} + >
{ + handleContentChange() + notifyDraftChange() + }} className="w-full h-auto border-0 bg-transparent px-0 py-1 text-4xl font-extrabold tracking-tight placeholder:text-muted-foreground/30 focus-visible:ring-0 shadow-none" />
diff --git a/ui/web/components/features/notes/NoteView.tsx b/ui/web/components/features/notes/NoteView.tsx index d113ef627f2..05537fe42ef 100644 --- a/ui/web/components/features/notes/NoteView.tsx +++ b/ui/web/components/features/notes/NoteView.tsx @@ -11,6 +11,7 @@ import { NoteClipboardService } from "@core/services/noteClipboard" import { useCopyNote } from "@ui/web/hooks/useCopyNote" import { NOTE_CONTENT_CLASS } from "@core/constants/typography" import type { Note } from "@core/types/domain" +import type { NoteViewSession } from "@core/services/noteWorkspaceTabs" // Define NoteRecord locally to match what's used in page.tsx type NoteRecord = Note & { @@ -27,6 +28,8 @@ interface NoteViewProps { onRemoveTag: (tag: string) => void onBack?: () => void wordpressConfigured?: boolean + initialScrollTop?: number + onViewSessionChange?: (view: Partial) => void } export const NoteView = React.memo(function NoteView({ @@ -37,6 +40,8 @@ export const NoteView = React.memo(function NoteView({ onRemoveTag, onBack, wordpressConfigured = false, + initialScrollTop = 0, + onViewSessionChange, }: NoteViewProps) { const bodyHtml = note.description || note.content || '' @@ -48,6 +53,14 @@ export const NoteView = React.memo(function NoteView({ const { copied, copyNote } = useCopyNote() const isBodyEmpty = React.useMemo(() => NoteClipboardService.isBodyEmpty(bodyHtml), [bodyHtml]) + const contentRef = React.useRef(null) + + React.useEffect(() => { + const frame = window.requestAnimationFrame(() => { + if (contentRef.current) contentRef.current.scrollTop = initialScrollTop + }) + return () => window.cancelAnimationFrame(frame) + }, [initialScrollTop, note.id]) // Форматирование дат для предотвращения повторных вычислений const formattedDates = React.useMemo(() => ({ @@ -125,7 +138,11 @@ export const NoteView = React.memo(function NoteView({
{/* Note Content */} -
+
onViewSessionChange?.({ scrollTop: event.currentTarget.scrollTop })} + >

{note.title} diff --git a/ui/web/components/features/notes/NotesShell.tsx b/ui/web/components/features/notes/NotesShell.tsx index 4f84eee0018..86e5878fbe8 100644 --- a/ui/web/components/features/notes/NotesShell.tsx +++ b/ui/web/components/features/notes/NotesShell.tsx @@ -31,7 +31,9 @@ import { useSupabase } from "@ui/web/providers/SupabaseProvider" import { WordPressSettingsService } from "@core/services/wordpressSettings" import { ApiKeysSettingsService } from "@core/services/apiKeysSettings" import { saveSettingsReturnState } from "@ui/web/lib/settingsNavigationState" -import { consumeActiveSettingsNoteReturnPath } from "@ui/web/lib/aiIndexNavigationState" +import { consumeActiveSettingsNoteReturnPath } from "@ui/web/lib/aiIndexNavigationState" +import { NotesTabStrip } from "@/components/features/notes/NotesTabStrip" +import { MobileNotesTabMenu } from "@/components/features/notes/MobileNotesTabMenu" import { NavRail } from "@/components/features/navigation/NavRail" import { TagsPage } from "@/components/features/tags/TagsPage" @@ -87,8 +89,14 @@ export function NotesShell({ controller }: NotesShellProps) { pendingCount, failedCount, isOffline, - selectedNote, - isEditing, + selectedNote, + isEditing, + notePaneVisible, + tabs = [], + activeTabId = '', + addTab, + activateTab, + closeTab, handleSelectNote, isSearchPanelOpen, setIsSearchPanelOpen, @@ -147,7 +155,7 @@ export function NotesShell({ controller }: NotesShellProps) { await controller.handleEditNote(note) }, [controller, supabase]) - const showEditor = !!(selectedNote || isEditing) + const showEditor = notePaneVisible ?? !!(selectedNote || isEditing) const handleOpenSearchPanel = React.useCallback(() => { if (activeMainView !== "notes") { setActiveMainView("notes") @@ -260,22 +268,38 @@ export function NotesShell({ controller }: NotesShellProps) { /> )} -
- -
+ data-testid="editor-container" + > + void addTab?.()} + onActivateTab={(tabId) => void activateTab?.(tabId)} + onCloseTab={(tabId) => void closeTab?.(tabId)} + /> + void addTab?.()} + onActivateTab={(tabId) => void activateTab?.(tabId)} + onCloseTab={(tabId) => void closeTab?.(tabId)} + /> +
+ +
+ )} @@ -288,9 +312,9 @@ export function NotesShell({ controller }: NotesShellProps) { function ListPane({ controller }: { controller: NoteAppController }) { const { notes, - notesQuery, - selectedNote, - handleSelectNote, + notesQuery, + selectedNote, + handleSelectNote, selectionMode, selectedNoteIds, toggleNoteSelection, @@ -329,8 +353,10 @@ function EditorPane({ pendingChunkFocus: PendingChunkFocus | null onPendingChunkFocusApplied: (requestId: string) => void }) { - const { - selectedNote, + const { + selectedNote, + activeTab, + activeTabId, isEditing, saving, autoSaving, @@ -381,11 +407,15 @@ function EditorPane({ if (isEditing) { return ( handleEditNote(selectedNote)} onDelete={() => handleDeleteNote(selectedNote)} onTagClick={controller.handleTagClick} onRemoveTag={(tag) => handleRemoveTagFromNote(selectedNote.id, tag)} onBack={onBack} - wordpressConfigured={wordpressConfigured} - /> + wordpressConfigured={wordpressConfigured} + initialScrollTop={activeTab.view.scrollTop} + onViewSessionChange={controller.handleViewSessionChange} + /> ) } diff --git a/ui/web/components/features/notes/NotesTabStrip.tsx b/ui/web/components/features/notes/NotesTabStrip.tsx new file mode 100644 index 00000000000..56a90cd6ac2 --- /dev/null +++ b/ui/web/components/features/notes/NotesTabStrip.tsx @@ -0,0 +1,147 @@ +"use client" + +import type { KeyboardEvent } from "react" +import { AlertCircle, Loader2, Plus, Circle, X } from "lucide-react" +import type { NoteWorkspaceTab } from "@core/services/noteWorkspaceTabs" +import { Button } from "@/components/ui/button" +import { cn } from "@ui/web/lib/utils" + +export type NotesTabStripProps = { + tabs: NoteWorkspaceTab[] + activeTabId: string + onAddTab: () => void + onActivateTab: (tabId: string) => void | Promise + onCloseTab: (tabId: string) => void | Promise +} + +function getTabLabel(tab: NoteWorkspaceTab): string { + const title = tab.note?.title?.trim() || tab.draft.title.trim() + if (title) return title + return tab.noteId ? "Untitled note" : "New note" +} + +function SaveStateIndicator({ tab }: { tab: NoteWorkspaceTab }) { + if (tab.saveState === "dirty") { + return ( + + + ) + } + + if (tab.saveState === "saving") { + return ( + + + ) + } + + if (tab.saveState === "error") { + return ( + + + ) + } + + return null +} + +function activateWithKeyboard( + event: KeyboardEvent, + tabIndex: number, + tabs: NoteWorkspaceTab[], + onActivateTab: NotesTabStripProps["onActivateTab"], +) { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight" && event.key !== "Home" && event.key !== "End") { + return + } + + event.preventDefault() + const nextIndex = event.key === "Home" + ? 0 + : event.key === "End" + ? tabs.length - 1 + : (tabIndex + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length + const nextTab = tabs[nextIndex] + if (nextTab) onActivateTab(nextTab.id) +} + +export function NotesTabStrip({ + tabs, + activeTabId, + onAddTab, + onActivateTab, + onCloseTab, +}: NotesTabStripProps) { + return ( +
+
+
+ {tabs.map((tab, index) => { + const label = getTabLabel(tab) + const isActive = tab.id === activeTabId + + return ( +
+ + +
+ ) + })} +
+
+ +
+ ) +} + +export { getTabLabel, SaveStateIndicator } diff --git a/ui/web/hooks/useNoteAppController.ts b/ui/web/hooks/useNoteAppController.ts index 51ba97e14b3..20e40b74357 100644 --- a/ui/web/hooks/useNoteAppController.ts +++ b/ui/web/hooks/useNoteAppController.ts @@ -12,7 +12,9 @@ import { useNoteSelection } from './useNoteSelection' import { useNoteSync } from './useNoteSync' import { useNoteData } from './useNoteData' import { useNoteSaveHandlers } from './useNoteSaveHandlers' -import { useNoteBulkActions } from './useNoteBulkActions' +import { useNoteBulkActions } from './useNoteBulkActions' +import { useNoteWorkspaceTabs } from './useNoteWorkspaceTabs' +import type { NoteDraftSnapshot, NoteViewSession } from '@core/services/noteWorkspaceTabs' import type { NoteEditorHandle } from '@ui/web/components/features/notes/NoteEditor' import { useSupabase } from '@ui/web/providers/SupabaseProvider' import { NoteService } from '@core/services/notes' @@ -52,13 +54,13 @@ export function useNoteAppController() { deleteAccountLoading } = useNoteAuth() - // -- Selection -- - const { - selectedNote, - setSelectedNote, - isEditing, - setIsEditing, - deleteDialogOpen, + // -- Selection -- + const { + selectedNote: legacySelectedNote, + isEditing: legacyIsEditing, + setSelectedNote: setLegacySelectedNote, + setIsEditing: setLegacyIsEditing, + deleteDialogOpen, setDeleteDialogOpen, noteToDelete, setNoteToDelete, @@ -66,17 +68,70 @@ export function useNoteAppController() { selectionMode, bulkDeleting, setBulkDeleting, - handleSelectNote, - handleSearchResultClick, - handleEditNote: handleEditNoteRaw, - handleCreateNote, - handleDeleteNote, + handleDeleteNote, enterSelectionMode, exitSelectionMode, toggleNoteSelection, selectAllVisible: selectAllVisibleCallback, - clearSelection, - } = useNoteSelection() + clearSelection, + handleSelectNote: handleSelectNoteLegacy, + handleSearchResultClick: handleSearchResultClickLegacy, + handleEditNote: handleEditNoteRaw, + handleCreateNote, + } = useNoteSelection() + + // -- Notes workspace tabs -- + // The existing selection hook still owns bulk-selection/dialog state. The + // selected note and editor mode are now derived from the active workspace tab + // so every save/navigation consumer observes the same session. + const workspace = useNoteWorkspaceTabs() + const { + activeTab, + hydrated: workspaceHydrated, + tabs, + activeTabId, + addTab, + activateTab, + openNote, + updateTab, + closeTab, + findTabByNoteId, + } = workspace + const selectedNote = activeTab.note + const isEditing = activeTab.mode === 'editing' + const [notePaneVisible, setNotePaneVisible] = useState(false) + const legacyBridgeAppliedRef = useRef(false) + + const setSelectedNote = useCallback((value: NoteViewModel | null | ((previous: NoteViewModel | null) => NoteViewModel | null)) => { + const currentNote = activeTab.note + const note = typeof value === 'function' ? value(currentNote) : value + updateTab(activeTabId, { + note, + noteId: note?.id ?? null, + }) + }, [activeTab.note, activeTabId, updateTab]) + + const setIsEditing = useCallback((value: boolean | ((previous: boolean) => boolean)) => { + const editing = typeof value === 'function' ? value(isEditing) : value + updateTab(activeTabId, { mode: editing ? 'editing' : 'reading' }) + }, [activeTabId, isEditing, updateTab]) + + useEffect(() => { + if (!workspaceHydrated || legacyBridgeAppliedRef.current) return + legacyBridgeAppliedRef.current = true + if (activeTab.note || (!legacySelectedNote && !legacyIsEditing)) return + updateTab(activeTabId, { + ...(legacySelectedNote ? { note: legacySelectedNote, noteId: legacySelectedNote.id } : {}), + mode: legacyIsEditing ? 'editing' : 'reading', + }) + }, [ + activeTab.note, + legacyIsEditing, + legacySelectedNote, + activeTabId, + updateTab, + workspaceHydrated, + ]) // -- Editor ref (cross-cutting: bridges UI editor with save/navigation logic) -- const noteEditorRef = useRef | null>(null) @@ -85,12 +140,27 @@ export function useNoteAppController() { noteEditorRef.current = ref }, []) - const flushPendingEditorSave = useCallback(async () => { - if (!isEditing) return - const handle = noteEditorRef.current?.current - if (!handle) return - await handle.flushPendingSave() - }, [isEditing]) + const flushPendingEditorSave = useCallback(async () => { + if (!isEditing) return + const handle = noteEditorRef.current?.current + if (!handle) return + await handle.flushPendingSave() + }, [isEditing]) + + const captureActiveTabSession = useCallback(() => { + const session = noteEditorRef.current?.current?.captureSession?.() + if (!session) return + + updateTab(activeTabId, { + draft: session.draft as NoteDraftSnapshot, + view: session.view as NoteViewSession, + }) + }, [activeTabId, updateTab]) + + const flushAndCaptureActiveTab = useCallback(async () => { + await flushPendingEditorSave() + captureActiveTabSession() + }, [captureActiveTabSession, flushPendingEditorSave]) // -- Infrastructure -- const queryClient = useQueryClient() @@ -265,8 +335,70 @@ export function useNoteAppController() { setNoteToDelete, notes, notesRef, - selectedNoteRef, - }) + selectedNoteRef, + }) + + const handleDraftChange = useCallback((draft: NoteDraftSnapshot) => { + updateTab(activeTabId, { + draft, + saveState: 'dirty', + saveError: null, + }) + }, [activeTabId, updateTab]) + + const handleViewSessionChange = useCallback((view: Partial) => { + updateTab(activeTabId, { view }) + }, [activeTabId, updateTab]) + + const handleAutoSaveWithWorkspace = useCallback(async (data: { + noteId?: string + title: string + description: string + tags: string + }) => { + updateTab(activeTabId, { + draft: { title: data.title, description: data.description, tags: data.tags }, + saveState: 'saving', + saveError: null, + }) + try { + const result = await handleAutoSave(data) + updateTab(activeTabId, { saveState: 'saved', saveError: null }) + return result + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + updateTab(activeTabId, { saveState: 'error', saveError: message }) + throw error + } + }, [activeTabId, handleAutoSave, updateTab]) + + const handleSaveNoteWithWorkspace = useCallback(async (data: { + title: string + description: string + tags: string + }) => { + updateTab(activeTabId, { + draft: data, + saveState: 'saving', + saveError: null, + }) + await handleSaveNote(data) + updateTab(activeTabId, { saveState: 'saved', saveError: null }) + }, [activeTabId, handleSaveNote, updateTab]) + + const handleReadNoteWithWorkspace = useCallback(async (data: { + title: string + description: string + tags: string + }) => { + updateTab(activeTabId, { + draft: data, + saveState: 'saving', + saveError: null, + }) + await handleReadNote(data) + updateTab(activeTabId, { saveState: 'saved', saveError: null }) + }, [activeTabId, handleReadNote, updateTab]) // -- Bulk actions -- const { selectAllVisible, deleteSelectedNotes, deleteNotesByIds } = useNoteBulkActions({ @@ -285,72 +417,193 @@ export function useNoteAppController() { selectAllVisibleCallback, }) - // -- Nav wrappers: flush pending editor save before any navigation -- - const wrappedHandleSelectNote = useCallback(async (note: NoteViewModel | null) => { - const requestId = ++latestSelectRequestRef.current - await flushPendingEditorSave() - if (requestId !== latestSelectRequestRef.current) return - clearActiveSettingsNoteReturnPath() - if (note?.id && selectedNoteRef.current?.id === note.id) { - setIsEditing(false) - setLastSavedAt(null) - return - } - const openableNote = note ? await resolveOpenableNote(note) : null - if (requestId !== latestSelectRequestRef.current) return - if (note && !openableNote) return - handleSelectNote(openableNote) - setLastSavedAt(null) - }, [flushPendingEditorSave, handleSelectNote, resolveOpenableNote, setLastSavedAt, setIsEditing]) - - const wrappedHandleCreateNote = useCallback(async () => { - await flushPendingEditorSave() - clearActiveSettingsNoteReturnPath() - handleCreateNote() - setLastSavedAt(null) - }, [flushPendingEditorSave, handleCreateNote, setLastSavedAt]) - - const wrappedHandleEditNote = useCallback(async (note: NoteViewModel) => { - const requestId = ++latestEditRequestRef.current - await flushPendingEditorSave() - if (requestId !== latestEditRequestRef.current) return - const openableNote = await resolveOpenableNote(note) - if (requestId !== latestEditRequestRef.current) return - if (!openableNote) { - handleSelectNote(null) - return - } - handleEditNoteRaw(openableNote) - setLastSavedAt(null) - }, [flushPendingEditorSave, handleEditNoteRaw, handleSelectNote, resolveOpenableNote, setLastSavedAt]) - - const handleTagClick = useCallback(async (tag: string) => { - await flushPendingEditorSave() - clearActiveSettingsNoteReturnPath() - onTagClick(tag) - setSelectedNote(null) - setIsEditing(false) - setLastSavedAt(null) - }, [flushPendingEditorSave, onTagClick, setSelectedNote, setIsEditing, setLastSavedAt]) - - const wrappedHandleSearchResultClick = useCallback(async (note: SearchResult) => { - const requestId = ++latestSearchClickRequestRef.current - await flushPendingEditorSave() - if (requestId !== latestSearchClickRequestRef.current) return - clearActiveSettingsNoteReturnPath() - if (note?.id && selectedNoteRef.current?.id === note.id) { - setIsEditing(false) - setLastSavedAt(null) - return - } - const openableNote = await resolveOpenableNote(resolveSearchResult(note)) - if (requestId !== latestSearchClickRequestRef.current) return - if (!openableNote) return - handleSearchResultClick(openableNote) - setLastSavedAt(null) - }, [flushPendingEditorSave, handleSearchResultClick, resolveOpenableNote, resolveSearchResult, setLastSavedAt, setIsEditing]) - - const aiPaginationControlsRef = useRef({ + // -- Nav wrappers: flush pending editor save before any navigation -- + const wrappedHandleSelectNote = useCallback(async (note: NoteViewModel | null) => { + const requestId = ++latestSelectRequestRef.current + await flushAndCaptureActiveTab() + if (requestId !== latestSelectRequestRef.current) return + clearActiveSettingsNoteReturnPath() + if (!note) { + // Mobile back/search navigation hides the pane without closing the tab. + handleSelectNoteLegacy(null) + setNotePaneVisible(false) + setLastSavedAt(null) + return + } + + const existingTab = findTabByNoteId(note.id) + if (existingTab) { + activateTab(existingTab.id) + setNotePaneVisible(true) + if (existingTab.id === activeTabId) { + setLegacyIsEditing(false) + setIsEditing(false) + } + setLastSavedAt(null) + return + } + + const openableNote = await resolveOpenableNote(note) + if (requestId !== latestSelectRequestRef.current) return + if (!openableNote) return + openNote(openableNote) + handleSelectNoteLegacy(openableNote) + setNotePaneVisible(true) + setLastSavedAt(null) + }, [ + flushAndCaptureActiveTab, + resolveOpenableNote, + setIsEditing, + setLastSavedAt, + activeTabId, + activateTab, + findTabByNoteId, + openNote, + handleSelectNoteLegacy, + setLegacyIsEditing, + ]) + + const wrappedHandleCreateNote = useCallback(async () => { + await flushAndCaptureActiveTab() + clearActiveSettingsNoteReturnPath() + updateTab(activeTabId, { + note: null, + noteId: null, + mode: 'editing', + draft: { title: '', description: '', tags: '' }, + view: { scrollTop: 0, titleSelection: undefined, editorSelection: undefined }, + saveState: 'saved', + saveError: null, + }) + handleCreateNote() + setNotePaneVisible(true) + setLastSavedAt(null) + }, [activeTabId, flushAndCaptureActiveTab, handleCreateNote, setLastSavedAt, updateTab]) + + const wrappedHandleEditNote = useCallback(async (note: NoteViewModel) => { + const requestId = ++latestEditRequestRef.current + await flushAndCaptureActiveTab() + if (requestId !== latestEditRequestRef.current) return + const openableNote = await resolveOpenableNote(note) + if (requestId !== latestEditRequestRef.current) return + if (!openableNote) { + handleSelectNoteLegacy(null) + setNotePaneVisible(false) + return + } + + const existingTab = findTabByNoteId(openableNote.id) + if (existingTab) { + activateTab(existingTab.id) + updateTab(existingTab.id, { mode: 'editing', saveState: 'saved', saveError: null }) + handleEditNoteRaw(openableNote) + } else { + openNote(openableNote) + updateTab(activeTabId, { mode: 'editing', saveState: 'saved', saveError: null }) + handleEditNoteRaw(openableNote) + } + setNotePaneVisible(true) + setLastSavedAt(null) + }, [ + flushAndCaptureActiveTab, + resolveOpenableNote, + setLastSavedAt, + activeTabId, + activateTab, + findTabByNoteId, + openNote, + updateTab, + handleEditNoteRaw, + handleSelectNoteLegacy, + ]) + + const handleTagClick = useCallback(async (tag: string) => { + await flushAndCaptureActiveTab() + clearActiveSettingsNoteReturnPath() + onTagClick(tag) + setLegacySelectedNote(null) + setLegacyIsEditing(false) + setNotePaneVisible(false) + setLastSavedAt(null) + }, [flushAndCaptureActiveTab, onTagClick, setLastSavedAt, setLegacySelectedNote, setLegacyIsEditing]) + + const wrappedHandleSearchResultClick = useCallback(async (note: SearchResult) => { + const requestId = ++latestSearchClickRequestRef.current + await flushAndCaptureActiveTab() + if (requestId !== latestSearchClickRequestRef.current) return + clearActiveSettingsNoteReturnPath() + const resolvedSearchNote = resolveSearchResult(note) + const existingTab = findTabByNoteId(resolvedSearchNote.id) + if (existingTab) { + activateTab(existingTab.id) + handleSearchResultClickLegacy(resolvedSearchNote) + setNotePaneVisible(true) + setLastSavedAt(null) + return + } + const openableNote = await resolveOpenableNote(resolvedSearchNote) + if (requestId !== latestSearchClickRequestRef.current) return + if (!openableNote) return + openNote(openableNote) + handleSearchResultClickLegacy(openableNote) + setNotePaneVisible(true) + setLastSavedAt(null) + }, [ + flushAndCaptureActiveTab, + resolveOpenableNote, + resolveSearchResult, + setLastSavedAt, + activateTab, + findTabByNoteId, + openNote, + handleSearchResultClickLegacy, + ]) + + const handleAddTab = useCallback(async () => { + await flushAndCaptureActiveTab() + addTab() + setNotePaneVisible(true) + }, [addTab, flushAndCaptureActiveTab]) + + const handleActivateTab = useCallback(async (tabId: string) => { + if (tabId === activeTabId) return + await flushAndCaptureActiveTab() + activateTab(tabId) + setNotePaneVisible(true) + setLastSavedAt(null) + }, [activateTab, activeTabId, flushAndCaptureActiveTab, setLastSavedAt]) + + const handleCloseTab = useCallback(async (tabId: string) => { + const tab = tabs.find((candidate) => candidate.id === tabId) + if (!tab) return + + const discardFailedSave = tab.saveState === 'error' + && typeof window !== 'undefined' + && window.confirm(`Discard unsaved changes in "${tab.note?.title || 'this tab'}"?`) + if (tab.saveState === 'error' && !discardFailedSave) return + + if (tab.id === activeTabId && !discardFailedSave) { + try { + await flushAndCaptureActiveTab() + } catch { + return + } + } + + closeTab(tabId) + setNotePaneVisible(true) + setLastSavedAt(null) + }, [activeTabId, closeTab, flushAndCaptureActiveTab, setLastSavedAt, tabs]) + + const workspaceHydrationAppliedRef = useRef(false) + useEffect(() => { + if (!workspaceHydrated || workspaceHydrationAppliedRef.current) return + workspaceHydrationAppliedRef.current = true + // eslint-disable-next-line react-hooks/set-state-in-effect -- restore pane visibility from sessionStorage + setNotePaneVisible(Boolean(activeTab.note || activeTab.mode === 'editing')) + }, [activeTab, workspaceHydrated]) + + const aiPaginationControlsRef = useRef({ resetAIResults: () => {}, loadMoreAI: () => {}, }) @@ -367,8 +620,8 @@ export function useNoteAppController() { aiPaginationControlsRef.current.loadMoreAI() }, []) - const captureSettingsReturnState = useCallback(async (): Promise => { - await flushPendingEditorSave() + const captureSettingsReturnState = useCallback(async (): Promise => { + await flushAndCaptureActiveTab() const selectedNoteForSnapshot = selectedNoteRef.current return { @@ -379,7 +632,7 @@ export function useNoteAppController() { searchQuery, filterByTag, } - }, [filterByTag, flushPendingEditorSave, isEditing, isSearchPanelOpen, searchQuery, selectedNoteRef]) + }, [filterByTag, flushAndCaptureActiveTab, isEditing, isSearchPanelOpen, searchQuery, selectedNoteRef]) const restoreUiState = useCallback(async (snapshot: NotesUiStateSnapshot) => { // Temporary bridge for the /settings route. The contract is intentionally narrow @@ -417,19 +670,43 @@ export function useNoteAppController() { snapshot.isEditing && (restoredSelectedNote !== null || snapshot.selectedNoteId === null) - setSelectedNote(restoredSelectedNote) - setIsEditing(canRestoreEditing) - }, [ + if (restoredSelectedNote) { + setLegacySelectedNote(restoredSelectedNote) + setLegacyIsEditing(canRestoreEditing) + const existingTab = findTabByNoteId(restoredSelectedNote.id) + if (existingTab) { + activateTab(existingTab.id) + updateTab(existingTab.id, { mode: canRestoreEditing ? 'editing' : 'reading' }) + } else { + openNote(restoredSelectedNote) + updateTab(activeTabId, { mode: canRestoreEditing ? 'editing' : 'reading' }) + } + } else { + setLegacySelectedNote(null) + setLegacyIsEditing(canRestoreEditing) + updateTab(activeTabId, { + note: null, + noteId: null, + mode: canRestoreEditing ? 'editing' : 'reading', + }) + } + setNotePaneVisible(Boolean(restoredSelectedNote || canRestoreEditing)) + }, [ handleClearTagFilter, - handleSearch, + handleSearch, + setLegacyIsEditing, + setLegacySelectedNote, noteService, notesRef, onTagClick, resetFtsResults, - setIsEditing, - setIsSearchPanelOpen, - setSelectedNote, - ]) + setIsSearchPanelOpen, + activeTabId, + activateTab, + findTabByNoteId, + openNote, + updateTab, + ]) // -- Main Navigation View -- const [activeMainView, setActiveMainView] = useState<'notes' | 'tags'>(() => { @@ -556,10 +833,14 @@ export function useNoteAppController() { // State user, loading: authLoadingState, - selectedNote, - searchQuery, - isEditing, - setIsEditing, + selectedNote, + searchQuery, + isEditing, + notePaneVisible, + tabs, + activeTabId, + activeTab, + setIsEditing, isSearchPanelOpen, setIsSearchPanelOpen, saving, @@ -616,16 +897,21 @@ export function useNoteAppController() { handleSkipAuth, handleSignOut, handleDeleteAccount, - handleCreateNote: wrappedHandleCreateNote, - handleEditNote: wrappedHandleEditNote, - handleSaveNote, - handleReadNote, - handleAutoSave, + handleCreateNote: wrappedHandleCreateNote, + handleEditNote: wrappedHandleEditNote, + handleSaveNote: handleSaveNoteWithWorkspace, + handleReadNote: handleReadNoteWithWorkspace, + handleAutoSave: handleAutoSaveWithWorkspace, + handleDraftChange, + handleViewSessionChange, handleDeleteNote, confirmDeleteNote, handleRemoveTagFromNote, handleSelectNote: wrappedHandleSelectNote, - handleSearchResultClick: wrappedHandleSearchResultClick, + handleSearchResultClick: wrappedHandleSearchResultClick, + addTab: handleAddTab, + activateTab: handleActivateTab, + closeTab: handleCloseTab, enterSelectionMode, exitSelectionMode, toggleNoteSelection, diff --git a/ui/web/hooks/useNoteWorkspaceTabs.ts b/ui/web/hooks/useNoteWorkspaceTabs.ts new file mode 100644 index 00000000000..a6932b4bda9 --- /dev/null +++ b/ui/web/hooks/useNoteWorkspaceTabs.ts @@ -0,0 +1,69 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + activateWorkspaceTab, + addWorkspaceTab, + closeWorkspaceTab, + createNoteWorkspaceState, + findWorkspaceTabByNoteId, + getActiveWorkspaceTab, + openNoteInWorkspace, + updateWorkspaceTab, + type NoteWorkspaceTabPatch, +} from '@core/services/noteWorkspaceTabs' +import type { NoteViewModel } from '@core/types/domain' +import { readNoteWorkspaceState, writeNoteWorkspaceState } from '@ui/web/lib/noteWorkspaceStorage' + +export function useNoteWorkspaceTabs() { + const [state, setState] = useState(createNoteWorkspaceState) + const [hydrated, setHydrated] = useState(false) + + useEffect(() => { + // The first render must be identical on server and client. sessionStorage is + // loaded after hydration, then the controller applies the restored active tab. + // eslint-disable-next-line react-hooks/set-state-in-effect -- sessionStorage is a browser-only hydration source + setState(readNoteWorkspaceState()) + setHydrated(true) + }, []) + + useEffect(() => { + if (!hydrated) return + writeNoteWorkspaceState(state) + }, [hydrated, state]) + + const addTab = useCallback(() => { + setState((current) => addWorkspaceTab(current)) + }, []) + + const activateTab = useCallback((tabId: string) => { + setState((current) => activateWorkspaceTab(current, tabId)) + }, []) + + const openNote = useCallback((note: NoteViewModel, tabId?: string) => { + setState((current) => openNoteInWorkspace(current, note, tabId)) + }, []) + + const updateTab = useCallback((tabId: string, patch: NoteWorkspaceTabPatch) => { + setState((current) => updateWorkspaceTab(current, tabId, patch)) + }, []) + + const closeTab = useCallback((tabId: string) => { + setState((current) => closeWorkspaceTab(current, tabId)) + }, []) + + const activeTab = useMemo(() => getActiveWorkspaceTab(state), [state]) + const findTabByNoteId = useCallback((noteId: string | null | undefined) => ( + findWorkspaceTabByNoteId(state, noteId) + ), [state]) + + return { + ...state, + activeTab, + hydrated, + addTab, + activateTab, + openNote, + updateTab, + closeTab, + findTabByNoteId, + } +} diff --git a/ui/web/lib/noteWorkspaceStorage.ts b/ui/web/lib/noteWorkspaceStorage.ts new file mode 100644 index 00000000000..af4d000928a --- /dev/null +++ b/ui/web/lib/noteWorkspaceStorage.ts @@ -0,0 +1,56 @@ +import { + hydrateNoteWorkspaceState, + serializeNoteWorkspaceState, + type NoteWorkspaceIdFactory, + type NoteWorkspaceState, +} from '@core/services/noteWorkspaceTabs' + +export const NOTE_WORKSPACE_STORAGE_KEY = 'everfreenote:notes-workspace:v1' + +function getSessionStorage(): Storage | null { + if (typeof globalThis.window === 'undefined') return null + + try { + return globalThis.window.sessionStorage + } catch { + return null + } +} + +export function readNoteWorkspaceState( + storage: Storage | null = getSessionStorage(), + idFactory?: NoteWorkspaceIdFactory, +): NoteWorkspaceState { + if (!storage) return hydrateNoteWorkspaceState(null, idFactory) + + try { + return hydrateNoteWorkspaceState(storage.getItem(NOTE_WORKSPACE_STORAGE_KEY), idFactory) + } catch { + return hydrateNoteWorkspaceState(null, idFactory) + } +} + +export function writeNoteWorkspaceState( + state: NoteWorkspaceState, + storage: Storage | null = getSessionStorage(), +): boolean { + if (!storage) return false + + try { + storage.setItem(NOTE_WORKSPACE_STORAGE_KEY, serializeNoteWorkspaceState(state)) + return true + } catch { + // Quota/private-mode failures must not block the in-memory workspace. + return false + } +} + +export function clearNoteWorkspaceState(storage: Storage | null = getSessionStorage()): void { + if (!storage) return + + try { + storage.removeItem(NOTE_WORKSPACE_STORAGE_KEY) + } catch { + // Best effort only. + } +} diff --git a/ui/web/tests/unit/components/notesWorkspaceTabs.test.tsx b/ui/web/tests/unit/components/notesWorkspaceTabs.test.tsx new file mode 100644 index 00000000000..dd1b8c8460d --- /dev/null +++ b/ui/web/tests/unit/components/notesWorkspaceTabs.test.tsx @@ -0,0 +1,103 @@ +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { NotesTabStrip } from '@/components/features/notes/NotesTabStrip' +import { MobileNotesTabMenu } from '@/components/features/notes/MobileNotesTabMenu' +import type { NoteWorkspaceTab } from '@core/services/noteWorkspaceTabs' + +const makeTab = (id: string, title: string, overrides: Partial = {}): NoteWorkspaceTab => ({ + id, + noteId: id, + note: { + id, + title, + description: '', + tags: [], + created_at: '2026-08-01T00:00:00.000Z', + updated_at: '2026-08-01T00:00:00.000Z', + user_id: 'user-1', + }, + mode: 'reading', + draft: { title, description: '', tags: '' }, + view: { scrollTop: 0 }, + saveState: 'saved', + saveError: null, + ...overrides, +}) + +const tabs = [ + makeTab('tab-1', 'First note'), + makeTab('tab-2', 'Second note', { saveState: 'dirty' }), +] + +describe('Notes workspace tab controls', () => { + it('renders desktop tabs, save indicators, add, activation, and close actions', () => { + const onAddTab = jest.fn() + const onActivateTab = jest.fn() + const onCloseTab = jest.fn() + + render( + , + ) + + expect(screen.getByRole('tab', { name: 'First note' }).getAttribute('aria-selected')).toBe('true') + expect(screen.getByRole('tab', { name: /Second note/ }).getAttribute('aria-selected')).toBe('false') + expect(screen.getByLabelText('Unsaved changes')).toBeTruthy() + + fireEvent.click(screen.getByRole('tab', { name: /Second note/ })) + fireEvent.click(screen.getByRole('button', { name: 'Close First note' })) + fireEvent.click(screen.getByRole('button', { name: 'Add note tab' })) + + expect(onActivateTab).toHaveBeenCalledWith('tab-2') + expect(onCloseTab).toHaveBeenCalledWith('tab-1') + expect(onAddTab).toHaveBeenCalledTimes(1) + }) + + it('supports keyboard tab navigation', () => { + const onActivateTab = jest.fn() + + render( + , + ) + + fireEvent.keyDown(screen.getByRole('tab', { name: 'First note' }), { key: 'ArrowRight' }) + expect(onActivateTab).toHaveBeenCalledWith('tab-2') + }) + + it('opens the compact mobile list and forwards activation, close, and add actions', () => { + const onAddTab = jest.fn() + const onActivateTab = jest.fn() + const onCloseTab = jest.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Open note tabs (2)' })) + fireEvent.click(screen.getByRole('option', { name: /Second note/ })) + fireEvent.click(screen.getByRole('button', { name: 'Open note tabs (2)' })) + fireEvent.click(screen.getByRole('button', { name: 'Close First note' })) + fireEvent.click(screen.getByRole('button', { name: 'Add tab' })) + + expect(onActivateTab).toHaveBeenCalledWith('tab-2') + expect(onCloseTab).toHaveBeenCalledWith('tab-1') + expect(onAddTab).toHaveBeenCalledTimes(1) + }) +}) diff --git a/ui/web/tests/unit/hooks/useNoteAppController.remoteDelete.test.tsx b/ui/web/tests/unit/hooks/useNoteAppController.remoteDelete.test.tsx index 1fac75bcd00..693cacd731f 100644 --- a/ui/web/tests/unit/hooks/useNoteAppController.remoteDelete.test.tsx +++ b/ui/web/tests/unit/hooks/useNoteAppController.remoteDelete.test.tsx @@ -221,6 +221,7 @@ function createWrapper() { describe('useNoteAppController — remote delete consistency', () => { beforeEach(() => { + window.sessionStorage.clear() mockIsOffline = false mockOfflineOverlay = [] mockGetNoteStatus.mockReset() diff --git a/ui/web/tests/unit/hooks/useNoteAppControllerAdditional.test.tsx b/ui/web/tests/unit/hooks/useNoteAppControllerAdditional.test.tsx index 538557f8550..7e4420333ce 100644 --- a/ui/web/tests/unit/hooks/useNoteAppControllerAdditional.test.tsx +++ b/ui/web/tests/unit/hooks/useNoteAppControllerAdditional.test.tsx @@ -217,6 +217,7 @@ function setup() { describe('useNoteAppController additional observable behavior', () => { beforeEach(() => { jest.clearAllMocks() + window.sessionStorage.clear() mockSelectedNote = null mockIsEditing = true mockIsOffline = false @@ -248,8 +249,8 @@ describe('useNoteAppController additional observable behavior', () => { const snapshot = await act(async () => result.current.captureSettingsReturnState()) expect(snapshot).toEqual({ - selectedNoteId: note.id, - selectedNote: note, + selectedNoteId: null, + selectedNote: null, isEditing: true, isSearchPanelOpen: true, searchQuery: 'initial search', @@ -275,7 +276,7 @@ describe('useNoteAppController additional observable behavior', () => { jest.clearAllMocks() await act(async () => { - await result.current.handleSelectNote(current) + await result.current.handleSelectNote(remote) }) expect(mockGetNoteStatus).not.toHaveBeenCalled() expect(mockSetIsEditing).toHaveBeenCalledWith(false) diff --git a/ui/web/tests/unit/lib/noteWorkspaceStorage.test.ts b/ui/web/tests/unit/lib/noteWorkspaceStorage.test.ts new file mode 100644 index 00000000000..cd963148f5f --- /dev/null +++ b/ui/web/tests/unit/lib/noteWorkspaceStorage.test.ts @@ -0,0 +1,68 @@ +import { + clearNoteWorkspaceState, + NOTE_WORKSPACE_STORAGE_KEY, + readNoteWorkspaceState, + writeNoteWorkspaceState, +} from '@ui/web/lib/noteWorkspaceStorage' +import { + createNoteWorkspaceState, + openNoteInWorkspace, +} from '@core/services/noteWorkspaceTabs' +import type { NoteViewModel } from '@core/types/domain' + +const note: NoteViewModel = { + id: 'note-1', + title: 'Persisted note', + description: '

Draft

', + tags: ['work'], + created_at: '2026-08-01T00:00:00.000Z', + updated_at: '2026-08-01T00:00:00.000Z', + user_id: 'user-1', +} + +describe('note workspace session storage', () => { + beforeEach(() => { + window.sessionStorage.clear() + }) + + it('round-trips workspace state through the versioned session key', () => { + const state = openNoteInWorkspace(createNoteWorkspaceState(() => 'tab-1'), note) + + expect(writeNoteWorkspaceState(state)).toBe(true) + expect(window.sessionStorage.getItem(NOTE_WORKSPACE_STORAGE_KEY)).toContain('note-1') + expect(readNoteWorkspaceState()).toEqual(state) + }) + + it('uses a blank workspace when stored JSON is invalid', () => { + window.sessionStorage.setItem(NOTE_WORKSPACE_STORAGE_KEY, '{invalid') + + const state = readNoteWorkspaceState(window.sessionStorage, () => 'fallback-tab') + + expect(state.tabs).toHaveLength(1) + expect(state.activeTabId).toBe('fallback-tab') + expect(state.tabs[0].noteId).toBeNull() + }) + + it('keeps the in-memory contract when storage access throws', () => { + const throwingStorage = { + getItem: () => { throw new Error('blocked') }, + setItem: () => { throw new Error('quota') }, + removeItem: () => { throw new Error('blocked') }, + } as unknown as Storage + const state = createNoteWorkspaceState(() => 'tab-1') + + expect(readNoteWorkspaceState(throwingStorage, () => 'fallback-tab').activeTabId).toBe('fallback-tab') + expect(writeNoteWorkspaceState(state, throwingStorage)).toBe(false) + expect(() => clearNoteWorkspaceState(throwingStorage)).not.toThrow() + }) + + it('clears only the workspace key', () => { + window.sessionStorage.setItem(NOTE_WORKSPACE_STORAGE_KEY, 'state') + window.sessionStorage.setItem('unrelated', 'keep') + + clearNoteWorkspaceState() + + expect(window.sessionStorage.getItem(NOTE_WORKSPACE_STORAGE_KEY)).toBeNull() + expect(window.sessionStorage.getItem('unrelated')).toBe('keep') + }) +}) From 6ff1dcde6f92d5d656095e0cff4a6a9195be4e41 Mon Sep 17 00:00:00 2001 From: Denys Date: Sat, 1 Aug 2026 22:02:47 +0200 Subject: [PATCH 02/23] fix(notes): harden workspace tab lifecycle --- core/services/noteWorkspaceTabs.ts | 94 +++++++++---- .../core-services-noteWorkspaceTabs.test.ts | 12 ++ .../notes/NotesShellFtsExitSave.cy.tsx | 25 ++++ .../notes/NotesShellLikeExitSave.cy.tsx | 25 ++++ .../notes/NotesShellOpenInContext.cy.tsx | 25 ++++ .../2026-08-01-feature-notes-tabs.md | 5 +- .../planning/2026-08-01-feature-notes-tabs.md | 2 +- .../testing/2026-08-01-feature-notes-tabs.md | 3 + .../features/notes/MobileNotesTabMenu.tsx | 11 +- .../components/features/notes/NoteEditor.tsx | 6 +- .../features/notes/NotesTabStrip.tsx | 22 +-- ui/web/hooks/useNoteAppController.ts | 129 +++++++++++------- ui/web/hooks/useNoteSaveHandlers.ts | 11 +- ui/web/hooks/useNoteWorkspaceTabs.ts | 6 +- ui/web/lib/noteWorkspaceStorage.ts | 2 +- .../tests/unit/components/notesShell.test.tsx | 32 ++++- .../components/notesWorkspaceTabs.test.tsx | 2 +- .../useNoteAppControllerAdditional.test.tsx | 50 ++++++- .../unit/hooks/useNoteSaveHandlers.test.tsx | 10 +- 19 files changed, 355 insertions(+), 117 deletions(-) diff --git a/core/services/noteWorkspaceTabs.ts b/core/services/noteWorkspaceTabs.ts index d4c7d1ba50f..6775f387fc6 100644 --- a/core/services/noteWorkspaceTabs.ts +++ b/core/services/noteWorkspaceTabs.ts @@ -1,6 +1,7 @@ import type { NoteViewModel } from '@core/types/domain' -export const NOTE_WORKSPACE_VERSION = 1 as const +export const NOTE_WORKSPACE_VERSION = 1 +export const MAX_NOTE_WORKSPACE_SERIALIZED_LENGTH = 2 * 1024 * 1024 export type NoteWorkspaceMode = 'reading' | 'editing' export type NoteWorkspaceSaveState = 'saved' | 'dirty' | 'saving' | 'error' @@ -49,11 +50,16 @@ export type NoteWorkspaceIdFactory = () => string const DEFAULT_DRAFT: NoteDraftSnapshot = { title: '', description: '', tags: '' } const DEFAULT_VIEW: NoteViewSession = { scrollTop: 0 } +let fallbackIdCounter = 0 + const defaultIdFactory: NoteWorkspaceIdFactory = () => { - if (typeof globalThis.crypto?.randomUUID === 'function') { - return globalThis.crypto.randomUUID() + const cryptoApi = typeof globalThis.crypto === 'undefined' ? null : globalThis.crypto + if (cryptoApi && typeof cryptoApi.randomUUID === 'function') { + return cryptoApi.randomUUID() } - return `note-tab-${Date.now()}-${Math.random().toString(36).slice(2)}` + + const counter = fallbackIdCounter++ + return `note-tab-${Date.now()}-${counter}` } const asString = (value: unknown, fallback: string): string => ( @@ -124,7 +130,7 @@ export function createNoteWorkspaceState(idFactory: NoteWorkspaceIdFactory = def } export function getActiveWorkspaceTab(state: NoteWorkspaceState): NoteWorkspaceTab { - return state.tabs.find((tab) => tab.id === state.activeTabId) ?? state.tabs[0]! + return state.tabs.find((tab) => tab.id === state.activeTabId) ?? state.tabs[0] } export function findWorkspaceTabByNoteId( @@ -231,43 +237,68 @@ const normalizeNote = (value: unknown): NoteViewModel | null => { : null } -const normalizeTab = ( - value: unknown, +const asRecord = (value: unknown): Record | null => ( + value && typeof value === 'object' ? value as Record : null +) + +const normalizeTabId = ( + raw: Record, idFactory: NoteWorkspaceIdFactory, usedTabIds: Set, -): NoteWorkspaceTab | null => { - if (!value || typeof value !== 'object') return null - const raw = value as Record +): string => { let id = typeof raw.id === 'string' && raw.id.length > 0 ? raw.id : idFactory() while (usedTabIds.has(id)) id = idFactory() usedTabIds.add(id) + return id +} + +const normalizeTabDraft = ( + rawDraft: Record, + note: NoteViewModel | null, +): NoteDraftSnapshot => ({ + title: asString(rawDraft.title, note?.title ?? ''), + description: asString(rawDraft.description, note?.description ?? note?.content ?? ''), + tags: asString(rawDraft.tags, note?.tags?.join(', ') ?? ''), +}) + +const normalizeTabView = (rawView: Record): NoteViewSession => { + const titleSelection = normalizeRange(rawView.titleSelection, 'titleSelection') + const editorSelection = normalizeRange(rawView.editorSelection, 'editorSelection') + return { + scrollTop: asNonNegativeFiniteNumber(rawView.scrollTop, 0), + ...(titleSelection ? { titleSelection } : {}), + ...(editorSelection ? { editorSelection } : {}), + } +} + +const normalizeSaveState = (value: unknown): NoteWorkspaceSaveState => ( + value === 'dirty' || value === 'saving' || value === 'error' ? value : 'saved' +) + +const normalizeTab = ( + value: unknown, + idFactory: NoteWorkspaceIdFactory, + usedTabIds: Set, +): NoteWorkspaceTab | null => { + const raw = asRecord(value) + if (!raw) return null + + const id = normalizeTabId(raw, idFactory, usedTabIds) const note = normalizeNote(raw.note) const rawNoteId = typeof raw.noteId === 'string' && raw.noteId.length > 0 ? raw.noteId : null const noteId = note?.id ?? rawNoteId - const rawDraft = raw.draft && typeof raw.draft === 'object' ? raw.draft as Record : {} - const rawView = raw.view && typeof raw.view === 'object' ? raw.view as Record : {} - const titleSelection = normalizeRange(rawView.titleSelection, 'titleSelection') - const editorSelection = normalizeRange(rawView.editorSelection, 'editorSelection') + const rawDraft = asRecord(raw.draft) ?? {} + const rawView = asRecord(raw.view) ?? {} return { id, noteId, note: noteId && note?.id === noteId ? note : null, mode: raw.mode === 'reading' ? 'reading' : 'editing', - draft: { - title: asString(rawDraft.title, note?.title ?? ''), - description: asString(rawDraft.description, note?.description ?? note?.content ?? ''), - tags: asString(rawDraft.tags, note?.tags?.join(', ') ?? ''), - }, - view: { - scrollTop: asNonNegativeFiniteNumber(rawView.scrollTop, 0), - ...(titleSelection ? { titleSelection } : {}), - ...(editorSelection ? { editorSelection } : {}), - }, - saveState: raw.saveState === 'dirty' || raw.saveState === 'saving' || raw.saveState === 'error' - ? raw.saveState - : 'saved', + draft: normalizeTabDraft(rawDraft, note), + view: normalizeTabView(rawView), + saveState: normalizeSaveState(raw.saveState), saveError: typeof raw.saveError === 'string' ? raw.saveError : null, } } @@ -278,6 +309,9 @@ export function hydrateNoteWorkspaceState( ): NoteWorkspaceState { let parsed: unknown = raw if (typeof raw === 'string') { + if (raw.length > MAX_NOTE_WORKSPACE_SERIALIZED_LENGTH) { + return createNoteWorkspaceState(idFactory) + } try { parsed = JSON.parse(raw) as unknown } catch { @@ -310,5 +344,9 @@ export function hydrateNoteWorkspaceState( } export function serializeNoteWorkspaceState(state: NoteWorkspaceState): string { - return JSON.stringify(state) + const serialized = JSON.stringify(state) + if (serialized.length > MAX_NOTE_WORKSPACE_SERIALIZED_LENGTH) { + throw new RangeError('Note workspace state exceeds the storage limit') + } + return serialized } diff --git a/core/tests/unit/core-services-noteWorkspaceTabs.test.ts b/core/tests/unit/core-services-noteWorkspaceTabs.test.ts index b4d5234d0e5..345a861ddb3 100644 --- a/core/tests/unit/core-services-noteWorkspaceTabs.test.ts +++ b/core/tests/unit/core-services-noteWorkspaceTabs.test.ts @@ -7,6 +7,7 @@ import { findWorkspaceTabByNoteId, getActiveWorkspaceTab, hydrateNoteWorkspaceState, + MAX_NOTE_WORKSPACE_SERIALIZED_LENGTH, openNoteInWorkspace, serializeNoteWorkspaceState, updateWorkspaceTab, @@ -134,6 +135,17 @@ describe('note workspace tab state', () => { const malformed = hydrateNoteWorkspaceState('{not-json', ids('fallback')) expect(malformed.tabs).toHaveLength(1) expect(malformed.activeTabId).toBe('fallback') + + const oversized = hydrateNoteWorkspaceState( + JSON.stringify({ version: 1, activeTabId: 'tab-1', tabs: [{ ...state.tabs[0], draft: { title: 'x'.repeat(MAX_NOTE_WORKSPACE_SERIALIZED_LENGTH) } }] }), + ids('oversized'), + ) + expect(oversized.tabs).toHaveLength(1) + expect(oversized.activeTabId).toBe('oversized') + expect(() => serializeNoteWorkspaceState({ + ...state, + tabs: [{ ...state.tabs[0], draft: { ...state.tabs[0].draft, description: 'x'.repeat(MAX_NOTE_WORKSPACE_SERIALIZED_LENGTH) } }], + })).toThrow('exceeds the storage limit') }) it('ignores activation and updates for unknown tab IDs', () => { diff --git a/cypress/component/features/notes/NotesShellFtsExitSave.cy.tsx b/cypress/component/features/notes/NotesShellFtsExitSave.cy.tsx index 993eec0ddad..c3378c60c68 100644 --- a/cypress/component/features/notes/NotesShellFtsExitSave.cy.tsx +++ b/cypress/component/features/notes/NotesShellFtsExitSave.cy.tsx @@ -3,6 +3,7 @@ import type { SupabaseClient, User } from '@supabase/supabase-js' import { NotesShell } from '../../../../ui/web/components/features/notes/NotesShell' import type { NoteViewModel, SearchResult } from '../../../../core/types/domain' +import type { NoteWorkspaceTab } from '@core/services/noteWorkspaceTabs' import type { NoteEditorHandle } from '../../../../ui/web/components/features/notes/NoteEditor' import { SupabaseTestProvider } from '../../../../ui/web/providers/SupabaseProvider' @@ -18,6 +19,21 @@ const pastePlainText = (text: string) => { type FakeController = Record +const makeWorkspaceTab = (note: NoteViewModel | null, isEditing: boolean): NoteWorkspaceTab => ({ + id: 'tab-1', + noteId: note?.id ?? null, + note, + mode: isEditing ? 'editing' : 'reading', + draft: { + title: note?.title ?? '', + description: note?.description ?? '', + tags: note?.tags.join(', ') ?? '', + }, + view: { scrollTop: 0 }, + saveState: 'saved', + saveError: null, +}) + const buildController = () => { const supabase = { functions: { @@ -67,6 +83,7 @@ const buildController = () => { () => notes.find((n) => n.id === selectedNoteId) ?? null, [notes, selectedNoteId] ) + const activeTab = React.useMemo(() => makeWorkspaceTab(selectedNote, isEditing), [selectedNote, isEditing]) const flushIfEditing = React.useCallback(async () => { if (!isEditing) return @@ -176,6 +193,14 @@ const buildController = () => { selectedNote, isEditing, + tabs: [activeTab], + activeTabId: activeTab.id, + activeTab, + addTab: () => {}, + activateTab: () => {}, + closeTab: () => {}, + handleDraftChange: () => {}, + handleViewSessionChange: () => {}, saving: false, autoSaving: false, lastSavedAt: null, diff --git a/cypress/component/features/notes/NotesShellLikeExitSave.cy.tsx b/cypress/component/features/notes/NotesShellLikeExitSave.cy.tsx index 737cf94e2b9..df25f1b5ca2 100644 --- a/cypress/component/features/notes/NotesShellLikeExitSave.cy.tsx +++ b/cypress/component/features/notes/NotesShellLikeExitSave.cy.tsx @@ -3,6 +3,7 @@ import type { SupabaseClient, User } from '@supabase/supabase-js' import { NotesShell } from '../../../../ui/web/components/features/notes/NotesShell' import type { NoteViewModel } from '../../../../core/types/domain' +import type { NoteWorkspaceTab } from '@core/services/noteWorkspaceTabs' import type { NoteEditorHandle } from '../../../../ui/web/components/features/notes/NoteEditor' import { SupabaseTestProvider } from '../../../../ui/web/providers/SupabaseProvider' @@ -18,6 +19,21 @@ const pastePlainText = (text: string) => { type FakeController = Record +const makeWorkspaceTab = (note: NoteViewModel | null, isEditing: boolean): NoteWorkspaceTab => ({ + id: 'tab-1', + noteId: note?.id ?? null, + note, + mode: isEditing ? 'editing' : 'reading', + draft: { + title: note?.title ?? '', + description: note?.description ?? '', + tags: note?.tags.join(', ') ?? '', + }, + view: { scrollTop: 0 }, + saveState: 'saved', + saveError: null, +}) + const buildController = () => { const supabase = { functions: { @@ -67,6 +83,7 @@ const buildController = () => { () => notes.find((n) => n.id === selectedNoteId) ?? null, [notes, selectedNoteId] ) + const activeTab = React.useMemo(() => makeWorkspaceTab(selectedNote, isEditing), [selectedNote, isEditing]) const flushIfEditing = React.useCallback(async () => { if (!isEditing) return @@ -154,6 +171,14 @@ const buildController = () => { selectedNote, isEditing, + tabs: [activeTab], + activeTabId: activeTab.id, + activeTab, + addTab: () => {}, + activateTab: () => {}, + closeTab: () => {}, + handleDraftChange: () => {}, + handleViewSessionChange: () => {}, saving: false, autoSaving: false, lastSavedAt: null, diff --git a/cypress/component/features/notes/NotesShellOpenInContext.cy.tsx b/cypress/component/features/notes/NotesShellOpenInContext.cy.tsx index e0915b9d75f..7f2c24ce569 100644 --- a/cypress/component/features/notes/NotesShellOpenInContext.cy.tsx +++ b/cypress/component/features/notes/NotesShellOpenInContext.cy.tsx @@ -3,11 +3,27 @@ import type { SupabaseClient, User } from '@supabase/supabase-js' import { NotesShell } from '../../../../ui/web/components/features/notes/NotesShell' import type { NoteViewModel } from '../../../../core/types/domain' +import type { NoteWorkspaceTab } from '@core/services/noteWorkspaceTabs' import type { NoteEditorHandle } from '../../../../ui/web/components/features/notes/NoteEditor' import { SupabaseTestProvider } from '../../../../ui/web/providers/SupabaseProvider' type FakeController = Record +const makeWorkspaceTab = (note: NoteViewModel | null, isEditing: boolean): NoteWorkspaceTab => ({ + id: 'tab-1', + noteId: note?.id ?? null, + note, + mode: isEditing ? 'editing' : 'reading', + draft: { + title: note?.title ?? '', + description: note?.description ?? '', + tags: note?.tags.join(', ') ?? '', + }, + view: { scrollTop: 0 }, + saveState: 'saved', + saveError: null, +}) + describe('NotesShell: AI open in context', () => { it('applies scroll and chunk highlight on the first open-in-context click', () => { cy.window().then((win) => { @@ -80,6 +96,7 @@ describe('NotesShell: AI open in context', () => { const [selectedNote, setSelectedNote] = React.useState(null) const [isEditing, setIsEditing] = React.useState(false) const [isSearchPanelOpen, setIsSearchPanelOpen] = React.useState(true) + const activeTab = React.useMemo(() => makeWorkspaceTab(selectedNote, isEditing), [selectedNote, isEditing]) const controller: FakeController = { registerNoteEditorRef: (ref: React.RefObject) => { @@ -138,6 +155,14 @@ describe('NotesShell: AI open in context', () => { deleteNotesByIds: async () => ({ total: 0, failed: 0, queuedOffline: false }), selectedNote, isEditing, + tabs: [activeTab], + activeTabId: activeTab.id, + activeTab, + addTab: () => {}, + activateTab: () => {}, + closeTab: () => {}, + handleDraftChange: () => {}, + handleViewSessionChange: () => {}, saving: false, autoSaving: false, lastSavedAt: null, diff --git a/docs/ai/implementation/2026-08-01-feature-notes-tabs.md b/docs/ai/implementation/2026-08-01-feature-notes-tabs.md index 3b58426ca17..0f92a82f241 100644 --- a/docs/ai/implementation/2026-08-01-feature-notes-tabs.md +++ b/docs/ai/implementation/2026-08-01-feature-notes-tabs.md @@ -8,7 +8,7 @@ description: Technical implementation notes, patterns, and code guidelines ## Development Setup -- Worktree: `.worktrees/codex-notes-tabs`, branch `codex/notes-tabs`. +- Worktree: use the Harness-managed worktree under the Worktree root configured in Harness settings; never create one inside the project checkout. - Root dependencies: `npm ci`. - Mobile dependencies: `npm --prefix ui/mobile ci`. - Fast validation: `npm run type-check` and `npx eslint . --max-warnings=0`. @@ -35,6 +35,8 @@ ui/web/components/features/notes/NoteView.tsx - Keep reducer transitions pure and make invalid states impossible through normalization. - Use an injected ID factory in reducer tests; production uses `crypto.randomUUID` with a safe fallback. +- Use a deterministic initial tab ID for the server/client first render, then replace it with the hydrated session workspace. +- Bound serialized workspace state and fall back to a blank tab when a persisted snapshot is malformed or exceeds the storage limit. - Hydrate once, then persist state changes through a guarded storage adapter. - Capture the current editor before unmount, flush autosave first, and then apply tab transitions. - Use the active tab's draft as editor initial content; never use a server refresh to overwrite a dirty local field without existing reconciliation rules. @@ -59,6 +61,7 @@ ui/web/components/features/notes/NoteView.tsx - Storage parse/access/quota errors fall back to the in-memory state and optionally log a debug warning. - Autosave errors set the active tab to `error`, retain the draft, and keep the existing toast/retry behavior. +- Manual save errors are propagated from the save handler to the controller wrapper so the active tab remains in `error` instead of being reported as saved. - Failed-tab close requires explicit confirmation; canceling leaves the tab and draft intact. - Invalid note snapshots are ignored or revalidated through existing note status logic. diff --git a/docs/ai/planning/2026-08-01-feature-notes-tabs.md b/docs/ai/planning/2026-08-01-feature-notes-tabs.md index e87bb7c1f9e..e8d1acd4f59 100644 --- a/docs/ai/planning/2026-08-01-feature-notes-tabs.md +++ b/docs/ai/planning/2026-08-01-feature-notes-tabs.md @@ -75,7 +75,7 @@ These are engineering estimates only; autosave/editor lifecycle and native navig - **Duplicate sessions from different entry points:** centralize all open-note actions in controller/reducer and test each source. - **Editor selection coordinates become stale:** treat restoration as best effort and validate bounds before applying. - **Native/web behavior diverges:** share reducer/invariants and keep presentation-specific adapters thin. -- **Unrelated dirty files in the main checkout:** work only in `.worktrees/codex-notes-tabs` and stage task files explicitly. +- **Unrelated dirty files in the main checkout:** work only in the Harness-managed worktree under its configured root and stage task files explicitly. ## Resources Needed diff --git a/docs/ai/testing/2026-08-01-feature-notes-tabs.md b/docs/ai/testing/2026-08-01-feature-notes-tabs.md index ceec2c2e65d..bdd51e53d85 100644 --- a/docs/ai/testing/2026-08-01-feature-notes-tabs.md +++ b/docs/ai/testing/2026-08-01-feature-notes-tabs.md @@ -72,6 +72,9 @@ description: Define testing approach, test cases, and quality assurance - Existing controller regression run under Allure Agent: 18/18 passed, findings 0. Report: `C:\Users\DenysKoreiba\AppData\Local\Temp\allure-agent-yHYkoU`. - `npm run type-check`: passed, including root, core, web tests, and `ui/mobile`. - Focused ESLint for changed source/test files: passed. +- Full unit coverage run: 184 suites and 1,430 tests passed; changed workspace/controller paths were exercised, including manual-save, autosave, Read error states, and oversized snapshot rejection. +- Targeted Chrome component run: 41/41 logical tests passed across NoteEditor, FTS exit-save, like-search exit-save, and AI open-in-context scenarios. The local Cypress process still returned exit 1 after the passing spec summaries; Allure attributed that to a runner-level webpack-dev-server bootstrap signal, with no assertion failures. +- `npx ai-devkit@latest lint --feature notes-tabs`: passed. - Native mobile route/store tab adaptation was not included; responsive web mobile controls are covered by component tests. - Desktop: keyboard tab navigation, ellipsis, horizontal overflow, dirty/error markers, close confirmation. diff --git a/ui/web/components/features/notes/MobileNotesTabMenu.tsx b/ui/web/components/features/notes/MobileNotesTabMenu.tsx index dc31ab99ef3..bfc024a8f58 100644 --- a/ui/web/components/features/notes/MobileNotesTabMenu.tsx +++ b/ui/web/components/features/notes/MobileNotesTabMenu.tsx @@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button" import { cn } from "@ui/web/lib/utils" import { getTabLabel, SaveStateIndicator, type NotesTabStripProps } from "./NotesTabStrip" -export type MobileNotesTabMenuProps = NotesTabStripProps +export type MobileNotesTabMenuProps = Readonly export function MobileNotesTabMenu({ tabs, @@ -21,12 +21,12 @@ export function MobileNotesTabMenu({ const activeLabel = activeTab ? getTabLabel(activeTab) : "No open notes" const handleActivate = (tabId: string) => { - onActivateTab(tabId) + void onActivateTab(tabId) setIsOpen(false) } const handleClose = (tab: NoteWorkspaceTab) => { - onCloseTab(tab.id) + void onCloseTab(tab.id) if (tab.id === activeTabId && tabs.length <= 1) setIsOpen(false) } @@ -55,7 +55,7 @@ export function MobileNotesTabMenu({

{isOpen && ( -
+
{tabs.map((tab) => { const label = getTabLabel(tab) const isActive = tab.id === activeTabId @@ -64,8 +64,7 @@ export function MobileNotesTabMenu({
diff --git a/ui/web/components/features/notes/NoteView.tsx b/ui/web/components/features/notes/NoteView.tsx index 05537fe42ef..90f654d417e 100644 --- a/ui/web/components/features/notes/NoteView.tsx +++ b/ui/web/components/features/notes/NoteView.tsx @@ -54,13 +54,14 @@ export const NoteView = React.memo(function NoteView({ const { copied, copyNote } = useCopyNote() const isBodyEmpty = React.useMemo(() => NoteClipboardService.isBodyEmpty(bodyHtml), [bodyHtml]) const contentRef = React.useRef(null) + const initialScrollTopRef = React.useRef(initialScrollTop) React.useEffect(() => { const frame = window.requestAnimationFrame(() => { - if (contentRef.current) contentRef.current.scrollTop = initialScrollTop + if (contentRef.current) contentRef.current.scrollTop = initialScrollTopRef.current }) return () => window.cancelAnimationFrame(frame) - }, [initialScrollTop, note.id]) + }, [note.id]) // Форматирование дат для предотвращения повторных вычислений const formattedDates = React.useMemo(() => ({ diff --git a/ui/web/components/features/notes/NotesTabStrip.tsx b/ui/web/components/features/notes/NotesTabStrip.tsx index c6035ead930..8c12871ccc2 100644 --- a/ui/web/components/features/notes/NotesTabStrip.tsx +++ b/ui/web/components/features/notes/NotesTabStrip.tsx @@ -22,7 +22,7 @@ function getTabLabel(tab: NoteWorkspaceTab): string { return tab.noteId ? "Untitled note" : "New note" } -function SaveStateIndicator({ tab }: { tab: NoteWorkspaceTab }) { +function SaveStateIndicator({ tab }: Readonly<{ tab: NoteWorkspaceTab }>) { if (tab.saveState === "dirty") { return ( event.preventDefault()} onClick={() => onActivateTab(tab.id)} onKeyDown={(event) => activateWithKeyboard(event, index, tabs, onActivateTab)} > @@ -134,6 +135,7 @@ export function NotesTabStrip({ className="mr-0.5 h-6 w-6 shrink-0 text-muted-foreground opacity-70 hover:text-foreground group-hover:opacity-100" aria-label={`Close ${label}`} title={`Close ${label}`} + onMouseDown={(event) => event.preventDefault()} onClick={() => onCloseTab(tab.id)} >
-
diff --git a/ui/web/hooks/useNoteAppController.ts b/ui/web/hooks/useNoteAppController.ts index 68ecf75a66b..dbbb60e2107 100644 --- a/ui/web/hooks/useNoteAppController.ts +++ b/ui/web/hooks/useNoteAppController.ts @@ -158,8 +158,8 @@ export function useNoteAppController() { }, [activeTabId, updateTab]) const flushAndCaptureActiveTab = useCallback(async () => { - await flushPendingEditorSave() captureActiveTabSession() + await flushPendingEditorSave() }, [captureActiveTabSession, flushPendingEditorSave]) // -- Infrastructure -- diff --git a/ui/web/tests/unit/components/noteEditor.test.tsx b/ui/web/tests/unit/components/noteEditor.test.tsx index 39e64a30b6c..212075680d9 100644 --- a/ui/web/tests/unit/components/noteEditor.test.tsx +++ b/ui/web/tests/unit/components/noteEditor.test.tsx @@ -5,6 +5,10 @@ import { copyNotePayloadToClipboard } from '@ui/web/lib/noteClipboard' const mockSetEditorContent = jest.fn() let mockEditorHtml = '

Body A

' +let mockEditorSelection = { from: 2, to: 5 } +const mockSetEditorSelection = jest.fn((selection: { from: number; to: number }) => { + mockEditorSelection = selection +}) jest.mock('@/components/ui/button', () => ({ Button: ({ children, ...props }: React.ButtonHTMLAttributes) => ( @@ -27,7 +31,14 @@ jest.mock('@/components/RichTextEditor', () => { const ReactModule = jest.requireActual('react') const MockRichTextEditor = ReactModule.forwardRef< - { getHTML: () => string; setContent: (html: string) => void; runCommand: () => void; scrollToChunk: () => void }, + { + getHTML: () => string + getSelection: () => { from: number; to: number } + setSelection: (selection: { from: number; to: number }) => void + setContent: (html: string) => void + runCommand: () => void + scrollToChunk: () => void + }, { initialContent: string; onContentChange?: () => void } >((props, ref) => { const initializedRef = ReactModule.useRef(false) @@ -38,6 +49,8 @@ jest.mock('@/components/RichTextEditor', () => { ReactModule.useImperativeHandle(ref, () => ({ getHTML: () => mockEditorHtml, + getSelection: () => mockEditorSelection, + setSelection: mockSetEditorSelection, setContent: (html: string) => { mockSetEditorContent(html) mockEditorHtml = html @@ -117,6 +130,7 @@ describe('NoteEditor same-note autosave reconciliation', () => { beforeEach(() => { jest.clearAllMocks() mockEditorHtml = '

Body A

' + mockEditorSelection = { from: 2, to: 5 } }) afterEach(() => { @@ -419,6 +433,17 @@ describe('NoteEditor same-note autosave reconciliation', () => { expect(screen.getByText('

Remote body

')).toBeTruthy() expect(screen.getByTestId('selected-tags').textContent).toBe('remote-tag') }) + + it('captures and restores the rich-text selection in the editing session', async () => { + const session = { + draft: { title: 'First', description: '

Body A

', tags: 'tag-a' }, + view: { scrollTop: 12, editorSelection: { from: 7, to: 14 } }, + } + const { ref } = renderEditor({ initialSession: session }) + + await waitFor(() => expect(mockSetEditorSelection).toHaveBeenCalledWith({ from: 7, to: 14 })) + expect(ref.current?.captureSession?.().view.editorSelection).toEqual({ from: 7, to: 14 }) + }) }) describe('NoteEditor copy action', () => { diff --git a/ui/web/tests/unit/components/noteView.test.tsx b/ui/web/tests/unit/components/noteView.test.tsx index 82ac402f3ac..9580486c8fc 100644 --- a/ui/web/tests/unit/components/noteView.test.tsx +++ b/ui/web/tests/unit/components/noteView.test.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { NoteView } from '@ui/web/components/features/notes/NoteView' import { toast } from 'sonner' import { copyNotePayloadToClipboard } from '@ui/web/lib/noteClipboard' @@ -33,7 +33,14 @@ jest.mock('@ui/web/lib/noteClipboard', () => ({ const mockCopy = copyNotePayloadToClipboard as jest.MockedFunction -function renderNoteView(overrides: Partial<{ description: string; content: string }> = {}) { +type NoteViewTestOverrides = Partial<{ + description: string + content: string + initialScrollTop: number + onViewSessionChange: (view: { scrollTop?: number }) => void +}> + +function renderNoteView(overrides: NoteViewTestOverrides = {}) { return render( , ) } @@ -99,4 +108,24 @@ describe('NoteView copy action', () => { expect(toast.error).toHaveBeenCalledWith('Failed to copy note') }) }) + + it('restores the saved scroll position and reports later scrolling', () => { + const requestAnimationFrame = jest.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + callback(0) + return 1 + }) + const onViewSessionChange = jest.fn() + const { container } = renderNoteView({ initialScrollTop: 48, onViewSessionChange }) + const content = container.querySelector('.overflow-y-auto') as HTMLDivElement + + expect(requestAnimationFrame).toHaveBeenCalled() + expect(content.scrollTop).toBe(48) + + act(() => { + fireEvent.scroll(content, { target: { scrollTop: 93 } }) + }) + expect(onViewSessionChange).toHaveBeenCalledWith({ scrollTop: 93 }) + + requestAnimationFrame.mockRestore() + }) }) diff --git a/ui/web/tests/unit/components/notesShell.test.tsx b/ui/web/tests/unit/components/notesShell.test.tsx index 8bff7654bc5..5484338c445 100644 --- a/ui/web/tests/unit/components/notesShell.test.tsx +++ b/ui/web/tests/unit/components/notesShell.test.tsx @@ -371,6 +371,19 @@ describe('NotesShell', () => { await waitFor(() => expect(mockWordPressGetStatus).toHaveBeenCalled()) }) + it('routes workspace tab controls through the notes controller', () => { + const controller = makeController({ selectedNote: note }) + renderShell(controller) + + fireEvent.click(screen.getByRole('tab', { name: 'Travel note' })) + fireEvent.click(screen.getByRole('button', { name: 'Add note tab' })) + fireEvent.click(screen.getByRole('button', { name: 'Close Travel note' })) + + expect(controller.activateTab).toHaveBeenCalledWith('tab-1') + expect(controller.addTab).toHaveBeenCalledTimes(1) + expect(controller.closeTab).toHaveBeenCalledWith('tab-1') + }) + it('passes editing state and pending chunk focus through the editor', async () => { const controller = makeController({ selectedNote: note, isEditing: true }) renderShell(controller) diff --git a/ui/web/tests/unit/components/notesWorkspaceTabs.test.tsx b/ui/web/tests/unit/components/notesWorkspaceTabs.test.tsx index 69b15fc1ec1..d78722d35aa 100644 --- a/ui/web/tests/unit/components/notesWorkspaceTabs.test.tsx +++ b/ui/web/tests/unit/components/notesWorkspaceTabs.test.tsx @@ -75,6 +75,44 @@ describe('Notes workspace tab controls', () => { expect(onActivateTab).toHaveBeenCalledWith('tab-2') }) + it('supports reverse, first, and last keyboard navigation', () => { + const onActivateTab = jest.fn() + + render( + , + ) + + fireEvent.keyDown(screen.getByRole('tab', { name: 'First note' }), { key: 'ArrowLeft' }) + fireEvent.keyDown(screen.getByRole('tab', { name: /Second note/ }), { key: 'Home' }) + fireEvent.keyDown(screen.getByRole('tab', { name: 'First note' }), { key: 'End' }) + + expect(onActivateTab.mock.calls).toEqual([['tab-2'], ['tab-1'], ['tab-2']]) + }) + + it('shows saving and error indicators for the active workspace tab', () => { + render( + , + ) + + expect(screen.getByLabelText('Saving changes')).toBeTruthy() + expect(screen.getByLabelText('Network unavailable')).toBeTruthy() + }) + it('opens the compact mobile list and forwards activation, close, and add actions', () => { const onAddTab = jest.fn() const onActivateTab = jest.fn() @@ -100,4 +138,25 @@ describe('Notes workspace tab controls', () => { expect(onCloseTab).toHaveBeenCalledWith('tab-1') expect(onAddTab).toHaveBeenCalledTimes(1) }) + + it('closes the mobile menu after closing its only active tab', () => { + const onCloseTab = jest.fn() + render( + , + ) + + const menuButton = screen.getByRole('button', { name: 'Open note tabs (1)' }) + fireEvent.click(menuButton) + fireEvent.click(screen.getByRole('button', { name: 'Close Only note' })) + + expect(onCloseTab).toHaveBeenCalledWith('tab-1') + expect(menuButton.getAttribute('aria-expanded')).toBe('false') + expect(screen.getByText('1 tab')).toBeTruthy() + }) }) diff --git a/ui/web/tests/unit/components/richTextEditorAdditional.test.tsx b/ui/web/tests/unit/components/richTextEditorAdditional.test.tsx index 39bac71100b..a8de78d1ff3 100644 --- a/ui/web/tests/unit/components/richTextEditorAdditional.test.tsx +++ b/ui/web/tests/unit/components/richTextEditorAdditional.test.tsx @@ -70,7 +70,7 @@ function createMockEditor() { dispatch: jest.fn(), serializeForClipboard: jest.fn(() => ({ dom: { innerHTML: '

Selected

' } })), }, - commands: { undo: jest.fn(), redo: jest.fn() }, + commands: { undo: jest.fn(), redo: jest.fn(), setTextSelection: jest.fn() }, chain: jest.fn(() => chain), can: jest.fn(() => ({ undo: () => true, redo: () => false })), __chain: chain, @@ -231,5 +231,18 @@ describe('RichTextEditor additional observable behavior', () => { capturedMenuBarProps?.onApplyMarkdown() expect(applySelectionAsMarkdown).toHaveBeenCalledWith(mockEditor, onContentChange) + + expect(ref.current?.getSelection?.()).toEqual({ from: 2, to: 5 }) + ref.current?.setSelection?.({ from: -4.8, to: 20.2 }) + expect(mockEditor.commands.setTextSelection).toHaveBeenCalledWith({ from: 1, to: 9 }) + }) + + it('keeps selection methods safe before the editor instance is available', () => { + mockEditor = null + const ref = React.createRef>() + render() + + expect(ref.current?.getSelection?.()).toBeUndefined() + expect(() => ref.current?.setSelection?.({ from: 1, to: 2 })).not.toThrow() }) }) diff --git a/ui/web/tests/unit/hooks/useNoteAppControllerAdditional.test.tsx b/ui/web/tests/unit/hooks/useNoteAppControllerAdditional.test.tsx index 990d3f722b7..e3d37ffa4d1 100644 --- a/ui/web/tests/unit/hooks/useNoteAppControllerAdditional.test.tsx +++ b/ui/web/tests/unit/hooks/useNoteAppControllerAdditional.test.tsx @@ -266,6 +266,29 @@ describe('useNoteAppController additional observable behavior', () => { window.history.pushState({}, '', '/') }) + it('captures the editor selection before switching to a new workspace tab', async () => { + const { result } = setup() + + const flushPendingSave = jest.fn().mockResolvedValue(undefined) + const captureSession = jest.fn(() => ({ + draft: { title: 'Draft', description: '

Body

', tags: '' }, + view: { scrollTop: 12, editorSelection: { from: 4, to: 9 } }, + })) + const editorRef = { current: { flushPendingSave, captureSession } } + act(() => result.current.registerNoteEditorRef(editorRef as never)) + + await act(async () => { + await result.current.addTab() + }) + + expect(captureSession).toHaveBeenCalledTimes(1) + expect(flushPendingSave).toHaveBeenCalledTimes(1) + expect(result.current.tabs[0].view).toEqual({ + scrollTop: 12, + editorSelection: { from: 4, to: 9 }, + }) + }) + it('selects the remote note after flushing, but exits editing when selecting the already selected note', async () => { const current = makeNote({ id: 'current' }) const remote = makeNote({ id: 'remote', title: 'Remote' }) From 56d4136190a360e39e961798c460c4358824ce46 Mon Sep 17 00:00:00 2001 From: Denys Date: Sun, 2 Aug 2026 10:13:02 +0200 Subject: [PATCH 04/23] fix(notes): avoid empty tab e2e locator collision --- ui/web/components/features/notes/MobileNotesTabMenu.tsx | 2 +- ui/web/components/features/notes/NotesTabStrip.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/web/components/features/notes/MobileNotesTabMenu.tsx b/ui/web/components/features/notes/MobileNotesTabMenu.tsx index 19173afed58..7a1fd9afb32 100644 --- a/ui/web/components/features/notes/MobileNotesTabMenu.tsx +++ b/ui/web/components/features/notes/MobileNotesTabMenu.tsx @@ -79,7 +79,7 @@ export function MobileNotesTabMenu({ variant="ghost" size="icon" className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground" - aria-label={`Close ${label}`} + aria-label={tab.noteId ? `Close ${label}` : "Close empty note tab"} title={`Close ${label}`} onMouseDown={(event) => event.preventDefault()} onClick={() => handleClose(tab)} diff --git a/ui/web/components/features/notes/NotesTabStrip.tsx b/ui/web/components/features/notes/NotesTabStrip.tsx index 8c12871ccc2..e9c2f9ab96e 100644 --- a/ui/web/components/features/notes/NotesTabStrip.tsx +++ b/ui/web/components/features/notes/NotesTabStrip.tsx @@ -133,7 +133,7 @@ export function NotesTabStrip({ variant="ghost" size="icon" className="mr-0.5 h-6 w-6 shrink-0 text-muted-foreground opacity-70 hover:text-foreground group-hover:opacity-100" - aria-label={`Close ${label}`} + aria-label={tab.noteId ? `Close ${label}` : "Close empty note tab"} title={`Close ${label}`} onMouseDown={(event) => event.preventDefault()} onClick={() => onCloseTab(tab.id)} From 8e9c214170eb6289e92e1831d2d6cc4b5cee1e63 Mon Sep 17 00:00:00 2001 From: Denys Date: Sun, 2 Aug 2026 10:30:07 +0200 Subject: [PATCH 05/23] fix(notes): remove invalid tablist semantics --- ui/web/components/features/notes/NotesTabStrip.tsx | 5 ++--- ui/web/tests/unit/components/notesShell.test.tsx | 2 +- .../unit/components/notesWorkspaceTabs.test.tsx | 14 +++++++------- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/ui/web/components/features/notes/NotesTabStrip.tsx b/ui/web/components/features/notes/NotesTabStrip.tsx index e9c2f9ab96e..c0862e13aba 100644 --- a/ui/web/components/features/notes/NotesTabStrip.tsx +++ b/ui/web/components/features/notes/NotesTabStrip.tsx @@ -95,7 +95,7 @@ export function NotesTabStrip({ }: ReadonlyNotesTabStripProps) { return (
-
+
{tabs.map((tab, index) => { const label = getTabLabel(tab) @@ -112,8 +112,7 @@ export function NotesTabStrip({ >
) })} - diff --git a/ui/web/components/features/notes/NotesShell.tsx b/ui/web/components/features/notes/NotesShell.tsx index 86e5878fbe8..1d08a901994 100644 --- a/ui/web/components/features/notes/NotesShell.tsx +++ b/ui/web/components/features/notes/NotesShell.tsx @@ -230,78 +230,80 @@ export function NotesShell({ controller }: NotesShellProps) { onDeleteTag={handleDeleteTag} /> - ) : ( - <> - void handleOpenSettings()} - onCreateNote={handleCreateNote} - onSignOut={handleSignOut} - onOpenSearch={handleOpenSearchPanel} - className={cn((showEditor || isSearchPanelOpen) ? "hidden md:flex" : "w-full md:w-80")} - data-testid="sidebar-container" - > - - - - {isSearchPanelOpen && ( - setIsSearchPanelOpen(false)} - className={cn(showEditor ? "hidden md:flex" : "w-full min-w-[300px] md:min-w-0")} - /> - )} - -
- void addTab?.()} - onActivateTab={(tabId) => void activateTab?.(tabId)} - onCloseTab={(tabId) => void closeTab?.(tabId)} - /> - void addTab?.()} - onActivateTab={(tabId) => void activateTab?.(tabId)} - onCloseTab={(tabId) => void closeTab?.(tabId)} - /> -
- + void addTab?.()} + onActivateTab={(tabId) => void activateTab?.(tabId)} + onCloseTab={(tabId) => void closeTab?.(tabId)} + /> +
+ void handleOpenSettings()} + onCreateNote={handleCreateNote} + onSignOut={handleSignOut} + onOpenSearch={handleOpenSearchPanel} + className={cn((showEditor || isSearchPanelOpen) ? "hidden md:flex" : "w-full md:w-80")} + data-testid="sidebar-container" + > + + + + {isSearchPanelOpen && ( + setIsSearchPanelOpen(false)} + className={cn(showEditor ? "hidden md:flex" : "w-full min-w-[300px] md:min-w-0")} /> -
-
- - )} + )} + +
+ void addTab?.()} + onActivateTab={(tabId) => void activateTab?.(tabId)} + onCloseTab={(tabId) => void closeTab?.(tabId)} + /> +
+ +
+
+
+
+ )}
diff --git a/ui/web/hooks/useNoteAppController.ts b/ui/web/hooks/useNoteAppController.ts index dbbb60e2107..529c6e2804b 100644 --- a/ui/web/hooks/useNoteAppController.ts +++ b/ui/web/hooks/useNoteAppController.ts @@ -572,21 +572,34 @@ export function useNoteAppController() { const handleAddTab = useCallback(async () => { await flushAndCaptureActiveTab() addTab() - setNotePaneVisible(true) + // On mobile, an empty tab must return to the note list so the user can + // choose which note fills the new active slot. Desktop keeps its editor + // pane visible through the responsive `md:flex` layout rule. + setNotePaneVisible(false) }, [addTab, flushAndCaptureActiveTab]) const handleActivateTab = useCallback(async (tabId: string) => { - if (tabId === activeTabId) return + const targetTab = tabs.find((tab) => tab.id === tabId) + if (!targetTab) return + if (tabId === activeTabId) { + setNotePaneVisible(Boolean(targetTab.note || targetTab.mode === 'editing')) + return + } await flushAndCaptureActiveTab() activateTab(tabId) - setNotePaneVisible(true) + setNotePaneVisible(Boolean(targetTab.note || targetTab.mode === 'editing')) setLastSavedAt(null) - }, [activateTab, activeTabId, flushAndCaptureActiveTab, setLastSavedAt]) + }, [activateTab, activeTabId, flushAndCaptureActiveTab, setLastSavedAt, tabs]) const handleCloseTab = useCallback(async (tabId: string) => { const tab = tabs.find((candidate) => candidate.id === tabId) if (!tab) return + const tabIndex = tabs.findIndex((candidate) => candidate.id === tabId) + const nextVisibleTab = tab.id === activeTabId + ? (tabs[tabIndex + 1] ?? tabs[tabIndex - 1] ?? null) + : tabs.find((candidate) => candidate.id === activeTabId) ?? null + const discardFailedSave = tab.saveState === 'error' && typeof window !== 'undefined' && window.confirm(`Discard unsaved changes in "${tab.note?.title || 'this tab'}"?`) @@ -601,7 +614,7 @@ export function useNoteAppController() { } closeTab(tabId) - setNotePaneVisible(true) + setNotePaneVisible(Boolean(nextVisibleTab?.note || nextVisibleTab?.mode === 'editing')) setLastSavedAt(null) }, [activeTabId, closeTab, flushAndCaptureActiveTab, setLastSavedAt, tabs]) diff --git a/ui/web/tests/unit/components/noteView.test.tsx b/ui/web/tests/unit/components/noteView.test.tsx index 9580486c8fc..ef98c1e6356 100644 --- a/ui/web/tests/unit/components/noteView.test.tsx +++ b/ui/web/tests/unit/components/noteView.test.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { NoteView } from '@ui/web/components/features/notes/NoteView' import { toast } from 'sonner' import { copyNotePayloadToClipboard } from '@ui/web/lib/noteClipboard' @@ -121,9 +121,7 @@ describe('NoteView copy action', () => { expect(requestAnimationFrame).toHaveBeenCalled() expect(content.scrollTop).toBe(48) - act(() => { - fireEvent.scroll(content, { target: { scrollTop: 93 } }) - }) + fireEvent.scroll(content, { target: { scrollTop: 93 } }) expect(onViewSessionChange).toHaveBeenCalledWith({ scrollTop: 93 }) requestAnimationFrame.mockRestore() diff --git a/ui/web/tests/unit/components/notesShell.test.tsx b/ui/web/tests/unit/components/notesShell.test.tsx index da8018079d3..658b5bd7198 100644 --- a/ui/web/tests/unit/components/notesShell.test.tsx +++ b/ui/web/tests/unit/components/notesShell.test.tsx @@ -345,6 +345,13 @@ describe('NotesShell', () => { expect(controller.handleSignOut).toHaveBeenCalled() }) + it('keeps the mobile workspace tab switcher available while the note list is visible', () => { + renderShell(makeController()) + + expect(screen.getByRole('button', { name: 'Open note tabs (1)' })).toBeTruthy() + expect(screen.getByTestId('mock-note-list')).toBeTruthy() + }) + it('renders a selected note and routes its callbacks, back action, and delete confirmation', async () => { const controller = makeController({ selectedNote: note, diff --git a/ui/web/tests/unit/components/notesWorkspaceTabs.test.tsx b/ui/web/tests/unit/components/notesWorkspaceTabs.test.tsx index cbf1ebb6665..52c1aecc08c 100644 --- a/ui/web/tests/unit/components/notesWorkspaceTabs.test.tsx +++ b/ui/web/tests/unit/components/notesWorkspaceTabs.test.tsx @@ -137,6 +137,7 @@ describe('Notes workspace tab controls', () => { expect(onActivateTab).toHaveBeenCalledWith('tab-2') expect(onCloseTab).toHaveBeenCalledWith('tab-1') expect(onAddTab).toHaveBeenCalledTimes(1) + expect(screen.getByRole('button', { name: 'Open note tabs (2)' }).getAttribute('aria-expanded')).toBe('false') }) it('closes the mobile menu after closing its only active tab', () => { diff --git a/ui/web/tests/unit/hooks/useNoteAppControllerAdditional.test.tsx b/ui/web/tests/unit/hooks/useNoteAppControllerAdditional.test.tsx index e3d37ffa4d1..b6a0634409e 100644 --- a/ui/web/tests/unit/hooks/useNoteAppControllerAdditional.test.tsx +++ b/ui/web/tests/unit/hooks/useNoteAppControllerAdditional.test.tsx @@ -289,6 +289,29 @@ describe('useNoteAppController additional observable behavior', () => { }) }) + it('returns to the note list after creating a blank tab so the active slot can receive a note', async () => { + const { result } = setup() + + await act(async () => { + await result.current.addTab() + }) + + expect(result.current.activeTab.note).toBeNull() + expect(result.current.notePaneVisible).toBe(false) + }) + + it('returns to the note list when closing the final blank tab replacement', async () => { + const { result } = setup() + const activeTabId = result.current.activeTabId + + await act(async () => { + await result.current.closeTab(activeTabId) + }) + + expect(result.current.activeTab.note).toBeNull() + expect(result.current.notePaneVisible).toBe(false) + }) + it('selects the remote note after flushing, but exits editing when selecting the already selected note', async () => { const current = makeNote({ id: 'current' }) const remote = makeNote({ id: 'remote', title: 'Remote' }) From 5474ed87c93f955964eba18f3f8667ba11d74584 Mon Sep 17 00:00:00 2001 From: Denys Date: Sun, 2 Aug 2026 12:41:19 +0200 Subject: [PATCH 08/23] fix(qodana): remove new notes test findings --- .../notes/NotesShellFtsExitSave.cy.tsx | 37 ++++++++++--------- .../notes/NotesShellLikeExitSave.cy.tsx | 35 +++++++++--------- .../features/notes/notesShellTestUtils.tsx | 30 ++++++++++++++- 3 files changed, 65 insertions(+), 37 deletions(-) diff --git a/cypress/component/features/notes/NotesShellFtsExitSave.cy.tsx b/cypress/component/features/notes/NotesShellFtsExitSave.cy.tsx index 0e0bc2d39fa..cd255f459a6 100644 --- a/cypress/component/features/notes/NotesShellFtsExitSave.cy.tsx +++ b/cypress/component/features/notes/NotesShellFtsExitSave.cy.tsx @@ -5,9 +5,8 @@ import { NotesShell } from '../../../../ui/web/components/features/notes/NotesSh import type { NoteViewModel, SearchResult } from '../../../../core/types/domain' import { SupabaseTestProvider } from '../../../../ui/web/providers/SupabaseProvider' import { - makeWorkspaceTab, pastePlainText, - useEditorExitState, + useNotesShellTestState, type FakeController, } from './notesShellTestUtils' @@ -51,16 +50,18 @@ const buildController = () => { ] const Harness = () => { - const [notes, setNotes] = React.useState(baseNotes) - const [selectedNoteId, setSelectedNoteId] = React.useState('note-1') - const [isEditing, setIsEditing] = React.useState(true) - const { registerNoteEditorRef, flushIfEditing } = useEditorExitState(isEditing) - - const selectedNote = React.useMemo( - () => notes.find((n) => n.id === selectedNoteId) ?? null, - [notes, selectedNoteId] - ) - const activeTab = React.useMemo(() => makeWorkspaceTab(selectedNote, isEditing), [selectedNote, isEditing]) + const { + notes, + setNotes, + selectedNoteId, + setSelectedNoteId, + isEditing, + setIsEditing, + registerNoteEditorRef, + flushIfEditing, + selectedNote, + activeTab, + } = useNotesShellTestState(baseNotes) const ftsResults: SearchResult[] = React.useMemo(() => { // Simulate search results payload that may be stale. We re-resolve on click. @@ -92,28 +93,28 @@ const buildController = () => { updated_at: new Date().toISOString(), } })) - }, [selectedNoteId]) + }, [selectedNoteId, setNotes]) const handleSaveNote = React.useCallback((data: { title: string; description: string; tags: string }) => { const noteId = selectedNoteId setNotes((prev) => prev.map((n) => (n.id === noteId ? { ...n, title: data.title, description: data.description, updated_at: new Date().toISOString() } : n))) - }, [selectedNoteId]) + }, [selectedNoteId, setNotes]) const handleReadNote = React.useCallback((data: { title: string; description: string; tags: string }) => { handleSaveNote(data) setIsEditing(false) - }, [handleSaveNote]) + }, [handleSaveNote, setIsEditing]) const handleEditNote = React.useCallback((note: NoteViewModel) => { setSelectedNoteId(note.id) setIsEditing(true) - }, []) + }, [setIsEditing, setSelectedNoteId]) const handleSelectNote = React.useCallback(async (note: NoteViewModel | null) => { await flushIfEditing() setSelectedNoteId(note?.id ?? '') setIsEditing(false) - }, [flushIfEditing]) + }, [flushIfEditing, setIsEditing, setSelectedNoteId]) const handleSearchResultClick = React.useCallback(async (note: SearchResult) => { await flushIfEditing() @@ -121,7 +122,7 @@ const buildController = () => { const latest = notes.find((n) => n.id === note.id) ?? (note as unknown as NoteViewModel) setSelectedNoteId(latest.id) setIsEditing(false) - }, [flushIfEditing, notes]) + }, [flushIfEditing, notes, setIsEditing, setSelectedNoteId]) const controller: FakeController = { registerNoteEditorRef, diff --git a/cypress/component/features/notes/NotesShellLikeExitSave.cy.tsx b/cypress/component/features/notes/NotesShellLikeExitSave.cy.tsx index 241f9e9213f..a94b93edf57 100644 --- a/cypress/component/features/notes/NotesShellLikeExitSave.cy.tsx +++ b/cypress/component/features/notes/NotesShellLikeExitSave.cy.tsx @@ -5,9 +5,8 @@ import { NotesShell } from '../../../../ui/web/components/features/notes/NotesSh import type { NoteViewModel } from '../../../../core/types/domain' import { SupabaseTestProvider } from '../../../../ui/web/providers/SupabaseProvider' import { - makeWorkspaceTab, pastePlainText, - useEditorExitState, + useNotesShellTestState, type FakeController, } from './notesShellTestUtils' @@ -51,16 +50,18 @@ const buildController = () => { ] const Harness = () => { - const [notes, setNotes] = React.useState(baseNotes) - const [selectedNoteId, setSelectedNoteId] = React.useState('note-1') - const [isEditing, setIsEditing] = React.useState(true) - const { registerNoteEditorRef, flushIfEditing } = useEditorExitState(isEditing) - - const selectedNote = React.useMemo( - () => notes.find((n) => n.id === selectedNoteId) ?? null, - [notes, selectedNoteId] - ) - const activeTab = React.useMemo(() => makeWorkspaceTab(selectedNote, isEditing), [selectedNote, isEditing]) + const { + notes, + setNotes, + selectedNoteId, + setSelectedNoteId, + isEditing, + setIsEditing, + registerNoteEditorRef, + flushIfEditing, + selectedNote, + activeTab, + } = useNotesShellTestState(baseNotes) const handleAutoSave = React.useCallback(async (data: { noteId?: string; title?: string; description?: string; tags?: string }) => { const noteId = data.noteId ?? selectedNoteId @@ -75,28 +76,28 @@ const buildController = () => { updated_at: new Date().toISOString(), } })) - }, [selectedNoteId]) + }, [selectedNoteId, setNotes]) const handleSaveNote = React.useCallback((data: { title: string; description: string; tags: string }) => { const noteId = selectedNoteId setNotes((prev) => prev.map((n) => (n.id === noteId ? { ...n, title: data.title, description: data.description, updated_at: new Date().toISOString() } : n))) - }, [selectedNoteId]) + }, [selectedNoteId, setNotes]) const handleReadNote = React.useCallback((data: { title: string; description: string; tags: string }) => { handleSaveNote(data) setIsEditing(false) - }, [handleSaveNote]) + }, [handleSaveNote, setIsEditing]) const handleEditNote = React.useCallback((note: NoteViewModel) => { setSelectedNoteId(note.id) setIsEditing(true) - }, []) + }, [setIsEditing, setSelectedNoteId]) const handleSelectNote = React.useCallback(async (note: NoteViewModel | null) => { await flushIfEditing() setSelectedNoteId(note?.id ?? '') setIsEditing(false) - }, [flushIfEditing]) + }, [flushIfEditing, setIsEditing, setSelectedNoteId]) const controller: FakeController = { registerNoteEditorRef, diff --git a/cypress/component/features/notes/notesShellTestUtils.tsx b/cypress/component/features/notes/notesShellTestUtils.tsx index a25a76dfaa9..37a004309cd 100644 --- a/cypress/component/features/notes/notesShellTestUtils.tsx +++ b/cypress/component/features/notes/notesShellTestUtils.tsx @@ -1,7 +1,7 @@ import React from 'react' import type { NoteWorkspaceTab } from '@core/services/noteWorkspaceTabs' -import type { NoteViewModel } from '../../../../core/types/domain' -import type { NoteEditorHandle } from '../../../../ui/web/components/features/notes/NoteEditor' +import type { NoteViewModel } from '@core/types/domain' +import type { NoteEditorHandle } from '@ui/web/components/features/notes/NoteEditor' export type FakeController = Record @@ -30,6 +30,32 @@ export const makeWorkspaceTab = (note: NoteViewModel | null, isEditing: boolean) saveError: null, }) +export function useNotesShellTestState(baseNotes: NoteViewModel[]) { + const [notes, setNotes] = React.useState(baseNotes) + const [selectedNoteId, setSelectedNoteId] = React.useState('note-1') + const [isEditing, setIsEditing] = React.useState(true) + const { registerNoteEditorRef, flushIfEditing } = useEditorExitState(isEditing) + + const selectedNote = React.useMemo( + () => notes.find((n) => n.id === selectedNoteId) ?? null, + [notes, selectedNoteId] + ) + const activeTab = React.useMemo(() => makeWorkspaceTab(selectedNote, isEditing), [selectedNote, isEditing]) + + return { + notes, + setNotes, + selectedNoteId, + setSelectedNoteId, + isEditing, + setIsEditing, + registerNoteEditorRef, + flushIfEditing, + selectedNote, + activeTab, + } +} + export function useEditorExitState(isEditing: boolean) { const registeredEditorRef = React.useRef | null>(null) From f0cc5aa242d83e9224fbae4bce02918dc9467bc7 Mon Sep 17 00:00:00 2001 From: Denys Date: Sun, 2 Aug 2026 13:44:53 +0200 Subject: [PATCH 09/23] feat(notes): improve browser-like tab capacity --- core/services/noteWorkspaceTabs.ts | 6 + .../core-services-noteWorkspaceTabs.test.ts | 13 +++ .../features/mobile/MobileLayout.cy.tsx | 44 +++++++ .../features/notes/NotesTabStrip.cy.tsx | 50 ++++++++ .../design/2026-08-01-feature-notes-tabs.md | 18 ++- .../2026-08-01-feature-notes-tabs.md | 11 ++ .../2026-08-01-feature-notes-tabs.md | 7 +- .../testing/2026-08-01-feature-notes-tabs.md | 4 +- .../features/notes/MobileNotesTabMenu.tsx | 91 +++++++++------ .../components/features/notes/NotesShell.tsx | 11 +- .../features/notes/NotesTabStrip.tsx | 107 ++++++++++++++++-- ui/web/hooks/useNoteAppController.ts | 6 +- ui/web/hooks/useNoteWorkspaceTabs.ts | 3 + .../tests/unit/components/notesShell.test.tsx | 13 +++ .../components/notesWorkspaceTabs.test.tsx | 104 ++++++++++++++++- .../useNoteAppControllerAdditional.test.tsx | 29 +++++ 16 files changed, 466 insertions(+), 51 deletions(-) create mode 100644 cypress/component/features/notes/NotesTabStrip.cy.tsx diff --git a/core/services/noteWorkspaceTabs.ts b/core/services/noteWorkspaceTabs.ts index 5b4dc5b534b..d23cd236155 100644 --- a/core/services/noteWorkspaceTabs.ts +++ b/core/services/noteWorkspaceTabs.ts @@ -2,6 +2,7 @@ import type { NoteViewModel } from '@core/types/domain' export const NOTE_WORKSPACE_VERSION = 1 export const MAX_NOTE_WORKSPACE_SERIALIZED_LENGTH = 2 * 1024 * 1024 +export const MAX_NOTE_WORKSPACE_TABS = 32 export type NoteWorkspaceMode = 'reading' | 'editing' export type NoteWorkspaceSaveState = 'saved' | 'dirty' | 'saving' | 'error' @@ -141,10 +142,15 @@ export function findWorkspaceTabByNoteId( return state.tabs.find((tab) => tab.noteId === noteId) ?? null } +export function canAddWorkspaceTab(state: NoteWorkspaceState): boolean { + return state.tabs.length < MAX_NOTE_WORKSPACE_TABS +} + export function addWorkspaceTab( state: NoteWorkspaceState, idFactory: NoteWorkspaceIdFactory = defaultIdFactory, ): NoteWorkspaceState { + if (!canAddWorkspaceTab(state)) return state const tab = createEmptyTab(idFactory) return { ...state, diff --git a/core/tests/unit/core-services-noteWorkspaceTabs.test.ts b/core/tests/unit/core-services-noteWorkspaceTabs.test.ts index 345a861ddb3..5e13cf55e63 100644 --- a/core/tests/unit/core-services-noteWorkspaceTabs.test.ts +++ b/core/tests/unit/core-services-noteWorkspaceTabs.test.ts @@ -2,12 +2,14 @@ import type { NoteViewModel } from '@core/types/domain' import { activateWorkspaceTab, addWorkspaceTab, + canAddWorkspaceTab, closeWorkspaceTab, createNoteWorkspaceState, findWorkspaceTabByNoteId, getActiveWorkspaceTab, hydrateNoteWorkspaceState, MAX_NOTE_WORKSPACE_SERIALIZED_LENGTH, + MAX_NOTE_WORKSPACE_TABS, openNoteInWorkspace, serializeNoteWorkspaceState, updateWorkspaceTab, @@ -50,6 +52,17 @@ describe('note workspace tab state', () => { expect(state.tabs[0]).toBe(first.tabs[0]) }) + it('guards the shared workspace tab limit in the core transition', () => { + let state = createNoteWorkspaceState(ids('tab-0')) + for (let index = 1; index < MAX_NOTE_WORKSPACE_TABS; index += 1) { + state = addWorkspaceTab(state, ids(`tab-${index}`)) + } + + expect(state.tabs).toHaveLength(MAX_NOTE_WORKSPACE_TABS) + expect(canAddWorkspaceTab(state)).toBe(false) + expect(addWorkspaceTab(state, ids('unexpected'))).toBe(state) + }) + it('replaces the active tab when opening a new note', () => { const first = createNoteWorkspaceState(ids('tab-1')) const state = openNoteInWorkspace(first, note('note-1', 'First')) diff --git a/cypress/component/features/mobile/MobileLayout.cy.tsx b/cypress/component/features/mobile/MobileLayout.cy.tsx index b09e743224c..adddc445dc9 100644 --- a/cypress/component/features/mobile/MobileLayout.cy.tsx +++ b/cypress/component/features/mobile/MobileLayout.cy.tsx @@ -1,5 +1,6 @@ import React from 'react' import { NotesShell } from '../../../../ui/web/components/features/notes/NotesShell' +import { MobileNotesTabMenu } from '../../../../ui/web/components/features/notes/MobileNotesTabMenu' import type { NoteAppController } from '../../../../ui/web/hooks/useNoteAppController' import { SupabaseTestProvider } from '../../../../ui/web/providers/SupabaseProvider' import type { SupabaseClient } from '@supabase/supabase-js' @@ -45,6 +46,8 @@ describe('Mobile Layout Adaptation', () => { tabs: [activeTab], activeTabId: activeTab.id, activeTab, + canAddTab: true, + workspaceHydrated: true, addTab: cy.stub().resolves(), activateTab: cy.stub().resolves(), closeTab: cy.stub().resolves(), @@ -211,6 +214,47 @@ describe('Mobile Layout Adaptation', () => { cy.get('@handleSelectNote').should('have.been.calledWith', note) }) + it('scrolls a large mobile tab list and exposes the shared disabled Add state', () => { + cy.viewport('iphone-se2') + const baseTab: NoteWorkspaceTab = { + id: 'mobile-tab-0', + noteId: null, + note: null, + mode: 'reading', + draft: { title: 'Note 1', description: '', tags: '' }, + view: { scrollTop: 0 }, + saveState: 'saved', + saveError: null, + } + const manyTabs = Array.from({ length: 40 }, (_, index) => ({ + ...baseTab, + id: `mobile-tab-${index}`, + draft: { ...baseTab.draft, title: `Note ${index + 1}` }, + })) + cy.mount( + + + + ) + + cy.get('[aria-label="Open note tabs (40)"]').click() + cy.get('[id="mobile-notes-tab-list"]') + .find('button[aria-label="Add tab (limit reached: 32 tabs)"]') + .should('be.visible') + .and('be.disabled') + cy.get('[id="mobile-notes-tab-list"] > div') + .first() + .should('have.class', 'overflow-y-auto') + }) + it('shows editor and hides sidebar when note is selected on mobile', () => { cy.viewport('iphone-se2') const selectedNote = { diff --git a/cypress/component/features/notes/NotesTabStrip.cy.tsx b/cypress/component/features/notes/NotesTabStrip.cy.tsx new file mode 100644 index 00000000000..59b21f33eed --- /dev/null +++ b/cypress/component/features/notes/NotesTabStrip.cy.tsx @@ -0,0 +1,50 @@ +import React from 'react' +import { NotesTabStrip } from '../../../../ui/web/components/features/notes/NotesTabStrip' +import type { NoteWorkspaceTab } from '@core/services/noteWorkspaceTabs' + +describe('Desktop notes tab strip', () => { + it('keeps Add visible on the left, scrolls excess tabs, and preserves tab actions at capacity', () => { + cy.viewport(1024, 600) + const baseTab: NoteWorkspaceTab = { + id: 'tab-0', + noteId: null, + note: null, + mode: 'reading', + draft: { title: 'Note 1', description: '', tags: '' }, + view: { scrollTop: 0 }, + saveState: 'saved', + saveError: null, + } + const tabs = Array.from({ length: 12 }, (_, index) => ({ + ...baseTab, + id: `tab-${index}`, + draft: { ...baseTab.draft, title: `Note ${index + 1}` }, + })) + const onActivateTab = cy.stub().as('activateTab') + const onCloseTab = cy.stub().as('closeTab') + + cy.mount( + + ) + + cy.get('button[aria-label^="Add note tab (limit reached:"]') + .should('be.visible') + .and('be.disabled') + .parent() + .children() + .first() + .should('have.attr', 'aria-label') + cy.get('[aria-label="Open notes"]').should('have.class', 'overflow-x-auto') + cy.get('button[title="Note 8"]').click() + cy.get('button[title="Close Note 1"]').click() + cy.get('@activateTab').should('have.been.calledWith', 'tab-7') + cy.get('@closeTab').should('have.been.calledWith', 'tab-0') + }) +}) diff --git a/docs/ai/design/2026-08-01-feature-notes-tabs.md b/docs/ai/design/2026-08-01-feature-notes-tabs.md index 49bc50bb9a4..dae082b4321 100644 --- a/docs/ai/design/2026-08-01-feature-notes-tabs.md +++ b/docs/ai/design/2026-08-01-feature-notes-tabs.md @@ -123,6 +123,20 @@ that list replaces the active blank slot through the existing controller rule; it does not create another tab. Activating an empty reading tab uses the same list state so an empty tab never traps the user in a blank editor screen. +### Responsive desktop tab capacity + +The desktop tab strip keeps Add as the first, non-scrolling control. The tab +viewport uses a browser-like flex layout: tabs grow while there is room, stop +at a 120px minimum, and horizontally scroll when a restored workspace is +larger than the viewport. A `ResizeObserver` measures the tab viewport and +disables Add when another tab would require shrinking below that minimum. +The shared core model/controller also enforces a 32-tab ceiling, so direct +calls cannot bypass the UI guard. The disabled button exposes the applicable +limit in its accessible name so the constraint is understandable without +relying on the visual layout. The mobile menu receives the controller's +disabled state explicitly and keeps its tab rows in a scrollable region while +leaving Add available as the fixed menu footer. + ### Active-slot replacement is the default `openNoteInWorkspace` updates the active tab. `addWorkspaceTab` is the only operation that increases tab count. This encodes the product's key rule in one reducer function instead of relying on individual click handlers. @@ -152,7 +166,9 @@ The workspace is modeled as a list of independent tab sessions, while the contro - Switching tabs performs no new network request unless existing note revalidation is required; reducer transitions are synchronous after the current save flush. - Storage writes are best-effort and serialized from a small, bounded state snapshot; storage failures never block editing. - Tab buttons are keyboard reachable, have accessible names, and expose active/dirty/error state. -- Long titles are ellipsized; desktop overflow is horizontal and mobile uses a compact list. +- Long titles are ellipsized; desktop tabs grow/shrink within the 120px + minimum and overflow horizontally when needed, with Add fixed on the left; + mobile uses a compact list. - No secrets or auth tokens are added to workspace storage. ## Design Review Resolution (2026-08-01) diff --git a/docs/ai/implementation/2026-08-01-feature-notes-tabs.md b/docs/ai/implementation/2026-08-01-feature-notes-tabs.md index c09fc9ef8bf..e959d4b94b3 100644 --- a/docs/ai/implementation/2026-08-01-feature-notes-tabs.md +++ b/docs/ai/implementation/2026-08-01-feature-notes-tabs.md @@ -41,6 +41,11 @@ ui/web/components/features/notes/NoteView.tsx - Capture the current editor before unmount, flush autosave first, and then apply tab transitions. - Use the active tab's draft as editor initial content; never use a server refresh to overwrite a dirty local field without existing reconciliation rules. - Keep tab indicators derived from explicit per-tab save state rather than global UI assumptions. +- Keep the desktop Add control outside the scrolling tab viewport. `NotesTabStrip` + measures that viewport, uses the same 120px minimum as its CSS `min-width`, + and disables Add when the next tab would violate the minimum. Existing tabs + can still be inspected by horizontal scrolling, including after restoring a + wider workspace into a narrow viewport. ### Patterns & Best Practices @@ -49,6 +54,12 @@ ui/web/components/features/notes/NoteView.tsx - Do not add direct Supabase calls to tab UI or storage. - Use `sessionStorage` only through the adapter and never read/write it during server rendering. - Keep accessibility labels stable so component tests can target behavior rather than CSS. +- When desktop capacity is reached, retain a real disabled button and expose + the measured limit in its accessible name; do not hide Add or rely on a + tooltip-only explanation. +- The core reducer and controller enforce the shared 32-tab ceiling before + flushing editor work. `MobileNotesTabMenu` receives that explicit state and + renders a scrollable tab-list body with Add as a non-scrolling footer. ## Integration Points diff --git a/docs/ai/requirements/2026-08-01-feature-notes-tabs.md b/docs/ai/requirements/2026-08-01-feature-notes-tabs.md index a2632304616..5e5571064c6 100644 --- a/docs/ai/requirements/2026-08-01-feature-notes-tabs.md +++ b/docs/ai/requirements/2026-08-01-feature-notes-tabs.md @@ -72,7 +72,12 @@ The feature serves authenticated EverFreeNote users who work with several notes The following review decisions resolve the v1 ambiguities without changing the requested behavior: -- No hard tab-count cap is introduced. Desktop uses horizontal overflow and mobile uses a compact list. +- Desktop Add capacity is responsive: tabs keep a 120px minimum, overflow + remains scrollable for existing/restored tabs, and Add is disabled when the + measured viewport cannot accommodate one more minimum-width tab. The shared + workspace model also enforces a 32-tab ceiling in the core/controller, and + mobile exposes that same disabled state while its compact list remains + scrollable. - Web persistence is explicitly `sessionStorage` per external browser tab. Native mobile persistence is an adapter concern and is not allowed to change the web contract. - If flushing the active editor fails during a normal switch, the transition is aborted so the user can retry and the failed state remains visible. Closing a failed tab is allowed only after an explicit discard confirmation. - Caret/text-selection restoration is best effort and may be skipped after remote content reconciliation; draft and scroll restoration remain mandatory. diff --git a/docs/ai/testing/2026-08-01-feature-notes-tabs.md b/docs/ai/testing/2026-08-01-feature-notes-tabs.md index 7dafbfdfbc2..6d16e93b714 100644 --- a/docs/ai/testing/2026-08-01-feature-notes-tabs.md +++ b/docs/ai/testing/2026-08-01-feature-notes-tabs.md @@ -43,6 +43,8 @@ description: Define testing approach, test cases, and quality assurance - [x] Reading scroll restores after activation. - [x] Settings return preserves tabs and active tab; Tags/Search view changes do not clear them. - [x] Mobile Add tab closes the compact menu, keeps the note list available, and lets the next list selection fill the new active slot. +- [x] Desktop Add tab stays before the scrolling tab viewport and disables at the measured minimum-width capacity. +- [x] Core/controller Add guard blocks the shared 32-tab ceiling without flushing, and mobile announces the same disabled state with a scrollable list. ## End-to-End Tests @@ -79,7 +81,7 @@ description: Define testing approach, test cases, and quality assurance - `npx ai-devkit@latest lint --feature notes-tabs`: passed. - Native mobile route/store tab adaptation was not included; responsive web mobile controls are covered by component tests. -- Desktop: keyboard tab navigation, ellipsis, horizontal overflow, dirty/error markers, close confirmation. +- Desktop: keyboard tab navigation, ellipsis, horizontal overflow, dirty/error markers, close confirmation, and accessible capacity state with Add fixed on the left. - Mobile viewport: active-note summary, tab count, compact list/sheet, touch targets, screen-reader labels. - Reload: verify `sessionStorage` restore and separate browser-tab isolation. - Network/offline: switch during autosave and after an induced save error. diff --git a/ui/web/components/features/notes/MobileNotesTabMenu.tsx b/ui/web/components/features/notes/MobileNotesTabMenu.tsx index b5df2e4ab96..a945ef52998 100644 --- a/ui/web/components/features/notes/MobileNotesTabMenu.tsx +++ b/ui/web/components/features/notes/MobileNotesTabMenu.tsx @@ -15,6 +15,9 @@ export function MobileNotesTabMenu({ onAddTab, onActivateTab, onCloseTab, + addTabDisabled = false, + addTabCapacityPending = false, + maximumTabCount, }: MobileNotesTabMenuProps) { const [isOpen, setIsOpen] = useState(false) const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0] @@ -26,10 +29,17 @@ export function MobileNotesTabMenu({ } const handleAdd = () => { + if (addTabDisabled || addTabCapacityPending) return setIsOpen(false) void onAddTab() } + const addTabLabel = addTabCapacityPending + ? "Add tab (checking workspace capacity)" + : addTabDisabled + ? `Add tab (limit reached: ${maximumTabCount ?? "maximum"} tabs)` + : "Add tab" + const handleClose = (tab: NoteWorkspaceTab) => { void onCloseTab(tab.id) if (tab.id === activeTabId && tabs.length <= 1) setIsOpen(false) @@ -61,43 +71,54 @@ export function MobileNotesTabMenu({ {isOpen && (
- {tabs.map((tab) => { - const label = getTabLabel(tab) - const isActive = tab.id === activeTabId +
+ {tabs.map((tab) => { + const label = getTabLabel(tab) + const isActive = tab.id === activeTabId - return ( -
- - -
- ) - })} - + +
+ ) + })} +
+
)} diff --git a/ui/web/components/features/notes/NotesShell.tsx b/ui/web/components/features/notes/NotesShell.tsx index 1d08a901994..c5123ef80eb 100644 --- a/ui/web/components/features/notes/NotesShell.tsx +++ b/ui/web/components/features/notes/NotesShell.tsx @@ -36,7 +36,8 @@ import { NotesTabStrip } from "@/components/features/notes/NotesTabStrip" import { MobileNotesTabMenu } from "@/components/features/notes/MobileNotesTabMenu" import { NavRail } from "@/components/features/navigation/NavRail" -import { TagsPage } from "@/components/features/tags/TagsPage" +import { TagsPage } from "@/components/features/tags/TagsPage" +import { MAX_NOTE_WORKSPACE_TABS } from "@core/services/noteWorkspaceTabs" type NoteRecord = Note & { content?: string | null @@ -97,6 +98,8 @@ export function NotesShell({ controller }: NotesShellProps) { addTab, activateTab, closeTab, + canAddTab = true, + workspaceHydrated = true, handleSelectNote, isSearchPanelOpen, setIsSearchPanelOpen, @@ -235,6 +238,9 @@ export function NotesShell({ controller }: NotesShellProps) { void addTab?.()} onActivateTab={(tabId) => void activateTab?.(tabId)} onCloseTab={(tabId) => void closeTab?.(tabId)} @@ -286,6 +292,9 @@ export function NotesShell({ controller }: NotesShellProps) { void addTab?.()} onActivateTab={(tabId) => void activateTab?.(tabId)} onCloseTab={(tabId) => void closeTab?.(tabId)} diff --git a/ui/web/components/features/notes/NotesTabStrip.tsx b/ui/web/components/features/notes/NotesTabStrip.tsx index d9d58fa5f55..1f649d2b94d 100644 --- a/ui/web/components/features/notes/NotesTabStrip.tsx +++ b/ui/web/components/features/notes/NotesTabStrip.tsx @@ -1,8 +1,8 @@ "use client" -import type { KeyboardEvent } from "react" +import { useEffect, useRef, useState, type KeyboardEvent } from "react" import { AlertCircle, Loader2, Plus, Circle, X } from "lucide-react" -import type { NoteWorkspaceTab } from "@core/services/noteWorkspaceTabs" +import { MAX_NOTE_WORKSPACE_TABS, type NoteWorkspaceTab } from "@core/services/noteWorkspaceTabs" import { Button } from "@/components/ui/button" import { cn } from "@ui/web/lib/utils" @@ -12,10 +12,26 @@ export type NotesTabStripProps = { onAddTab: () => void onActivateTab: (tabId: string) => void | Promise onCloseTab: (tabId: string) => void | Promise + addTabDisabled?: boolean + addTabCapacityPending?: boolean + maximumTabCount?: number } type ReadonlyNotesTabStripProps = Readonly +/** + * Keep the close affordance and a useful part of the title visible at the + * narrowest desktop size. The CSS min-width below and this value are kept in + * sync so add capacity reflects the actual layout constraint. + */ +export const MIN_TAB_WIDTH_PX = 120 +const TAB_GAP_PX = 4 + +export function getTabCapacity(availableWidth: number): number { + if (!Number.isFinite(availableWidth) || availableWidth <= 0) return 1 + return Math.max(1, Math.floor((availableWidth + TAB_GAP_PX) / (MIN_TAB_WIDTH_PX + TAB_GAP_PX))) +} + function getTabLabel(tab: NoteWorkspaceTab): string { const title = tab.note?.title?.trim() || tab.draft.title.trim() if (title) return title @@ -67,6 +83,7 @@ function activateWithKeyboard( tabIndex: number, tabs: NoteWorkspaceTab[], onActivateTab: ReadonlyNotesTabStripProps["onActivateTab"], + onFocusTab: (tabId: string) => void, ) { if (event.key !== "ArrowLeft" && event.key !== "ArrowRight" && event.key !== "Home" && event.key !== "End") { return @@ -83,7 +100,10 @@ function activateWithKeyboard( nextIndex = (tabIndex + direction + tabs.length) % tabs.length } const nextTab = tabs[nextIndex] - if (nextTab) void onActivateTab(nextTab.id) + if (nextTab) { + void onActivateTab(nextTab.id) + onFocusTab(nextTab.id) + } } export function NotesTabStrip({ @@ -92,11 +112,77 @@ export function NotesTabStrip({ onAddTab, onActivateTab, onCloseTab, + addTabDisabled = false, + addTabCapacityPending = false, + maximumTabCount = MAX_NOTE_WORKSPACE_TABS, }: ReadonlyNotesTabStripProps) { + const tabViewportRef = useRef(null) + const tabButtonRefs = useRef(new Map()) + const [tabViewportWidth, setTabViewportWidth] = useState(null) + + useEffect(() => { + const viewport = tabViewportRef.current + if (!viewport) return + + const measure = () => { + const width = viewport.clientWidth || viewport.getBoundingClientRect().width + setTabViewportWidth(width > 0 ? width : null) + } + + measure() + if (typeof ResizeObserver === "undefined") return + + const observer = new ResizeObserver(measure) + observer.observe(viewport) + return () => observer.disconnect() + }, []) + + const measuredCapacity = tabViewportWidth === null ? null : getTabCapacity(tabViewportWidth) + const tabCapacity = measuredCapacity === null + ? null + : Math.min(measuredCapacity, maximumTabCount) + const isCapacityPending = addTabCapacityPending || tabViewportWidth === null + const isAddDisabled = addTabDisabled || isCapacityPending || (tabCapacity !== null && tabs.length >= tabCapacity) + let addTabLabel = "Add note tab" + if (addTabCapacityPending) { + addTabLabel = "Add note tab (checking workspace capacity)" + } else if (addTabDisabled) { + addTabLabel = `Add note tab (limit reached: ${maximumTabCount} tabs)` + } else if (isCapacityPending) { + addTabLabel = "Add note tab (checking capacity)" + } else if (isAddDisabled) { + addTabLabel = `Add note tab (limit reached: ${tabCapacity} tabs)` + } + + const focusTab = (tabId: string) => { + const button = tabButtonRefs.current.get(tabId) + if (!button) return + button.focus() + button.scrollIntoView?.({ block: "nearest", inline: "nearest" }) + } + + useEffect(() => { + const button = tabButtonRefs.current.get(activeTabId) + button?.scrollIntoView?.({ block: "nearest", inline: "nearest" }) + }, [activeTabId]) + return (
-
-
+ +
+
{tabs.map((tab, index) => { const label = getTabLabel(tab) const isActive = tab.id === activeTabId @@ -105,7 +191,7 @@ export function NotesTabStrip({
{ + if (button) tabButtonRefs.current.set(tab.id, button) + else tabButtonRefs.current.delete(tab.id) + }} variant="ghost" className={cn( "h-8 min-w-0 flex-1 justify-start rounded-md px-2 text-xs", @@ -123,7 +213,7 @@ export function NotesTabStrip({ title={label} onMouseDown={(event) => event.preventDefault()} onClick={() => onActivateTab(tab.id)} - onKeyDown={(event) => activateWithKeyboard(event, index, tabs, onActivateTab)} + onKeyDown={(event) => activateWithKeyboard(event, index, tabs, onActivateTab, focusTab)} > {label} @@ -145,9 +235,6 @@ export function NotesTabStrip({ })}
-
) } diff --git a/ui/web/hooks/useNoteAppController.ts b/ui/web/hooks/useNoteAppController.ts index 529c6e2804b..d3371e45d07 100644 --- a/ui/web/hooks/useNoteAppController.ts +++ b/ui/web/hooks/useNoteAppController.ts @@ -96,6 +96,7 @@ export function useNoteAppController() { updateTab, closeTab, findTabByNoteId, + canAddTab, } = workspace const selectedNote = activeTab.note const isEditing = activeTab.mode === 'editing' @@ -570,13 +571,14 @@ export function useNoteAppController() { ]) const handleAddTab = useCallback(async () => { + if (!workspaceHydrated || !canAddTab) return await flushAndCaptureActiveTab() addTab() // On mobile, an empty tab must return to the note list so the user can // choose which note fills the new active slot. Desktop keeps its editor // pane visible through the responsive `md:flex` layout rule. setNotePaneVisible(false) - }, [addTab, flushAndCaptureActiveTab]) + }, [addTab, canAddTab, flushAndCaptureActiveTab, workspaceHydrated]) const handleActivateTab = useCallback(async (tabId: string) => { const targetTab = tabs.find((tab) => tab.id === tabId) @@ -948,6 +950,8 @@ export function useNoteAppController() { addTab: handleAddTab, activateTab: handleActivateTab, closeTab: handleCloseTab, + canAddTab, + workspaceHydrated, enterSelectionMode, exitSelectionMode, toggleNoteSelection, diff --git a/ui/web/hooks/useNoteWorkspaceTabs.ts b/ui/web/hooks/useNoteWorkspaceTabs.ts index f5b56a2584d..54049bf13fb 100644 --- a/ui/web/hooks/useNoteWorkspaceTabs.ts +++ b/ui/web/hooks/useNoteWorkspaceTabs.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { activateWorkspaceTab, addWorkspaceTab, + canAddWorkspaceTab, closeWorkspaceTab, createNoteWorkspaceState, findWorkspaceTabByNoteId, @@ -58,6 +59,7 @@ export function useNoteWorkspaceTabs() { const findTabByNoteId = useCallback((noteId: string | null | undefined) => ( findWorkspaceTabByNoteId(state, noteId) ), [state]) + const canAddTab = canAddWorkspaceTab(state) return { ...state, @@ -69,5 +71,6 @@ export function useNoteWorkspaceTabs() { updateTab, closeTab, findTabByNoteId, + canAddTab, } } diff --git a/ui/web/tests/unit/components/notesShell.test.tsx b/ui/web/tests/unit/components/notesShell.test.tsx index 658b5bd7198..eebe6191a31 100644 --- a/ui/web/tests/unit/components/notesShell.test.tsx +++ b/ui/web/tests/unit/components/notesShell.test.tsx @@ -12,6 +12,7 @@ const mockWordPressGetStatus = jest.fn() const mockApiKeysGetStatus = jest.fn() const mockSearchFocusInput = jest.fn() const mockMaybeSingle = jest.fn() +const originalClientWidthDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth') const note = { id: 'note-1', @@ -320,6 +321,18 @@ describe('NotesShell', () => { jest.mocked(useQuery).mockReturnValue({ data: { gemini: { configured: true } } } as never) mockConsumeReturnPath.mockReturnValue(null) mockMaybeSingle.mockResolvedValue({ data: fetchedNote }) + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { + configurable: true, + get: () => 800, + }) + }) + + afterEach(() => { + if (originalClientWidthDescriptor) { + Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidthDescriptor) + } else { + delete (HTMLElement.prototype as unknown as { clientWidth?: number }).clientWidth + } }) it('renders the empty editor, registers the editor ref, and wires list actions', async () => { diff --git a/ui/web/tests/unit/components/notesWorkspaceTabs.test.tsx b/ui/web/tests/unit/components/notesWorkspaceTabs.test.tsx index 52c1aecc08c..5f3a7923852 100644 --- a/ui/web/tests/unit/components/notesWorkspaceTabs.test.tsx +++ b/ui/web/tests/unit/components/notesWorkspaceTabs.test.tsx @@ -1,6 +1,6 @@ import React from 'react' import { fireEvent, render, screen } from '@testing-library/react' -import { NotesTabStrip } from '@/components/features/notes/NotesTabStrip' +import { getTabCapacity, MIN_TAB_WIDTH_PX, NotesTabStrip } from '@/components/features/notes/NotesTabStrip' import { MobileNotesTabMenu } from '@/components/features/notes/MobileNotesTabMenu' import type { NoteWorkspaceTab } from '@core/services/noteWorkspaceTabs' @@ -29,7 +29,34 @@ const tabs = [ makeTab('tab-2', 'Second note', { saveState: 'dirty' }), ] +function mockClientWidth(width: number) { + const descriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth') + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { + configurable: true, + get: () => width, + }) + + return () => { + if (descriptor) { + Object.defineProperty(HTMLElement.prototype, 'clientWidth', descriptor) + } else { + delete (HTMLElement.prototype as unknown as { clientWidth?: number }).clientWidth + } + } +} + describe('Notes workspace tab controls', () => { + let restoreDefaultClientWidth: (() => void) | null = null + + beforeEach(() => { + restoreDefaultClientWidth = mockClientWidth(800) + }) + + afterEach(() => { + restoreDefaultClientWidth?.() + restoreDefaultClientWidth = null + }) + it('renders desktop tabs, save indicators, add, activation, and close actions', () => { const onAddTab = jest.fn() const onActivateTab = jest.fn() @@ -58,6 +85,40 @@ describe('Notes workspace tab controls', () => { expect(onAddTab).toHaveBeenCalledTimes(1) }) + it('keeps Add tab first and disables it when the measured minimum-width capacity is reached', () => { + const restoreClientWidth = mockClientWidth(MIN_TAB_WIDTH_PX * 2 + 4) + + try { + const manyTabs = [ + makeTab('tab-1', 'First note'), + makeTab('tab-2', 'Second note'), + makeTab('tab-3', 'Third note'), + ] + render( + , + ) + + const addButton = screen.getByRole('button', { name: 'Add note tab (limit reached: 2 tabs)' }) + expect(addButton.hasAttribute('disabled')).toBe(true) + expect(addButton.parentElement?.firstElementChild).toBe(addButton) + expect(screen.getByLabelText('Open notes').className).toContain('overflow-x-auto') + } finally { + restoreClientWidth() + } + }) + + it('calculates capacity from the tab minimum instead of allowing zero-width tabs', () => { + expect(getTabCapacity(MIN_TAB_WIDTH_PX - 1)).toBe(1) + expect(getTabCapacity(MIN_TAB_WIDTH_PX * 2 + 4)).toBe(2) + expect(getTabCapacity(0)).toBe(1) + }) + it('supports keyboard tab navigation', () => { const onActivateTab = jest.fn() @@ -140,6 +201,47 @@ describe('Notes workspace tab controls', () => { expect(screen.getByRole('button', { name: 'Open note tabs (2)' }).getAttribute('aria-expanded')).toBe('false') }) + it('keeps the mobile tab list scrollable and exposes a disabled Add state at the shared limit', () => { + const manyTabs = Array.from({ length: 40 }, (_, index) => makeTab(`tab-${index}`, `Note ${index}`)) + const onAddTab = jest.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Open note tabs (40)' })) + const addButton = screen.getByRole('button', { name: 'Add tab (limit reached: 32 tabs)' }) + expect(addButton.hasAttribute('disabled')).toBe(true) + expect(onAddTab).not.toHaveBeenCalled() + expect(screen.getByLabelText('Open notes').firstElementChild?.className).toContain('overflow-y-auto') + }) + + it('announces capacity checking instead of claiming the limit during hydration', () => { + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Open note tabs (1)' })) + const addButton = screen.getByRole('button', { name: 'Add tab (checking workspace capacity)' }) + expect(addButton.hasAttribute('disabled')).toBe(true) + }) + it('closes the mobile menu after closing its only active tab', () => { const onCloseTab = jest.fn() render( diff --git a/ui/web/tests/unit/hooks/useNoteAppControllerAdditional.test.tsx b/ui/web/tests/unit/hooks/useNoteAppControllerAdditional.test.tsx index b6a0634409e..4f97338a003 100644 --- a/ui/web/tests/unit/hooks/useNoteAppControllerAdditional.test.tsx +++ b/ui/web/tests/unit/hooks/useNoteAppControllerAdditional.test.tsx @@ -3,6 +3,13 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { useNoteAppController } from '@ui/web/hooks/useNoteAppController' import type { NoteViewModel, SearchResult } from '@core/types/domain' +import { + addWorkspaceTab, + createNoteWorkspaceState, + MAX_NOTE_WORKSPACE_TABS, + serializeNoteWorkspaceState, +} from '@core/services/noteWorkspaceTabs' +import { NOTE_WORKSPACE_STORAGE_KEY } from '@ui/web/lib/noteWorkspaceStorage' import { toast } from 'sonner' let mockSelectedNote: NoteViewModel | null = null @@ -312,6 +319,28 @@ describe('useNoteAppController additional observable behavior', () => { expect(result.current.notePaneVisible).toBe(false) }) + it('blocks controller Add tab before flushing when the shared workspace limit is reached', async () => { + let nextId = 0 + let state = createNoteWorkspaceState(() => `tab-${nextId++}`) + while (state.tabs.length < MAX_NOTE_WORKSPACE_TABS) { + state = addWorkspaceTab(state, () => `tab-${nextId++}`) + } + window.sessionStorage.setItem(NOTE_WORKSPACE_STORAGE_KEY, serializeNoteWorkspaceState(state)) + + const { result } = setup() + await waitFor(() => expect(result.current.tabs).toHaveLength(MAX_NOTE_WORKSPACE_TABS)) + expect(result.current.canAddTab).toBe(false) + + const flushPendingSave = jest.fn().mockResolvedValue(undefined) + act(() => result.current.registerNoteEditorRef({ current: { flushPendingSave } } as never)) + await act(async () => { + await result.current.addTab() + }) + + expect(flushPendingSave).not.toHaveBeenCalled() + expect(result.current.tabs).toHaveLength(MAX_NOTE_WORKSPACE_TABS) + }) + it('selects the remote note after flushing, but exits editing when selecting the already selected note', async () => { const current = makeNote({ id: 'current' }) const remote = makeNote({ id: 'remote', title: 'Remote' }) From aaf63cacadf0a09bdaa262161e32250267ca6de9 Mon Sep 17 00:00:00 2001 From: Denys Date: Sun, 2 Aug 2026 14:29:47 +0200 Subject: [PATCH 10/23] fix(notes-tabs): clean up Qodana findings --- .../features/mobile/MobileLayout.cy.tsx | 2 +- .../notes/NotesShellFtsExitSave.cy.tsx | 19 ++----------- .../notes/NotesShellLikeExitSave.cy.tsx | 16 ++--------- .../features/notes/NotesTabStrip.cy.tsx | 2 +- .../features/notes/notesShellTestUtils.tsx | 28 +++++++++++++++++++ 5 files changed, 35 insertions(+), 32 deletions(-) diff --git a/cypress/component/features/mobile/MobileLayout.cy.tsx b/cypress/component/features/mobile/MobileLayout.cy.tsx index adddc445dc9..1f48faf1cdb 100644 --- a/cypress/component/features/mobile/MobileLayout.cy.tsx +++ b/cypress/component/features/mobile/MobileLayout.cy.tsx @@ -1,6 +1,6 @@ import React from 'react' import { NotesShell } from '../../../../ui/web/components/features/notes/NotesShell' -import { MobileNotesTabMenu } from '../../../../ui/web/components/features/notes/MobileNotesTabMenu' +import { MobileNotesTabMenu } from '@ui/web/components/features/notes/MobileNotesTabMenu' import type { NoteAppController } from '../../../../ui/web/hooks/useNoteAppController' import { SupabaseTestProvider } from '../../../../ui/web/providers/SupabaseProvider' import type { SupabaseClient } from '@supabase/supabase-js' diff --git a/cypress/component/features/notes/NotesShellFtsExitSave.cy.tsx b/cypress/component/features/notes/NotesShellFtsExitSave.cy.tsx index cd255f459a6..6726f6d2e41 100644 --- a/cypress/component/features/notes/NotesShellFtsExitSave.cy.tsx +++ b/cypress/component/features/notes/NotesShellFtsExitSave.cy.tsx @@ -6,6 +6,7 @@ import type { NoteViewModel, SearchResult } from '../../../../core/types/domain' import { SupabaseTestProvider } from '../../../../ui/web/providers/SupabaseProvider' import { pastePlainText, + useNotesShellAutoSave, useNotesShellTestState, type FakeController, } from './notesShellTestUtils' @@ -63,6 +64,8 @@ const buildController = () => { activeTab, } = useNotesShellTestState(baseNotes) + const handleAutoSave = useNotesShellAutoSave(selectedNoteId, setNotes) + const ftsResults: SearchResult[] = React.useMemo(() => { // Simulate search results payload that may be stale. We re-resolve on click. return notes.map((n) => ({ @@ -79,22 +82,6 @@ const buildController = () => { })) }, [notes]) - const handleAutoSave = React.useCallback(async (data: { noteId?: string; title?: string; description?: string; tags?: string }) => { - const noteId = data.noteId ?? selectedNoteId - if (!noteId) return - - setNotes((prev) => prev.map((n) => { - if (n.id !== noteId) return n - return { - ...n, - title: data.title ?? n.title, - description: data.description ?? n.description, - // tags not relevant for this test - updated_at: new Date().toISOString(), - } - })) - }, [selectedNoteId, setNotes]) - const handleSaveNote = React.useCallback((data: { title: string; description: string; tags: string }) => { const noteId = selectedNoteId setNotes((prev) => prev.map((n) => (n.id === noteId ? { ...n, title: data.title, description: data.description, updated_at: new Date().toISOString() } : n))) diff --git a/cypress/component/features/notes/NotesShellLikeExitSave.cy.tsx b/cypress/component/features/notes/NotesShellLikeExitSave.cy.tsx index a94b93edf57..80277a233f2 100644 --- a/cypress/component/features/notes/NotesShellLikeExitSave.cy.tsx +++ b/cypress/component/features/notes/NotesShellLikeExitSave.cy.tsx @@ -6,6 +6,7 @@ import type { NoteViewModel } from '../../../../core/types/domain' import { SupabaseTestProvider } from '../../../../ui/web/providers/SupabaseProvider' import { pastePlainText, + useNotesShellAutoSave, useNotesShellTestState, type FakeController, } from './notesShellTestUtils' @@ -63,20 +64,7 @@ const buildController = () => { activeTab, } = useNotesShellTestState(baseNotes) - const handleAutoSave = React.useCallback(async (data: { noteId?: string; title?: string; description?: string; tags?: string }) => { - const noteId = data.noteId ?? selectedNoteId - if (!noteId) return - - setNotes((prev) => prev.map((n) => { - if (n.id !== noteId) return n - return { - ...n, - title: data.title ?? n.title, - description: data.description ?? n.description, - updated_at: new Date().toISOString(), - } - })) - }, [selectedNoteId, setNotes]) + const handleAutoSave = useNotesShellAutoSave(selectedNoteId, setNotes) const handleSaveNote = React.useCallback((data: { title: string; description: string; tags: string }) => { const noteId = selectedNoteId diff --git a/cypress/component/features/notes/NotesTabStrip.cy.tsx b/cypress/component/features/notes/NotesTabStrip.cy.tsx index 59b21f33eed..847f08c637f 100644 --- a/cypress/component/features/notes/NotesTabStrip.cy.tsx +++ b/cypress/component/features/notes/NotesTabStrip.cy.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { NotesTabStrip } from '../../../../ui/web/components/features/notes/NotesTabStrip' +import { NotesTabStrip } from '@ui/web/components/features/notes/NotesTabStrip' import type { NoteWorkspaceTab } from '@core/services/noteWorkspaceTabs' describe('Desktop notes tab strip', () => { diff --git a/cypress/component/features/notes/notesShellTestUtils.tsx b/cypress/component/features/notes/notesShellTestUtils.tsx index 37a004309cd..b8486627ceb 100644 --- a/cypress/component/features/notes/notesShellTestUtils.tsx +++ b/cypress/component/features/notes/notesShellTestUtils.tsx @@ -5,6 +5,13 @@ import type { NoteEditorHandle } from '@ui/web/components/features/notes/NoteEdi export type FakeController = Record +type NotesShellAutoSaveData = { + noteId?: string + title?: string + description?: string + tags?: string +} + export const pastePlainText = (text: string) => { cy.get('[data-cy="editor-content"]').click() cy.get('.ProseMirror').trigger('paste', { @@ -56,6 +63,27 @@ export function useNotesShellTestState(baseNotes: NoteViewModel[]) { } } +export function useNotesShellAutoSave( + selectedNoteId: string, + setNotes: React.Dispatch> +) { + return React.useCallback(async (data: NotesShellAutoSaveData) => { + const noteId = data.noteId ?? selectedNoteId + if (!noteId) return + + setNotes((prev) => prev.map((n) => { + if (n.id !== noteId) return n + return { + ...n, + title: data.title ?? n.title, + description: data.description ?? n.description, + // Tags are not relevant to these exit-save scenarios. + updated_at: new Date().toISOString(), + } + })) + }, [selectedNoteId, setNotes]) +} + export function useEditorExitState(isEditing: boolean) { const registeredEditorRef = React.useRef | null>(null) From 9960a6bf7d783ba2bb51298e2d53b18bfe1d83d6 Mon Sep 17 00:00:00 2001 From: Denys Date: Mon, 3 Aug 2026 18:30:29 +0200 Subject: [PATCH 11/23] fix(notes-tabs): share Cypress note save flow --- .../notes/NotesShellFtsExitSave.cy.tsx | 25 +++------------ .../notes/NotesShellLikeExitSave.cy.tsx | 26 +++------------- .../features/notes/notesShellTestUtils.tsx | 31 +++++++++++++++++++ 3 files changed, 39 insertions(+), 43 deletions(-) diff --git a/cypress/component/features/notes/NotesShellFtsExitSave.cy.tsx b/cypress/component/features/notes/NotesShellFtsExitSave.cy.tsx index 6726f6d2e41..7a774ab241a 100644 --- a/cypress/component/features/notes/NotesShellFtsExitSave.cy.tsx +++ b/cypress/component/features/notes/NotesShellFtsExitSave.cy.tsx @@ -62,6 +62,10 @@ const buildController = () => { flushIfEditing, selectedNote, activeTab, + handleSaveNote, + handleReadNote, + handleEditNote, + handleSelectNote, } = useNotesShellTestState(baseNotes) const handleAutoSave = useNotesShellAutoSave(selectedNoteId, setNotes) @@ -82,27 +86,6 @@ const buildController = () => { })) }, [notes]) - const handleSaveNote = React.useCallback((data: { title: string; description: string; tags: string }) => { - const noteId = selectedNoteId - setNotes((prev) => prev.map((n) => (n.id === noteId ? { ...n, title: data.title, description: data.description, updated_at: new Date().toISOString() } : n))) - }, [selectedNoteId, setNotes]) - - const handleReadNote = React.useCallback((data: { title: string; description: string; tags: string }) => { - handleSaveNote(data) - setIsEditing(false) - }, [handleSaveNote, setIsEditing]) - - const handleEditNote = React.useCallback((note: NoteViewModel) => { - setSelectedNoteId(note.id) - setIsEditing(true) - }, [setIsEditing, setSelectedNoteId]) - - const handleSelectNote = React.useCallback(async (note: NoteViewModel | null) => { - await flushIfEditing() - setSelectedNoteId(note?.id ?? '') - setIsEditing(false) - }, [flushIfEditing, setIsEditing, setSelectedNoteId]) - const handleSearchResultClick = React.useCallback(async (note: SearchResult) => { await flushIfEditing() // Resolve to latest note state on click diff --git a/cypress/component/features/notes/NotesShellLikeExitSave.cy.tsx b/cypress/component/features/notes/NotesShellLikeExitSave.cy.tsx index 80277a233f2..f7c55c065ac 100644 --- a/cypress/component/features/notes/NotesShellLikeExitSave.cy.tsx +++ b/cypress/component/features/notes/NotesShellLikeExitSave.cy.tsx @@ -55,38 +55,20 @@ const buildController = () => { notes, setNotes, selectedNoteId, - setSelectedNoteId, isEditing, setIsEditing, registerNoteEditorRef, flushIfEditing, selectedNote, activeTab, + handleSaveNote, + handleReadNote, + handleEditNote, + handleSelectNote, } = useNotesShellTestState(baseNotes) const handleAutoSave = useNotesShellAutoSave(selectedNoteId, setNotes) - const handleSaveNote = React.useCallback((data: { title: string; description: string; tags: string }) => { - const noteId = selectedNoteId - setNotes((prev) => prev.map((n) => (n.id === noteId ? { ...n, title: data.title, description: data.description, updated_at: new Date().toISOString() } : n))) - }, [selectedNoteId, setNotes]) - - const handleReadNote = React.useCallback((data: { title: string; description: string; tags: string }) => { - handleSaveNote(data) - setIsEditing(false) - }, [handleSaveNote, setIsEditing]) - - const handleEditNote = React.useCallback((note: NoteViewModel) => { - setSelectedNoteId(note.id) - setIsEditing(true) - }, [setIsEditing, setSelectedNoteId]) - - const handleSelectNote = React.useCallback(async (note: NoteViewModel | null) => { - await flushIfEditing() - setSelectedNoteId(note?.id ?? '') - setIsEditing(false) - }, [flushIfEditing, setIsEditing, setSelectedNoteId]) - const controller: FakeController = { registerNoteEditorRef, user, diff --git a/cypress/component/features/notes/notesShellTestUtils.tsx b/cypress/component/features/notes/notesShellTestUtils.tsx index b8486627ceb..3dfbe58d7e4 100644 --- a/cypress/component/features/notes/notesShellTestUtils.tsx +++ b/cypress/component/features/notes/notesShellTestUtils.tsx @@ -12,6 +12,12 @@ type NotesShellAutoSaveData = { tags?: string } +type NotesShellSaveData = { + title: string + description: string + tags: string +} + export const pastePlainText = (text: string) => { cy.get('[data-cy="editor-content"]').click() cy.get('.ProseMirror').trigger('paste', { @@ -49,6 +55,27 @@ export function useNotesShellTestState(baseNotes: NoteViewModel[]) { ) const activeTab = React.useMemo(() => makeWorkspaceTab(selectedNote, isEditing), [selectedNote, isEditing]) + const handleSaveNote = React.useCallback((data: NotesShellSaveData) => { + const noteId = selectedNoteId + setNotes((prev) => prev.map((n) => (n.id === noteId ? { ...n, title: data.title, description: data.description, updated_at: new Date().toISOString() } : n))) + }, [selectedNoteId, setNotes]) + + const handleReadNote = React.useCallback((data: NotesShellSaveData) => { + handleSaveNote(data) + setIsEditing(false) + }, [handleSaveNote, setIsEditing]) + + const handleEditNote = React.useCallback((note: NoteViewModel) => { + setSelectedNoteId(note.id) + setIsEditing(true) + }, [setIsEditing, setSelectedNoteId]) + + const handleSelectNote = React.useCallback(async (note: NoteViewModel | null) => { + await flushIfEditing() + setSelectedNoteId(note?.id ?? '') + setIsEditing(false) + }, [flushIfEditing, setIsEditing, setSelectedNoteId]) + return { notes, setNotes, @@ -60,6 +87,10 @@ export function useNotesShellTestState(baseNotes: NoteViewModel[]) { flushIfEditing, selectedNote, activeTab, + handleSaveNote, + handleReadNote, + handleEditNote, + handleSelectNote, } } From 2e76626a2483cffca63bff491062e84c1cf310a9 Mon Sep 17 00:00:00 2001 From: denyskoreiba Date: Thu, 20 Aug 2026 20:18:57 +0200 Subject: [PATCH 12/23] fix(notes-tabs): resolve final review findings - debounce draft/scroll session sync (250ms) so typing and scrolling no longer write workspace state and sessionStorage per event; capture-safe flush/cancel semantics keep tab transitions loss-free - make flushAndCaptureActiveTab return a success flag: transitions abort explicitly on autosave-flush failure instead of leaking unhandled promise rejections through every navigation wrapper - reset workspace tabs whose note was deleted (single and bulk) via new resetWorkspaceTabsForNotes reducer; bulk delete reports actually deleted IDs and no longer blanks a surviving active tab - keep dirty/error save markers when entering edit mode - resolve Codacy findings (optional chains, void operator) and ignore in-repo agent worktrees (.claude/worktrees) in Jest and ESLint - update feature docs and extend unit coverage for the new behavior Co-Authored-By: Claude Fable 5 --- core/services/noteWorkspaceTabs.ts | 25 + .../core-services-noteWorkspaceTabs.test.ts | 35 + .../web/hooks/useNoteBulkActionsDirect.cy.tsx | 6 +- .../design/2026-08-01-feature-notes-tabs.md | 25 + .../2026-08-01-feature-notes-tabs.md | 5 +- .../planning/2026-08-01-feature-notes-tabs.md | 13 +- .../testing/2026-08-01-feature-notes-tabs.md | 36 +- eslint.config.mjs | 1 + jest.config.cjs | 3 + .../features/notes/MobileNotesTabMenu.tsx | 2 +- .../components/features/notes/NoteEditor.tsx | 81 +- ui/web/components/features/notes/NoteView.tsx | 13 +- .../features/notes/NotesTabStrip.tsx | 11 +- ui/web/hooks/useDebouncedSessionCallback.ts | 69 ++ ui/web/hooks/useNoteAppController.ts | 840 +++++++++--------- ui/web/hooks/useNoteBulkActions.ts | 11 +- ui/web/hooks/useNoteSaveHandlers.ts | 88 +- ui/web/hooks/useNoteWorkspaceTabs.ts | 6 + .../tests/unit/components/noteView.test.tsx | 48 +- .../useDebouncedSessionCallback.test.tsx | 93 ++ .../useNoteAppControllerAdditional.test.tsx | 79 +- .../unit/hooks/useNoteBulkActions.test.tsx | 7 +- 22 files changed, 975 insertions(+), 522 deletions(-) create mode 100644 ui/web/hooks/useDebouncedSessionCallback.ts create mode 100644 ui/web/tests/unit/hooks/useDebouncedSessionCallback.test.tsx diff --git a/core/services/noteWorkspaceTabs.ts b/core/services/noteWorkspaceTabs.ts index d23cd236155..8f3b3e0a33b 100644 --- a/core/services/noteWorkspaceTabs.ts +++ b/core/services/noteWorkspaceTabs.ts @@ -214,6 +214,31 @@ export function updateWorkspaceTab( return { ...state, tabs } } +/** + * Turns every tab that shows one of the given notes back into a blank landing + * slot. Used after a note is deleted so no tab keeps rendering (or can + * resurrect through autosave) a note that no longer exists. Tab identity, + * order, and the active tab are preserved. + */ +export function resetWorkspaceTabsForNotes( + state: NoteWorkspaceState, + noteIds: readonly string[], +): NoteWorkspaceState { + const deletedNoteIds = new Set(noteIds.filter((noteId) => noteId.length > 0)) + if (deletedNoteIds.size === 0) return state + + let changed = false + const tabs = state.tabs.map((tab) => { + if (!tab.noteId || !deletedNoteIds.has(tab.noteId)) return tab + changed = true + return { + ...createEmptyTab(() => tab.id), + } + }) + + return changed ? { ...state, tabs } : state +} + export function closeWorkspaceTab( state: NoteWorkspaceState, tabId: string, diff --git a/core/tests/unit/core-services-noteWorkspaceTabs.test.ts b/core/tests/unit/core-services-noteWorkspaceTabs.test.ts index 5e13cf55e63..c86cc2fac03 100644 --- a/core/tests/unit/core-services-noteWorkspaceTabs.test.ts +++ b/core/tests/unit/core-services-noteWorkspaceTabs.test.ts @@ -11,6 +11,7 @@ import { MAX_NOTE_WORKSPACE_SERIALIZED_LENGTH, MAX_NOTE_WORKSPACE_TABS, openNoteInWorkspace, + resetWorkspaceTabsForNotes, serializeNoteWorkspaceState, updateWorkspaceTab, } from '@core/services/noteWorkspaceTabs' @@ -118,6 +119,40 @@ describe('note workspace tab state', () => { expect(afterRightmostClose.activeTabId).toBe('tab-1') }) + it('resets tabs showing deleted notes back to blank slots without changing order or activation', () => { + let state = openNoteInWorkspace(createNoteWorkspaceState(ids('tab-1')), note('note-1')) + state = addWorkspaceTab(state, ids('tab-2')) + state = openNoteInWorkspace(state, note('note-2')) + state = addWorkspaceTab(state, ids('tab-3')) + state = openNoteInWorkspace(state, note('note-3')) + state = updateWorkspaceTab(state, 'tab-2', { draft: { title: 'Unsaved edits' }, saveState: 'dirty' }) + + const reset = resetWorkspaceTabsForNotes(state, ['note-2', 'missing-note']) + + expect(reset.tabs.map((tab) => tab.id)).toEqual(['tab-1', 'tab-2', 'tab-3']) + expect(reset.activeTabId).toBe('tab-3') + expect(findWorkspaceTabByNoteId(reset, 'note-2')).toBeNull() + expect(reset.tabs[1]).toMatchObject({ + id: 'tab-2', + noteId: null, + note: null, + mode: 'reading', + draft: { title: '', description: '', tags: '' }, + saveState: 'saved', + saveError: null, + }) + expect(reset.tabs[0]).toBe(state.tabs[0]) + expect(reset.tabs[2]).toBe(state.tabs[2]) + }) + + it('returns the same state when no tab shows a deleted note', () => { + const state = openNoteInWorkspace(createNoteWorkspaceState(ids('tab-1')), note('note-1')) + + expect(resetWorkspaceTabsForNotes(state, ['other-note'])).toBe(state) + expect(resetWorkspaceTabsForNotes(state, [])).toBe(state) + expect(resetWorkspaceTabsForNotes(state, [''])).toBe(state) + }) + it('keeps one blank active tab when the last tab closes', () => { const state = createNoteWorkspaceState(ids('tab-1')) const closed = closeWorkspaceTab(state, 'tab-1', ids('tab-replacement')) diff --git a/cypress/component/ui/web/hooks/useNoteBulkActionsDirect.cy.tsx b/cypress/component/ui/web/hooks/useNoteBulkActionsDirect.cy.tsx index a098d742f48..10cdd7cf0bf 100644 --- a/cypress/component/ui/web/hooks/useNoteBulkActionsDirect.cy.tsx +++ b/cypress/component/ui/web/hooks/useNoteBulkActionsDirect.cy.tsx @@ -69,7 +69,7 @@ const Harness = ({ selectedIds, isOffline = false, deleteShouldRejectIds = [] }: const setPendingCount = React.useMemo(() => cy.stub().as('setPendingCount'), []) const exitSelectionMode = React.useMemo(() => cy.stub().as('exitSelectionMode'), []) const setBulkDeleting = React.useMemo(() => cy.stub().as('setBulkDeleting'), []) - const setSelectedNote = React.useMemo(() => cy.stub().as('setSelectedNote'), []) + const onNotesDeleted = React.useMemo(() => cy.stub().as('onNotesDeleted'), []) const selectAllVisibleCallback = React.useMemo(() => cy.stub().as('selectAllVisibleCallback'), []) const actions = useNoteBulkActions({ @@ -82,7 +82,7 @@ const Harness = ({ selectedIds, isOffline = false, deleteShouldRejectIds = [] }: deleteNoteMutation: deleteNoteMutation as never, exitSelectionMode: exitSelectionMode as never, setBulkDeleting: setBulkDeleting as never, - setSelectedNote: setSelectedNote as never, + onNotesDeleted: onNotesDeleted as never, queryClient, notes: sampleNotes, selectAllVisibleCallback, @@ -145,7 +145,7 @@ describe('useNoteBulkActions direct', () => { cy.get('@mutateAsync').should('have.callCount', 2) cy.get('@invalidateQueries').should('have.been.calledWithMatch', { queryKey: ['notes'] }) cy.get('@invalidateQueries').should('have.been.calledWithMatch', { queryKey: ['aiSearch'] }) - cy.get('@setSelectedNote').should('have.been.calledWith', null) + cy.get('@onNotesDeleted').should('have.been.calledWith', ['note-1']) }) it('queues offline delete path and marks pending count', () => { diff --git a/docs/ai/design/2026-08-01-feature-notes-tabs.md b/docs/ai/design/2026-08-01-feature-notes-tabs.md index dae082b4321..5caaf18c83e 100644 --- a/docs/ai/design/2026-08-01-feature-notes-tabs.md +++ b/docs/ai/design/2026-08-01-feature-notes-tabs.md @@ -88,6 +88,7 @@ activateWorkspaceTab(state, tabId): NoteWorkspaceState openNoteInWorkspace(state, note, tabId?): NoteWorkspaceState closeWorkspaceTab(state, tabId): NoteWorkspaceState updateWorkspaceTab(state, tabId, patch): NoteWorkspaceState +resetWorkspaceTabsForNotes(state, noteIds): NoteWorkspaceState findWorkspaceTabByNoteId(state, noteId): NoteWorkspaceTab | null ``` @@ -157,6 +158,30 @@ The existing editor remains mounted only for the active tab, so its DOM/TipTap i The tab stores `saveState: 'error'` and `saveError`. Closing a failed tab requires an explicit confirmation from the user; an error is never silently discarded. Dirty/saving states are non-blocking indicators. +`flushAndCaptureActiveTab` never rejects: it returns a success flag, and every +tab/note transition aborts explicitly when the flush fails, so the failed tab +keeps its draft and error marker and no caller is left with an unhandled +promise rejection. Entering edit mode changes only the tab mode and never +clears an unresolved dirty/error marker. + +### Deleted notes reset their tabs + +A successful delete (single or bulk, online or queued offline) resets every tab +whose `noteId` was deleted back to a blank landing slot via +`resetWorkspaceTabsForNotes`. Tab identity, order, and activation are +preserved; only affected tabs change. This prevents a stale tab from rendering +a deleted note or re-creating it through autosave, and bulk delete no longer +clears the active tab when its note survived. + +### Session updates are debounced + +Typing and scrolling notify workspace state through +`useDebouncedSessionCallback` (250 ms, below the 500 ms autosave debounce). +Draft updates are cancelled on unmount and on manual Save/Read because every +transition captures the live editor synchronously first — a late flush would +re-mark a saved tab as dirty. Reading scroll has no synchronous capture path, +so its latest value is flushed on unmount instead. + ### Split View remains possible The workspace is modeled as a list of independent tab sessions, while the controller exposes one active session today. A future split view can mount two active-session presenters without changing tab identity or persistence semantics. diff --git a/docs/ai/implementation/2026-08-01-feature-notes-tabs.md b/docs/ai/implementation/2026-08-01-feature-notes-tabs.md index e959d4b94b3..1548bcdb73f 100644 --- a/docs/ai/implementation/2026-08-01-feature-notes-tabs.md +++ b/docs/ai/implementation/2026-08-01-feature-notes-tabs.md @@ -20,6 +20,7 @@ description: Technical implementation notes, patterns, and code guidelines core/services/noteWorkspaceTabs.ts ui/web/lib/noteWorkspaceStorage.ts ui/web/hooks/useNoteWorkspaceTabs.ts +ui/web/hooks/useDebouncedSessionCallback.ts ui/web/components/features/notes/NotesTabStrip.tsx ui/web/components/features/notes/MobileNotesTabMenu.tsx ui/web/hooks/useNoteAppController.ts @@ -38,7 +39,9 @@ ui/web/components/features/notes/NoteView.tsx - Use a deterministic initial tab ID for the server/client first render, then replace it with the hydrated session workspace. - Bound serialized workspace state and fall back to a blank tab when a persisted snapshot is malformed or exceeds the storage limit. - Hydrate once, then persist state changes through a guarded storage adapter. -- Capture the current editor before unmount, flush autosave first, and then apply tab transitions. +- Capture the current editor before unmount, flush autosave first, and then apply tab transitions. `flushAndCaptureActiveTab` returns a success flag instead of rejecting; transitions abort when it reports failure. +- Debounce per-keystroke draft and per-frame scroll notifications with `useDebouncedSessionCallback` (250 ms) so workspace state and `sessionStorage` are not written on every event. Draft updates cancel on unmount/manual save (the capture path reads the live editor); reading scroll flushes on unmount. +- After a successful delete, call `resetTabsForNotes` with the deleted IDs so no tab keeps a deleted note; bulk delete reports the actually-deleted IDs through `onNotesDeleted`. - Use the active tab's draft as editor initial content; never use a server refresh to overwrite a dirty local field without existing reconciliation rules. - Keep tab indicators derived from explicit per-tab save state rather than global UI assumptions. - Keep the desktop Add control outside the scrolling tab viewport. `NotesTabStrip` diff --git a/docs/ai/planning/2026-08-01-feature-notes-tabs.md b/docs/ai/planning/2026-08-01-feature-notes-tabs.md index e8d1acd4f59..0e550e8658d 100644 --- a/docs/ai/planning/2026-08-01-feature-notes-tabs.md +++ b/docs/ai/planning/2026-08-01-feature-notes-tabs.md @@ -11,8 +11,8 @@ description: Break down work into actionable tasks and estimate timeline - [x] M1: Shared tab model, safe persistence, and focused unit tests. - [x] M2: Web controller integration and editor/reader session preservation. - [x] M3: Desktop and mobile web tab presentations plus all open-note paths. -- [ ] M4: Native mobile presentation using the shared model semantics. -- [ ] M5: Full verification, documentation updates, and review. +- [ ] M4: Native mobile presentation using the shared model semantics. *(deferred follow-up; `ui/mobile` intentionally unchanged in this branch)* +- [x] M5: Full verification, documentation updates, and review. ## Task Breakdown @@ -47,7 +47,7 @@ description: Break down work into actionable tasks and estimate timeline - [x] T5.1: Update implementation/testing/deployment/monitoring docs with actual commands and evidence. - [x] T5.2: Run focused unit/integration tests, production type-check, ESLint, and mobile checks independently. - [x] T5.3: Run the project Allure agent-mode focused tests and inspect results/evidence. -- [ ] T5.4: Review diff/status for unrelated files and perform final code review. +- [x] T5.4: Review diff/status for unrelated files and perform final code review. ## Dependencies @@ -92,3 +92,10 @@ These are engineering estimates only; autosave/editor lifecycle and native navig - T1.1 — done (2026-08-01): pure workspace model and 9 focused Jest tests. Allure agent output `C:\Users\DenysKoreiba\AppData\Local\Temp\allure-agent-QVm6RR`; 9/9 passed, expectations matched, no findings. - T1.2/T1.3 — done (2026-08-01): web storage adapter, hook, and combined 13-test model/storage run. Allure agent output `C:\Users\DenysKoreiba\AppData\Local\Temp\allure-agent-Rc7Bph`; 13/13 passed, expectations matched, no findings. +- T5.4 — done (2026-08-20): final code review found and fixed four issues: + 1. Scroll/typing wrote the full workspace to state and `sessionStorage` on every event; session updates are now debounced (`useDebouncedSessionCallback`, 250 ms) with capture-safe flush/cancel semantics. + 2. A failed autosave flush made every navigation wrapper reject with unhandled promise rejections; `flushAndCaptureActiveTab` now returns a success flag and transitions abort explicitly. + 3. Deleting a note (single or bulk) left it alive in other workspace tabs where typing could re-create it, and bulk delete blanked the active tab even when its note survived; `resetWorkspaceTabsForNotes` now resets exactly the affected tabs. + 4. Entering edit mode wiped a tab's dirty/error save marker without saving; the transition now changes only the mode. + Codacy findings (optional chains, `void` operator) resolved; Jest/ESLint configs now ignore in-repo agent worktrees under `.claude/worktrees/`. +- Native mobile (M4) remains a separately scoped follow-up: expose the shared reducer through the native store/adapter, route `useOpenNote` and note routes through it, and add a native session UI. Desktop Playwright E2E flows are a follow-up in the `koreyba/EverFreeNote-e2e` repository. diff --git a/docs/ai/testing/2026-08-01-feature-notes-tabs.md b/docs/ai/testing/2026-08-01-feature-notes-tabs.md index 6d16e93b714..f258974db61 100644 --- a/docs/ai/testing/2026-08-01-feature-notes-tabs.md +++ b/docs/ai/testing/2026-08-01-feature-notes-tabs.md @@ -48,12 +48,19 @@ description: Define testing approach, test cases, and quality assurance ## End-to-End Tests -- [ ] Open note A, add tab, open note B, switch A/B, and verify both contexts. -- [ ] Open note A from normal list, search, tag-only results, and AI/internal flow and verify one tab. -- [ ] Reload the same browser tab and verify order, active tab, mode, draft, and scroll. -- [ ] Close active tabs in both neighbor directions and close the final tab. -- [x] Use the compact mobile tab list to switch and close tabs. -- [x] Create a blank mobile tab, close the menu, and select a note from the visible list. +Playwright E2E lives in the separate `koreyba/EverFreeNote-e2e` repository, which +has no notes-tabs coverage yet. The four scenarios below are a scoped follow-up +in that repository; they are not deliverable from this branch. The mobile items +are covered by in-repo Cypress component tests (`MobileLayout.cy.tsx`), and the +desktop flows were verified manually against the PR preview deployment (see +Recorded Evidence). + +- [ ] Open note A, add tab, open note B, switch A/B, and verify both contexts. *(follow-up in EverFreeNote-e2e)* +- [ ] Open note A from normal list, search, tag-only results, and AI/internal flow and verify one tab. *(follow-up in EverFreeNote-e2e)* +- [ ] Reload the same browser tab and verify order, active tab, mode, draft, and scroll. *(follow-up in EverFreeNote-e2e)* +- [ ] Close active tabs in both neighbor directions and close the final tab. *(follow-up in EverFreeNote-e2e)* +- [x] Use the compact mobile tab list to switch and close tabs. *(Cypress component coverage)* +- [x] Create a blank mobile tab, close the menu, and select a note from the visible list. *(Cypress component coverage)* ## Test Data @@ -70,6 +77,11 @@ description: Define testing approach, test cases, and quality assurance ## Manual Testing +- Desktop: keyboard tab navigation, ellipsis, horizontal overflow, dirty/error markers, close confirmation, and accessible capacity state with Add fixed on the left. +- Mobile viewport: active-note summary, tab count, compact list/sheet, touch targets, screen-reader labels. +- Reload: verify `sessionStorage` restore and separate browser-tab isolation. +- Network/offline: switch during autosave and after an induced save error. + ## Recorded Evidence - Allure Agent workspace/model/UI run: 34/34 passed, expectations matched, findings 0. Report: `C:\Users\DenysKoreiba\AppData\Local\Temp\allure-agent-58hLNC`. @@ -81,10 +93,14 @@ description: Define testing approach, test cases, and quality assurance - `npx ai-devkit@latest lint --feature notes-tabs`: passed. - Native mobile route/store tab adaptation was not included; responsive web mobile controls are covered by component tests. -- Desktop: keyboard tab navigation, ellipsis, horizontal overflow, dirty/error markers, close confirmation, and accessible capacity state with Add fixed on the left. -- Mobile viewport: active-note summary, tab count, compact list/sheet, touch targets, screen-reader labels. -- Reload: verify `sessionStorage` restore and separate browser-tab isolation. -- Network/offline: switch during autosave and after an induced save error. +### Final review pass (2026-08-20) + +- `npm run type-check`: passed (root, core, core tests, web tests, `ui/mobile`). +- `npx eslint . --max-warnings=0`: passed. +- `npm run test:unit`: 183 suites, 1,441 tests passed — includes new coverage for `resetWorkspaceTabsForNotes`, `useDebouncedSessionCallback`, flush-failure transition abort, delete-driven tab reset, and `onNotesDeleted` bulk reporting. +- `npm run test:integration:core`: 2 suites, 20 tests passed. +- Focused Cypress component run (`NotesTabStrip`, `MobileLayout`, `NotesShellFtsExitSave`, `NotesShellLikeExitSave`, `NotesShellOpenInContext`, `useNoteBulkActionsDirect`): 20/20 passed in Electron. +- Manual verification against the PR preview deployment (test-auth user): desktop tab add/replace/dedupe/switch/close, reload restore, mobile compact menu. ## Performance Testing diff --git a/eslint.config.mjs b/eslint.config.mjs index ec3e91ce33d..f5cdd6cfdb8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -28,6 +28,7 @@ export default defineConfig([ 'allure-report/**', '.tmp-artifacts/**', '.worktrees/**', + '.claude/worktrees/**', // In-repo agent worktrees must not affect lint runs 'act-artifacts/**', 'out/**', 'next-env.d.ts', diff --git a/jest.config.cjs b/jest.config.cjs index edaba6d5cf1..077967debed 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -68,6 +68,7 @@ module.exports = { transform, moduleNameMapper, clearMocks: true, + testPathIgnorePatterns: ['/node_modules/', '/.claude/'], }, { displayName: 'integration-core', @@ -79,6 +80,7 @@ module.exports = { transform, moduleNameMapper, clearMocks: true, + testPathIgnorePatterns: ['/node_modules/', '/.claude/'], }, { displayName: 'unit-web', @@ -90,6 +92,7 @@ module.exports = { transform, moduleNameMapper, clearMocks: true, + testPathIgnorePatterns: ['/node_modules/', '/.claude/'], }, ], } diff --git a/ui/web/components/features/notes/MobileNotesTabMenu.tsx b/ui/web/components/features/notes/MobileNotesTabMenu.tsx index a945ef52998..2347a1f0733 100644 --- a/ui/web/components/features/notes/MobileNotesTabMenu.tsx +++ b/ui/web/components/features/notes/MobileNotesTabMenu.tsx @@ -31,7 +31,7 @@ export function MobileNotesTabMenu({ const handleAdd = () => { if (addTabDisabled || addTabCapacityPending) return setIsOpen(false) - void onAddTab() + onAddTab() } const addTabLabel = addTabCapacityPending diff --git a/ui/web/components/features/notes/NoteEditor.tsx b/ui/web/components/features/notes/NoteEditor.tsx index 482e1a0f305..f0f309a4c68 100644 --- a/ui/web/components/features/notes/NoteEditor.tsx +++ b/ui/web/components/features/notes/NoteEditor.tsx @@ -12,9 +12,13 @@ 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 { useDebouncedSessionCallback } from "@ui/web/hooks/useDebouncedSessionCallback" import type { NoteDraftSnapshot, NoteViewSession } from "@core/services/noteWorkspaceTabs" const DEFAULT_AUTOSAVE_DELAY_MS = 500 +// Below the autosave delay so a pending draft notification always lands before +// the autosave completion marks the tab as saved. +const SESSION_SYNC_DELAY_MS = 250 const NOOP_CANCEL = () => {} export interface NoteEditorHandle { @@ -112,9 +116,24 @@ export const NoteEditor = React.memo(React.forwardRef( + onDraftChange, + SESSION_SYNC_DELAY_MS, + 'cancel', + ) + const debouncedViewNotify = useDebouncedSessionCallback>( + onViewSessionChange, + SESSION_SYNC_DELAY_MS, + 'flush', + ) + const notifyDraftChange = React.useCallback(() => { - onDraftChange?.(getFormData()) - }, [getFormData, onDraftChange]) + debouncedDraftNotify.schedule(getFormData()) + }, [debouncedDraftNotify, getFormData]) const applyExternalSnapshot = React.useCallback(( snapshot: { title: string; description: string; tags: string }, @@ -184,11 +203,15 @@ export const NoteEditor = React.memo(React.forwardRef { cancelAutoSave() + // The manual save records the draft itself; a late debounced notification + // would re-mark the saved tab as dirty. + debouncedDraftNotify.cancel() onSave(getFormData()) } const handleRead = () => { cancelAutoSave() + debouncedDraftNotify.cancel() onRead(getFormData()) } @@ -223,44 +246,44 @@ export const NoteEditor = React.memo(React.forwardRef { const next = selectedTagsRef.current.filter((tag) => tag !== tagToRemove) selectedTagsRef.current = next setSelectedTags(next) setTagQuery("") - onDraftChange?.({ - ...getFormData(), - tags: buildTagString(next), - }) - }, [getFormData, onDraftChange]) + notifyDraftChange() + }, [notifyDraftChange]) React.useImperativeHandle(ref, () => ({ flushPendingSave, - captureSession: () => ({ - draft: getFormData(), - view: { - scrollTop: scrollContainerRef.current?.scrollTop ?? 0, - ...(titleInputRef.current?.selectionStart !== null && titleInputRef.current?.selectionStart !== undefined && titleInputRef.current?.selectionEnd !== null && titleInputRef.current?.selectionEnd !== undefined - ? { - titleSelection: { - start: titleInputRef.current.selectionStart, - end: titleInputRef.current.selectionEnd, - }, - } - : {}), - ...(editorRef.current?.getSelection?.() ? { editorSelection: editorRef.current.getSelection?.() } : {}), - }, - }), + captureSession: () => { + const titleInput = titleInputRef.current + const titleSelection = titleInput && titleInput.selectionStart !== null && titleInput.selectionEnd !== null + ? { start: titleInput.selectionStart, end: titleInput.selectionEnd } + : undefined + const editorSelection = editorRef.current?.getSelection?.() + + // The capture reads the live editor, so a pending debounced draft + // notification is stale and must not fire after the transition. + debouncedDraftNotify.cancel() + debouncedViewNotify.cancel() + + return { + draft: getFormData(), + view: { + scrollTop: scrollContainerRef.current?.scrollTop ?? 0, + ...(titleSelection ? { titleSelection } : {}), + ...(editorSelection ? { editorSelection } : {}), + }, + } + }, scrollToChunk: (charOffset: number, chunkLength: number) => { editorRef.current?.scrollToChunk(charOffset, chunkLength) }, - }), [flushPendingSave, getFormData]) + }), [debouncedDraftNotify, debouncedViewNotify, flushPendingSave, getFormData]) React.useEffect(() => { const session = initialSessionRef.current @@ -374,7 +397,7 @@ export const NoteEditor = React.memo(React.forwardRef onViewSessionChange?.({ scrollTop: event.currentTarget.scrollTop })} + onScroll={(event) => debouncedViewNotify.schedule({ scrollTop: event.currentTarget.scrollTop })} >
diff --git a/ui/web/components/features/notes/NoteView.tsx b/ui/web/components/features/notes/NoteView.tsx index 90f654d417e..379f59948ee 100644 --- a/ui/web/components/features/notes/NoteView.tsx +++ b/ui/web/components/features/notes/NoteView.tsx @@ -9,10 +9,14 @@ import { MoreActionsMenu } from "@/components/features/notes/MoreActionsMenu" import { SanitizationService } from "@core/services/sanitizer" import { NoteClipboardService } from "@core/services/noteClipboard" import { useCopyNote } from "@ui/web/hooks/useCopyNote" +import { useDebouncedSessionCallback } from "@ui/web/hooks/useDebouncedSessionCallback" import { NOTE_CONTENT_CLASS } from "@core/constants/typography" import type { Note } from "@core/types/domain" import type { NoteViewSession } from "@core/services/noteWorkspaceTabs" +// Reading scroll fires per frame; batch updates before they hit workspace state. +const SCROLL_SYNC_DELAY_MS = 250 + // Define NoteRecord locally to match what's used in page.tsx type NoteRecord = Note & { content?: string | null @@ -55,6 +59,13 @@ export const NoteView = React.memo(function NoteView({ const isBodyEmpty = React.useMemo(() => NoteClipboardService.isBodyEmpty(bodyHtml), [bodyHtml]) const contentRef = React.useRef(null) const initialScrollTopRef = React.useRef(initialScrollTop) + // Reading mode has no synchronous capture path, so the latest scroll position + // is flushed when this view unmounts (tab switch, edit, close). + const debouncedViewNotify = useDebouncedSessionCallback>( + onViewSessionChange, + SCROLL_SYNC_DELAY_MS, + 'flush', + ) React.useEffect(() => { const frame = window.requestAnimationFrame(() => { @@ -142,7 +153,7 @@ export const NoteView = React.memo(function NoteView({
onViewSessionChange?.({ scrollTop: event.currentTarget.scrollTop })} + onScroll={(event) => debouncedViewNotify.schedule({ scrollTop: event.currentTarget.scrollTop })} >

diff --git a/ui/web/components/features/notes/NotesTabStrip.tsx b/ui/web/components/features/notes/NotesTabStrip.tsx index 1f649d2b94d..845df812ac1 100644 --- a/ui/web/components/features/notes/NotesTabStrip.tsx +++ b/ui/web/components/features/notes/NotesTabStrip.tsx @@ -38,6 +38,13 @@ function getTabLabel(tab: NoteWorkspaceTab): string { return tab.noteId ? "Untitled note" : "New note" } +function scrollTabIntoView(button: HTMLButtonElement) { + // jsdom does not implement scrollIntoView, so guard explicitly. + if (typeof button.scrollIntoView === "function") { + button.scrollIntoView({ block: "nearest", inline: "nearest" }) + } +} + function SaveStateIndicator({ tab }: Readonly<{ tab: NoteWorkspaceTab }>) { if (tab.saveState === "dirty") { return ( @@ -158,12 +165,12 @@ export function NotesTabStrip({ const button = tabButtonRefs.current.get(tabId) if (!button) return button.focus() - button.scrollIntoView?.({ block: "nearest", inline: "nearest" }) + scrollTabIntoView(button) } useEffect(() => { const button = tabButtonRefs.current.get(activeTabId) - button?.scrollIntoView?.({ block: "nearest", inline: "nearest" }) + if (button) scrollTabIntoView(button) }, [activeTabId]) return ( diff --git a/ui/web/hooks/useDebouncedSessionCallback.ts b/ui/web/hooks/useDebouncedSessionCallback.ts new file mode 100644 index 00000000000..a525ed5b23a --- /dev/null +++ b/ui/web/hooks/useDebouncedSessionCallback.ts @@ -0,0 +1,69 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react' + +export type DebouncedSessionCallback = { + schedule: (value: T) => void + cancel: () => void + flush: () => void +} + +/** + * Debounces high-frequency editor/reader session updates (scroll, typing draft) + * before they reach workspace-tab state, so per-event React updates and + * sessionStorage serialization do not run on every scroll frame or keystroke. + * + * `onUnmount` decides what happens to a pending value when the component goes + * away: reading scroll must be flushed (there is no other capture path), while + * draft updates must be cancelled because every tab transition captures the + * live editor synchronously first and a late flush would re-mark a + * just-saved tab as dirty. + */ +export function useDebouncedSessionCallback( + callback: ((value: T) => void) | undefined, + delayMs: number, + // Treated as fixed for the lifetime of the component. + onUnmount: 'flush' | 'cancel', +): DebouncedSessionCallback { + const callbackRef = useRef(callback) + const timerRef = useRef | null>(null) + const pendingRef = useRef<{ value: T } | null>(null) + + useEffect(() => { + callbackRef.current = callback + }, [callback]) + + const cancel = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + pendingRef.current = null + }, []) + + const flush = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + const pending = pendingRef.current + pendingRef.current = null + if (pending) callbackRef.current?.(pending.value) + }, []) + + const schedule = useCallback((value: T) => { + pendingRef.current = { value } + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = setTimeout(flush, delayMs) + }, [delayMs, flush]) + + const onUnmountRef = useRef(onUnmount) + + useEffect(() => () => { + if (onUnmountRef.current === 'flush') { + flush() + } else { + cancel() + } + }, [cancel, flush]) + + return useMemo(() => ({ schedule, cancel, flush }), [schedule, cancel, flush]) +} diff --git a/ui/web/hooks/useNoteAppController.ts b/ui/web/hooks/useNoteAppController.ts index d3371e45d07..539d788fabf 100644 --- a/ui/web/hooks/useNoteAppController.ts +++ b/ui/web/hooks/useNoteAppController.ts @@ -1,59 +1,59 @@ -import { useEffect, useCallback, useMemo, useRef, useState } from 'react' -import { useQueryClient } from '@tanstack/react-query' -import { toast } from 'sonner' - -import { useNotesQuery } from './useNotesQuery' -import { useCreateNote, useUpdateNote, useDeleteNote, useRemoveTag } from './useNotesMutations' -import { useInfiniteScroll } from './useInfiniteScroll' -import type { NoteViewModel, SearchResult } from '@core/types/domain' -import { useNoteAuth } from './useNoteAuth' -import { useNoteSearch } from './useNoteSearch' -import { useNoteSelection } from './useNoteSelection' -import { useNoteSync } from './useNoteSync' -import { useNoteData } from './useNoteData' -import { useNoteSaveHandlers } from './useNoteSaveHandlers' +import { useEffect, useCallback, useMemo, useRef, useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { useNotesQuery } from './useNotesQuery' +import { useCreateNote, useUpdateNote, useDeleteNote, useRemoveTag } from './useNotesMutations' +import { useInfiniteScroll } from './useInfiniteScroll' +import type { NoteViewModel, SearchResult } from '@core/types/domain' +import { useNoteAuth } from './useNoteAuth' +import { useNoteSearch } from './useNoteSearch' +import { useNoteSelection } from './useNoteSelection' +import { useNoteSync } from './useNoteSync' +import { useNoteData } from './useNoteData' +import { useNoteSaveHandlers } from './useNoteSaveHandlers' import { useNoteBulkActions } from './useNoteBulkActions' import { useNoteWorkspaceTabs } from './useNoteWorkspaceTabs' import type { NoteDraftSnapshot, NoteViewSession } from '@core/services/noteWorkspaceTabs' -import type { NoteEditorHandle } from '@ui/web/components/features/notes/NoteEditor' -import { useSupabase } from '@ui/web/providers/SupabaseProvider' -import { NoteService } from '@core/services/notes' +import type { NoteEditorHandle } from '@ui/web/components/features/notes/NoteEditor' +import { useSupabase } from '@ui/web/providers/SupabaseProvider' +import { NoteService } from '@core/services/notes' import { renameTagInNotes, deleteTagFromNotes, cleanUnusedOrEmptyTagsInNotes, } from '@core/services/tags' -import { type NotesUiStateSnapshot } from '@ui/web/lib/settingsNavigationState' -import { clearActiveSettingsNoteReturnPath } from '@ui/web/lib/aiIndexNavigationState' -import { mergeNoteFields, pickLatestNote } from '@core/utils/noteSnapshot' - - -export type EditFormState = { - title: string - description: string - tags: string -} - -type AIPaginationControls = { - resetAIResults: () => void - loadMoreAI: () => void -} - -export function useNoteAppController() { - const { supabase } = useSupabase() - - // -- Auth -- - const { - user, - loading: authLoadingState, - handleSignInWithGoogle, - handleTestLogin, - handleSkipAuth, - handleSignOut, - handleDeleteAccount, - deleteAccountLoading - } = useNoteAuth() - +import { type NotesUiStateSnapshot } from '@ui/web/lib/settingsNavigationState' +import { clearActiveSettingsNoteReturnPath } from '@ui/web/lib/aiIndexNavigationState' +import { mergeNoteFields, pickLatestNote } from '@core/utils/noteSnapshot' + + +export type EditFormState = { + title: string + description: string + tags: string +} + +type AIPaginationControls = { + resetAIResults: () => void + loadMoreAI: () => void +} + +export function useNoteAppController() { + const { supabase } = useSupabase() + + // -- Auth -- + const { + user, + loading: authLoadingState, + handleSignInWithGoogle, + handleTestLogin, + handleSkipAuth, + handleSignOut, + handleDeleteAccount, + deleteAccountLoading + } = useNoteAuth() + // -- Selection -- const { selectedNote: legacySelectedNote, @@ -61,18 +61,18 @@ export function useNoteAppController() { setSelectedNote: setLegacySelectedNote, setIsEditing: setLegacyIsEditing, deleteDialogOpen, - setDeleteDialogOpen, - noteToDelete, - setNoteToDelete, - selectedNoteIds, - selectionMode, - bulkDeleting, - setBulkDeleting, + setDeleteDialogOpen, + noteToDelete, + setNoteToDelete, + selectedNoteIds, + selectionMode, + bulkDeleting, + setBulkDeleting, handleDeleteNote, - enterSelectionMode, - exitSelectionMode, - toggleNoteSelection, - selectAllVisible: selectAllVisibleCallback, + enterSelectionMode, + exitSelectionMode, + toggleNoteSelection, + selectAllVisible: selectAllVisibleCallback, clearSelection, handleSelectNote: handleSelectNoteLegacy, handleSearchResultClick: handleSearchResultClickLegacy, @@ -95,6 +95,7 @@ export function useNoteAppController() { openNote, updateTab, closeTab, + resetTabsForNotes, findTabByNoteId, canAddTab, } = workspace @@ -133,14 +134,14 @@ export function useNoteAppController() { updateTab, workspaceHydrated, ]) - - // -- Editor ref (cross-cutting: bridges UI editor with save/navigation logic) -- - const noteEditorRef = useRef | null>(null) - - const registerNoteEditorRef = useCallback((ref: React.RefObject) => { - noteEditorRef.current = ref - }, []) - + + // -- Editor ref (cross-cutting: bridges UI editor with save/navigation logic) -- + const noteEditorRef = useRef | null>(null) + + const registerNoteEditorRef = useCallback((ref: React.RefObject) => { + noteEditorRef.current = ref + }, []) + const flushPendingEditorSave = useCallback(async () => { if (!isEditing) return const handle = noteEditorRef.current?.current @@ -158,187 +159,208 @@ export function useNoteAppController() { }) }, [activeTabId, updateTab]) - const flushAndCaptureActiveTab = useCallback(async () => { + /** + * Captures the outgoing tab session and flushes pending autosave work. + * Returns `false` when the flush fails so callers abort the transition: + * the failed tab keeps its draft and error state and the user can retry. + * Never rejects — save errors are already surfaced on the tab itself. + */ + const flushAndCaptureActiveTab = useCallback(async (): Promise => { captureActiveTabSession() - await flushPendingEditorSave() + try { + await flushPendingEditorSave() + return true + } catch { + return false + } }, [captureActiveTabSession, flushPendingEditorSave]) - - // -- Infrastructure -- - const queryClient = useQueryClient() - const noteService = useMemo(() => new NoteService(supabase), [supabase]) - const createNoteMutation = useCreateNote() - const updateNoteMutation = useUpdateNote() - const deleteNoteMutation = useDeleteNote() - const removeTagMutation = useRemoveTag() - - // -- Sync & Offline -- - const { - offlineOverlay, - setOfflineOverlay, - pendingCount, - setPendingCount, - failedCount, - setFailedCount, - isOffline, - lastSavedAt, - setLastSavedAt, - offlineCache, - enqueueMutation, - enqueueBatchAndDrainIfOnline, - offlineQueueRef - } = useNoteSync({ - user, - createNoteMutation, - updateNoteMutation, - deleteNoteMutation - }) - - // -- Search -- - const { - searchQuery, - filterByTag, - isSearchPanelOpen, - setIsSearchPanelOpen, - handleSearch, - handleTagClick: onTagClick, - handleClearTagFilter, - showFTSResults, - aggregatedFtsData, - ftsObserverTarget, - ftsHasMore, - ftsLoadingMore, - ftsAccumulatedResults, - loadMoreFts, - ftsSearchResult, - resetFtsResults, - showTagOnlyResults, - tagOnlyResults, - tagOnlyTotal, - tagOnlyLoading, - tagOnlyHasMore, - tagOnlyLoadingMore, - loadMoreTagOnly, - } = useNoteSearch(user?.id) - - // -- Notes query -- - const notesQuery = useNotesQuery({ - userId: user?.id, - // Main notes list must stay stable while search is rendered in SearchResultsPanel. - searchQuery: '', - // Tag filtering is now scoped to search panel results only. - selectedTag: null, - enabled: !!user, - }) - - // -- Computed note data -- - const { - notes, - resolveSearchResult, - mergedFtsData, - notesDisplayed, - notesTotal, - selectedCount, - notesRef, - } = useNoteData({ - notesQuery, - offlineOverlay, - aggregatedFtsData, - selectedNoteIds, - }) - - // -- Infinite Scroll -- - const observerTarget = useInfiniteScroll( - notesQuery.fetchNextPage, - notesQuery.hasNextPage, - notesQuery.isFetchingNextPage, - { threshold: 0.8, rootMargin: '200px' } - ) - - // Ref to avoid stale closure in save handlers and nav wrappers - const selectedNoteRef = useRef(selectedNote) - const latestEditRequestRef = useRef(0) - const latestSelectRequestRef = useRef(0) - const latestSearchClickRequestRef = useRef(0) - useEffect(() => { - selectedNoteRef.current = selectedNote - }, [selectedNote]) - - const hasPendingLocalWrites = useCallback((noteId: string) => ( - offlineOverlay.some((note) => ( - note.id === noteId && - ( - note.status !== 'synced' || - Boolean(note.pendingOps?.some((operation) => operation !== 'delete')) - ) - )) - ), [offlineOverlay]) - - const resolveOpenableNote = useCallback(async (note: T): Promise => { - if (isOffline || hasPendingLocalWrites(note.id)) { - return note - } - - try { - const remoteResult = await noteService.getNoteStatus(note.id) - if (remoteResult.status === 'found') { - return pickLatestNote([ - mergeNoteFields(note, remoteResult.note), - note, - ]) ?? mergeNoteFields(note, remoteResult.note) - } - - if (remoteResult.status === 'not_found') { - await offlineCache.deleteNote(note.id) - setOfflineOverlay((current) => current.filter((cachedNote) => cachedNote.id !== note.id)) - toast.error('This note was deleted on another device.') - queryClient.invalidateQueries({ queryKey: ['notes'] }).catch(() => {}) - queryClient.invalidateQueries({ queryKey: ['aiSearch'] }).catch(() => {}) - return null - } - - console.warn('Transient error checking note status, using local version:', remoteResult.error) - } catch (error) { - console.warn('Failed to check note status, using local version:', error) - } - - return note - }, [hasPendingLocalWrites, isOffline, noteService, offlineCache, setOfflineOverlay, queryClient]) - - // -- Save handlers -- - const { - saving, - autoSaving, - handleAutoSave, - handleSaveNote, + + // -- Infrastructure -- + const queryClient = useQueryClient() + const noteService = useMemo(() => new NoteService(supabase), [supabase]) + const createNoteMutation = useCreateNote() + const updateNoteMutation = useUpdateNote() + const deleteNoteMutation = useDeleteNote() + const removeTagMutation = useRemoveTag() + + // -- Sync & Offline -- + const { + offlineOverlay, + setOfflineOverlay, + pendingCount, + setPendingCount, + failedCount, + setFailedCount, + isOffline, + lastSavedAt, + setLastSavedAt, + offlineCache, + enqueueMutation, + enqueueBatchAndDrainIfOnline, + offlineQueueRef + } = useNoteSync({ + user, + createNoteMutation, + updateNoteMutation, + deleteNoteMutation + }) + + // -- Search -- + const { + searchQuery, + filterByTag, + isSearchPanelOpen, + setIsSearchPanelOpen, + handleSearch, + handleTagClick: onTagClick, + handleClearTagFilter, + showFTSResults, + aggregatedFtsData, + ftsObserverTarget, + ftsHasMore, + ftsLoadingMore, + ftsAccumulatedResults, + loadMoreFts, + ftsSearchResult, + resetFtsResults, + showTagOnlyResults, + tagOnlyResults, + tagOnlyTotal, + tagOnlyLoading, + tagOnlyHasMore, + tagOnlyLoadingMore, + loadMoreTagOnly, + } = useNoteSearch(user?.id) + + // -- Notes query -- + const notesQuery = useNotesQuery({ + userId: user?.id, + // Main notes list must stay stable while search is rendered in SearchResultsPanel. + searchQuery: '', + // Tag filtering is now scoped to search panel results only. + selectedTag: null, + enabled: !!user, + }) + + // -- Computed note data -- + const { + notes, + resolveSearchResult, + mergedFtsData, + notesDisplayed, + notesTotal, + selectedCount, + notesRef, + } = useNoteData({ + notesQuery, + offlineOverlay, + aggregatedFtsData, + selectedNoteIds, + }) + + // -- Infinite Scroll -- + const observerTarget = useInfiniteScroll( + notesQuery.fetchNextPage, + notesQuery.hasNextPage, + notesQuery.isFetchingNextPage, + { threshold: 0.8, rootMargin: '200px' } + ) + + // Ref to avoid stale closure in save handlers and nav wrappers + const selectedNoteRef = useRef(selectedNote) + const latestEditRequestRef = useRef(0) + const latestSelectRequestRef = useRef(0) + const latestSearchClickRequestRef = useRef(0) + useEffect(() => { + selectedNoteRef.current = selectedNote + }, [selectedNote]) + + const hasPendingLocalWrites = useCallback((noteId: string) => ( + offlineOverlay.some((note) => ( + note.id === noteId && + ( + note.status !== 'synced' || + Boolean(note.pendingOps?.some((operation) => operation !== 'delete')) + ) + )) + ), [offlineOverlay]) + + const resolveOpenableNote = useCallback(async (note: T): Promise => { + if (isOffline || hasPendingLocalWrites(note.id)) { + return note + } + + try { + const remoteResult = await noteService.getNoteStatus(note.id) + if (remoteResult.status === 'found') { + return pickLatestNote([ + mergeNoteFields(note, remoteResult.note), + note, + ]) ?? mergeNoteFields(note, remoteResult.note) + } + + if (remoteResult.status === 'not_found') { + await offlineCache.deleteNote(note.id) + setOfflineOverlay((current) => current.filter((cachedNote) => cachedNote.id !== note.id)) + toast.error('This note was deleted on another device.') + queryClient.invalidateQueries({ queryKey: ['notes'] }).catch(() => {}) + queryClient.invalidateQueries({ queryKey: ['aiSearch'] }).catch(() => {}) + return null + } + + console.warn('Transient error checking note status, using local version:', remoteResult.error) + } catch (error) { + console.warn('Failed to check note status, using local version:', error) + } + + return note + }, [hasPendingLocalWrites, isOffline, noteService, offlineCache, setOfflineOverlay, queryClient]) + + // -- Save handlers -- + const { + saving, + autoSaving, + handleAutoSave, + handleSaveNote, handleReadNote, confirmDeleteNote, handleRemoveTagFromNote, persistOfflineNoteUpdates, } = useNoteSaveHandlers({ - user, - isOffline, - offlineCache, - enqueueMutation, - offlineQueueRef, - setOfflineOverlay, - setPendingCount, - setFailedCount, - setLastSavedAt, - createNoteMutation, - updateNoteMutation, - deleteNoteMutation, - removeTagMutation, - selectedNote, - setSelectedNote, - setIsEditing, - noteToDelete, - setDeleteDialogOpen, - setNoteToDelete, - notes, - notesRef, + user, + isOffline, + offlineCache, + enqueueMutation, + offlineQueueRef, + setOfflineOverlay, + setPendingCount, + setFailedCount, + setLastSavedAt, + createNoteMutation, + updateNoteMutation, + deleteNoteMutation, + removeTagMutation, + selectedNote, + setSelectedNote, + setIsEditing, + noteToDelete, + setDeleteDialogOpen, + setNoteToDelete, + notes, + notesRef, selectedNoteRef, }) + const confirmDeleteNoteWithWorkspace = useCallback(async () => { + const deletedNoteId = noteToDelete?.id ?? null + const deleted = await confirmDeleteNote() + // A deleted note must not survive in any workspace tab: a stale tab would + // keep rendering it and typing there would re-create the note. + if (deleted && deletedNoteId) { + resetTabsForNotes([deletedNoteId]) + } + }, [confirmDeleteNote, noteToDelete, resetTabsForNotes]) + const handleDraftChange = useCallback((draft: NoteDraftSnapshot) => { updateTab(activeTabId, { draft, @@ -410,29 +432,29 @@ export function useNoteAppController() { updateTab(activeTabId, { saveState: 'error', saveError: message }) } }, [activeTabId, handleReadNote, updateTab]) - - // -- Bulk actions -- - const { selectAllVisible, deleteSelectedNotes, deleteNotesByIds } = useNoteBulkActions({ - selectedNoteIds, - isOffline, - enqueueBatchAndDrainIfOnline, - offlineCache, - setOfflineOverlay, - setPendingCount, - deleteNoteMutation, - exitSelectionMode, - setBulkDeleting, - setSelectedNote, - queryClient, - notes, - selectAllVisibleCallback, - }) - + + // -- Bulk actions -- + const { selectAllVisible, deleteSelectedNotes, deleteNotesByIds } = useNoteBulkActions({ + selectedNoteIds, + isOffline, + enqueueBatchAndDrainIfOnline, + offlineCache, + setOfflineOverlay, + setPendingCount, + deleteNoteMutation, + exitSelectionMode, + setBulkDeleting, + onNotesDeleted: resetTabsForNotes, + queryClient, + notes, + selectAllVisibleCallback, + }) + // -- Nav wrappers: flush pending editor save before any navigation -- const wrappedHandleSelectNote = useCallback(async (note: NoteViewModel | null) => { const requestId = ++latestSelectRequestRef.current - await flushAndCaptureActiveTab() - if (requestId !== latestSelectRequestRef.current) return + const flushed = await flushAndCaptureActiveTab() + if (!flushed || requestId !== latestSelectRequestRef.current) return clearActiveSettingsNoteReturnPath() if (!note) { // Mobile back/search navigation hides the pane without closing the tab. @@ -475,7 +497,7 @@ export function useNoteAppController() { ]) const wrappedHandleCreateNote = useCallback(async () => { - await flushAndCaptureActiveTab() + if (!(await flushAndCaptureActiveTab())) return clearActiveSettingsNoteReturnPath() updateTab(activeTabId, { note: null, @@ -493,8 +515,8 @@ export function useNoteAppController() { const wrappedHandleEditNote = useCallback(async (note: NoteViewModel) => { const requestId = ++latestEditRequestRef.current - await flushAndCaptureActiveTab() - if (requestId !== latestEditRequestRef.current) return + const flushed = await flushAndCaptureActiveTab() + if (!flushed || requestId !== latestEditRequestRef.current) return const openableNote = await resolveOpenableNote(note) if (requestId !== latestEditRequestRef.current) return if (!openableNote) { @@ -506,11 +528,13 @@ export function useNoteAppController() { const existingTab = findTabByNoteId(openableNote.id) if (existingTab) { activateTab(existingTab.id) - updateTab(existingTab.id, { mode: 'editing', saveState: 'saved', saveError: null }) + // Only the mode changes: an unsaved dirty/error marker must survive + // entering the editor, otherwise a failed save looks resolved. + updateTab(existingTab.id, { mode: 'editing' }) handleEditNoteRaw(openableNote) } else { openNote(openableNote) - updateTab(activeTabId, { mode: 'editing', saveState: 'saved', saveError: null }) + updateTab(activeTabId, { mode: 'editing' }) handleEditNoteRaw(openableNote) } setNotePaneVisible(true) @@ -540,8 +564,8 @@ export function useNoteAppController() { const wrappedHandleSearchResultClick = useCallback(async (note: SearchResult) => { const requestId = ++latestSearchClickRequestRef.current - await flushAndCaptureActiveTab() - if (requestId !== latestSearchClickRequestRef.current) return + const flushed = await flushAndCaptureActiveTab() + if (!flushed || requestId !== latestSearchClickRequestRef.current) return clearActiveSettingsNoteReturnPath() const resolvedSearchNote = resolveSearchResult(note) const existingTab = findTabByNoteId(resolvedSearchNote.id) @@ -572,7 +596,7 @@ export function useNoteAppController() { const handleAddTab = useCallback(async () => { if (!workspaceHydrated || !canAddTab) return - await flushAndCaptureActiveTab() + if (!(await flushAndCaptureActiveTab())) return addTab() // On mobile, an empty tab must return to the note list so the user can // choose which note fills the new active slot. Desktop keeps its editor @@ -587,7 +611,7 @@ export function useNoteAppController() { setNotePaneVisible(Boolean(targetTab.note || targetTab.mode === 'editing')) return } - await flushAndCaptureActiveTab() + if (!(await flushAndCaptureActiveTab())) return activateTab(tabId) setNotePaneVisible(Boolean(targetTab.note || targetTab.mode === 'editing')) setLastSavedAt(null) @@ -608,11 +632,7 @@ export function useNoteAppController() { if (tab.saveState === 'error' && !discardFailedSave) return if (tab.id === activeTabId && !discardFailedSave) { - try { - await flushAndCaptureActiveTab() - } catch { - return - } + if (!(await flushAndCaptureActiveTab())) return } closeTab(tabId) @@ -629,36 +649,36 @@ export function useNoteAppController() { }, [activeTab, workspaceHydrated]) const aiPaginationControlsRef = useRef({ - resetAIResults: () => {}, - loadMoreAI: () => {}, - }) - - const registerAIPaginationControls = useCallback((controls: AIPaginationControls) => { - aiPaginationControlsRef.current = controls - }, []) - - const resetAIResults = useCallback(() => { - aiPaginationControlsRef.current.resetAIResults() - }, []) - - const loadMoreAI = useCallback(() => { - aiPaginationControlsRef.current.loadMoreAI() - }, []) - + resetAIResults: () => {}, + loadMoreAI: () => {}, + }) + + const registerAIPaginationControls = useCallback((controls: AIPaginationControls) => { + aiPaginationControlsRef.current = controls + }, []) + + const resetAIResults = useCallback(() => { + aiPaginationControlsRef.current.resetAIResults() + }, []) + + const loadMoreAI = useCallback(() => { + aiPaginationControlsRef.current.loadMoreAI() + }, []) + const captureSettingsReturnState = useCallback(async (): Promise => { await flushAndCaptureActiveTab() - const selectedNoteForSnapshot = selectedNoteRef.current - - return { - selectedNoteId: selectedNoteForSnapshot?.id ?? null, - selectedNote: selectedNoteForSnapshot ?? null, - isEditing, - isSearchPanelOpen, - searchQuery, - filterByTag, - } + const selectedNoteForSnapshot = selectedNoteRef.current + + return { + selectedNoteId: selectedNoteForSnapshot?.id ?? null, + selectedNote: selectedNoteForSnapshot ?? null, + isEditing, + isSearchPanelOpen, + searchQuery, + filterByTag, + } }, [filterByTag, flushAndCaptureActiveTab, isEditing, isSearchPanelOpen, searchQuery, selectedNoteRef]) - + const resolveSettingsReturnNote = useCallback(async (snapshot: NotesUiStateSnapshot) => { if (!snapshot.selectedNoteId) return null @@ -745,7 +765,7 @@ export function useNoteAppController() { restoreSettingsWorkspaceState, setIsSearchPanelOpen, ]) - + // -- Main Navigation View -- const [activeMainView, setActiveMainView] = useState<'notes' | 'tags'>(() => { if (typeof window === 'undefined') return 'notes' @@ -824,53 +844,53 @@ export function useNoteAppController() { const handleRenameTag = useCallback(async (oldTag: string, newTag: string) => { const updatedNotes = renameTagInNotes(notes, oldTag, newTag) const changedNotes = updatedNotes.filter((note, i) => note !== notes[i]) - - if (changedNotes.length === 0) return + + if (changedNotes.length === 0) return try { await persistTagChanges(updatedNotes, notes) toast.success(`Tag "${oldTag}" renamed to "${newTag}"`) - } catch (error) { - toast.error(`Failed to rename tag: ${(error as Error).message}`) - } + } catch (error) { + toast.error(`Failed to rename tag: ${(error as Error).message}`) + } }, [notes, persistTagChanges]) - - const handleDeleteTag = useCallback(async (targetTag: string) => { - const updatedNotes = deleteTagFromNotes(notes, targetTag) - const changedNotes = updatedNotes.filter((note, i) => note !== notes[i]) - + + const handleDeleteTag = useCallback(async (targetTag: string) => { + const updatedNotes = deleteTagFromNotes(notes, targetTag) + const changedNotes = updatedNotes.filter((note, i) => note !== notes[i]) + if (changedNotes.length === 0) return try { await persistTagChanges(updatedNotes, notes) toast.success(`Tag "${targetTag}" deleted`) - } catch (error) { - toast.error(`Failed to delete tag: ${(error as Error).message}`) - } + } catch (error) { + toast.error(`Failed to delete tag: ${(error as Error).message}`) + } }, [notes, persistTagChanges]) - - const handleCleanTags = useCallback(async () => { - const updatedNotes = cleanUnusedOrEmptyTagsInNotes(notes) - const changedNotes = updatedNotes.filter((note, i) => note !== notes[i]) - - if (changedNotes.length === 0) { - toast.info("No empty or duplicate tags found to clean") - return + + const handleCleanTags = useCallback(async () => { + const updatedNotes = cleanUnusedOrEmptyTagsInNotes(notes) + const changedNotes = updatedNotes.filter((note, i) => note !== notes[i]) + + if (changedNotes.length === 0) { + toast.info("No empty or duplicate tags found to clean") + return } try { await persistTagChanges(updatedNotes, notes) toast.success("Cleaned up empty and duplicate tags") - } catch (error) { - toast.error(`Failed to clean tags: ${(error as Error).message}`) - } + } catch (error) { + toast.error(`Failed to clean tags: ${(error as Error).message}`) + } }, [notes, persistTagChanges]) - - return { - registerNoteEditorRef, - // State - user, - loading: authLoadingState, + + return { + registerNoteEditorRef, + // State + user, + loading: authLoadingState, selectedNote, searchQuery, isEditing, @@ -879,62 +899,62 @@ export function useNoteAppController() { activeTabId, activeTab, setIsEditing, - isSearchPanelOpen, - setIsSearchPanelOpen, - saving, - filterByTag, - deleteDialogOpen, - setDeleteDialogOpen, - noteToDelete, - selectionMode, - selectedNoteIds, - selectedCount, - bulkDeleting, - deleteAccountLoading, - isOffline, - activeMainView, - setActiveMainView, + isSearchPanelOpen, + setIsSearchPanelOpen, + saving, + filterByTag, + deleteDialogOpen, + setDeleteDialogOpen, + noteToDelete, + selectionMode, + selectedNoteIds, + selectedCount, + bulkDeleting, + deleteAccountLoading, + isOffline, + activeMainView, + setActiveMainView, handleRenameTag, handleDeleteTag, handleCleanTags, - - - - // Data - - notes, - notesQuery, - ftsSearchResult, - ftsData: mergedFtsData, - ftsResults: ftsAccumulatedResults, - ftsHasMore, - ftsLoadingMore, - showFTSResults, - showTagOnlyResults, - tagOnlyResults, - tagOnlyTotal, - tagOnlyLoading, - tagOnlyHasMore, - tagOnlyLoadingMore, - observerTarget, - ftsObserverTarget, - totalNotes: notesTotal, - notesDisplayed, - notesTotal, - pendingCount, - failedCount, - lastSavedAt, - autoSaving, - - // Handlers - handleSearch, - handleTagClick, - handleClearTagFilter, - handleSignInWithGoogle, - handleTestLogin, - handleSkipAuth, - handleSignOut, - handleDeleteAccount, + + + + // Data + + notes, + notesQuery, + ftsSearchResult, + ftsData: mergedFtsData, + ftsResults: ftsAccumulatedResults, + ftsHasMore, + ftsLoadingMore, + showFTSResults, + showTagOnlyResults, + tagOnlyResults, + tagOnlyTotal, + tagOnlyLoading, + tagOnlyHasMore, + tagOnlyLoadingMore, + observerTarget, + ftsObserverTarget, + totalNotes: notesTotal, + notesDisplayed, + notesTotal, + pendingCount, + failedCount, + lastSavedAt, + autoSaving, + + // Handlers + handleSearch, + handleTagClick, + handleClearTagFilter, + handleSignInWithGoogle, + handleTestLogin, + handleSkipAuth, + handleSignOut, + handleDeleteAccount, handleCreateNote: wrappedHandleCreateNote, handleEditNote: wrappedHandleEditNote, handleSaveNote: handleSaveNoteWithWorkspace, @@ -942,36 +962,36 @@ export function useNoteAppController() { handleAutoSave: handleAutoSaveWithWorkspace, handleDraftChange, handleViewSessionChange, - handleDeleteNote, - confirmDeleteNote, - handleRemoveTagFromNote, - handleSelectNote: wrappedHandleSelectNote, + handleDeleteNote, + confirmDeleteNote: confirmDeleteNoteWithWorkspace, + handleRemoveTagFromNote, + handleSelectNote: wrappedHandleSelectNote, handleSearchResultClick: wrappedHandleSearchResultClick, addTab: handleAddTab, activateTab: handleActivateTab, closeTab: handleCloseTab, canAddTab, workspaceHydrated, - enterSelectionMode, - exitSelectionMode, - toggleNoteSelection, - selectAllVisible, - clearSelection, - loadMoreFts, - loadMoreTagOnly, - resetFtsResults, - loadMoreAI, - resetAIResults, - registerAIPaginationControls, - captureSettingsReturnState, - restoreUiState, - deleteSelectedNotes, - deleteNotesByIds, - - // Helpers - invalidateNotes: () => queryClient.invalidateQueries({ queryKey: ['notes'] }), - } -} - -export type NoteAppController = ReturnType - + enterSelectionMode, + exitSelectionMode, + toggleNoteSelection, + selectAllVisible, + clearSelection, + loadMoreFts, + loadMoreTagOnly, + resetFtsResults, + loadMoreAI, + resetAIResults, + registerAIPaginationControls, + captureSettingsReturnState, + restoreUiState, + deleteSelectedNotes, + deleteNotesByIds, + + // Helpers + invalidateNotes: () => queryClient.invalidateQueries({ queryKey: ['notes'] }), + } +} + +export type NoteAppController = ReturnType + diff --git a/ui/web/hooks/useNoteBulkActions.ts b/ui/web/hooks/useNoteBulkActions.ts index 732fc7ba60f..47a1330d166 100644 --- a/ui/web/hooks/useNoteBulkActions.ts +++ b/ui/web/hooks/useNoteBulkActions.ts @@ -6,6 +6,8 @@ import type { useNoteSelection } from './useNoteSelection' import type { useDeleteNote } from './useNotesMutations' import type { QueryClient } from '@tanstack/react-query' +export type NotesDeletedHandler = (noteIds: string[]) => void + type UseNoteBulkActionsParams = { selectedNoteIds: Set isOffline: boolean @@ -16,7 +18,8 @@ type UseNoteBulkActionsParams = { deleteNoteMutation: Pick, 'mutateAsync'> exitSelectionMode: ReturnType['exitSelectionMode'] setBulkDeleting: ReturnType['setBulkDeleting'] - setSelectedNote: ReturnType['setSelectedNote'] + /** Receives the IDs that were actually deleted (or queued for deletion offline). */ + onNotesDeleted: NotesDeletedHandler queryClient: QueryClient notes: NoteViewModel[] selectAllVisibleCallback: (source: NoteViewModel[]) => void @@ -41,7 +44,7 @@ export function useNoteBulkActions({ deleteNoteMutation, exitSelectionMode, setBulkDeleting, - setSelectedNote, + onNotesDeleted, queryClient, notes, selectAllVisibleCallback, @@ -57,6 +60,7 @@ export function useNoteBulkActions({ try { let failed = 0 + let deletedIds = ids if (isOffline) { await enqueueBatchAndDrainIfOnline( ids.map((id) => ({ @@ -91,6 +95,7 @@ export function useNoteBulkActions({ ids.map((id) => deleteNoteMutation.mutateAsync({ id, silent: true })) ) failed = results.filter((r) => r.status === 'rejected').length + deletedIds = ids.filter((_, index) => results[index].status === 'fulfilled') if (failed > 0) { toast.error(`Failed to delete ${failed} notes`) } else { @@ -102,7 +107,7 @@ export function useNoteBulkActions({ queryClient.invalidateQueries({ queryKey: ['notes'] }), queryClient.invalidateQueries({ queryKey: ['aiSearch'] }), ]) - setSelectedNote(null) + onNotesDeleted(deletedIds) return { total: ids.length, failed, queuedOffline: isOffline } } catch (error) { diff --git a/ui/web/hooks/useNoteSaveHandlers.ts b/ui/web/hooks/useNoteSaveHandlers.ts index 12644f45a1c..ba412118bc9 100644 --- a/ui/web/hooks/useNoteSaveHandlers.ts +++ b/ui/web/hooks/useNoteSaveHandlers.ts @@ -12,19 +12,19 @@ import type { useNoteSelection } from './useNoteSelection' type UseNoteSaveHandlersParams = { user: { id: string } | null isOffline: boolean - offlineCache: Pick['offlineCache'], 'saveNote'> + offlineCache: Pick['offlineCache'], 'saveNote'> enqueueMutation: ReturnType['enqueueMutation'] - offlineQueueRef: { - current: Pick['offlineQueueRef']['current'], 'getQueue'> - } + offlineQueueRef: { + current: Pick['offlineQueueRef']['current'], 'getQueue'> + } setOfflineOverlay: ReturnType['setOfflineOverlay'] setPendingCount: ReturnType['setPendingCount'] setFailedCount: ReturnType['setFailedCount'] setLastSavedAt: ReturnType['setLastSavedAt'] - createNoteMutation: Pick, 'mutateAsync'> - updateNoteMutation: Pick, 'mutateAsync'> - deleteNoteMutation: Pick, 'mutateAsync'> - removeTagMutation: Pick, 'mutateAsync'> + createNoteMutation: Pick, 'mutateAsync'> + updateNoteMutation: Pick, 'mutateAsync'> + deleteNoteMutation: Pick, 'mutateAsync'> + removeTagMutation: Pick, 'mutateAsync'> selectedNote: NoteViewModel | null setSelectedNote: ReturnType['setSelectedNote'] setIsEditing: ReturnType['setIsEditing'] @@ -127,7 +127,7 @@ export function useNoteSaveHandlers({ return cached }, [offlineCache, setOfflineOverlay, enqueueMutation, setPendingCount]) - const executeAutoSaveUpdateNote = useCallback(async ( + const executeAutoSaveUpdateNote = useCallback(async ( targetId: string, title: string, description: string, @@ -146,26 +146,26 @@ export function useNoteSaveHandlers({ } as NoteViewModel }) await executeOfflineWrite({ operation: 'update', noteId: targetId, payload: partialPayload, clientUpdatedAt }) - }, [syncSelectedNote, executeOfflineWrite]) - - const persistOfflineNoteUpdates = useCallback(async (updatedNotes: NoteViewModel[]) => { - const clientUpdatedAt = new Date().toISOString() - - await Promise.all(updatedNotes.map((note) => executeOfflineWrite({ - operation: 'update', - noteId: note.id, - payload: { - title: note.title, - description: note.description, - tags: note.tags, - }, - clientUpdatedAt, - }))) - - const queue = await offlineQueueRef.current.getQueue() - setPendingCount(queue.filter((item) => item.status === 'pending').length) - setFailedCount(queue.filter((item) => item.status === 'failed').length) - }, [executeOfflineWrite, offlineQueueRef, setFailedCount, setPendingCount]) + }, [syncSelectedNote, executeOfflineWrite]) + + const persistOfflineNoteUpdates = useCallback(async (updatedNotes: NoteViewModel[]) => { + const clientUpdatedAt = new Date().toISOString() + + await Promise.all(updatedNotes.map((note) => executeOfflineWrite({ + operation: 'update', + noteId: note.id, + payload: { + title: note.title, + description: note.description, + tags: note.tags, + }, + clientUpdatedAt, + }))) + + const queue = await offlineQueueRef.current.getQueue() + setPendingCount(queue.filter((item) => item.status === 'pending').length) + setFailedCount(queue.filter((item) => item.status === 'failed').length) + }, [executeOfflineWrite, offlineQueueRef, setFailedCount, setPendingCount]) const handleAutoSave = useCallback(async (data: { noteId?: string; title?: string; description?: string; tags?: string }) => { if (!user) return @@ -360,12 +360,12 @@ export function useNoteSaveHandlers({ if (savedNote) { syncSelectedNote(savedNote) } - } catch (error) { - console.error('Error saving note:', error) - throw error - } finally { - setSaving(false) - } + } catch (error) { + console.error('Error saving note:', error) + throw error + } finally { + setSaving(false) + } } const handleReadNote = async (data: { title: string; description: string; tags: string }) => { @@ -376,8 +376,8 @@ export function useNoteSaveHandlers({ setIsEditing(false) } - const confirmDeleteNote = async () => { - if (!noteToDelete) return + const confirmDeleteNote = async (): Promise => { + if (!noteToDelete) return false try { if (isOffline) { @@ -413,8 +413,10 @@ export function useNoteSaveHandlers({ syncSelectedNote(null) setIsEditing(false) } + return true } catch (error) { console.error('Error deleting note:', error) + return false } finally { setDeleteDialogOpen(false) setNoteToDelete(null) @@ -446,10 +448,10 @@ export function useNoteSaveHandlers({ autoSaving, handleAutoSave, handleSaveNote, - handleReadNote, - confirmDeleteNote, - handleRemoveTagFromNote, - persistOfflineNoteUpdates, - } -} + handleReadNote, + confirmDeleteNote, + handleRemoveTagFromNote, + persistOfflineNoteUpdates, + } +} diff --git a/ui/web/hooks/useNoteWorkspaceTabs.ts b/ui/web/hooks/useNoteWorkspaceTabs.ts index 54049bf13fb..50bd4ca465f 100644 --- a/ui/web/hooks/useNoteWorkspaceTabs.ts +++ b/ui/web/hooks/useNoteWorkspaceTabs.ts @@ -8,6 +8,7 @@ import { findWorkspaceTabByNoteId, getActiveWorkspaceTab, openNoteInWorkspace, + resetWorkspaceTabsForNotes, updateWorkspaceTab, type NoteWorkspaceTabPatch, } from '@core/services/noteWorkspaceTabs' @@ -55,6 +56,10 @@ export function useNoteWorkspaceTabs() { setState((current) => closeWorkspaceTab(current, tabId)) }, []) + const resetTabsForNotes = useCallback((noteIds: readonly string[]) => { + setState((current) => resetWorkspaceTabsForNotes(current, noteIds)) + }, []) + const activeTab = useMemo(() => getActiveWorkspaceTab(state), [state]) const findTabByNoteId = useCallback((noteId: string | null | undefined) => ( findWorkspaceTabByNoteId(state, noteId) @@ -70,6 +75,7 @@ export function useNoteWorkspaceTabs() { openNote, updateTab, closeTab, + resetTabsForNotes, findTabByNoteId, canAddTab, } diff --git a/ui/web/tests/unit/components/noteView.test.tsx b/ui/web/tests/unit/components/noteView.test.tsx index ef98c1e6356..2cb4a595819 100644 --- a/ui/web/tests/unit/components/noteView.test.tsx +++ b/ui/web/tests/unit/components/noteView.test.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { NoteView } from '@ui/web/components/features/notes/NoteView' import { toast } from 'sonner' import { copyNotePayloadToClipboard } from '@ui/web/lib/noteClipboard' @@ -109,21 +109,49 @@ describe('NoteView copy action', () => { }) }) - it('restores the saved scroll position and reports later scrolling', () => { + it('restores the saved scroll position and reports later scrolling after the debounce delay', () => { + jest.useFakeTimers() const requestAnimationFrame = jest.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { callback(0) return 1 }) - const onViewSessionChange = jest.fn() - const { container } = renderNoteView({ initialScrollTop: 48, onViewSessionChange }) - const content = container.querySelector('.overflow-y-auto') as HTMLDivElement + try { + const onViewSessionChange = jest.fn() + const { container } = renderNoteView({ initialScrollTop: 48, onViewSessionChange }) + const content = container.querySelector('.overflow-y-auto') as HTMLDivElement + + expect(requestAnimationFrame).toHaveBeenCalled() + expect(content.scrollTop).toBe(48) + + fireEvent.scroll(content, { target: { scrollTop: 60 } }) + fireEvent.scroll(content, { target: { scrollTop: 93 } }) + // Scroll updates are debounced: nothing is reported per event. + expect(onViewSessionChange).not.toHaveBeenCalled() + + act(() => { + jest.advanceTimersByTime(250) + }) + expect(onViewSessionChange).toHaveBeenCalledTimes(1) + expect(onViewSessionChange).toHaveBeenCalledWith({ scrollTop: 93 }) + } finally { + requestAnimationFrame.mockRestore() + jest.useRealTimers() + } + }) - expect(requestAnimationFrame).toHaveBeenCalled() - expect(content.scrollTop).toBe(48) + it('flushes the latest scroll position when the reading view unmounts', () => { + jest.useFakeTimers() + try { + const onViewSessionChange = jest.fn() + const { container, unmount } = renderNoteView({ onViewSessionChange }) + const content = container.querySelector('.overflow-y-auto') as HTMLDivElement - fireEvent.scroll(content, { target: { scrollTop: 93 } }) - expect(onViewSessionChange).toHaveBeenCalledWith({ scrollTop: 93 }) + fireEvent.scroll(content, { target: { scrollTop: 77 } }) + unmount() - requestAnimationFrame.mockRestore() + expect(onViewSessionChange).toHaveBeenCalledWith({ scrollTop: 77 }) + } finally { + jest.useRealTimers() + } }) }) diff --git a/ui/web/tests/unit/hooks/useDebouncedSessionCallback.test.tsx b/ui/web/tests/unit/hooks/useDebouncedSessionCallback.test.tsx new file mode 100644 index 00000000000..98eeead34f2 --- /dev/null +++ b/ui/web/tests/unit/hooks/useDebouncedSessionCallback.test.tsx @@ -0,0 +1,93 @@ +import { act, renderHook } from '@testing-library/react' +import { useDebouncedSessionCallback } from '@ui/web/hooks/useDebouncedSessionCallback' + +describe('useDebouncedSessionCallback', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('delivers only the latest scheduled value after the delay', () => { + const callback = jest.fn() + const { result } = renderHook(() => useDebouncedSessionCallback(callback, 250, 'cancel')) + + act(() => { + result.current.schedule(1) + result.current.schedule(2) + result.current.schedule(3) + }) + expect(callback).not.toHaveBeenCalled() + + act(() => { + jest.advanceTimersByTime(250) + }) + expect(callback).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenCalledWith(3) + }) + + it('always uses the latest callback reference', () => { + const first = jest.fn() + const second = jest.fn() + const { result, rerender } = renderHook( + ({ callback }) => useDebouncedSessionCallback(callback, 250, 'cancel'), + { initialProps: { callback: first } }, + ) + + act(() => result.current.schedule('value')) + rerender({ callback: second }) + act(() => { + jest.advanceTimersByTime(250) + }) + + expect(first).not.toHaveBeenCalled() + expect(second).toHaveBeenCalledWith('value') + }) + + it('cancel drops the pending value', () => { + const callback = jest.fn() + const { result } = renderHook(() => useDebouncedSessionCallback(callback, 250, 'cancel')) + + act(() => { + result.current.schedule('value') + result.current.cancel() + jest.advanceTimersByTime(250) + }) + + expect(callback).not.toHaveBeenCalled() + }) + + it('flushes a pending value on unmount when configured to flush', () => { + const callback = jest.fn() + const { result, unmount } = renderHook(() => useDebouncedSessionCallback(callback, 250, 'flush')) + + act(() => result.current.schedule('pending')) + unmount() + + expect(callback).toHaveBeenCalledWith('pending') + }) + + it('drops a pending value on unmount when configured to cancel', () => { + const callback = jest.fn() + const { result, unmount } = renderHook(() => useDebouncedSessionCallback(callback, 250, 'cancel')) + + act(() => result.current.schedule('pending')) + unmount() + act(() => { + jest.advanceTimersByTime(250) + }) + + expect(callback).not.toHaveBeenCalled() + }) + + it('does nothing when flushing without a pending value', () => { + const callback = jest.fn() + const { result } = renderHook(() => useDebouncedSessionCallback(callback, 250, 'flush')) + + act(() => result.current.flush()) + + expect(callback).not.toHaveBeenCalled() + }) +}) diff --git a/ui/web/tests/unit/hooks/useNoteAppControllerAdditional.test.tsx b/ui/web/tests/unit/hooks/useNoteAppControllerAdditional.test.tsx index 4f97338a003..a685b5e6e27 100644 --- a/ui/web/tests/unit/hooks/useNoteAppControllerAdditional.test.tsx +++ b/ui/web/tests/unit/hooks/useNoteAppControllerAdditional.test.tsx @@ -18,6 +18,7 @@ let mockIsOffline = false let mockOfflineOverlay: Array<{ id: string; status: string }> = [] let mockNotes: NoteViewModel[] = [] let mockResolvedSearchResult: NoteViewModel | null = null +let mockNoteToDelete: NoteViewModel | null = null const mockGetNoteStatus = jest.fn() const mockGetNote = jest.fn() @@ -39,6 +40,7 @@ const mockClearActiveSettingsNoteReturnPath = jest.fn() const mockResolveSearchResult = jest.fn(() => mockResolvedSearchResult) const mockUpdateNoteMutation = jest.fn() const mockPersistOfflineNoteUpdates = jest.fn().mockResolvedValue(undefined) +const mockConfirmDeleteNote = jest.fn() jest.mock('sonner', () => ({ toast: { error: jest.fn(), info: jest.fn(), success: jest.fn() } })) jest.mock('@ui/web/providers/SupabaseProvider', () => ({ @@ -81,7 +83,7 @@ jest.mock('@ui/web/hooks/useNoteSelection', () => ({ setIsEditing: mockSetIsEditing, deleteDialogOpen: false, setDeleteDialogOpen: jest.fn(), - noteToDelete: null, + noteToDelete: mockNoteToDelete, setNoteToDelete: jest.fn(), selectedNoteIds: new Set(), selectionMode: false, @@ -152,7 +154,7 @@ jest.mock('@ui/web/hooks/useNoteSaveHandlers', () => ({ handleAutoSave: mockHandleAutoSave, handleSaveNote: mockHandleSaveNote, handleReadNote: mockHandleReadNote, - confirmDeleteNote: jest.fn(), + confirmDeleteNote: mockConfirmDeleteNote, handleRemoveTagFromNote: jest.fn(), persistOfflineNoteUpdates: mockPersistOfflineNoteUpdates, }), @@ -234,9 +236,11 @@ describe('useNoteAppController additional observable behavior', () => { mockOfflineOverlay = [] mockNotes = [] mockResolvedSearchResult = null + mockNoteToDelete = null mockHandleAutoSave.mockReset().mockResolvedValue(undefined) mockHandleSaveNote.mockReset().mockResolvedValue(undefined) mockHandleReadNote.mockReset().mockResolvedValue(undefined) + mockConfirmDeleteNote.mockReset().mockResolvedValue(true) mockUpdateNoteMutation.mockReset() mockUpdateNoteMutation.mockResolvedValue(undefined) }) @@ -460,6 +464,75 @@ describe('useNoteAppController additional observable behavior', () => { expect(mockHandleSelectNote).toHaveBeenCalledWith(expect.objectContaining({ id: 'second' })) }) + it('aborts a note selection when flushing the outgoing editor fails', async () => { + const target = makeNote({ id: 'target' }) + mockGetNoteStatus.mockResolvedValue({ status: 'found', note: target }) + const { result } = setup() + await waitFor(() => expect(result.current.workspaceHydrated).toBe(true)) + + const flushPendingSave = jest.fn().mockRejectedValue(new Error('autosave failed')) + act(() => result.current.registerNoteEditorRef({ current: { flushPendingSave } } as never)) + + await act(async () => { + await result.current.handleSelectNote(target) + }) + + expect(flushPendingSave).toHaveBeenCalledTimes(1) + expect(mockGetNoteStatus).not.toHaveBeenCalled() + expect(mockHandleSelectNote).not.toHaveBeenCalled() + expect(result.current.activeTab.noteId).toBeNull() + }) + + it('resets every workspace tab that shows a deleted note', async () => { + const first = makeNote({ id: 'first', title: 'First' }) + const second = makeNote({ id: 'second', title: 'Second' }) + mockNoteToDelete = first + mockGetNoteStatus.mockImplementation((id: string) => Promise.resolve({ + status: 'found', + note: id === 'first' ? first : second, + })) + const { result } = setup() + await waitFor(() => expect(result.current.workspaceHydrated).toBe(true)) + + await act(async () => { + await result.current.handleSelectNote(first) + }) + await act(async () => { + await result.current.addTab() + }) + await act(async () => { + await result.current.handleSelectNote(second) + }) + expect(result.current.tabs.map((tab) => tab.noteId)).toEqual(['first', 'second']) + + await act(async () => { + await result.current.confirmDeleteNote() + }) + + expect(mockConfirmDeleteNote).toHaveBeenCalledTimes(1) + expect(result.current.tabs).toHaveLength(2) + expect(result.current.tabs.map((tab) => tab.noteId)).toEqual([null, 'second']) + expect(result.current.tabs[0].draft).toEqual({ title: '', description: '', tags: '' }) + }) + + it('keeps workspace tabs untouched when the delete fails', async () => { + const first = makeNote({ id: 'first', title: 'First' }) + mockNoteToDelete = first + mockConfirmDeleteNote.mockResolvedValue(false) + mockGetNoteStatus.mockResolvedValue({ status: 'found', note: first }) + const { result } = setup() + await waitFor(() => expect(result.current.workspaceHydrated).toBe(true)) + + await act(async () => { + await result.current.handleSelectNote(first) + }) + await act(async () => { + await result.current.confirmDeleteNote() + }) + + expect(result.current.tabs.map((tab) => tab.noteId)).toEqual(['first']) + }) + it('registers, resets, and loads AI pagination controls', () => { const { result } = setup() const resetAIResults = jest.fn() @@ -626,4 +699,4 @@ describe('useNoteAppController additional observable behavior', () => { expect(toast.success).toHaveBeenCalledWith('Tag "old" renamed to "new"') }) }) - + diff --git a/ui/web/tests/unit/hooks/useNoteBulkActions.test.tsx b/ui/web/tests/unit/hooks/useNoteBulkActions.test.tsx index 8b60139d96e..ceca79f197a 100644 --- a/ui/web/tests/unit/hooks/useNoteBulkActions.test.tsx +++ b/ui/web/tests/unit/hooks/useNoteBulkActions.test.tsx @@ -32,7 +32,7 @@ const makeParams = (overrides: Partial = {}) => { deleteNoteMutation: { mutateAsync: jest.fn().mockResolvedValue(undefined) }, exitSelectionMode: jest.fn(), setBulkDeleting: jest.fn(), - setSelectedNote: jest.fn(), + onNotesDeleted: jest.fn(), queryClient, notes: [note('visible')], selectAllVisibleCallback: jest.fn(), @@ -80,7 +80,7 @@ describe('useNoteBulkActions', () => { expect(toast.success).toHaveBeenCalledWith('Deleted 2 notes') expect(invalidate).toHaveBeenCalledWith({ queryKey: ['notes'] }) expect(invalidate).toHaveBeenCalledWith({ queryKey: ['aiSearch'] }) - expect(params.setSelectedNote).toHaveBeenCalledWith(null) + expect(params.onNotesDeleted).toHaveBeenCalledWith(['one', 'two']) }) it('reports partial online failures but still refreshes the local queries', async () => { @@ -98,7 +98,7 @@ describe('useNoteBulkActions', () => { }) expect(toast.error).toHaveBeenCalledWith('Failed to delete 1 notes') expect(invalidate).toHaveBeenCalledTimes(2) - expect(params.setSelectedNote).toHaveBeenCalledWith(null) + expect(params.onNotesDeleted).toHaveBeenCalledWith(['good']) }) it('queues offline deletions, persists optimistic tombstones, and updates overlay state', async () => { @@ -132,6 +132,7 @@ describe('useNoteBulkActions', () => { expect(applyOverlay([]).map((entry) => entry.id)).toEqual(['one', 'two']) expect(setPendingCount).toHaveBeenCalledWith(expect.any(Function)) expect(toast.success).toHaveBeenCalledWith('Queued deletion of 2 notes (offline)') + expect(params.onNotesDeleted).toHaveBeenCalledWith(['one', 'two']) }) it('manages the bulk deleting transition and exits selection mode only on success', async () => { From 9f0c9ed1cbc772d56e2e744a521191c4955790eb Mon Sep 17 00:00:00 2001 From: denyskoreiba Date: Thu, 20 Aug 2026 20:34:55 +0200 Subject: [PATCH 13/23] fix(notes-tabs): stop draft echo from cancelling autosave Manual preview verification showed typing never reached Supabase: the live tab draft was wired into NoteEditor's initial* props, so the autosave reconciliation acknowledged each keystroke echo as an external refresh, rebased the debouncer baseline, and dropped the pending save for new and existing notes alike. NotesShell now freezes the editor's seed draft per session (tab + note + mode); live draft updates keep feeding tab indicators and capture but no longer masquerade as external snapshots. Add a NotesShellAutoSaveEcho component spec that reproduces the echo loop with a real editor and fails without the fix. Co-Authored-By: Claude Fable 5 --- .../notes/NotesShellAutoSaveEcho.cy.tsx | 172 +++++++++++ .../design/2026-08-01-feature-notes-tabs.md | 11 + .../planning/2026-08-01-feature-notes-tabs.md | 1 + .../testing/2026-08-01-feature-notes-tabs.md | 5 +- .../components/features/notes/NotesShell.tsx | 271 +++++++++--------- 5 files changed, 328 insertions(+), 132 deletions(-) create mode 100644 cypress/component/features/notes/NotesShellAutoSaveEcho.cy.tsx diff --git a/cypress/component/features/notes/NotesShellAutoSaveEcho.cy.tsx b/cypress/component/features/notes/NotesShellAutoSaveEcho.cy.tsx new file mode 100644 index 00000000000..a595f35fb72 --- /dev/null +++ b/cypress/component/features/notes/NotesShellAutoSaveEcho.cy.tsx @@ -0,0 +1,172 @@ +import React from 'react' +import type { SupabaseClient, User } from '@supabase/supabase-js' + +import { NotesShell } from '../../../../ui/web/components/features/notes/NotesShell' +import type { NoteDraftSnapshot } from '../../../../core/services/noteWorkspaceTabs' +import type { NoteViewModel } from '../../../../core/types/domain' +import { SupabaseTestProvider } from '../../../../ui/web/providers/SupabaseProvider' +import { + useNotesShellTestState, + type FakeController, +} from './notesShellTestUtils' + +/** + * Regression guard: the real controller echoes every keystroke back into + * `activeTab.draft`, which NotesShell forwards to the editor. If that live + * echo reaches the editor's initial* props, the autosave reconciliation + * treats the user's own typing as an external refresh and silently cancels + * the pending autosave. This harness reproduces the echo loop and asserts + * the debounced autosave still fires. + */ +const buildController = () => { + const supabase = { + functions: { + invoke: cy.stub().resolves({ + data: { configured: false, integration: null }, + error: null, + }), + }, + } as unknown as SupabaseClient + + const user: User = { + id: 'test-user', + app_metadata: {}, + user_metadata: {}, + aud: 'authenticated', + created_at: new Date().toISOString(), + } as unknown as User + + const baseNotes: NoteViewModel[] = [ + { + id: 'note-1', + title: 'Note 1', + description: '', + tags: [], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + user_id: user.id, + }, + ] + + const handleAutoSave = cy.stub().as('autoSave').resolves(undefined) + + const Harness = () => { + const { + notes, + isEditing, + setIsEditing, + registerNoteEditorRef, + selectedNote, + activeTab: baseTab, + handleSaveNote, + handleReadNote, + handleEditNote, + handleSelectNote, + } = useNotesShellTestState(baseNotes) + + // Echo local typing back into the workspace tab exactly like + // useNoteAppController.handleDraftChange does. + const [draft, setDraft] = React.useState(null) + const activeTab = React.useMemo(() => ({ + ...baseTab, + draft: draft ?? baseTab.draft, + saveState: draft ? ('dirty' as const) : baseTab.saveState, + }), [baseTab, draft]) + + const controller: FakeController = { + registerNoteEditorRef, + user, + notes, + notesQuery: { + isLoading: false, + fetchNextPage: () => {}, + hasNextPage: false, + isFetchingNextPage: false, + }, + notesDisplayed: notes.length, + notesTotal: notes.length, + + selectionMode: false, + selectedCount: 0, + bulkDeleting: false, + enterSelectionMode: () => {}, + exitSelectionMode: () => {}, + selectAllVisible: () => {}, + clearSelection: () => {}, + deleteSelectedNotes: async () => {}, + selectedNoteIds: new Set(), + toggleNoteSelection: () => {}, + + filterByTag: null, + searchQuery: '', + handleSearch: () => {}, + handleClearTagFilter: () => {}, + handleCreateNote: () => setIsEditing(true), + handleSignOut: async () => {}, + handleDeleteAccount: async () => {}, + deleteAccountLoading: false, + invalidateNotes: async () => {}, + + pendingCount: 0, + failedCount: 0, + isOffline: false, + + selectedNote, + isEditing, + tabs: [activeTab], + activeTabId: activeTab.id, + activeTab, + addTab: () => {}, + activateTab: () => {}, + closeTab: () => {}, + handleDraftChange: (nextDraft: NoteDraftSnapshot) => setDraft(nextDraft), + handleViewSessionChange: () => {}, + saving: false, + autoSaving: false, + lastSavedAt: null, + + handleSelectNote, + handleSearchResultClick: async () => {}, + handleTagClick: () => {}, + handleEditNote, + handleSaveNote, + handleReadNote, + handleAutoSave, + handleDeleteNote: () => {}, + handleRemoveTagFromNote: async () => {}, + + ftsSearchResult: { isLoading: false }, + showFTSResults: false, + ftsData: { total: 0, executionTime: 1, results: [] }, + ftsHasMore: false, + ftsLoadingMore: false, + loadMoreFts: () => {}, + ftsObserverTarget: null, + ftsResults: [], + } + + return ( + + + + ) + } + + return Harness +} + +describe('NotesShell: autosave with live workspace draft echo', () => { + it('fires the debounced autosave even while typing echoes into the active tab draft', () => { + const typed = 'Echo safe text' + const Harness = buildController() + + cy.mount() + + cy.get('[data-cy="editor-content"]').click().type(typed) + + // The 500ms debounced autosave must survive the draft echo re-renders. + cy.get('@autoSave', { timeout: 4000 }).should('have.been.called') + cy.get('@autoSave').should('have.been.calledWithMatch', Cypress.sinon.match((payload: { description?: string }) => + Boolean(payload?.description?.includes(typed)))) + }) +}) diff --git a/docs/ai/design/2026-08-01-feature-notes-tabs.md b/docs/ai/design/2026-08-01-feature-notes-tabs.md index 5caaf18c83e..e6d2a1d7d24 100644 --- a/docs/ai/design/2026-08-01-feature-notes-tabs.md +++ b/docs/ai/design/2026-08-01-feature-notes-tabs.md @@ -173,6 +173,17 @@ preserved; only affected tabs change. This prevents a stale tab from rendering a deleted note or re-creating it through autosave, and bulk delete no longer clears the active tab when its note survived. +### The editor's initial snapshot is frozen per session + +`NoteEditor` reconciles its `initial*` props as an external (server-backed) +snapshot. The live tab draft echoes every keystroke back through +`handleDraftChange`, so `NotesShell` freezes the draft passed to the editor per +editor session (tab + note + mode). Feeding the live draft in directly makes +the autosave reconciliation acknowledge the user's own typing as an external +refresh and silently cancel every pending autosave — nothing is ever written +to Supabase until a manual Save. Guarded by the `NotesShellAutoSaveEcho` +component spec, which reproduces the echo loop with a real editor. + ### Session updates are debounced Typing and scrolling notify workspace state through diff --git a/docs/ai/planning/2026-08-01-feature-notes-tabs.md b/docs/ai/planning/2026-08-01-feature-notes-tabs.md index 0e550e8658d..a86e01048fa 100644 --- a/docs/ai/planning/2026-08-01-feature-notes-tabs.md +++ b/docs/ai/planning/2026-08-01-feature-notes-tabs.md @@ -98,4 +98,5 @@ These are engineering estimates only; autosave/editor lifecycle and native navig 3. Deleting a note (single or bulk) left it alive in other workspace tabs where typing could re-create it, and bulk delete blanked the active tab even when its note survived; `resetWorkspaceTabsForNotes` now resets exactly the affected tabs. 4. Entering edit mode wiped a tab's dirty/error save marker without saving; the transition now changes only the mode. Codacy findings (optional chains, `void` operator) resolved; Jest/ESLint configs now ignore in-repo agent worktrees under `.claude/worktrees/`. +- Manual preview verification (2026-08-20) found a release blocker the suites missed: wiring the live tab draft into the editor's `initial*` props made autosave reconciliation treat local typing as an external refresh and cancel every debounced autosave (new and existing notes; zero Supabase writes until manual Save). Fixed by freezing the editor's session draft in `NotesShell` per tab/note/mode; regression-guarded by `NotesShellAutoSaveEcho.cy.tsx`, which fails without the fix. Existing exit-save specs passed because their fake controller used a no-op `handleDraftChange` and never echoed drafts. - Native mobile (M4) remains a separately scoped follow-up: expose the shared reducer through the native store/adapter, route `useOpenNote` and note routes through it, and add a native session UI. Desktop Playwright E2E flows are a follow-up in the `koreyba/EverFreeNote-e2e` repository. diff --git a/docs/ai/testing/2026-08-01-feature-notes-tabs.md b/docs/ai/testing/2026-08-01-feature-notes-tabs.md index f258974db61..ca14918c884 100644 --- a/docs/ai/testing/2026-08-01-feature-notes-tabs.md +++ b/docs/ai/testing/2026-08-01-feature-notes-tabs.md @@ -99,8 +99,9 @@ Recorded Evidence). - `npx eslint . --max-warnings=0`: passed. - `npm run test:unit`: 183 suites, 1,441 tests passed — includes new coverage for `resetWorkspaceTabsForNotes`, `useDebouncedSessionCallback`, flush-failure transition abort, delete-driven tab reset, and `onNotesDeleted` bulk reporting. - `npm run test:integration:core`: 2 suites, 20 tests passed. -- Focused Cypress component run (`NotesTabStrip`, `MobileLayout`, `NotesShellFtsExitSave`, `NotesShellLikeExitSave`, `NotesShellOpenInContext`, `useNoteBulkActionsDirect`): 20/20 passed in Electron. -- Manual verification against the PR preview deployment (test-auth user): desktop tab add/replace/dedupe/switch/close, reload restore, mobile compact menu. +- Focused Cypress component run (`NotesTabStrip`, `MobileLayout`, `NotesShellFtsExitSave`, `NotesShellLikeExitSave`, `NotesShellOpenInContext`, `NotesShellAutoSaveEcho`, `useNoteBulkActionsDirect`): 21/21 passed in Electron. +- Manual verification against the PR preview deployment (test-auth user): desktop tab add/replace/dedupe/switch/close, draft/mode restore across switches, reload restore, mobile compact menu. +- The manual preview pass caught an autosave-killing draft-echo defect that the suites missed (fake controllers used a no-op `handleDraftChange`): typing produced zero Supabase writes and the dirty marker never cleared. Fixed in `NotesShell` (frozen session draft) and regression-guarded by `NotesShellAutoSaveEcho.cy.tsx`, verified to fail without the fix. ## Performance Testing diff --git a/ui/web/components/features/notes/NotesShell.tsx b/ui/web/components/features/notes/NotesShell.tsx index c5123ef80eb..d6fdd9c97f2 100644 --- a/ui/web/components/features/notes/NotesShell.tsx +++ b/ui/web/components/features/notes/NotesShell.tsx @@ -31,13 +31,13 @@ import { useSupabase } from "@ui/web/providers/SupabaseProvider" import { WordPressSettingsService } from "@core/services/wordpressSettings" import { ApiKeysSettingsService } from "@core/services/apiKeysSettings" import { saveSettingsReturnState } from "@ui/web/lib/settingsNavigationState" -import { consumeActiveSettingsNoteReturnPath } from "@ui/web/lib/aiIndexNavigationState" -import { NotesTabStrip } from "@/components/features/notes/NotesTabStrip" -import { MobileNotesTabMenu } from "@/components/features/notes/MobileNotesTabMenu" +import { consumeActiveSettingsNoteReturnPath } from "@ui/web/lib/aiIndexNavigationState" +import { NotesTabStrip } from "@/components/features/notes/NotesTabStrip" +import { MobileNotesTabMenu } from "@/components/features/notes/MobileNotesTabMenu" import { NavRail } from "@/components/features/navigation/NavRail" -import { TagsPage } from "@/components/features/tags/TagsPage" -import { MAX_NOTE_WORKSPACE_TABS } from "@core/services/noteWorkspaceTabs" +import { TagsPage } from "@/components/features/tags/TagsPage" +import { MAX_NOTE_WORKSPACE_TABS } from "@core/services/noteWorkspaceTabs" type NoteRecord = Note & { content?: string | null @@ -90,24 +90,24 @@ export function NotesShell({ controller }: NotesShellProps) { pendingCount, failedCount, isOffline, - selectedNote, - isEditing, - notePaneVisible, - tabs = [], - activeTabId = '', - addTab, - activateTab, - closeTab, - canAddTab = true, - workspaceHydrated = true, + selectedNote, + isEditing, + notePaneVisible, + tabs = [], + activeTabId = '', + addTab, + activateTab, + closeTab, + canAddTab = true, + workspaceHydrated = true, handleSelectNote, isSearchPanelOpen, setIsSearchPanelOpen, - activeMainView, - setActiveMainView, - handleRenameTag, - handleDeleteTag, - notes, + activeMainView, + setActiveMainView, + handleRenameTag, + handleDeleteTag, + notes, } = controller const refreshWordPressStatus = React.useCallback(async () => { @@ -158,7 +158,7 @@ export function NotesShell({ controller }: NotesShellProps) { await controller.handleEditNote(note) }, [controller, supabase]) - const showEditor = notePaneVisible ?? !!(selectedNote || isEditing) + const showEditor = notePaneVisible ?? !!(selectedNote || isEditing) const handleOpenSearchPanel = React.useCallback(() => { if (activeMainView !== "notes") { setActiveMainView("notes") @@ -200,10 +200,10 @@ export function NotesShell({ controller }: NotesShellProps) { }) }, [handleSelectNote, router]) - const handleSelectTagFromTagsPage = React.useCallback((tag: string) => { - controller.handleTagClick(tag).catch(() => undefined) - setActiveMainView("notes") - }, [controller, setActiveMainView]) + const handleSelectTagFromTagsPage = React.useCallback((tag: string) => { + controller.handleTagClick(tag).catch(() => undefined) + setActiveMainView("notes") + }, [controller, setActiveMainView]) return (
- ) : ( -
- void addTab?.()} - onActivateTab={(tabId) => void activateTab?.(tabId)} - onCloseTab={(tabId) => void closeTab?.(tabId)} - /> -
- void handleOpenSettings()} - onCreateNote={handleCreateNote} - onSignOut={handleSignOut} - onOpenSearch={handleOpenSearchPanel} - className={cn((showEditor || isSearchPanelOpen) ? "hidden md:flex" : "w-full md:w-80")} - data-testid="sidebar-container" - > - - - - {isSearchPanelOpen && ( - setIsSearchPanelOpen(false)} - className={cn(showEditor ? "hidden md:flex" : "w-full min-w-[300px] md:min-w-0")} - /> - )} - -
- void addTab?.()} - onActivateTab={(tabId) => void activateTab?.(tabId)} - onCloseTab={(tabId) => void closeTab?.(tabId)} - /> -
- -
-
-
-
- )} + ) : ( +
+ void addTab?.()} + onActivateTab={(tabId) => void activateTab?.(tabId)} + onCloseTab={(tabId) => void closeTab?.(tabId)} + /> +
+ void handleOpenSettings()} + onCreateNote={handleCreateNote} + onSignOut={handleSignOut} + onOpenSearch={handleOpenSearchPanel} + className={cn((showEditor || isSearchPanelOpen) ? "hidden md:flex" : "w-full md:w-80")} + data-testid="sidebar-container" + > + + + + {isSearchPanelOpen && ( + setIsSearchPanelOpen(false)} + className={cn(showEditor ? "hidden md:flex" : "w-full min-w-[300px] md:min-w-0")} + /> + )} + +
+ void addTab?.()} + onActivateTab={(tabId) => void activateTab?.(tabId)} + onCloseTab={(tabId) => void closeTab?.(tabId)} + /> +
+ +
+
+
+
+ )}
@@ -323,9 +323,9 @@ export function NotesShell({ controller }: NotesShellProps) { function ListPane({ controller }: { controller: NoteAppController }) { const { notes, - notesQuery, - selectedNote, - handleSelectNote, + notesQuery, + selectedNote, + handleSelectNote, selectionMode, selectedNoteIds, toggleNoteSelection, @@ -364,10 +364,10 @@ function EditorPane({ pendingChunkFocus: PendingChunkFocus | null onPendingChunkFocusApplied: (requestId: string) => void }) { - const { - selectedNote, - activeTab, - activeTabId, + const { + selectedNote, + activeTab, + activeTabId, isEditing, saving, autoSaving, @@ -411,6 +411,17 @@ function EditorPane({ return counts }, [allTagsQuery.data, notes]) + // The editor treats its initial* props as an external snapshot and + // reconciles them against local typing. The live tab draft echoes every + // keystroke back through the controller, so it must be frozen per editor + // session (tab + note + mode) — otherwise the echo is acknowledged as an + // external refresh and the pending autosave is cancelled before it runs. + const sessionDraft = React.useMemo( + () => activeTab.draft, + // eslint-disable-next-line react-hooks/exhaustive-deps -- session identity only; the live draft must not retrigger + [activeTabId, activeTab.noteId, activeTab.mode], + ) + if (!selectedNote && !isEditing) { return } @@ -418,15 +429,15 @@ function EditorPane({ if (isEditing) { return ( handleEditNote(selectedNote)} onDelete={() => handleDeleteNote(selectedNote)} onTagClick={controller.handleTagClick} onRemoveTag={(tag) => handleRemoveTagFromNote(selectedNote.id, tag)} onBack={onBack} - wordpressConfigured={wordpressConfigured} - initialScrollTop={activeTab.view.scrollTop} - onViewSessionChange={controller.handleViewSessionChange} - /> + wordpressConfigured={wordpressConfigured} + initialScrollTop={activeTab.view.scrollTop} + onViewSessionChange={controller.handleViewSessionChange} + /> ) } From cc80d5da7b5972beb20eb3df8c508aa5e3335e91 Mon Sep 17 00:00:00 2001 From: denyskoreiba Date: Thu, 20 Aug 2026 21:55:54 +0200 Subject: [PATCH 14/23] fix(notes-tabs): cap tab strip width and clear deleted-note tab draft Many tabs blew the page width: the NotesShell column, row, main, and editor-pane flex items were missing min-w-0, so the strip's intrinsic width propagated up and pushed the whole layout (NavRail off-screen on desktop, tab controls off-screen on mobile). Tabs now scroll inside the strip viewport as designed; the mobile tab header stays constrained. Also reset the active tab by id after a delete: the save handler clears the tab's noteId before the workspace reset runs, so the noteId-based reset alone left a stale draft as the tab label. Co-Authored-By: Claude Fable 5 --- .../features/notes/MobileNotesTabMenu.tsx | 2 +- .../components/features/notes/NotesShell.tsx | 8 ++++---- ui/web/hooks/useNoteAppController.ts | 19 +++++++++++++++++-- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/ui/web/components/features/notes/MobileNotesTabMenu.tsx b/ui/web/components/features/notes/MobileNotesTabMenu.tsx index 2347a1f0733..9d4aa49d71b 100644 --- a/ui/web/components/features/notes/MobileNotesTabMenu.tsx +++ b/ui/web/components/features/notes/MobileNotesTabMenu.tsx @@ -46,7 +46,7 @@ export function MobileNotesTabMenu({ } return ( -
+

{activeLabel}

diff --git a/ui/web/components/features/notes/NotesShell.tsx b/ui/web/components/features/notes/NotesShell.tsx index d6fdd9c97f2..0e696f795e7 100644 --- a/ui/web/components/features/notes/NotesShell.tsx +++ b/ui/web/components/features/notes/NotesShell.tsx @@ -234,7 +234,7 @@ export function NotesShell({ controller }: NotesShellProps) { /> ) : ( -
+
void activateTab?.(tabId)} onCloseTab={(tabId) => void closeTab?.(tabId)} /> -
+
void activateTab?.(tabId)} onCloseTab={(tabId) => void closeTab?.(tabId)} /> -
+
{ const deletedNoteId = noteToDelete?.id ?? null + // The save handler clears the active tab's noteId before we run, so the + // tab that shows the note must be resolved before the delete. + const tabHoldingNote = deletedNoteId ? findTabByNoteId(deletedNoteId) : null const deleted = await confirmDeleteNote() // A deleted note must not survive in any workspace tab: a stale tab would - // keep rendering it and typing there would re-create the note. + // keep rendering it (or its draft as the tab label) and typing there + // would re-create the note. if (deleted && deletedNoteId) { resetTabsForNotes([deletedNoteId]) + if (tabHoldingNote) { + updateTab(tabHoldingNote.id, { + note: null, + noteId: null, + mode: 'reading', + draft: { title: '', description: '', tags: '' }, + view: { scrollTop: 0, titleSelection: undefined, editorSelection: undefined }, + saveState: 'saved', + saveError: null, + }) + } } - }, [confirmDeleteNote, noteToDelete, resetTabsForNotes]) + }, [confirmDeleteNote, findTabByNoteId, noteToDelete, resetTabsForNotes, updateTab]) const handleDraftChange = useCallback((draft: NoteDraftSnapshot) => { updateTab(activeTabId, { From 6dffe51bc06511efaa52b3d88c596a54dfefdb4f Mon Sep 17 00:00:00 2001 From: denyskoreiba Date: Thu, 10 Sep 2026 21:02:17 +0200 Subject: [PATCH 15/23] fix(notes-tabs): make the tab strip scrollable instead of capped, float the mobile menu Browser pass over the notes workspace tabs at 320-1440px with 1, 2, 8 and 24 tabs found the tab strip treating "runs out of horizontal room" as a capacity limit, and the mobile tab list displacing the note it should float over. Desktop strip: - Add is gated on MAX_NOTE_WORKSPACE_TABS only. The measured width previously capped the workspace at 8 tabs on a 1440px window and 4 on a 1024px one, and announced "limit reached: 4 tabs" while eight tabs were open. - Chevron controls and wheel-to-horizontal scrolling reach the overflow; macOS overlay scrollbars stay invisible until a scroll is already running. - The native scrollbar is hidden, so the strip no longer grows 43px -> 54px when it starts overflowing. - A ResizeObserver re-reveals the active tab, which otherwise scrolled out of sight on resize, and scrollIntoView now targets the whole tab so its close button is inside the viewport too. - Scroll snapping stops the strip from leaving a tab clipped down to just its close button. Mobile menu: - Absolutely positioned popover; in flow it pushed the note down 545px. - Dismisses on outside pointer-down and on Escape. - Scrolls the active row into view on open. Also fall back to the live draft title in the close-tab confirmation, which said "this tab" for an unsaved new note. Co-Authored-By: Claude Opus 5 --- .../features/notes/NotesTabStrip.cy.tsx | 9 +- .../design/2026-08-01-feature-notes-tabs.md | 48 ++++-- .../2026-08-01-feature-notes-tabs.md | 28 +++- .../testing/2026-08-01-feature-notes-tabs.md | 43 +++++ .../features/notes/MobileNotesTabMenu.tsx | 55 ++++++- .../features/notes/NotesTabStrip.tsx | 149 +++++++++++++---- ui/web/hooks/useNoteAppController.ts | 6 +- .../components/notesWorkspaceTabs.test.tsx | 154 +++++++++++++++++- 8 files changed, 426 insertions(+), 66 deletions(-) diff --git a/cypress/component/features/notes/NotesTabStrip.cy.tsx b/cypress/component/features/notes/NotesTabStrip.cy.tsx index 847f08c637f..85c64076414 100644 --- a/cypress/component/features/notes/NotesTabStrip.cy.tsx +++ b/cypress/component/features/notes/NotesTabStrip.cy.tsx @@ -34,9 +34,14 @@ describe('Desktop notes tab strip', () => { /> ) - cy.get('button[aria-label^="Add note tab (limit reached:"]') + // Overflowing the strip must not block opening more notes: the excess + // scrolls and Add stays available until the shared 32-tab maximum. + // (The scroll arrows depend on measured overflow, which this component + // harness does not reproduce faithfully — they are covered in the jsdom + // unit test where the viewport size is controlled directly.) + cy.get('button[aria-label="Add note tab"]') .should('be.visible') - .and('be.disabled') + .and('not.be.disabled') .parent() .children() .first() diff --git a/docs/ai/design/2026-08-01-feature-notes-tabs.md b/docs/ai/design/2026-08-01-feature-notes-tabs.md index e6d2a1d7d24..42c51c93d09 100644 --- a/docs/ai/design/2026-08-01-feature-notes-tabs.md +++ b/docs/ai/design/2026-08-01-feature-notes-tabs.md @@ -128,15 +128,38 @@ list state so an empty tab never traps the user in a blank editor screen. The desktop tab strip keeps Add as the first, non-scrolling control. The tab viewport uses a browser-like flex layout: tabs grow while there is room, stop -at a 120px minimum, and horizontally scroll when a restored workspace is -larger than the viewport. A `ResizeObserver` measures the tab viewport and -disables Add when another tab would require shrinking below that minimum. -The shared core model/controller also enforces a 32-tab ceiling, so direct -calls cannot bypass the UI guard. The disabled button exposes the applicable -limit in its accessible name so the constraint is understandable without -relying on the visual layout. The mobile menu receives the controller's -disabled state explicitly and keeps its tab rows in a scrollable region while -leaving Add available as the fixed menu footer. +at a 120px minimum, and horizontally scroll once they no longer fit. + +Running out of horizontal room is a scrolling concern, not a capacity limit. +Add stays enabled until the shared 32-tab ceiling enforced by the core model +and controller, and its accessible name only reports that one number — an +earlier revision derived a second, width-based limit, which made Add dead at +four tabs on a 1024px window and announced "limit reached: 4 tabs" while eight +tabs were open. The screen width now decides how the strip scrolls, never how +many notes the user may keep open. + +Because the strip scrolls, it also owns the affordances that make the hidden +tabs reachable: + +- Chevron buttons appear on either side while the strip overflows and are + disabled at each end. They are the primary control, since macOS hides + overlay scrollbars until a scroll is already in progress. +- A vertical wheel over the strip scrolls it horizontally, for pointers with + no horizontal axis. +- The native scrollbar is hidden (`.scrollbar-none`). It would otherwise add + ~11px to the strip and make its height jump as tabs are opened and closed. +- Scroll snapping aligns tabs to the left edge, so the strip never leaves a + sliver of a tab whose only visible part is its close button. +- A `ResizeObserver` on the tab viewport re-reveals the active tab. Without it, + narrowing the window leaves the active tab scrolled out of sight while its + note is the one on screen. + +The mobile menu receives the controller's disabled state explicitly and keeps +its tab rows in a scrollable region while leaving Add available as the fixed +menu footer. The menu is an absolutely positioned popover: in flow it pushed +the note down by its own height (over 500px with a full tab list), leaving a +sliver of the note visible. It scrolls its active row into view on open and +dismisses on outside pointer-down and on Escape. ### Active-slot replacement is the default @@ -203,8 +226,11 @@ The workspace is modeled as a list of independent tab sessions, while the contro - Storage writes are best-effort and serialized from a small, bounded state snapshot; storage failures never block editing. - Tab buttons are keyboard reachable, have accessible names, and expose active/dirty/error state. - Long titles are ellipsized; desktop tabs grow/shrink within the 120px - minimum and overflow horizontally when needed, with Add fixed on the left; - mobile uses a compact list. + minimum and overflow horizontally when needed, with Add fixed on the left + and chevron controls for the overflow; mobile uses a compact list. +- The strip height does not change as tabs are added, removed, or overflow. +- The active tab stays scrolled into view across activation, tab open/close, + and window resize. - No secrets or auth tokens are added to workspace storage. ## Design Review Resolution (2026-08-01) diff --git a/docs/ai/implementation/2026-08-01-feature-notes-tabs.md b/docs/ai/implementation/2026-08-01-feature-notes-tabs.md index 1548bcdb73f..319936200cb 100644 --- a/docs/ai/implementation/2026-08-01-feature-notes-tabs.md +++ b/docs/ai/implementation/2026-08-01-feature-notes-tabs.md @@ -44,11 +44,18 @@ ui/web/components/features/notes/NoteView.tsx - After a successful delete, call `resetTabsForNotes` with the deleted IDs so no tab keeps a deleted note; bulk delete reports the actually-deleted IDs through `onNotesDeleted`. - Use the active tab's draft as editor initial content; never use a server refresh to overwrite a dirty local field without existing reconciliation rules. - Keep tab indicators derived from explicit per-tab save state rather than global UI assumptions. -- Keep the desktop Add control outside the scrolling tab viewport. `NotesTabStrip` - measures that viewport, uses the same 120px minimum as its CSS `min-width`, - and disables Add when the next tab would violate the minimum. Existing tabs - can still be inspected by horizontal scrolling, including after restoring a - wider workspace into a narrow viewport. +- Keep the desktop Add control outside the scrolling tab viewport, and keep it + enabled until the shared 32-tab ceiling. Screen width decides how the strip + scrolls, not how many notes may be open: `NotesTabStrip` measures the + viewport only to drive the chevron controls and to keep the active tab + revealed. Existing tabs can still be inspected by horizontal scrolling, + including after restoring a wider workspace into a narrow viewport. +- Scroll the tab strip with instant (`behavior: "auto"`) scrolling. Mandatory + scroll snapping re-snaps the strip on the next layout, which aborts an + in-flight smooth animation and makes the chevrons look dead. +- Reveal the active tab from a single effect keyed on the active tab id and + the tab count, and re-run it from a `ResizeObserver`. Reading `.current` of + a ref inside a `useCallback` trips `react-hooks/preserve-manual-memoization`. ### Patterns & Best Practices @@ -57,12 +64,17 @@ ui/web/components/features/notes/NoteView.tsx - Do not add direct Supabase calls to tab UI or storage. - Use `sessionStorage` only through the adapter and never read/write it during server rendering. - Keep accessibility labels stable so component tests can target behavior rather than CSS. -- When desktop capacity is reached, retain a real disabled button and expose - the measured limit in its accessible name; do not hide Add or rely on a - tooltip-only explanation. +- When the tab ceiling is reached, retain a real disabled button and expose + that one limit in its accessible name; do not hide Add or rely on a + tooltip-only explanation. Never announce a limit smaller than the number of + tabs already open — an accessible name has to describe the same rule the + user can see. - The core reducer and controller enforce the shared 32-tab ceiling before flushing editor work. `MobileNotesTabMenu` receives that explicit state and renders a scrollable tab-list body with Add as a non-scrolling footer. +- Render the mobile tab list as an absolutely positioned popover, and give it + the dismissals a popover is expected to have (outside pointer-down, Escape). + In normal flow it displaces the note it is meant to float over. ## Integration Points diff --git a/docs/ai/testing/2026-08-01-feature-notes-tabs.md b/docs/ai/testing/2026-08-01-feature-notes-tabs.md index ca14918c884..e861494be4a 100644 --- a/docs/ai/testing/2026-08-01-feature-notes-tabs.md +++ b/docs/ai/testing/2026-08-01-feature-notes-tabs.md @@ -103,6 +103,49 @@ Recorded Evidence). - Manual verification against the PR preview deployment (test-auth user): desktop tab add/replace/dedupe/switch/close, draft/mode restore across switches, reload restore, mobile compact menu. - The manual preview pass caught an autosave-killing draft-echo defect that the suites missed (fake controllers used a no-op `handleDraftChange`): typing produced zero Supabase writes and the dirty marker never cleared. Fixed in `NotesShell` (frozen session draft) and regression-guarded by `NotesShellAutoSaveEcho.cy.tsx`, verified to fail without the fix. +### Responsive/browser pass (2026-09-10) + +Run against a local Supabase stack and the Next dev server, driven through the +in-app browser at 320, 360, 375, 390, 768, 900, 1024, 1280 and 1440 CSS px, +with workspaces of 1, 2, 8 and 24 tabs (including titles long enough to be +ellipsized, Cyrillic titles, and notes long enough to scroll). + +Verified working: + +- Open note into the active tab, Add tab, fill the new blank tab from the list, + close tab, and duplicate-note deduplication (re-selecting an open note + activates its existing tab instead of adding one). +- Reading and editing scroll positions survive tab switches and a page reload, + and carry over from reading into editing for the same note. (Note when + re-testing: the restore runs inside `requestAnimationFrame`, which is + suspended while the browser pane is hidden — measure with the page visible + or the restore appears to be lost.) +- Autosave from the tab-aware controller reaches the database; the tab label + follows the edited title. +- Long titles ellipsize in the desktop tab, the mobile header, and the mobile + menu rows. +- No horizontal page overflow at any tested width. + +Defects found and fixed in this pass: + +| Defect | Where | Fix | +|---|---|---| +| Add disabled by measured width: 8 tabs max at 1440px, 4 at 1024px, never the documented 32; the label announced a limit lower than the number of open tabs | `NotesTabStrip` | Add is gated on `MAX_NOTE_WORKSPACE_TABS` only | +| Overflowing tabs unreachable — no arrows, and macOS overlay scrollbars stay invisible until scrolling | `NotesTabStrip` | Chevron controls plus wheel-to-horizontal scrolling | +| Strip grew 43px → 54px when the horizontal scrollbar appeared | `NotesTabStrip` | `.scrollbar-none` on the viewport | +| Active tab scrolled out of view after a window resize, and clipped on load with 24 tabs | `NotesTabStrip` | `ResizeObserver` re-reveals it; `scrollIntoView` targets the whole tab, not just its title button | +| A clipped tab could show only its close button, inviting a click that closes an unreadable tab | `NotesTabStrip` | `snap-x snap-mandatory` with `snap-start` tabs | +| Mobile tab menu sat in flow and pushed the note down 545px | `MobileNotesTabMenu` | Absolutely positioned popover | +| Mobile menu had no outside-click or Escape dismissal | `MobileNotesTabMenu` | Pointer-down and Escape handlers | +| Mobile menu opened at the top of a 24-row list, hiding the active tab | `MobileNotesTabMenu` | Active row scrolled into view on open | +| Close-tab confirmation said "this tab" for an unsaved new note | `useNoteAppController` | Falls back to the live draft title | + +Known, not fixed here (pre-existing, outside this feature): + +- At 320px the `NoteView`/`NoteEditor` action bar overflows its header by + ~12px, clipping the "more actions" button. Unchanged by this PR and fine + from 360px up. + ## Performance Testing - Verify switching does not mount more than one editor or trigger duplicate fetches. diff --git a/ui/web/components/features/notes/MobileNotesTabMenu.tsx b/ui/web/components/features/notes/MobileNotesTabMenu.tsx index 9d4aa49d71b..550ec9ff209 100644 --- a/ui/web/components/features/notes/MobileNotesTabMenu.tsx +++ b/ui/web/components/features/notes/MobileNotesTabMenu.tsx @@ -1,6 +1,6 @@ "use client" -import { useState } from "react" +import { useEffect, useRef, useState } from "react" import { List, Plus, X } from "lucide-react" import type { NoteWorkspaceTab } from "@core/services/noteWorkspaceTabs" import { Button } from "@/components/ui/button" @@ -20,9 +20,43 @@ export function MobileNotesTabMenu({ maximumTabCount, }: MobileNotesTabMenuProps) { const [isOpen, setIsOpen] = useState(false) + const containerRef = useRef(null) + const activeRowRef = useRef(null) const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0] const activeLabel = activeTab ? getTabLabel(activeTab) : "No open notes" + // With many tabs the active one can sit far down the scrollable list; bring + // it into view so opening the menu always shows where you currently are. + useEffect(() => { + if (!isOpen) return + const row = activeRowRef.current + if (row && typeof row.scrollIntoView === "function") { + row.scrollIntoView({ block: "nearest" }) + } + }, [isOpen]) + + // The panel floats over the note, so it needs the dismissal affordances a + // popover is expected to have: tapping outside it and pressing Escape. + useEffect(() => { + if (!isOpen) return + + const handlePointerDown = (event: PointerEvent | MouseEvent) => { + const target = event.target + if (target instanceof Node && containerRef.current?.contains(target)) return + setIsOpen(false) + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setIsOpen(false) + } + + document.addEventListener("pointerdown", handlePointerDown) + document.addEventListener("keydown", handleKeyDown) + return () => { + document.removeEventListener("pointerdown", handlePointerDown) + document.removeEventListener("keydown", handleKeyDown) + } + }, [isOpen]) + const handleActivate = (tabId: string) => { void onActivateTab(tabId) setIsOpen(false) @@ -46,7 +80,10 @@ export function MobileNotesTabMenu({ } return ( -
+

{activeLabel}

@@ -70,14 +107,24 @@ export function MobileNotesTabMenu({
{isOpen && ( -
+
{tabs.map((tab) => { const label = getTabLabel(tab) const isActive = tab.id === activeTabId return ( -
+
+ ) + } return (
@@ -188,7 +251,20 @@ export function NotesTabStrip({ >