diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index faa87f0a..2e4856bf 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -240,7 +240,7 @@ On Linux, `run()` applies WebKitGTK startup safeguards before Tauri creates the
## Multi-Window (Note Windows)
-Notes can be opened in separate Tauri windows for focused editing. Secondary windows show only the editor panel (no sidebar, no note list).
+Notes can be opened in separate Tauri windows for focused editing. Secondary windows boot the same `App` shell and load the same active workspace graph as the main window, but they start in the editor-only view mode with side panels collapsed.
**Triggers:**
- `Cmd+Shift+Click` on any note in the note list or sidebar
@@ -250,8 +250,7 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win
**Architecture:**
- `openNoteInNewWindow()` (`src/utils/openNoteWindow.ts`) creates a new `WebviewWindow` via the Tauri v2 JS API with URL query params (`?window=note&path=...&vault=...&title=...`)
-- `main.tsx` checks `isNoteWindow()` at boot to route between `App` (main window) and `NoteWindow` (secondary window)
-- `NoteWindow` (`src/NoteWindow.tsx`) is a minimal shell that loads vault entries, fetches note content, applies the theme, and renders a single `Editor` instance
+- `main.tsx` always mounts `App`; `App` checks `isNoteWindow()` at startup, keeps normal vault/workspace loading active, and `useNoteWindowLifecycle` opens the requested note after the app graph is ready
- Each window has its own auto-save via `useEditorSaveWithLinks` (same 1.5s low-end-safe idle debounce, same Rust `save_note_content` command), and raw-editor typing also derives frontmatter-backed `VaultEntry` state in the renderer so Inspector and note-list surfaces react immediately without waiting for a full reload
- Secondary windows are sized 800×700; macOS keeps the overlay title bar, while Linux mounts the shared React titlebar on undecorated windows
- Capabilities config (`src-tauri/capabilities/default.json`) grants permissions to both `main` and `note-*` window labels
diff --git a/docs/adr/0123-full-vault-graph-for-secondary-note-windows.md b/docs/adr/0123-full-vault-graph-for-secondary-note-windows.md
new file mode 100644
index 00000000..6ad87541
--- /dev/null
+++ b/docs/adr/0123-full-vault-graph-for-secondary-note-windows.md
@@ -0,0 +1,32 @@
+---
+type: ADR
+id: "0123"
+title: "Full vault graph for secondary note windows"
+status: active
+date: 2026-05-25
+supersedes: "0118"
+---
+
+## Context
+
+ADR-0118 made secondary note windows entry-scoped to avoid repeated full-vault scans. That reduced startup work, but it also removed the vault-index context that normal Tolaria capabilities depend on: properties, view actions, quick open/search, workspace-aware navigation, and other command surfaces no longer behaved like the main window.
+
+The product expectation is that opening a note in a separate window creates another capable Tolaria window, not a reduced editor shell. Performance remains important, but capability parity is the stronger contract.
+
+## Decision
+
+**Secondary note windows load the same active vault/workspace graph as a normal Tolaria window.** They still start in editor-only view mode and auto-open the requested note from the URL parameters, but the app keeps the shared vault loader, mounted-workspace filtering, watcher scope, editor entry list, and workspace-aware note actions enabled.
+
+`main.tsx` always mounts `App`; note-window mode is handled inside `App` through `getNoteWindowParams()` and `useNoteWindowLifecycle`.
+
+## Alternatives considered
+
+- **Entry-scoped note windows**: faster startup, but loses app-level features that require repository-wide context. Rejected because it breaks the secondary-window product contract.
+- **Full app with full active graph** (chosen): keeps one window architecture and restores feature parity. Trade-off: each secondary window performs its own vault load.
+- **Shared state from the main window over IPC**: could provide parity without duplicate scans, but adds synchronization complexity and failure modes. Deferred until profiling proves the duplicate load is a real bottleneck.
+
+## Consequences
+
+- Secondary windows can use normal Tolaria capabilities such as Properties, view actions, quick open/search, wikilink navigation, and workspace-aware note operations.
+- Opening several note windows can repeat vault-loading work. This is acceptable for now because correctness and parity are more important than avoiding the scan.
+- ADR-0118 is superseded. If secondary-window startup becomes too slow, optimize with shared state or incremental loading without removing app capabilities.
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 9afa3b21..a1f922da 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -170,8 +170,9 @@ proposed → active → superseded
| [0114](0114-mounted-workspaces-unified-graph.md) | Mounted workspaces unified graph | active |
| [0115](0115-scoped-react-context-for-shared-ui-preferences.md) | Scoped React Context for shared UI preferences | active |
| [0116](0116-rich-raw-transition-and-serialization-ownership.md) | Rich/raw transition and serialization ownership | active |
-| [0118](0118-entry-scoped-note-windows-without-vault-index-scans.md) | Entry-scoped note windows without vault index scans | active |
+| [0118](0118-entry-scoped-note-windows-without-vault-index-scans.md) | Entry-scoped note windows without vault index scans | superseded -> [0123](0123-full-vault-graph-for-secondary-note-windows.md) |
| [0119](0119-vault-neutral-mcp-registration-with-mounted-workspace-guidance.md) | Vault-neutral MCP registration with mounted workspace guidance | active |
| [0120](0120-stable-appimage-mcp-server-path-with-opencode-registration.md) | Stable AppImage MCP server path with OpenCode registration | active |
| [0121](0121-appimage-external-fallback-for-audio-and-video-previews.md) | AppImage external fallback for audio and video previews | active |
| [0122](0122-scalar-array-frontmatter-properties.md) | Scalar array frontmatter properties | active |
+| [0123](0123-full-vault-graph-for-secondary-note-windows.md) | Full vault graph for secondary note windows | active |
diff --git a/src/App.note-window-properties.test.tsx b/src/App.note-window-properties.test.tsx
index 39f1c1c7..dc0cc05d 100644
--- a/src/App.note-window-properties.test.tsx
+++ b/src/App.note-window-properties.test.tsx
@@ -221,7 +221,7 @@ describe('App note windows', () => {
)
})
- it('keeps note windows scoped to the active entry without loading the vault index', async () => {
+ it('loads the active vault graph in note windows while opening the requested note', async () => {
renderApp()
await waitFor(() => {
@@ -230,20 +230,20 @@ describe('App note windows', () => {
vaultPath: '/vault',
})
})
- expect(commandResults.list_vault).not.toHaveBeenCalled()
+ expect(commandResults.list_vault).toHaveBeenCalled()
await waitFor(() => {
expect(screen.getByTestId('mock-editor-entry-titles')).toHaveTextContent(
- 'Test Project',
+ 'Test Project|Software Development|Second Project',
)
})
expect(editorSnapshots.at(-1)).toEqual({
activeTabPath: activeEntry.path,
- entryTitles: ['Test Project'],
+ entryTitles: ['Test Project', 'Software Development', 'Second Project'],
})
})
- it('opens repeated note windows without repeated full-vault scans', async () => {
+ it('opens repeated note windows through the full app vault loader', async () => {
const firstWindow = renderApp()
await waitFor(() => {
@@ -268,6 +268,6 @@ describe('App note windows', () => {
})
})
expect(commandResults.reload_vault_entry).toHaveBeenCalledTimes(2)
- expect(commandResults.list_vault).not.toHaveBeenCalled()
+ expect(vi.mocked(commandResults.list_vault).mock.calls.length).toBeGreaterThanOrEqual(2)
})
})
diff --git a/src/App.test.tsx b/src/App.test.tsx
index 3eddbb63..1303f033 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -576,7 +576,7 @@ describe('App', () => {
})
})
- it('opens a note window by loading only the requested entry', async () => {
+ it('opens a note window after loading the active vault graph', async () => {
const listVault = vi.fn(() => mockEntries)
const reloadVaultEntry = vi.fn(({ path }: { path: string }) =>
mockEntries.find((entry) => entry.path === path) ?? null,
@@ -599,7 +599,7 @@ describe('App', () => {
expect(getNoteContent).toHaveBeenCalledWith({ path: '/vault/project/test.md', vaultPath: '/vault' })
await waitFor(() => expect(window.__laputaTest?.activeTabPath).toBe('/vault/project/test.md'))
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'true')
- expect(listVault).not.toHaveBeenCalled()
+ expect(listVault).toHaveBeenCalled()
})
it('shows keyboard shortcut hints', async () => {
diff --git a/src/App.tsx b/src/App.tsx
index 8fa69540..d3b1bc39 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -356,7 +356,7 @@ function App() {
resolvedPath,
settings,
vaultSwitcherLoaded: vaultSwitcher.loaded,
- windowMode: Boolean(noteWindowParams),
+ windowMode: false,
})
const vaultWorkspaceOrder = useMemo(
() => vaultSwitcher.allVaults.map((vault) => vault.path),
@@ -382,12 +382,7 @@ function App() {
windowMode: Boolean(noteWindowParams),
})
- const vault = useVaultLoader(
- noteWindowParams ? '' : resolvedPath,
- graphVaults,
- multiWorkspaceEnabled ? defaultWorkspacePath : null,
- folderVaults,
- )
+ const vault = useVaultLoader(resolvedPath, graphVaults, multiWorkspaceEnabled ? defaultWorkspacePath : null, folderVaults)
const gitRepositories = useMemo(() => activeGitRepositories({
defaultVaultPath: graphDefaultWorkspacePath,
multiWorkspaceEnabled,
@@ -402,21 +397,20 @@ function App() {
repositories: gitRepositories,
})
const watchedVaultPaths = useMemo(() => {
- if (noteWindowParams) return []
if (visibleWorkspacePathList && visibleWorkspacePathList.length > 0) return visibleWorkspacePathList
return resolvedPath.trim() ? [resolvedPath] : []
- }, [noteWindowParams, resolvedPath, visibleWorkspacePathList])
+ }, [resolvedPath, visibleWorkspacePathList])
const visibleEntries = useVisibleWorkspaceEntries({
entries: vault.entries,
multiWorkspaceEnabled,
visibleWorkspacePathList,
})
- const runtimeMissingVaultPath = !noteWindowParams ? vault.unavailableVaultPath : null
+ const runtimeMissingVaultPath = vault.unavailableVaultPath
const {
markInternalWrite: markRecentVaultWrite,
filterExternalPaths: filterExternalVaultPaths,
} = useRecentVaultWrites({
- vaultPath: noteWindowParams ? '' : resolvedPath,
+ vaultPath: resolvedPath,
vaultPaths: watchedVaultPaths,
})
const {
@@ -608,7 +602,7 @@ function App() {
setToastMessage,
updateEntry: vault.updateEntry,
vaultPath: resolvedPath,
- defaultWorkspacePath: noteWindowParams || !multiWorkspaceEnabled ? null : defaultWorkspacePath,
+ defaultWorkspacePath: multiWorkspaceEnabled ? defaultWorkspacePath : null,
vaults: graphVaults ?? [],
addPendingSave: handleCreatedVaultEntryPersisting,
removePendingSave: vault.removePendingSave,
@@ -695,7 +689,7 @@ function App() {
}
}, [watchedVaultPaths])
useVaultWatcher({
- vaultPath: noteWindowParams ? '' : resolvedPath,
+ vaultPath: resolvedPath,
vaultPaths: watchedVaultPaths,
onVaultChanged: handleFocusedVaultUpdate,
filterChangedPaths: filterExternalVaultPaths,
@@ -1742,7 +1736,7 @@ function App() {
tabs={notes.tabs}
activeTabPath={notes.activeTabPath}
isVaultLoading={isVaultContentLoading}
- entries={noteWindowParams && activeTab ? [activeTab.entry] : visibleEntries}
+ entries={visibleEntries}
onNavigateWikilink={notes.handleNavigateWikilink}
onLoadDiff={loadDiffForPath}
onLoadDiffAtCommit={loadDiffAtCommitForPath}
diff --git a/src/NoteWindowApp.test.tsx b/src/NoteWindowApp.test.tsx
deleted file mode 100644
index 35d5bec9..00000000
--- a/src/NoteWindowApp.test.tsx
+++ /dev/null
@@ -1,148 +0,0 @@
-import { act, fireEvent, render, screen } from '@testing-library/react'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import NoteWindowApp from './NoteWindowApp'
-import type { NoteStatus, VaultEntry } from './types'
-
-type MockEditorProps = {
- activeTabPath: string | null
- entries: VaultEntry[]
- getNoteStatus?: (path: string) => NoteStatus
- onContentChange?: (path: string, content: string) => void
- tabs: Array<{ entry: VaultEntry; content: string }>
- vaultPath?: string
- vaultPaths?: string[]
-}
-
-const mocks = vi.hoisted(() => ({
- editorProps: [] as MockEditorProps[],
- invoke: vi.fn(),
- setTitle: vi.fn(),
- warn: vi.fn(),
-}))
-
-vi.mock('@tauri-apps/api/core', () => ({
- invoke: mocks.invoke,
-}))
-
-vi.mock('@tauri-apps/api/window', () => ({
- getCurrentWindow: () => ({ setTitle: mocks.setTitle }),
-}))
-
-vi.mock('./mock-tauri', () => ({
- isTauri: () => true,
- mockInvoke: vi.fn(),
-}))
-
-vi.mock('./components/Editor', () => ({
- Editor: (props: MockEditorProps) => {
- mocks.editorProps.push(props)
- return (
-
- )
- },
-}))
-
-vi.mock('./components/Toast', () => ({
- Toast: ({ message }: { message: string | null }) => (
-
{message}
- ),
-}))
-
-function makeEntry(): VaultEntry {
- return {
- path: 'Notes/entry.md',
- filename: 'entry.md',
- title: 'Entry',
- isA: 'Note',
- aliases: [],
- belongsTo: [],
- relatedTo: [],
- status: null,
- archived: false,
- modifiedAt: 1_700_000_000,
- createdAt: null,
- fileSize: 256,
- snippet: '',
- wordCount: 10,
- relationships: {},
- icon: null,
- color: null,
- order: null,
- sidebarLabel: null,
- template: null,
- sort: null,
- view: null,
- visible: true,
- organized: false,
- favorite: false,
- favoriteIndex: null,
- listPropertiesDisplay: [],
- outgoingLinks: [],
- properties: {},
- hasH1: true,
- fileKind: 'markdown',
- }
-}
-
-function setSearch(search: string) {
- Object.defineProperty(window, 'location', {
- writable: true,
- value: { ...window.location, search },
- })
-}
-
-describe('NoteWindowApp', () => {
- beforeEach(() => {
- vi.useRealTimers()
- vi.clearAllMocks()
- vi.spyOn(console, 'warn').mockImplementation(mocks.warn)
- mocks.editorProps.length = 0
- setSearch('?window=note&path=%2Fvault%2FNotes%2Fentry.md&vault=%2Fvault&title=Entry')
- mocks.invoke.mockImplementation(async (command: string) => {
- if (command === 'sync_vault_asset_scope_for_window') return undefined
- if (command === 'reload_vault_entry') return makeEntry()
- if (command === 'get_note_content') return '# Entry'
- if (command === 'save_note_content') return undefined
- throw new Error(`Unexpected command: ${command}`)
- })
- })
-
- afterEach(() => {
- vi.useRealTimers()
- vi.restoreAllMocks()
- })
-
- it('loads one note without booting the full vault and saves editor changes', async () => {
- render()
- const editor = await screen.findByTestId('mock-note-window-editor')
-
- expect(editor).toHaveTextContent('# Entry')
- expect(mocks.editorProps.at(-1)).toEqual(expect.objectContaining({
- activeTabPath: 'Notes/entry.md',
- vaultPath: '/vault',
- vaultPaths: ['/vault'],
- }))
- expect(mocks.invoke).not.toHaveBeenCalledWith('list_vault', expect.anything())
-
- vi.useFakeTimers()
- fireEvent.click(editor)
- expect(mocks.editorProps.at(-1)?.getNoteStatus?.('Notes/entry.md')).toBe('unsaved')
-
- await act(async () => {
- await vi.advanceTimersByTimeAsync(700)
- })
- vi.useRealTimers()
-
- expect(mocks.invoke).toHaveBeenCalledWith('save_note_content', {
- path: 'Notes/entry.md',
- content: 'Updated content',
- vaultPath: '/vault',
- })
- })
-})
diff --git a/src/NoteWindowApp.tsx b/src/NoteWindowApp.tsx
deleted file mode 100644
index 7feb5635..00000000
--- a/src/NoteWindowApp.tsx
+++ /dev/null
@@ -1,199 +0,0 @@
-import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react'
-import { invoke } from '@tauri-apps/api/core'
-import { Editor } from './components/Editor'
-import { Toast } from './components/Toast'
-import { isTauri, mockInvoke } from './mock-tauri'
-import type { NoteStatus, VaultEntry } from './types'
-import {
- getNoteWindowPathCandidates,
- getNoteWindowParams,
- type NoteWindowParams,
-} from './utils/windowMode'
-import './App.css'
-
-const NOTE_WINDOW_SAVE_DELAY_MS = 700
-const MISSING_NOTE_WINDOW_PARAMS_MESSAGE = ''
-const noop = () => {}
-
-type NoteWindowLoadState =
- | { kind: 'loading' }
- | { kind: 'ready'; entry: VaultEntry; content: string }
- | { kind: 'error'; message: string }
-
-type ReadyNoteWindowState = Extract
-type SaveRequest = { path: string; content: string; vaultPath: string }
-type NoteWindowCommand =
- | 'get_note_content'
- | 'reload_vault_entry'
- | 'save_note_content'
- | 'sync_vault_asset_scope_for_window'
-
-function tauriCall(command: NoteWindowCommand, args: Record): Promise {
- return isTauri() ? invoke(command, args) : mockInvoke(command, args)
-}
-
-async function resolveNoteWindowEntry(params: NoteWindowParams): Promise {
- for (const path of getNoteWindowPathCandidates(params)) {
- try {
- const entry = await tauriCall('reload_vault_entry', {
- path,
- vaultPath: params.vaultPath,
- })
- if (entry) return entry
- } catch {
- // Keep trying normalized path candidates before surfacing a load failure.
- }
- }
-
- return null
-}
-
-async function loadNoteWindow(params: NoteWindowParams): Promise> {
- await tauriCall('sync_vault_asset_scope_for_window', { vaultPath: params.vaultPath }).catch(() => undefined)
- const entry = await resolveNoteWindowEntry(params)
- if (!entry) throw new Error(`Could not open "${params.noteTitle}" in this window`)
- const content = await tauriCall('get_note_content', {
- path: entry.path,
- vaultPath: params.vaultPath,
- })
- return { kind: 'ready', entry, content }
-}
-
-function errorMessage(error: unknown): string {
- return error instanceof Error ? error.message : String(error)
-}
-
-function initialNoteWindowState(params: NoteWindowParams | null): NoteWindowLoadState {
- return params ? { kind: 'loading' } : { kind: 'error', message: MISSING_NOTE_WINDOW_PARAMS_MESSAGE }
-}
-
-function useNoteWindowState(params: NoteWindowParams | null): NoteWindowLoadState {
- const [state, setState] = useState(() => initialNoteWindowState(params))
-
- useEffect(() => {
- if (!params) return
- let cancelled = false
-
- loadNoteWindow(params)
- .then((nextState) => {
- if (!cancelled) setState(nextState)
- })
- .catch((error) => {
- if (!cancelled) setState({ kind: 'error', message: errorMessage(error) })
- })
-
- return () => { cancelled = true }
- }, [params])
-
- return state
-}
-
-function getWindowTitle(state: NoteWindowLoadState, params: NoteWindowParams | null): string | null {
- return state.kind === 'ready' ? state.entry.title : params?.noteTitle ?? null
-}
-
-function useNoteWindowTitle(state: NoteWindowLoadState, params: NoteWindowParams | null): void {
- useEffect(() => {
- const title = getWindowTitle(state, params)
- if (!title) return
-
- document.title = title
- if (!isTauri()) return
-
- import('@tauri-apps/api/window')
- .then(({ getCurrentWindow }) => getCurrentWindow().setTitle(title))
- .catch((error) => console.warn('[window] Failed to update note window title:', error))
- }, [params, state])
-}
-
-function clearSaveTimer(saveTimerRef: MutableRefObject): void {
- if (saveTimerRef.current === null) return
-
- window.clearTimeout(saveTimerRef.current)
- saveTimerRef.current = null
-}
-
-function saveNoteWindowContent(request: SaveRequest, setStatus: (status: NoteStatus) => void): void {
- setStatus('pendingSave')
- void tauriCall('save_note_content', request)
- .then(() => setStatus('clean'))
- .catch((error) => {
- console.warn('[window] Failed to save note window content:', error)
- setStatus('unsaved')
- })
-}
-
-function useDebouncedNoteWindowSave(vaultPath: string | null, setStatus: (status: NoteStatus) => void) {
- const saveTimerRef = useRef(null)
-
- useEffect(() => () => clearSaveTimer(saveTimerRef), [])
-
- return useCallback((path: string, content: string) => {
- if (!vaultPath) return
- setStatus('unsaved')
- clearSaveTimer(saveTimerRef)
-
- saveTimerRef.current = window.setTimeout(() => {
- saveTimerRef.current = null
- saveNoteWindowContent({ path, content, vaultPath }, setStatus)
- }, NOTE_WINDOW_SAVE_DELAY_MS)
- }, [setStatus, vaultPath])
-}
-
-function NoteWindowEditor({ params, state }: {
- params: NoteWindowParams
- state: ReadyNoteWindowState
-}) {
- const [tabContent, setTabContent] = useState(state.content)
- const [noteStatus, setNoteStatus] = useState('clean')
- const saveContent = useDebouncedNoteWindowSave(params.vaultPath, setNoteStatus)
- const tab = useMemo(() => ({ entry: state.entry, content: tabContent }), [state.entry, tabContent])
- const updateContent = useCallback((path: string, content: string) => {
- setTabContent(content)
- saveContent(path, content)
- }, [saveContent])
-
- return (
- noteStatus}
- vaultPath={params.vaultPath}
- vaultPaths={[params.vaultPath]}
- locale="en"
- />
- )
-}
-
-export default function NoteWindowApp() {
- const params = useMemo(() => getNoteWindowParams(), [])
- const state = useNoteWindowState(params)
- useNoteWindowTitle(state, params)
-
- return (
-
-
-
- {state.kind === 'ready' && params ? (
-
- ) : (
-
- {state.kind === 'error' ? state.message : null}
-
- )}
-
-
- {state.kind === 'error' &&
}
-
- )
-}
diff --git a/src/main.tsx b/src/main.tsx
index b74671a4..85457d82 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -19,11 +19,10 @@ import {
import { isRecoveredBlockNoteRenderError } from './components/blockNoteRenderRecovery'
import { isMac, shouldUseCustomWindowChrome } from './utils/platform'
import { reloadFrontendOnceIfStartupFailed } from './utils/frontendReady'
-import { isNoteWindow } from './utils/windowMode'
const TLDRAW_CONTEXT_MENU_SELECTOR = '.tldraw-whiteboard'
-const RootApp = lazy(() => (isNoteWindow() ? import('./NoteWindowApp') : import('./App.tsx')))
+const RootApp = lazy(() => import('./App.tsx'))
function dataTransferHasFiles(dataTransfer: DataTransfer | null): boolean {
if (!dataTransfer) return false
diff --git a/src/utils/openNoteWindow.ts b/src/utils/openNoteWindow.ts
index 107e4357..07b1f45d 100644
--- a/src/utils/openNoteWindow.ts
+++ b/src/utils/openNoteWindow.ts
@@ -36,7 +36,7 @@ export function buildRuntimeNoteWindowUrl(
}
/**
- * Opens a note in a new Tauri window with a minimal editor-only layout.
+ * Opens a note in a new Tauri window that starts in an editor-only layout.
* In browser mode (non-Tauri), this is a no-op.
*/
export async function openNoteInNewWindow(notePath: string, vaultPath: string, noteTitle: string): Promise {