From c47a67168cf3141c3a27a089cbfdd12327bd2f6c Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 8 Mar 2026 12:32:52 +0100 Subject: [PATCH] fix: AI chat receives note body from open tabs instead of empty allContent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit allContent is empty ({}) in Tauri mode because loadVaultData never populates it — note content only enters allContent on explicit save. mergeTabContent() enriches allContent with open tab content so the AI context snapshot always includes the active note's body. Co-Authored-By: Claude Opus 4.6 --- src/components/Editor.tsx | 10 ++++- src/utils/mergeTabContent.test.ts | 61 +++++++++++++++++++++++++++++++ src/utils/mergeTabContent.ts | 18 +++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 src/utils/mergeTabContent.test.ts create mode 100644 src/utils/mergeTabContent.ts diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 4900288d..a6ffe719 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -1,4 +1,4 @@ -import { useRef, useEffect, useCallback, memo } from 'react' +import { useRef, useEffect, useCallback, memo, useMemo } from 'react' import { useEditorTabSwap, getH1TextFromBlocks } from '../hooks/useEditorTabSwap' import { useHeadingTitleSync } from '../hooks/useHeadingTitleSync' import { useCreateBlockNote } from '@blocknote/react' @@ -15,6 +15,7 @@ import { useEditorFocus } from '../hooks/useEditorFocus' import { EditorRightPanel } from './EditorRightPanel' import { EditorContent } from './EditorContent' import { schema } from './editorSchema' +import { mergeTabContent } from '../utils/mergeTabContent' import './Editor.css' import './EditorTheme.css' @@ -172,6 +173,11 @@ export const Editor = memo(function Editor({ diffMode, rawMode, handleToggleDiff, handleToggleRaw, rawToggleRef, diffToggleRef, }) + const enrichedAllContent = useMemo( + () => mergeTabContent(allContent, tabs), + [allContent, tabs], + ) + const isLoadingNewTab = activeTabPath !== null && !activeTab const activeStatus = activeTab ? getNoteStatus?.(activeTab.entry.path) ?? 'clean' : 'clean' const showDiffToggle = !!(activeTab && (diffMode || activeStatus === 'modified')) @@ -234,7 +240,7 @@ export const Editor = memo(function Editor({ inspectorEntry={inspectorEntry} inspectorContent={inspectorContent} entries={entries} - allContent={allContent} + allContent={enrichedAllContent} gitHistory={gitHistory} vaultPath={vaultPath ?? ''} openTabs={tabs.map(t => t.entry)} diff --git a/src/utils/mergeTabContent.test.ts b/src/utils/mergeTabContent.test.ts new file mode 100644 index 00000000..b0993434 --- /dev/null +++ b/src/utils/mergeTabContent.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest' +import { mergeTabContent } from './mergeTabContent' + +describe('mergeTabContent', () => { + it('adds tab content when allContent is empty', () => { + const result = mergeTabContent({}, [ + { entry: { path: '/vault/a.md' }, content: '# Hello\nBody text' }, + ]) + expect(result['/vault/a.md']).toBe('# Hello\nBody text') + }) + + it('overrides allContent with tab content (editor state is fresher)', () => { + const result = mergeTabContent( + { '/vault/a.md': 'old saved content' }, + [{ entry: { path: '/vault/a.md' }, content: 'current editor content' }], + ) + expect(result['/vault/a.md']).toBe('current editor content') + }) + + it('preserves allContent for paths without open tabs', () => { + const result = mergeTabContent( + { '/vault/b.md': 'other note content' }, + [{ entry: { path: '/vault/a.md' }, content: '# Hello' }], + ) + expect(result['/vault/b.md']).toBe('other note content') + expect(result['/vault/a.md']).toBe('# Hello') + }) + + it('returns original object when no tabs have content to merge', () => { + const original = { '/vault/a.md': 'content' } + expect(mergeTabContent(original, [])).toBe(original) + }) + + it('skips tabs with empty content', () => { + const original = { '/vault/a.md': 'content' } + const result = mergeTabContent(original, [ + { entry: { path: '/vault/b.md' }, content: '' }, + ]) + expect(result).toBe(original) + expect(result['/vault/b.md']).toBeUndefined() + }) + + it('handles multiple tabs', () => { + const result = mergeTabContent({}, [ + { entry: { path: '/vault/a.md' }, content: 'Note A' }, + { entry: { path: '/vault/b.md' }, content: 'Note B' }, + ]) + expect(result['/vault/a.md']).toBe('Note A') + expect(result['/vault/b.md']).toBe('Note B') + }) + + it('does not mutate the original allContent object', () => { + const original = { '/vault/a.md': 'old' } + const result = mergeTabContent(original, [ + { entry: { path: '/vault/a.md' }, content: 'new' }, + ]) + expect(original['/vault/a.md']).toBe('old') + expect(result['/vault/a.md']).toBe('new') + expect(result).not.toBe(original) + }) +}) diff --git a/src/utils/mergeTabContent.ts b/src/utils/mergeTabContent.ts new file mode 100644 index 00000000..44580346 --- /dev/null +++ b/src/utils/mergeTabContent.ts @@ -0,0 +1,18 @@ +/** + * Merge open tab content into the allContent dictionary. + * Tab content takes priority (it reflects the current editor state). + * Returns the original object if no changes are needed (stable reference for useMemo). + */ +export function mergeTabContent( + allContent: Record, + tabs: ReadonlyArray<{ entry: { path: string }; content: string }>, +): Record { + let merged: Record | null = null + for (const tab of tabs) { + if (!tab.content) continue + if (allContent[tab.entry.path] === tab.content) continue + if (!merged) merged = { ...allContent } + merged[tab.entry.path] = tab.content + } + return merged ?? allContent +}