Compare commits

...

5 Commits

Author SHA1 Message Date
lucaronin
6d86770abf fix: compact large ai agent context 2026-05-02 19:24:52 +02:00
lucaronin
e23ba0ae27 fix: normalize windows wikilink targets 2026-05-02 19:01:00 +02:00
lucaronin
7a97f24a0e fix: detect codex windows npm shims 2026-05-02 18:41:38 +02:00
lucaronin
20f34ab9d1 fix: preserve ai panel delete shortcuts 2026-05-02 18:12:37 +02:00
lucaronin
19b31cc96a fix: recover when active vault disappears 2026-05-02 17:53:13 +02:00
26 changed files with 1021 additions and 195 deletions

View File

@@ -367,7 +367,7 @@ Command-facing vault content is filtered through `vault::filter_gitignored_entri
A `vault_health_check` command detects stray files in non-protected subfolders and filename-title mismatches. On vault load, a migration banner offers to flatten stray files to the root via `flatten_vault`.
Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, folder mutations, and image attachment writes cannot step outside the active vault. Image attachment commands add the current vault root to the runtime asset scope after saving so files created under a previously missing `attachments/` directory can render immediately.
Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, folder mutations, and image attachment writes cannot step outside the active vault. If the active root itself cannot be canonicalized, the renderer treats `Active vault is not available` the same as no active vault: it clears stale vault state, drops prefetched note content, and shows the missing-vault recovery screen instead of continuing note/view requests against the disappeared path. Image attachment commands add the current vault root to the runtime asset scope after saving so files created under a previously missing `attachments/` directory can render immediately.
UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder routes through the Tauri opener plugin, external-open routes through the `open_vault_file_external` command and active-vault boundary before invoking the native opener, and copy-path uses the browser clipboard API. Plain-text paste reads the desktop clipboard through `read_text_from_clipboard` in Tauri so macOS WKWebView clipboard permissions do not block the command; browser/mock mode falls back to the Web Clipboard API or mock handlers. None of those actions mutate vault contents or bypass the backend write boundary.

View File

@@ -298,15 +298,15 @@ The agent panel (`ai-context.ts`) builds a structured JSON snapshot from the act
```json
{
"activeNote": { "path", "title", "type", "frontmatter", "content" },
"linkedNotes": [{ "path", "title", "content" }],
"openTabs": [{ "title", "snippet" }],
"vaultMetadata": { "noteTypes", "stats", "filter" },
"references": [{ "title", "path", "type" }]
"activeNote": { "path", "title", "type", "frontmatter", "body", "wordCount", "bodyTruncated?" },
"openTabs": [{ "path", "title", "type", "frontmatter" }],
"noteList": [{ "path", "title", "type" }],
"vault": { "types", "totalNotes" },
"referencedNotes": [{ "title", "path", "type" }]
}
```
Token budget: 60% of 180k context limit (~108k tokens max). Active note gets priority, then linked notes, then truncation.
Large active notes are compacted into a head/tail body snapshot before they enter the CLI prompt. The snapshot records `bodyTruncated` metadata and instructs agents to call `get_note(path)` before content-sensitive edits or summaries, keeping lower-context OpenCode providers from failing on oversized active-note context while preserving access to the full note through MCP.
### Authentication
@@ -490,6 +490,8 @@ On first launch, `useOnboarding` checks if the default vault exists. If not, it
- **Open an existing folder** → system file picker; plain Markdown folders without `.git` open immediately in supported non-git mode
- **Get started with a template** → pick a parent folder, then call `create_getting_started_vault()` with the derived `.../Getting Started` child path so the cloned vault opens into the populated repo root immediately
If the selected vault disappears after startup, `useVaultLoader` re-checks `check_vault_exists` when reloads or vault-derived surfaces fail. A confirmed missing path clears cached entries, folders, views, modified-file state, and prefetched note content, then `App` reuses the `vault-missing` `WelcomeScreen` state so note and view actions cannot keep targeting the stale active vault.
When an opened folder is not yet a git repo, Tolaria shows a dismissible Git setup dialog and a persistent `Git disabled` status-bar warning. Markdown scanning, note browsing, note editing, and search continue normally. Git-dependent surfaces (history, changes, commit, sync, conflict resolution, remotes, AutoGit, and auto-sync) stay unavailable until the user explicitly initializes Git from the dialog, the status-bar warning, or the `Initialize Git for Current Vault` command-palette action.
When the user enables Git later, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. Before app-managed setup and remote-connection commits, Tolaria ensures the vault has local `user.name` / `user.email` values, falling back to `Tolaria <vault@tolaria.md>` when the vault has no local Git identity yet. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures.
@@ -556,6 +558,11 @@ sequenceDiagram
VL->>T: invoke('reload_vault') → allow requested vault roots in asset scope + scan_vault_cached()
T-->>VL: VaultEntry[]
VL->>T: invoke('get_modified_files')
alt Runtime vault path disappears
VL->>T: invoke('check_vault_exists')
VL-->>A: unavailable vault path + cleared stale state
A-->>U: WelcomeScreen (vault missing)
end
A->>T: useMcpStatus — check explicit MCP setup state
A->>T: sync_mcp_bridge_vault(selected path)
VL-->>A: entries ready

View File

@@ -17,7 +17,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/autosave-low-end-typing.spec.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multibyte-search-snippet.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/pull-refresh-open-note.spec.ts tests/smoke/vault-loading-skeleton.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/autosave-low-end-typing.spec.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-active-vault-recovery.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multibyte-search-snippet.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/pull-refresh-open-note.spec.ts tests/smoke/vault-loading-skeleton.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:regression": "playwright test tests/smoke/",
"playwright:integration": "playwright test --config playwright.integration.config.ts",
"test:coverage": "node scripts/run-vitest-coverage.mjs",

View File

@@ -39,13 +39,21 @@ fn find_codex_binary() -> Result<PathBuf, String> {
}
fn find_codex_binary_on_path() -> Option<PathBuf> {
crate::hidden_command("which")
crate::hidden_command(codex_path_lookup_command())
.arg("codex")
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
}
fn codex_path_lookup_command() -> &'static str {
if cfg!(windows) {
"where"
} else {
"which"
}
}
fn find_codex_binary_in_user_shell() -> Option<PathBuf> {
user_shell_candidates()
.into_iter()
@@ -102,13 +110,27 @@ fn codex_binary_candidates() -> Vec<PathBuf> {
fn codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
let mut candidates = vec![
home.join(".local/bin/codex"),
home.join(".local/bin/codex.exe"),
home.join(".codex/bin/codex"),
home.join(".codex/bin/codex.exe"),
home.join(".local/share/mise/shims/codex"),
home.join(".local/share/mise/shims/codex.exe"),
home.join(".asdf/shims/codex"),
home.join(".asdf/shims/codex.exe"),
home.join(".npm-global/bin/codex"),
home.join(".npm-global/bin/codex.cmd"),
home.join(".npm-global/bin/codex.exe"),
home.join(".npm/bin/codex"),
home.join(".npm/bin/codex.cmd"),
home.join(".npm/bin/codex.exe"),
home.join(".bun/bin/codex"),
home.join(".bun/bin/codex.exe"),
home.join(".linuxbrew/bin/codex"),
home.join("AppData/Roaming/npm/codex.cmd"),
home.join("AppData/Roaming/npm/codex.exe"),
home.join("AppData/Local/pnpm/codex.cmd"),
home.join("AppData/Local/pnpm/codex.exe"),
home.join("scoop/shims/codex.exe"),
PathBuf::from("/home/linuxbrew/.linuxbrew/bin/codex"),
PathBuf::from("/usr/local/bin/codex"),
PathBuf::from("/opt/homebrew/bin/codex"),
@@ -705,6 +727,34 @@ exit 2
}
}
#[test]
fn codex_binary_candidates_include_windows_npm_and_toolchain_shims() {
let home = PathBuf::from("C:/Users/alex");
let candidates = codex_binary_candidates_for_home(&home);
let expected = [
home.join(".local/bin/codex.exe"),
home.join(".local/share/mise/shims/codex.exe"),
home.join(".asdf/shims/codex.exe"),
home.join(".npm-global/bin/codex.cmd"),
home.join(".npm-global/bin/codex.exe"),
home.join(".npm/bin/codex.cmd"),
home.join(".npm/bin/codex.exe"),
home.join("AppData/Roaming/npm/codex.cmd"),
home.join("AppData/Roaming/npm/codex.exe"),
home.join("AppData/Local/pnpm/codex.cmd"),
home.join("AppData/Local/pnpm/codex.exe"),
home.join("scoop/shims/codex.exe"),
];
for candidate in expected {
assert!(
candidates.contains(&candidate),
"missing {}",
candidate.display()
);
}
}
#[test]
fn codex_binary_candidates_include_nvm_managed_node_installs() {
let home = tempfile::tempdir().unwrap();

View File

@@ -108,6 +108,7 @@ import {
buildVaultAiGuidanceRefreshKey,
} from './lib/vaultAiGuidance'
import { extractDeletedContentFromDiff } from './components/note-list/noteListUtils'
import { isActiveVaultUnavailableError } from './utils/vaultErrors'
import { hasNoteIconValue } from './utils/noteIcon'
import { filenameStemToTitle } from './utils/noteTitle'
import {
@@ -375,6 +376,7 @@ function App() {
}, [resolvedPath, setToastMessage])
const vault = useVaultLoader(noteWindowParams ? '' : resolvedPath)
const runtimeMissingVaultPath = !noteWindowParams ? vault.unavailableVaultPath : null
const {
markInternalWrite: markRecentVaultWrite,
filterExternalPaths: filterExternalVaultPaths,
@@ -578,6 +580,9 @@ function App() {
markRecentVaultWrite(path)
vault.loadModifiedFiles()
}, [markRecentVaultWrite, vault])
const handleMissingActiveVault = useCallback(() => {
if (!noteWindowParams && resolvedPath) vault.markVaultUnavailable(resolvedPath)
}, [noteWindowParams, resolvedPath, vault])
const notes = useNoteActions({
addEntry: vault.addEntry,
@@ -596,6 +601,7 @@ function App() {
unsavedPaths: vault.unsavedPaths,
markContentPending: (path, content) => appSave.contentChangeRef.current(path, content),
onNewNotePersisted: handleCreatedVaultEntryPersisted,
onMissingActiveVault: handleMissingActiveVault,
onTypeStateChanged: async () => { await vault.reloadVault() },
replaceEntry: vault.replaceEntry,
onFrontmatterPersisted: vault.loadModifiedFiles,
@@ -1169,7 +1175,15 @@ function App() {
const handleDeleteView = useCallback(async (filename: string) => {
const target = isTauri() ? invoke : mockInvoke
await target('delete_view_cmd', { vaultPath: resolvedPath, filename })
try {
await target('delete_view_cmd', { vaultPath: resolvedPath, filename })
} catch (err) {
if (isActiveVaultUnavailableError(err)) {
vault.markVaultUnavailable(resolvedPath)
return
}
throw err
}
await vault.reloadViews()
await vault.reloadVault()
vault.reloadFolders()
@@ -1636,8 +1650,17 @@ function App() {
}
// Show welcome/onboarding screen when vault doesn't exist (skip for note windows — vault path is known)
if (!noteWindowParams && (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing' || shouldResumeFreshStartOnboarding)) {
const welcomeOnboarding = shouldResumeFreshStartOnboarding
if (!noteWindowParams && (runtimeMissingVaultPath || onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing' || shouldResumeFreshStartOnboarding)) {
const welcomeOnboarding = runtimeMissingVaultPath
? {
...onboarding,
state: {
status: 'vault-missing' as const,
vaultPath: runtimeMissingVaultPath,
defaultPath: vaultSwitcher.defaultPath || runtimeMissingVaultPath,
},
}
: shouldResumeFreshStartOnboarding
? { ...onboarding, state: { status: 'welcome' as const, defaultPath: vaultSwitcher.vaultPath } }
: onboarding
return <WelcomeView onboarding={welcomeOnboarding} isOffline={networkStatus.isOffline} />

View File

@@ -0,0 +1,55 @@
import { useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { AiPanelMessageHistory } from './AiPanelChrome'
const aiMessageRender = vi.hoisted(() => vi.fn())
vi.mock('./AiMessage', () => ({
AiMessage: (props: { id?: string; response: string }) => {
aiMessageRender(props.id)
return <div data-testid="ai-message">{props.response}</div>
},
}))
const noop = () => {}
function HistoryRerenderHarness() {
const [draft, setDraft] = useState('')
const [messages] = useState([{
userMessage: 'Explain the note',
actions: [],
response: 'Here is a long answer with [[Test Note]].',
id: 'msg-stable',
}])
return (
<>
<button type="button" onClick={() => setDraft('typing')}>type</button>
<span data-testid="draft">{draft}</span>
<AiPanelMessageHistory
agentLabel="Claude Code"
agentReadiness="ready"
messages={messages}
isActive={false}
onOpenNote={noop}
onNavigateWikilink={noop}
hasContext
/>
</>
)
}
describe('AiPanelChrome performance', () => {
it('keeps stable message history from re-rendering while composer state changes', () => {
render(<HistoryRerenderHarness />)
expect(screen.getByTestId('ai-message')).toHaveTextContent('Here is a long answer')
expect(aiMessageRender).toHaveBeenCalledTimes(1)
aiMessageRender.mockClear()
fireEvent.click(screen.getByRole('button', { name: 'type' }))
expect(screen.getByTestId('draft')).toHaveTextContent('typing')
expect(aiMessageRender).not.toHaveBeenCalled()
})
})

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react'
import { memo, useEffect, useRef } from 'react'
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
import { AiMessage } from './AiMessage'
import { Button } from '@/components/ui/button'
@@ -164,7 +164,7 @@ function AiPanelEmptyState({
)
}
export function AiPanelHeader({
export const AiPanelHeader = memo(function AiPanelHeader({
agentLabel,
agentReadiness,
locale = 'en',
@@ -221,7 +221,7 @@ export function AiPanelHeader({
/>
</div>
)
}
})
function AiPermissionModeToggle({
value,
@@ -277,7 +277,11 @@ function AiPermissionModeToggle({
)
}
export function AiPanelContextBar({ activeEntry, linkedCount, locale = 'en' }: AiPanelContextBarProps) {
export const AiPanelContextBar = memo(function AiPanelContextBar({
activeEntry,
linkedCount,
locale = 'en',
}: AiPanelContextBarProps) {
const t = createTranslator(locale)
return (
@@ -293,9 +297,9 @@ export function AiPanelContextBar({ activeEntry, linkedCount, locale = 'en' }: A
)}
</div>
)
}
})
export function AiPanelMessageHistory({
export const AiPanelMessageHistory = memo(function AiPanelMessageHistory({
agentLabel,
agentReadiness,
locale = 'en',
@@ -332,7 +336,7 @@ export function AiPanelMessageHistory({
<div ref={endRef} />
</div>
)
}
})
export function AiPanelComposer({
entries,

View File

@@ -437,6 +437,26 @@ describe('WikilinkChatInput', () => {
expect(screen.queryByTestId('inline-wikilink-chip')).toBeNull()
})
it('lets native modified delete shortcuts reach the browser editor pipeline', () => {
const onDraftChange = vi.fn()
render(<Controlled onDraftChange={onDraftChange} />)
updateEditorText('delete the previous words')
onDraftChange.mockClear()
const editor = screen.getByTestId('agent-input')
const optionBackspace = createEvent.keyDown(editor, { key: 'Backspace', altKey: true })
fireEvent(editor, optionBackspace)
const commandBackspace = createEvent.keyDown(editor, { key: 'Backspace', metaKey: true })
fireEvent(editor, commandBackspace)
const controlDelete = createEvent.keyDown(editor, { key: 'Delete', ctrlKey: true })
fireEvent(editor, controlDelete)
expect(optionBackspace.defaultPrevented).toBe(false)
expect(commandBackspace.defaultPrevented).toBe(false)
expect(controlDelete.defaultPrevented).toBe(false)
expect(onDraftChange).not.toHaveBeenCalled()
})
it('submits serialized wikilink text and resolved references', () => {
const onSend = vi.fn()
render(<Controlled onSend={onSend} />)

View File

@@ -61,6 +61,7 @@ function handleDeleteKeys({
onDeleteContent,
}: HandleDeleteKeysArgs): boolean {
if (isInlineWikilinkCompositionEvent(event, isComposing)) return false
if (event.altKey || event.ctrlKey || event.metaKey) return false
if (event.key === 'Backspace') {
event.preventDefault()

View File

@@ -2,6 +2,7 @@ import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry } from '../types'
import { markNoteOpenTrace } from '../utils/noteOpenPerformance'
import { errorMessage, isActiveVaultUnavailableError } from '../utils/vaultErrors'
import { getNoteWindowParams, isNoteWindow } from '../utils/windowMode'
type NotePath = VaultEntry['path']
@@ -273,14 +274,8 @@ export async function loadContentForOpen(options: {
return loadNoteContent(entry, forceReload || hasResolvedCachedContent(cachedEntry))
}
function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message
if (typeof error === 'string') return error
return String(error)
}
export function isNoActiveVaultSelectedError(error: unknown): boolean {
return /no active vault selected/i.test(errorMessage(error))
return isActiveVaultUnavailableError(error)
}
export function isUnreadableNoteContentError(error: unknown): boolean {

View File

@@ -33,6 +33,8 @@ export interface NoteActionsConfig {
onNewNotePersisted?: (path: string) => void
replaceEntry?: (oldPath: string, patch: Partial<VaultEntry> & { path: string }) => void
onPathRenamed?: (oldPath: string, newPath: string) => void
/** Called when note loading proves the active vault path is no longer usable. */
onMissingActiveVault?: (entry: VaultEntry, error: unknown) => void | Promise<void>
/** Called after frontmatter is written to disk — used for live-reloading theme CSS vars. */
onFrontmatterContentChanged?: (path: string, content: string) => void
/** Called after a frontmatter mutation is fully persisted, including follow-up renames. */
@@ -194,15 +196,19 @@ async function updateFrontmatterAndMaybeRename({
}
function buildTabManagementOptions(
config: Pick<NoteActionsConfig, 'flushBeforeNoteSwitch' | 'reloadVault' | 'setToastMessage' | 'unsavedPaths'>,
config: Pick<NoteActionsConfig, 'flushBeforeNoteSwitch' | 'onMissingActiveVault' | 'reloadVault' | 'setToastMessage' | 'unsavedPaths'>,
) {
const options: {
beforeNavigate?: (fromPath: string, toPath: string) => Promise<void>
hasUnsavedChanges: (path: string) => boolean
onMissingActiveVault: (entry: VaultEntry, error: unknown) => void | Promise<void>
onMissingNotePath: (entry: VaultEntry) => void
onUnreadableNoteContent: (entry: VaultEntry) => void
} = {
hasUnsavedChanges: (path) => config.unsavedPaths?.has(path) ?? false,
onMissingActiveVault: (entry, error) => {
void config.onMissingActiveVault?.(entry, error)
},
onMissingNotePath: (entry) => {
const label = entry.title.trim() || entry.filename
config.setToastMessage(`"${label}" could not be opened because its file is missing or moved.`)

View File

@@ -258,6 +258,23 @@ describe('useTabManagement (single-note model)', () => {
warnSpy.mockRestore()
})
it('reports an unavailable active vault instead of opening a blank stale tab', async () => {
vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('Active vault is not available'))
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const onMissingActiveVault = vi.fn()
const { result } = renderHook(() => useTabManagement({ onMissingActiveVault }))
await selectNote(result, { path: '/vault/note/orphaned.md', title: 'Orphaned Note' })
expect(result.current.tabs).toEqual([])
expect(result.current.activeTabPath).toBeNull()
expect(onMissingActiveVault).toHaveBeenCalledWith(
expect.objectContaining({ path: '/vault/note/orphaned.md', title: 'Orphaned Note' }),
expect.any(Error),
)
warnSpy.mockRestore()
})
it('uses the note-window vault path when Tauri reloads the selected note', async () => {
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(invoke).mockResolvedValue('# Window content')

View File

@@ -51,6 +51,7 @@ export type { Tab }
interface TabManagementOptions {
beforeNavigate?: (fromPath: string, toPath: string) => Promise<void>
hasUnsavedChanges?: (path: string) => boolean
onMissingActiveVault?: (entry: VaultEntry, error: unknown) => void | Promise<void>
onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise<void>
onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise<void>
}
@@ -64,6 +65,7 @@ interface NavigateToEntryOptions {
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
hasUnsavedChanges?: (path: string) => boolean
onMissingActiveVault?: (entry: VaultEntry, error: unknown) => void | Promise<void>
onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise<void>
onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise<void>
}
@@ -252,6 +254,7 @@ function handleRecoverableEntryLoadFailure(options: {
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
error: unknown
onMissingActiveVault?: (entry: VaultEntry, error: unknown) => void | Promise<void>
onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise<void>
onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise<void>
}) {
@@ -263,6 +266,7 @@ function handleRecoverableEntryLoadFailure(options: {
setTabs,
setActiveTabPath,
error,
onMissingActiveVault,
onMissingNotePath,
onUnreadableNoteContent,
} = options
@@ -279,6 +283,16 @@ function handleRecoverableEntryLoadFailure(options: {
})
failNoteOpenTrace(entry.path, kind)
if (kind === 'missing-active-vault') {
runEntryFailureCallback({
callback: onMissingActiveVault,
entry,
error,
warning: 'Failed to handle missing active vault:',
})
return
}
if (kind === 'missing-path') {
runEntryFailureCallback({
callback: onMissingNotePath,
@@ -308,6 +322,7 @@ function handleEntryLoadFailure(options: {
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
error: unknown
onMissingActiveVault?: (entry: VaultEntry, error: unknown) => void | Promise<void>
onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise<void>
onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise<void>
}) {
@@ -320,6 +335,7 @@ function handleEntryLoadFailure(options: {
setTabs,
setActiveTabPath,
error,
onMissingActiveVault,
onMissingNotePath,
onUnreadableNoteContent,
} = options
@@ -337,6 +353,7 @@ function handleEntryLoadFailure(options: {
setTabs,
setActiveTabPath,
error,
onMissingActiveVault,
onMissingNotePath,
onUnreadableNoteContent,
})
@@ -370,6 +387,7 @@ async function loadTextEntry(options: Required<Pick<NavigateToEntryOptions, 'for
activeTabPathRef,
setTabs,
setActiveTabPath,
onMissingActiveVault,
onMissingNotePath,
onUnreadableNoteContent,
} = options
@@ -409,6 +427,7 @@ async function loadTextEntry(options: Required<Pick<NavigateToEntryOptions, 'for
setTabs,
setActiveTabPath,
error: err,
onMissingActiveVault,
onMissingNotePath,
onUnreadableNoteContent,
})
@@ -443,6 +462,7 @@ export function useTabManagement(options: TabManagementOptions = {}) {
const beforeNavigateSeqRef = useRef(0)
const beforeNavigate = options.beforeNavigate
const hasUnsavedChanges = options.hasUnsavedChanges
const onMissingActiveVault = options.onMissingActiveVault
const onMissingNotePath = options.onMissingNotePath
const onUnreadableNoteContent = options.onUnreadableNoteContent
@@ -484,13 +504,14 @@ export function useTabManagement(options: TabManagementOptions = {}) {
setTabs,
setActiveTabPath,
hasUnsavedChanges,
onMissingActiveVault,
onMissingNotePath,
onUnreadableNoteContent,
}))
if (!navigated) {
resetRequestedPathIfStillPending(requestedActiveTabPathRef, activeTabPathRef, entry.path)
}
}, [executeNavigationWithBoundary, hasUnsavedChanges, onMissingNotePath, onUnreadableNoteContent])
}, [executeNavigationWithBoundary, hasUnsavedChanges, onMissingActiveVault, onMissingNotePath, onUnreadableNoteContent])
const handleSwitchTab = useCallback((path: string) => {
requestedActiveTabPathRef.current = path
@@ -523,13 +544,14 @@ export function useTabManagement(options: TabManagementOptions = {}) {
activeTabPathRef,
setTabs,
setActiveTabPath,
onMissingActiveVault,
onMissingNotePath,
onUnreadableNoteContent,
}))
if (!navigated) {
resetRequestedPathIfStillPending(requestedActiveTabPathRef, activeTabPathRef, entry.path)
}
}, [executeNavigationWithBoundary, onMissingNotePath, onUnreadableNoteContent])
}, [executeNavigationWithBoundary, onMissingActiveVault, onMissingNotePath, onUnreadableNoteContent])
const closeAllTabs = useCallback(() => {
tabsRef.current = []

View File

@@ -0,0 +1,60 @@
import { useCallback, useState } from 'react'
import { clearPrefetchCache } from './useTabManagement'
import { resetVaultState, type VaultStateResetOptions } from './vaultStateReset'
interface UnavailableVaultStateOptions extends VaultStateResetOptions {
isCurrentVaultPath: (path: string) => boolean
vaultPath: string
}
export function useUnavailableVaultState(options: UnavailableVaultStateOptions) {
const [unavailableVaultPath, setUnavailableVaultPath] = useState<string | null>(null)
const {
clearNewPaths,
clearUnsaved,
isCurrentVaultPath,
setEntries,
setFolders,
setIsLoading,
setModifiedFiles,
setModifiedFilesError,
setViews,
vaultPath,
} = options
const markVaultUnavailable = useCallback((path: string) => {
if (!isCurrentVaultPath(path)) return
clearPrefetchCache()
resetVaultState({
clearNewPaths,
clearUnsaved,
setEntries,
setFolders,
setIsLoading,
setModifiedFiles,
setModifiedFilesError,
setViews,
})
setUnavailableVaultPath(path)
}, [
clearNewPaths,
clearUnsaved,
isCurrentVaultPath,
setEntries,
setFolders,
setIsLoading,
setModifiedFiles,
setModifiedFilesError,
setViews,
])
const markVaultAvailable = useCallback((path: string) => {
if (isCurrentVaultPath(path)) setUnavailableVaultPath(null)
}, [isCurrentVaultPath])
return {
markVaultAvailable,
markVaultUnavailable,
unavailableVaultPath: unavailableVaultPath === vaultPath ? unavailableVaultPath : null,
}
}

View File

@@ -311,6 +311,30 @@ describe('useVaultLoader', () => {
expect(issuedCommands).not.toContain('list_vault')
})
it('marks the vault unavailable when the initial load finds a missing active vault', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
backendInvokeFn.mockImplementation(((cmd: string) => {
if (isVaultLoadCommand(cmd)) return Promise.reject(new Error('No such file or directory'))
if (cmd === 'check_vault_exists') return Promise.resolve(false)
if (cmd === 'get_modified_files') return Promise.resolve(mockModifiedFiles)
if (cmd === 'list_vault_folders') return Promise.reject(new Error('Active vault is not available'))
if (cmd === 'list_views') return Promise.reject(new Error('Active vault is not available'))
return Promise.resolve(null)
}) as typeof defaultMockInvoke)
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitFor(() => {
expect(result.current.unavailableVaultPath).toBe('/vault')
})
expect(result.current.entries).toEqual([])
expect(result.current.folders).toEqual([])
expect(result.current.views).toEqual([])
expect(result.current.modifiedFiles).toEqual([])
warnSpy.mockRestore()
})
it('ignores stale reload_vault results after the vault path changes', async () => {
await enableTauriMode()
const firstLoad = createDeferred<VaultEntry[]>()
@@ -919,6 +943,46 @@ describe('useVaultLoader', () => {
expect(result.current.entries).toEqual(mockEntries)
warnSpy.mockRestore()
})
it('clears stale entries and marks the vault unavailable when the active vault disappears', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const initialViews = [{
filename: 'work.yml',
definition: {
name: 'Work',
icon: null,
color: null,
order: null,
sort: null,
filters: { all: [] },
},
}]
backendInvokeFn.mockImplementation(((cmd: string) => {
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
if (cmd === 'reload_vault') return Promise.reject(new Error('No such file or directory'))
if (cmd === 'check_vault_exists') return Promise.resolve(false)
if (cmd === 'get_modified_files') return Promise.resolve(mockModifiedFiles)
if (cmd === 'list_vault_folders') return Promise.resolve([{ name: 'note', path: '/vault/note', children: [] }])
if (cmd === 'list_views') return Promise.resolve(initialViews)
return Promise.resolve(null)
}) as typeof defaultMockInvoke)
const { result } = await renderVaultLoader()
await waitFor(() => expect(result.current.views).toHaveLength(1))
let entries: VaultEntry[] = []
await act(async () => {
entries = await result.current.reloadVault()
})
expect(entries).toEqual([])
expect(result.current.entries).toEqual([])
expect(result.current.folders).toEqual([])
expect(result.current.views).toEqual([])
expect(result.current.modifiedFiles).toEqual([])
expect(result.current.unavailableVaultPath).toBe('/vault')
warnSpy.mockRestore()
})
})
describe('reloadViews', () => {

View File

@@ -7,6 +7,7 @@ import {
} from '../lib/gitignoredVisibilityEvents'
import { clearPrefetchCache } from './useTabManagement'
import {
checkVaultPathAvailability,
commitWithPush,
hasVaultPath,
loadVaultChrome,
@@ -17,55 +18,91 @@ import {
tauriCall,
} from './vaultLoaderCommands'
import { normalizeVaultEntry } from '../utils/vaultMetadataNormalization'
import { useUnavailableVaultState } from './useUnavailableVaultState'
import { resetVaultState } from './vaultStateReset'
function resetVaultState(options: {
clearNewPaths: () => void
clearUnsaved: () => void
setEntries: (entries: VaultEntry[]) => void
setFolders: (folders: FolderNode[]) => void
setIsLoading: (isLoading: boolean) => void
setModifiedFiles: (files: ModifiedFile[]) => void
setModifiedFilesError: (message: string | null) => void
setViews: (views: ViewFile[]) => void
}) {
options.setEntries([])
options.setFolders([])
options.setViews([])
options.setModifiedFiles([])
options.setModifiedFilesError(null)
options.setIsLoading(false)
options.clearNewPaths()
options.clearUnsaved()
}
async function loadInitialVaultState(options: {
interface InitialVaultLoadStateOptions {
handleVaultAvailable: (path: string) => void
path: string
handleVaultUnavailable: (path: string) => void
isCurrentVaultPath: (path: string) => boolean
setEntries: (entries: VaultEntry[]) => void
setFolders: (folders: FolderNode[]) => void
setIsLoading: (isLoading: boolean) => void
setViews: (views: ViewFile[]) => void
}) {
const { path, isCurrentVaultPath, setEntries, setFolders, setIsLoading, setViews } = options
const chromeLoad = loadVaultChrome({ vaultPath: path }).then(({ folders, views }) => {
if (!isCurrentVaultPath(path)) return
setFolders(folders)
setViews(views)
}
interface InitialVaultChromeOptions extends Pick<
InitialVaultLoadStateOptions,
'handleVaultUnavailable' | 'isCurrentVaultPath' | 'path' | 'setFolders' | 'setViews'
> {
shouldApplyChrome: () => boolean
}
async function loadInitialVaultChromeState(options: InitialVaultChromeOptions): Promise<boolean> {
const { handleVaultUnavailable, isCurrentVaultPath, path, setFolders, setViews, shouldApplyChrome } = options
try {
const { folders, views } = await loadVaultChrome({ vaultPath: path })
if (shouldApplyChrome()) {
setFolders(folders)
setViews(views)
}
} catch (err) {
const unavailable = await handleUnavailableVaultPath({ handleVaultUnavailable, isCurrentVaultPath, path })
if (unavailable) return true
console.warn('Vault chrome load failed:', err)
}
return false
}
async function loadInitialVaultEntriesState(options: Pick<
InitialVaultLoadStateOptions,
'handleVaultAvailable' | 'handleVaultUnavailable' | 'isCurrentVaultPath' | 'path' | 'setEntries'
>): Promise<boolean> {
const { handleVaultAvailable, handleVaultUnavailable, isCurrentVaultPath, path, setEntries } = options
try {
const { entries } = await loadVaultData({ vaultPath: path })
if (isCurrentVaultPath(path)) {
handleVaultAvailable(path)
setEntries(entries)
}
} catch (err) {
const unavailable = await handleUnavailableVaultPath({ handleVaultUnavailable, isCurrentVaultPath, path })
if (unavailable) return true
console.warn('Vault scan failed:', err)
}
return false
}
async function loadInitialVaultState(options: InitialVaultLoadStateOptions) {
const { path, isCurrentVaultPath, setIsLoading } = options
let vaultUnavailable = false
const chromeLoad = loadInitialVaultChromeState({
...options,
shouldApplyChrome: () => !vaultUnavailable && isCurrentVaultPath(path),
})
setIsLoading(true)
try {
const { entries } = await loadVaultData({ vaultPath: path })
if (isCurrentVaultPath(path)) setEntries(entries)
} catch (err) {
console.warn('Vault scan failed:', err)
} finally {
if (isCurrentVaultPath(path)) setIsLoading(false)
}
vaultUnavailable = await loadInitialVaultEntriesState(options)
if (isCurrentVaultPath(path)) setIsLoading(false)
await chromeLoad
}
async function handleUnavailableVaultPath(options: {
handleVaultUnavailable: (path: string) => void
isCurrentVaultPath: (path: string) => boolean
path: string
}): Promise<boolean> {
const { handleVaultUnavailable, isCurrentVaultPath, path } = options
if (!isCurrentVaultPath(path)) return true
const available = await checkVaultPathAvailability({ vaultPath: path })
if (available !== false) return false
if (isCurrentVaultPath(path)) handleVaultUnavailable(path)
return true
}
function useCurrentVaultPathGuard(vaultPath: string) {
const currentPathRef = useRef(vaultPath)
@@ -164,19 +201,9 @@ export function resolveNoteStatus({
return resolveGitBackedNoteStatus(modifiedFiles.find((file) => file.path === path))
}
function useInitialVaultLoad({
vaultPath,
tracker,
unsaved,
isCurrentVaultPath,
resetReloading,
setEntries,
setFolders,
setIsLoading,
setModifiedFiles,
setModifiedFilesError,
setViews,
}: {
interface InitialVaultLoadOptions {
handleVaultAvailable: (path: string) => void
handleVaultUnavailable: (path: string) => void
vaultPath: string
tracker: ReturnType<typeof useNewNoteTracker>
unsaved: ReturnType<typeof useUnsavedTracker>
@@ -188,7 +215,25 @@ function useInitialVaultLoad({
setModifiedFiles: (files: ModifiedFile[]) => void
setModifiedFilesError: (message: string | null) => void
setViews: (views: ViewFile[]) => void
}) {
}
function useInitialVaultLoad(options: InitialVaultLoadOptions) {
const {
handleVaultAvailable,
handleVaultUnavailable,
vaultPath,
tracker,
unsaved,
isCurrentVaultPath,
resetReloading,
setEntries,
setFolders,
setIsLoading,
setModifiedFiles,
setModifiedFilesError,
setViews,
} = options
useEffect(() => {
const path = vaultPath
clearPrefetchCache()
@@ -208,7 +253,9 @@ function useInitialVaultLoad({
let cancelled = false
void loadInitialVaultState({
handleVaultAvailable,
path,
handleVaultUnavailable,
isCurrentVaultPath: (candidate) => !cancelled && isCurrentVaultPath(candidate),
setEntries,
setFolders,
@@ -217,6 +264,8 @@ function useInitialVaultLoad({
})
return () => { cancelled = true }
}, [
handleVaultAvailable,
handleVaultUnavailable,
vaultPath,
tracker.clear,
unsaved.clearAll,
@@ -349,42 +398,71 @@ function useGitLoaders(vaultPath: string) {
return { loadGitHistory, loadDiffAtCommit, loadDiff, commitAndPush }
}
function useVaultReloads({
vaultPath,
isCurrentVaultPath,
loadModifiedFiles,
setEntries,
setFolders,
setViews,
}: {
interface VaultReloadOptions {
handleVaultAvailable: (path: string) => void
handleVaultUnavailable: (path: string) => void
vaultPath: string
isCurrentVaultPath: (path: string) => boolean
loadModifiedFiles: () => Promise<void>
setEntries: (entries: VaultEntry[]) => void
setFolders: (folders: FolderNode[]) => void
setViews: (views: ViewFile[]) => void
}) {
const [activeReloads, setActiveReloads] = useState(0)
const isReloading = activeReloads > 0
const beginReload = useCallback(() => setActiveReloads((count) => count + 1), [])
const finishReload = useCallback(() => setActiveReloads((count) => Math.max(0, count - 1)), [])
const resetReloading = useCallback(() => setActiveReloads(0), [])
}
const reloadFolders = useCallback(async () => {
const path = vaultPath
if (!hasVaultPath({ vaultPath: path })) return [] as FolderNode[]
try {
const folders = await loadVaultFolders({ vaultPath: path })
if (!isCurrentVaultPath(path)) return [] as FolderNode[]
const nextFolders = folders ?? []
setFolders(nextFolders)
return nextFolders
} catch {
return [] as FolderNode[]
}
}, [vaultPath, isCurrentVaultPath, setFolders])
interface EntryReloadOptions extends VaultReloadOptions {
beginReload: () => void
finishReload: () => void
}
const reloadVault = useCallback(async () => {
interface CollectionReloadOptions<T> {
handleVaultUnavailable: (path: string) => void
isCurrentVaultPath: (path: string) => boolean
loadCollection: (options: { vaultPath: string }) => Promise<T[]>
path: string
setCollection: (items: T[]) => void
}
async function reloadVaultCollection<T>(options: CollectionReloadOptions<T>): Promise<T[]> {
const { handleVaultUnavailable, isCurrentVaultPath, loadCollection, path, setCollection } = options
if (!hasVaultPath({ vaultPath: path })) return []
try {
const items = await loadCollection({ vaultPath: path })
if (!isCurrentVaultPath(path)) return []
const nextItems = items ?? []
setCollection(nextItems)
return nextItems
} catch {
await handleUnavailableVaultPath({ handleVaultUnavailable, isCurrentVaultPath, path })
return []
}
}
function useFolderReload({
handleVaultUnavailable,
isCurrentVaultPath,
setFolders,
vaultPath,
}: Pick<VaultReloadOptions, 'handleVaultUnavailable' | 'isCurrentVaultPath' | 'setFolders' | 'vaultPath'>) {
return useCallback(() => reloadVaultCollection({
handleVaultUnavailable,
isCurrentVaultPath,
loadCollection: loadVaultFolders,
path: vaultPath,
setCollection: setFolders,
}), [handleVaultUnavailable, vaultPath, isCurrentVaultPath, setFolders])
}
function useEntryReload({
beginReload,
finishReload,
handleVaultAvailable,
handleVaultUnavailable,
isCurrentVaultPath,
loadModifiedFiles,
setEntries,
vaultPath,
}: EntryReloadOptions) {
return useCallback(async () => {
const path = vaultPath
if (!hasVaultPath({ vaultPath: path })) return [] as VaultEntry[]
clearPrefetchCache()
@@ -392,29 +470,44 @@ function useVaultReloads({
try {
const entries = await reloadVaultEntries({ vaultPath: path })
if (!isCurrentVaultPath(path)) return [] as VaultEntry[]
handleVaultAvailable(path)
setEntries(entries)
void loadModifiedFiles()
return entries
} catch (err) {
if (await handleUnavailableVaultPath({ handleVaultUnavailable, isCurrentVaultPath, path })) return [] as VaultEntry[]
console.warn('Vault reload failed:', err)
return [] as VaultEntry[]
} finally {
finishReload()
}
}, [vaultPath, beginReload, finishReload, loadModifiedFiles, isCurrentVaultPath, setEntries])
}, [handleVaultAvailable, handleVaultUnavailable, vaultPath, beginReload, finishReload, loadModifiedFiles, isCurrentVaultPath, setEntries])
}
const reloadViews = useCallback(async () => {
const path = vaultPath
if (!hasVaultPath({ vaultPath: path })) return []
try {
const nextViews = await loadVaultViews({ vaultPath: path })
if (!isCurrentVaultPath(path)) return []
const resolvedViews = nextViews ?? []
setViews(resolvedViews)
return resolvedViews
} catch { /* views are optional */ }
return []
}, [vaultPath, isCurrentVaultPath, setViews])
function useViewReload({
handleVaultUnavailable,
isCurrentVaultPath,
setViews,
vaultPath,
}: Pick<VaultReloadOptions, 'handleVaultUnavailable' | 'isCurrentVaultPath' | 'setViews' | 'vaultPath'>) {
return useCallback(() => reloadVaultCollection({
handleVaultUnavailable,
isCurrentVaultPath,
loadCollection: loadVaultViews,
path: vaultPath,
setCollection: setViews,
}), [handleVaultUnavailable, vaultPath, isCurrentVaultPath, setViews])
}
function useVaultReloads(options: VaultReloadOptions) {
const [activeReloads, setActiveReloads] = useState(0)
const isReloading = activeReloads > 0
const beginReload = useCallback(() => setActiveReloads((count) => count + 1), [])
const finishReload = useCallback(() => setActiveReloads((count) => Math.max(0, count - 1)), [])
const resetReloading = useCallback(() => setActiveReloads(0), [])
const reloadFolders = useFolderReload(options)
const reloadVault = useEntryReload({ ...options, beginReload, finishReload })
const reloadViews = useViewReload(options)
return { isReloading, reloadFolders, reloadVault, reloadViews, resetReloading }
}
@@ -445,7 +538,7 @@ function useGitignoredVisibilityReloads(
}, [reloadFolders, reloadVault, reloadViews])
}
export function useVaultLoader(vaultPath: string) {
function useVaultState(vaultPath: string) {
const [entries, setEntries] = useState<VaultEntry[]>([])
const [folders, setFolders] = useState<FolderNode[]>([])
const [isLoading, setIsLoading] = useState(() => hasVaultPath({ vaultPath }))
@@ -454,16 +547,67 @@ export function useVaultLoader(vaultPath: string) {
const pendingSave = usePendingSaveTracker()
const unsaved = useUnsavedTracker()
const isCurrentVaultPath = useCurrentVaultPathGuard(vaultPath)
const modified = useModifiedFilesLoader(vaultPath, isCurrentVaultPath)
return {
entries,
folders,
isCurrentVaultPath,
isLoading,
modified,
pendingSave,
setEntries,
setFolders,
setIsLoading,
setViews,
tracker,
unsaved,
views,
}
}
function useVaultUnavailable(vaultPath: string, state: ReturnType<typeof useVaultState>) {
const {
isCurrentVaultPath,
modified,
setEntries,
setFolders,
setIsLoading,
setViews,
tracker,
unsaved,
} = state
return useUnavailableVaultState({
clearNewPaths: tracker.clear,
clearUnsaved: unsaved.clearAll,
isCurrentVaultPath,
setEntries,
setFolders,
setIsLoading,
setModifiedFiles: modified.setModifiedFiles,
setModifiedFilesError: modified.setModifiedFilesError,
setViews,
vaultPath,
})
}
export function useVaultLoader(vaultPath: string) {
const state = useVaultState(vaultPath)
const { entries, folders, isCurrentVaultPath, isLoading, modified, pendingSave, setEntries, setFolders, setIsLoading, setViews, tracker, unsaved, views } = state
const {
modifiedFiles,
modifiedFilesError,
setModifiedFiles,
setModifiedFilesError,
loadModifiedFiles,
} = useModifiedFilesLoader(vaultPath, isCurrentVaultPath)
} = modified
const entryMutations = useEntryMutations(setEntries, tracker.trackNew)
const gitLoaders = useGitLoaders(vaultPath)
const unavailableVault = useVaultUnavailable(vaultPath, state)
const vaultReloads = useVaultReloads({
handleVaultAvailable: unavailableVault.markVaultAvailable,
handleVaultUnavailable: unavailableVault.markVaultUnavailable,
vaultPath,
isCurrentVaultPath,
loadModifiedFiles,
@@ -474,6 +618,8 @@ export function useVaultLoader(vaultPath: string) {
useGitignoredVisibilityReloads(vaultReloads)
useInitialVaultLoad({
handleVaultAvailable: unavailableVault.markVaultAvailable,
handleVaultUnavailable: unavailableVault.markVaultUnavailable,
vaultPath,
tracker,
unsaved,
@@ -498,6 +644,7 @@ export function useVaultLoader(vaultPath: string) {
return {
entries, folders, isLoading, isReloading: vaultReloads.isReloading, views, modifiedFiles, modifiedFilesError,
unavailableVaultPath: unavailableVault.unavailableVaultPath,
...entryMutations,
loadModifiedFiles,
...gitLoaders,
@@ -505,6 +652,7 @@ export function useVaultLoader(vaultPath: string) {
reloadVault: vaultReloads.reloadVault,
reloadFolders: vaultReloads.reloadFolders,
reloadViews: vaultReloads.reloadViews,
markVaultUnavailable: unavailableVault.markVaultUnavailable,
addPendingSave: pendingSave.addPendingSave,
removePendingSave: pendingSave.removePendingSave,
unsavedPaths: unsaved.unsavedPaths,

View File

@@ -34,6 +34,19 @@ export function tauriCall<T>({ command, tauriArgs, mockArgs }: TauriCallOptions)
return isTauri() ? invoke<T>(command, tauriArgs) : mockInvoke<T>(command, mockArgs ?? tauriArgs)
}
export async function checkVaultPathAvailability({ vaultPath }: VaultPathOptions): Promise<boolean | null> {
if (!hasVaultPath({ vaultPath })) return false
try {
return await tauriCall<boolean>({
command: 'check_vault_exists',
tauriArgs: { path: vaultPath },
})
} catch {
return null
}
}
function loadVaultEntriesWithCommand({ vaultPath, command }: VaultPathOptions & { command: string }): Promise<VaultEntry[]> {
return tauriCall<unknown>({ command, tauriArgs: { path: vaultPath } })
.then((entries) => normalizeVaultEntries(entries, vaultPath))

View File

@@ -0,0 +1,23 @@
import type { FolderNode, ModifiedFile, VaultEntry, ViewFile } from '../types'
export interface VaultStateResetOptions {
clearNewPaths: () => void
clearUnsaved: () => void
setEntries: (entries: VaultEntry[]) => void
setFolders: (folders: FolderNode[]) => void
setIsLoading: (isLoading: boolean) => void
setModifiedFiles: (files: ModifiedFile[]) => void
setModifiedFilesError: (message: string | null) => void
setViews: (views: ViewFile[]) => void
}
export function resetVaultState(options: VaultStateResetOptions) {
options.setEntries([])
options.setFolders([])
options.setViews([])
options.setModifiedFiles([])
options.setModifiedFilesError(null)
options.setIsLoading(false)
options.clearNewPaths()
options.clearUnsaved()
}

View File

@@ -219,6 +219,37 @@ describe('aiAgentStreamCallbacks', () => {
])
})
it('gives OpenCode an actionable empty-response message', () => {
const messages = createMessageStore([
{
id: 'msg-1',
userMessage: 'Summarize the current note',
actions: [],
isStreaming: true,
},
])
const status = createStatusStore('thinking')
const callbacks = createStreamCallbacks({
agent: 'opencode',
messageId: 'msg-1',
vaultPath: '/vault',
setMessages: messages.setMessages,
setStatus: status.setStatus,
abortRef: { current: { aborted: false } },
responseAccRef: { current: '' },
toolInputMapRef: { current: new Map() },
fileCallbacksRef: { current: undefined },
})
callbacks.onDone()
expect(status.getStatus()).toBe('done')
expect(messages.getMessages()[0].response).toContain('OpenCode returned no assistant text')
expect(messages.getMessages()[0].response).toContain('provider/model context limit')
expect(messages.getMessages()[0].response).not.toContain('finished without returning a reply')
})
it('ignores stream events after the request has been aborted', () => {
const messages = createMessageStore([
{

View File

@@ -26,9 +26,17 @@ export interface StreamMutationContext {
}
function finalResponseText(response: string, agent: AiAgentId): string {
return response.trim()
? response
: `${getAiAgentDefinition(agent).label} finished without returning a reply.`
if (response.trim()) return response
if (agent === 'opencode') {
return [
'OpenCode returned no assistant text.',
'Check the selected provider/model context limit or retry the request.',
'For large active notes, Tolaria sends a compact note snapshot and OpenCode can read the full file with get_note(path).',
].join(' ')
}
return `${getAiAgentDefinition(agent).label} finished without returning a reply.`
}
export function createStreamCallbacks(context: StreamMutationContext) {

View File

@@ -312,6 +312,34 @@ describe('buildContextSnapshot', () => {
expect(json.activeNote.body).toBe('Fresh editor content')
})
it('compacts large active note bodies and points agents at the full note tool', () => {
const largeBody = [
'Opening section '.repeat(900),
'Middle section that should not be fully embedded '.repeat(900),
'Closing section '.repeat(900),
].join('\n')
const result = buildContextSnapshot({
activeEntry: makeEntry({
path: '/vault/large-note.md',
title: 'Large Note',
wordCount: 2700,
}),
entries,
activeNoteContent: largeBody,
})
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.activeNote.body.length).toBeLessThan(largeBody.length)
expect(json.activeNote.body).toContain('Opening section')
expect(json.activeNote.body).toContain('Closing section')
expect(json.activeNote.body).toContain('get_note("/vault/large-note.md")')
expect(json.activeNote.bodyTruncated).toEqual({
shownChars: expect.any(Number),
totalChars: largeBody.trim().length,
strategy: 'head-tail',
})
})
it('returns empty body when no activeNoteContent', () => {
const result = buildContextSnapshot({ activeEntry: active, entries })
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])

View File

@@ -81,92 +81,177 @@ export interface ContextSnapshotParams {
references?: NoteReference[]
}
const MAX_ACTIVE_NOTE_BODY_CHARS = 24_000
const ACTIVE_NOTE_BODY_HEAD_CHARS = 16_000
const ACTIVE_NOTE_BODY_TAIL_CHARS = 4_000
const MAX_NOTE_LIST_ITEMS = 100
interface ActiveNoteBody {
body: string
bodyTruncated?: {
shownChars: number
totalChars: number
strategy: 'head-tail'
}
}
function isPresentValue(value: unknown): boolean {
if (value === null) return false
if (value === undefined) return false
if (value === '') return false
return true
}
function assignIfPresent(target: Record<string, unknown>, key: string, value: unknown): void {
if (isPresentValue(value)) target[key] = value
}
function assignIfNonEmpty(target: Record<string, unknown>, key: string, values: unknown[]): void {
if (values.length > 0) {
target[key] = values
}
}
function propertyString(value: unknown): string | undefined {
if (!isPresentValue(value)) return undefined
return typeof value === 'string' ? value : String(value)
}
function entryFrontmatter(e: VaultEntry): Record<string, unknown> {
const fm: Record<string, unknown> = {}
if (e.isA) fm.type = e.isA
if (e.status) fm.status = e.status
// Owner and cadence are now stored in properties, not first-class fields
const owner = e.properties?.Owner ?? e.properties?.owner
const cadence = e.properties?.Cadence ?? e.properties?.cadence
if (owner) fm.owner = typeof owner === 'string' ? owner : String(owner)
if (cadence) fm.cadence = typeof cadence === 'string' ? cadence : String(cadence)
if (e.belongsTo.length > 0) fm.belongsTo = e.belongsTo
if (e.relatedTo.length > 0) fm.relatedTo = e.relatedTo
if (Object.keys(e.relationships).length > 0) fm.relationships = e.relationships
assignIfPresent(fm, 'type', e.isA)
assignIfPresent(fm, 'status', e.status)
assignIfPresent(fm, 'owner', propertyString(e.properties?.Owner ?? e.properties?.owner))
assignIfPresent(fm, 'cadence', propertyString(e.properties?.Cadence ?? e.properties?.cadence))
assignIfNonEmpty(fm, 'belongsTo', e.belongsTo)
assignIfNonEmpty(fm, 'relatedTo', e.relatedTo)
assignIfNonEmpty(fm, 'relationships', Object.keys(e.relationships))
if (fm.relationships) fm.relationships = e.relationships
return fm
}
const MAX_NOTE_LIST_ITEMS = 100
function unavailableBodyInstruction(activeEntry: VaultEntry): string {
return `[Content not available in editor context — use get_note("${activeEntry.path}") to read the full note (${activeEntry.wordCount} words)]`
}
/** Build a structured context snapshot as a system prompt for Claude. */
export function buildContextSnapshot(params: ContextSnapshotParams): string {
const { activeEntry, activeNoteContent, openTabs, noteList, noteListFilter, entries, references } = params
function truncatedBodyInstruction(path: string, omittedChars: number): string {
return [
'[Active note body truncated by Tolaria to keep CLI agent context within provider limits.',
`Omitted approximately ${omittedChars} characters from the middle.`,
`Use get_note("${path}") to read the full note before making content-sensitive edits or summaries.]`,
].join(' ')
}
const rawContent = activeNoteContent || ''
let body = extractBody(rawContent)
// Defence-in-depth: when body is empty but the note has content on disk,
// include an explicit instruction in the body field itself (more reliable
// than a preamble instruction that Claude might skip).
if (!body && activeEntry.wordCount > 0) {
body = `[Content not available in editor context — use get_note("${activeEntry.path}") to read the full note (${activeEntry.wordCount} words)]`
function compactActiveNoteBody(body: string, path: string): ActiveNoteBody {
if (body.length <= MAX_ACTIVE_NOTE_BODY_CHARS) {
return { body }
}
const snapshot: Record<string, unknown> = {
activeNote: {
path: activeEntry.path,
title: activeEntry.title,
type: activeEntry.isA ?? 'Note',
frontmatter: entryFrontmatter(activeEntry),
body,
wordCount: activeEntry.wordCount,
const head = body.slice(0, ACTIVE_NOTE_BODY_HEAD_CHARS).trimEnd()
const tail = body.slice(-ACTIVE_NOTE_BODY_TAIL_CHARS).trimStart()
const omittedChars = Math.max(0, body.length - ACTIVE_NOTE_BODY_HEAD_CHARS - ACTIVE_NOTE_BODY_TAIL_CHARS)
return {
body: `${head}\n\n${truncatedBodyInstruction(path, omittedChars)}\n\n${tail}`,
bodyTruncated: {
shownChars: ACTIVE_NOTE_BODY_HEAD_CHARS + ACTIVE_NOTE_BODY_TAIL_CHARS,
totalChars: body.length,
strategy: 'head-tail',
},
}
}
function activeNoteBody(activeEntry: VaultEntry, activeNoteContent?: string): ActiveNoteBody {
const body = extractBody(activeNoteContent || '')
if (!body && activeEntry.wordCount > 0) {
return { body: unavailableBodyInstruction(activeEntry) }
}
return compactActiveNoteBody(body, activeEntry.path)
}
function activeNoteSnapshot(activeEntry: VaultEntry, activeNoteContent?: string): Record<string, unknown> {
const bodySnapshot = activeNoteBody(activeEntry, activeNoteContent)
const note: Record<string, unknown> = {
path: activeEntry.path,
title: activeEntry.title,
type: activeEntry.isA ?? 'Note',
frontmatter: entryFrontmatter(activeEntry),
body: bodySnapshot.body,
wordCount: activeEntry.wordCount,
}
assignIfPresent(note, 'bodyTruncated', bodySnapshot.bodyTruncated)
return note
}
function appendOpenTabs(snapshot: Record<string, unknown>, activeEntry: VaultEntry, openTabs?: VaultEntry[]): void {
const otherTabs = openTabs?.filter(t => t.path !== activeEntry.path)
if (otherTabs && otherTabs.length > 0) {
snapshot.openTabs = otherTabs.map(t => ({
path: t.path,
title: t.title,
type: t.isA ?? 'Note',
frontmatter: entryFrontmatter(t),
}))
}
if (!otherTabs?.length) return
if (noteList && noteList.length > 0) {
const items = noteList.slice(0, MAX_NOTE_LIST_ITEMS)
snapshot.noteList = items
if (noteList.length > MAX_NOTE_LIST_ITEMS) {
snapshot.noteListTruncated = { shown: MAX_NOTE_LIST_ITEMS, total: noteList.length }
}
}
snapshot.openTabs = otherTabs.map(t => ({
path: t.path,
title: t.title,
type: t.isA ?? 'Note',
frontmatter: entryFrontmatter(t),
}))
}
if (noteListFilter && (noteListFilter.type || noteListFilter.query)) {
snapshot.noteListFilter = noteListFilter
}
function appendNoteList(snapshot: Record<string, unknown>, noteList?: NoteListItem[]): void {
if (!noteList?.length) return
snapshot.noteList = noteList.slice(0, MAX_NOTE_LIST_ITEMS)
if (noteList.length > MAX_NOTE_LIST_ITEMS) {
snapshot.noteListTruncated = { shown: MAX_NOTE_LIST_ITEMS, total: noteList.length }
}
}
function hasNoteListFilter(noteListFilter?: { type: string | null; query: string }): boolean {
return Boolean(noteListFilter?.type || noteListFilter?.query)
}
function appendReferencedNotes(snapshot: Record<string, unknown>, references?: NoteReference[]): void {
if (!references?.length) return
snapshot.referencedNotes = references.map(ref => ({
path: ref.path,
title: ref.title,
type: ref.type ?? 'Note',
}))
}
function vaultSummary(entries: VaultEntry[]): Record<string, unknown> {
const types = new Set<string>()
for (const e of entries) {
if (e.isA) types.add(e.isA)
}
snapshot.vault = {
return {
types: [...types].sort(),
totalNotes: entries.length,
}
}
if (references && references.length > 0) {
snapshot.referencedNotes = references.map(ref => ({
path: ref.path,
title: ref.title,
type: ref.type ?? 'Note',
}))
function contextSnapshot(params: ContextSnapshotParams): Record<string, unknown> {
const { activeEntry, activeNoteContent, openTabs, noteList, noteListFilter, entries, references } = params
const snapshot: Record<string, unknown> = {
activeNote: activeNoteSnapshot(activeEntry, activeNoteContent),
}
appendOpenTabs(snapshot, activeEntry, openTabs)
appendNoteList(snapshot, noteList)
if (hasNoteListFilter(noteListFilter)) snapshot.noteListFilter = noteListFilter
snapshot.vault = vaultSummary(entries)
appendReferencedNotes(snapshot, references)
return snapshot
}
/** Build a structured context snapshot as a system prompt for Claude. */
export function buildContextSnapshot(params: ContextSnapshotParams): string {
const snapshot = contextSnapshot(params)
const preamble = [
'You are an AI assistant integrated into Tolaria, a personal knowledge management app.',
'The user is viewing a specific note. Use the structured context below to answer questions accurately.',
'You can also use MCP tools to search, read, create, or edit notes in the vault.',
'If the body field is empty but wordCount is > 0, the content may be stale — use get_note to read the full note from disk.',
'If the body field is empty or truncated, use get_note to read the full note from disk before content-sensitive edits or summaries.',
'When you mention or reference a note by name, always use [[Note Title]] wikilink syntax so the user can click to open it.',
].join('\n')

9
src/utils/vaultErrors.ts Normal file
View File

@@ -0,0 +1,9 @@
export function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message
if (typeof error === 'string') return error
return String(error)
}
export function isActiveVaultUnavailableError(error: unknown): boolean {
return /no active vault selected|active vault is not available/i.test(errorMessage(error))
}

View File

@@ -129,6 +129,24 @@ describe('relativePathStem', () => {
expect(relativePathStem('/Users/luca/Vault/docs/adr/0031.md', '/Users/luca/Vault')).toBe('docs/adr/0031')
})
it('normalizes Windows extended-length paths before extracting the vault-relative stem', () => {
expect(
relativePathStem(
'\\\\?\\C:\\Users\\lrfno\\Documents\\tolaria-vault\\application-design-and-build.md',
'C:\\Users\\lrfno\\Documents\\tolaria-vault',
),
).toBe('application-design-and-build')
})
it('keeps nested Windows note paths vault-relative with slash separators', () => {
expect(
relativePathStem(
'C:\\Users\\lrfno\\Documents\\Tolaria Vault\\projects\\application-design-and-build.md',
'c:/users/lrfno/documents/tolaria vault',
),
).toBe('projects/application-design-and-build')
})
it('falls back to filename stem when vault path does not match', () => {
expect(relativePathStem('/other/path/note.md', '/Users/luca/Vault')).toBe('note')
})

View File

@@ -3,15 +3,21 @@
import type { VaultEntry } from '../types'
import { slugifyNoteStem } from './noteSlug'
export type AbsoluteNotePath = string
export type NoteTitleOrTarget = string
export type VaultPath = string
export type WikilinkReference = string
export type WikilinkTarget = string
/** Extracts the target path from a wikilink reference (strips [[ ]] and display text). */
export function wikilinkTarget(ref: string): string {
export function wikilinkTarget(ref: WikilinkReference): WikilinkTarget {
const inner = ref.replace(/^\[\[|\]\]$/g, '')
const pipeIdx = inner.indexOf('|')
return pipeIdx !== -1 ? inner.slice(0, pipeIdx) : inner
}
/** Extracts the display label from a wikilink reference. Falls back to humanised path stem. */
export function wikilinkDisplay(ref: string): string {
export function wikilinkDisplay(ref: WikilinkReference): string {
const inner = ref.replace(/^\[\[|\]\]$/g, '')
const pipeIdx = inner.indexOf('|')
if (pipeIdx !== -1) return inner.slice(pipeIdx + 1)
@@ -19,29 +25,49 @@ export function wikilinkDisplay(ref: string): string {
return last.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
}
function stripWindowsExtendedPathPrefix(path: AbsoluteNotePath | VaultPath): string {
return path
.replace(/^\\\\\?\\UNC\\/i, '//')
.replace(/^\\\\\?\\/, '')
}
function normalizeFilesystemPath(path: AbsoluteNotePath | VaultPath): string {
return stripWindowsExtendedPathPrefix(path)
.replace(/\\/g, '/')
.replace(/\/+$/g, '')
}
function withoutMarkdownExtension(pathStem: WikilinkTarget): WikilinkTarget {
return pathStem.replace(/\.md$/i, '')
}
/** Extract the vault-relative path stem (no leading slash, no .md extension). */
export function relativePathStem(absolutePath: string, vaultPath: string): string {
const prefix = vaultPath.endsWith('/') ? vaultPath : vaultPath + '/'
if (absolutePath.startsWith(prefix)) return absolutePath.slice(prefix.length).replace(/\.md$/, '')
export function relativePathStem(absolutePath: AbsoluteNotePath, vaultPath: VaultPath): WikilinkTarget {
const normalizedAbsolutePath = normalizeFilesystemPath(absolutePath)
const normalizedVaultPath = normalizeFilesystemPath(vaultPath)
const prefix = normalizedVaultPath.endsWith('/') ? normalizedVaultPath : normalizedVaultPath + '/'
if (normalizedAbsolutePath.toLowerCase().startsWith(prefix.toLowerCase())) {
return withoutMarkdownExtension(normalizedAbsolutePath.slice(prefix.length))
}
// Fallback: just the filename stem
const filename = absolutePath.split('/').pop() ?? absolutePath
return filename.replace(/\.md$/, '')
const filename = normalizedAbsolutePath.split('/').pop() ?? normalizedAbsolutePath
return withoutMarkdownExtension(filename)
}
/** Slugify a human-readable title into the canonical wikilink filename stem. */
export const slugifyWikilinkTarget = slugifyNoteStem
/** Build the canonical wikilink target for a vault entry. */
export function canonicalWikilinkTargetForEntry(entry: VaultEntry, vaultPath: string): string {
export function canonicalWikilinkTargetForEntry(entry: VaultEntry, vaultPath: VaultPath): WikilinkTarget {
return relativePathStem(entry.path, vaultPath)
}
/** Resolve a user-facing title/path input to the canonical wikilink target. */
export function canonicalWikilinkTargetForTitle(
titleOrTarget: string,
titleOrTarget: NoteTitleOrTarget,
entries: VaultEntry[],
vaultPath: string,
): string {
vaultPath: VaultPath,
): WikilinkTarget {
const trimmed = titleOrTarget.trim()
const resolved = resolveEntry(entries, trimmed)
return resolved
@@ -52,7 +78,7 @@ export function canonicalWikilinkTargetForTitle(
}
/** Wrap a target in wikilink syntax. */
export function formatWikilinkRef(target: string): string {
export function formatWikilinkRef(target: WikilinkTarget): WikilinkReference {
return `[[${target}]]`
}
@@ -63,7 +89,7 @@ interface ResolutionKey {
humanizedTarget: string | null
}
function buildResolutionKey(rawTarget: string): ResolutionKey {
function buildResolutionKey(rawTarget: WikilinkTarget): ResolutionKey {
const exactTarget = rawTarget.includes('|') ? rawTarget.split('|')[0] : rawTarget
const normalizedTarget = exactTarget.toLowerCase()
const lastSegment = exactTarget.includes('/') ? (exactTarget.split('/').pop() ?? exactTarget).toLowerCase() : normalizedTarget
@@ -116,7 +142,7 @@ function findEntryByHumanizedTitle(entries: VaultEntry[], resolutionKey: Resolut
* 4. Exact title match
* 5. Humanized title match (kebab-case → words)
*/
export function resolveEntry(entries: VaultEntry[], rawTarget: string): VaultEntry | undefined {
export function resolveEntry(entries: VaultEntry[], rawTarget: WikilinkTarget): VaultEntry | undefined {
const resolutionKey = buildResolutionKey(rawTarget)
return (
findEntryByPathSuffix(entries, resolutionKey)

View File

@@ -0,0 +1,113 @@
import { test, expect, type Page } from '@playwright/test'
import { executeCommand, openCommandPalette } from './helpers'
type MockHandlers = Record<string, (args?: unknown) => unknown>
type MockWindow = Window & {
__markVaultMissing?: () => void
}
const entry = {
path: '/vault/note/runtime.md',
filename: 'runtime.md',
title: 'Runtime Vault Note',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: 1700000000,
createdAt: 1700000000,
fileSize: 64,
snippet: 'Loaded before the vault path disappears.',
wordCount: 7,
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',
}
async function installMissingVaultMock(page: Page): Promise<void> {
await page.addInitScript((noteEntry: typeof entry) => {
localStorage.setItem('tolaria_welcome_dismissed', '1')
localStorage.setItem('tolaria:ai-agents-onboarding-dismissed', '1')
localStorage.setItem('tolaria:claude-code-onboarding-dismissed', '1')
const missingError = () => new Error('Active vault is not available')
const mockWindow = window as MockWindow
let vaultAvailable = true
let handlers: MockHandlers | null = null
mockWindow.__markVaultMissing = () => {
vaultAvailable = false
}
Object.defineProperty(window, '__mockHandlers', {
configurable: true,
set(value: unknown) {
handlers = value as MockHandlers
handlers.load_vault_list = () => ({
vaults: [{ label: 'Runtime Vault', path: '/vault' }],
active_vault: '/vault',
hidden_defaults: [],
})
handlers.check_vault_exists = () => vaultAvailable
handlers.get_default_vault_path = () => '/vault'
handlers.get_settings = () => ({
auto_pull_interval_minutes: null,
auto_advance_inbox_after_organize: null,
telemetry_consent: true,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
})
handlers.get_vault_settings = () => ({ theme: null })
handlers.list_vault = () => vaultAvailable ? [noteEntry] : Promise.reject(new Error('No such file or directory'))
handlers.reload_vault = () => vaultAvailable ? [noteEntry] : Promise.reject(new Error('No such file or directory'))
handlers.list_vault_folders = () => vaultAvailable ? [] : Promise.reject(missingError())
handlers.list_views = () => vaultAvailable ? [] : Promise.reject(missingError())
handlers.get_modified_files = () => vaultAvailable ? [] : Promise.reject(missingError())
handlers.get_all_content = () => ({ [noteEntry.path]: '# Runtime Vault Note\n\nBody.' })
handlers.get_note_content = () => vaultAvailable
? '# Runtime Vault Note\n\nBody.'
: Promise.reject(missingError())
handlers.is_git_repo = () => true
handlers.sync_mcp_bridge_vault = () => null
},
get() {
return handlers
},
})
}, entry)
}
test('missing active vault reload shows recovery state and clears stale notes @smoke', async ({ page }) => {
await installMissingVaultMock(page)
await page.goto('/', { waitUntil: 'domcontentloaded' })
await expect(page.getByText('Runtime Vault Note')).toBeVisible({ timeout: 5_000 })
await page.evaluate(() => {
(window as MockWindow).__markVaultMissing?.()
})
await openCommandPalette(page)
await executeCommand(page, 'Reload Vault')
await expect(page.getByText('Vault not found')).toBeVisible({ timeout: 5_000 })
await expect(page.getByTestId('welcome-open-folder')).toContainText('Choose a different folder')
await expect(page.getByText('Runtime Vault Note')).not.toBeVisible()
})