Compare commits

...

3 Commits

Author SHA1 Message Date
Test
068d434344 fix: auto-save structural UI changes to disk immediately
Structural changes (sidebar visibility, label, order, template, icon/color)
now auto-create missing Type entries and call onFrontmatterPersisted to
refresh git status, so changes appear in git diff without user intervention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 11:06:10 +01:00
Test
6b2a1b0659 fix: scope qmd update/embed to current vault collection only
Previously called 'qmd update' and 'qmd embed' without arguments, which
updated all global qmd collections. If any other collection had a broken
path, the entire indexing would fail.

Now passes the vault name explicitly:
  qmd update <vault_name>
  qmd embed -c <vault_name>

This makes reindex independent of other qmd collections on the system.
2026-03-07 10:08:39 +01:00
Test
bf5f5521af fix: wikilink clicks in AI chat now open note in tab
AiPanel.handleNavigateWikilink was double-resolving: it resolved the
wikilink target to an entry path, then passed the path to onOpenNote
which is notes.handleNavigateWikilink (expects a title-based target).
The path didn't match any entry's title, so navigation silently failed.

Fix: pass the wikilink target string directly to onOpenNote, letting
the parent's handleNavigateWikilink do the resolution.

Also enhanced the Playwright smoke test to verify click → tab opens.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 07:14:34 +01:00
5 changed files with 84 additions and 56 deletions

View File

@@ -464,7 +464,9 @@ where
ensure_collection(vault_path)?;
// Phase 1: update (scan files)
let vault_name = vault_dir_name(vault_path);
// Phase 1: update (scan files) — scoped to this vault's collection only
on_progress(IndexingProgress {
phase: "scanning".to_string(),
current: 0,
@@ -475,7 +477,7 @@ where
let update_output = qmd
.command()
.args(["update"])
.args(["update", &vault_name])
.output()
.map_err(|e| format!("qmd update failed: {e}"))?;
@@ -504,7 +506,7 @@ where
error: None,
});
// Phase 2: embed (generate vectors)
// Phase 2: embed (generate vectors) — scoped to this vault's collection only
on_progress(IndexingProgress {
phase: "embedding".to_string(),
current: 0,
@@ -515,7 +517,7 @@ where
let embed_output = qmd
.command()
.args(["embed"])
.args(["embed", "-c", &vault_name])
.output()
.map_err(|e| format!("qmd embed failed: {e}"))?;
@@ -579,7 +581,7 @@ pub fn run_incremental_update(vault_path: &str) -> Result<(), String> {
let output = qmd
.command()
.args(["update"])
.args(["update", &vault_name])
.output()
.map_err(|e| format!("qmd incremental update failed: {e}"))?;

View File

@@ -4,7 +4,6 @@ import { AiMessage } from './AiMessage'
import { WikilinkChatInput } from './WikilinkChatInput'
import { useAiAgent, type AiAgentMessage, type AgentFileCallbacks } from '../hooks/useAiAgent'
import { collectLinkedEntries, buildContextSnapshot, type NoteReference, type NoteListItem } from '../utils/ai-context'
import { findEntryByTarget } from '../utils/wikilinkColors'
import type { VaultEntry } from '../types'
export type { AiAgentMessage } from '../hooks/useAiAgent'
@@ -169,10 +168,8 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, va
}, [handleEscape])
const handleNavigateWikilink = useCallback((target: string) => {
if (!entries) return
const entry = findEntryByTarget(entries, target)
if (entry) onOpenNote?.(entry.path)
}, [entries, onOpenNote])
onOpenNote?.(target)
}, [onOpenNote])
const handleSend = useCallback((text: string, references: NoteReference[]) => {
if (!text.trim() || isActive) return

View File

@@ -141,6 +141,7 @@ describe('useEntryActions', () => {
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'icon', 'cooking-pot')
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'color', 'green')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { icon: 'cooking-pot', color: 'green' })
expect(onFrontmatterPersisted).toHaveBeenCalled()
})
it('auto-creates type entry when not found and applies customization', async () => {
@@ -174,50 +175,52 @@ describe('useEntryActions', () => {
})
describe('handleUpdateTypeTemplate', () => {
it('updates template on the type entry', () => {
it('updates template on the type entry', async () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' })
const { result } = setup([typeEntry])
act(() => {
result.current.handleUpdateTypeTemplate('Project', '## Objective\n\n## Notes')
await act(async () => {
await result.current.handleUpdateTypeTemplate('Project', '## Objective\n\n## Notes')
})
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/project.md', 'template', '## Objective\n\n## Notes')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/project.md', { template: '## Objective\n\n## Notes' })
expect(onFrontmatterPersisted).toHaveBeenCalled()
})
it('sets template to null when empty string', () => {
it('sets template to null when empty string', async () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' })
const { result } = setup([typeEntry])
act(() => {
result.current.handleUpdateTypeTemplate('Project', '')
await act(async () => {
await result.current.handleUpdateTypeTemplate('Project', '')
})
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/project.md', 'template', '')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/project.md', { template: null })
})
it('does nothing when type entry not found', () => {
it('auto-creates type entry when not found', async () => {
const { result } = setup([])
act(() => {
result.current.handleUpdateTypeTemplate('NonExistent', '## Template')
await act(async () => {
await result.current.handleUpdateTypeTemplate('NonExistent', '## Template')
})
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
expect(updateEntry).not.toHaveBeenCalled()
expect(createTypeEntry).toHaveBeenCalledWith('NonExistent')
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/nonexistent.md', 'template', '## Template')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/nonexistent.md', { template: '## Template' })
})
})
describe('handleReorderSections', () => {
it('updates order on multiple type entries', () => {
it('updates order on multiple type entries', async () => {
const typeA = makeEntry({ isA: 'Type', title: 'Note', path: '/vault/type/note.md' })
const typeB = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' })
const { result } = setup([typeA, typeB])
act(() => {
result.current.handleReorderSections([
await act(async () => {
await result.current.handleReorderSections([
{ typeName: 'Note', order: 0 },
{ typeName: 'Project', order: 1 },
])
@@ -227,23 +230,24 @@ describe('useEntryActions', () => {
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/project.md', 'order', 1)
expect(updateEntry).toHaveBeenCalledWith('/vault/type/note.md', { order: 0 })
expect(updateEntry).toHaveBeenCalledWith('/vault/type/project.md', { order: 1 })
expect(onFrontmatterPersisted).toHaveBeenCalled()
})
it('skips types that are not found', () => {
it('auto-creates type entries when not found', async () => {
const typeA = makeEntry({ isA: 'Type', title: 'Note', path: '/vault/type/note.md' })
const { result } = setup([typeA])
act(() => {
result.current.handleReorderSections([
await act(async () => {
await result.current.handleReorderSections([
{ typeName: 'Note', order: 0 },
{ typeName: 'Missing', order: 1 },
])
})
// Only Note's order was set; Missing was skipped
expect(handleUpdateFrontmatter).toHaveBeenCalledTimes(1)
expect(createTypeEntry).toHaveBeenCalledWith('Missing')
expect(handleUpdateFrontmatter).toHaveBeenCalledTimes(2)
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/note.md', 'order', 0)
expect(updateEntry).toHaveBeenCalledTimes(1)
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/missing.md', 'order', 1)
})
})
@@ -258,6 +262,7 @@ describe('useEntryActions', () => {
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'sidebar label', 'Recipes')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { sidebarLabel: 'Recipes' })
expect(onFrontmatterPersisted).toHaveBeenCalled()
})
it('trims whitespace before saving', async () => {
@@ -285,15 +290,16 @@ describe('useEntryActions', () => {
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
})
it('does nothing when type entry not found', async () => {
it('auto-creates type entry when not found', async () => {
const { result } = setup([])
await act(async () => {
await result.current.handleRenameSection('NonExistent', 'Label')
})
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
expect(updateEntry).not.toHaveBeenCalled()
expect(createTypeEntry).toHaveBeenCalledWith('NonExistent')
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/nonexistent.md', 'sidebar label', 'Label')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/nonexistent.md', { sidebarLabel: 'Label' })
})
})
@@ -308,6 +314,7 @@ describe('useEntryActions', () => {
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/journal.md', 'visible', false)
expect(updateEntry).toHaveBeenCalledWith('/vault/type/journal.md', { visible: false })
expect(onFrontmatterPersisted).toHaveBeenCalled()
})
it('sets visible to true (deletes property) when currently hidden', async () => {
@@ -320,6 +327,7 @@ describe('useEntryActions', () => {
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/type/journal.md', 'visible')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/journal.md', { visible: null })
expect(onFrontmatterPersisted).toHaveBeenCalled()
})
it('auto-creates type entry when not found', async () => {

View File

@@ -55,27 +55,30 @@ export function useEntryActions({
updateEntry(typeEntry.path, { icon, color })
await handleUpdateFrontmatter(typeEntry.path, 'icon', icon)
await handleUpdateFrontmatter(typeEntry.path, 'color', color)
}, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry])
onFrontmatterPersisted?.()
}, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry, onFrontmatterPersisted])
const handleReorderSections = useCallback((orderedTypes: { typeName: string; order: number }[]) => {
const handleReorderSections = useCallback(async (orderedTypes: { typeName: string; order: number }[]) => {
for (const { typeName, order } of orderedTypes) {
const typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) continue
handleUpdateFrontmatter(typeEntry.path, 'order', order)
let typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
await handleUpdateFrontmatter(typeEntry.path, 'order', order)
updateEntry(typeEntry.path, { order })
}
}, [entries, handleUpdateFrontmatter, updateEntry])
onFrontmatterPersisted?.()
}, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry, onFrontmatterPersisted])
const handleUpdateTypeTemplate = useCallback((typeName: string, template: string) => {
const typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) return
handleUpdateFrontmatter(typeEntry.path, 'template', template)
const handleUpdateTypeTemplate = useCallback(async (typeName: string, template: string) => {
let typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
await handleUpdateFrontmatter(typeEntry.path, 'template', template)
updateEntry(typeEntry.path, { template: template || null })
}, [entries, handleUpdateFrontmatter, updateEntry])
onFrontmatterPersisted?.()
}, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry, onFrontmatterPersisted])
const handleRenameSection = useCallback(async (typeName: string, label: string) => {
const typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) return
let typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
const trimmed = label.trim()
updateEntry(typeEntry.path, { sidebarLabel: trimmed || null })
if (trimmed) {
@@ -83,7 +86,8 @@ export function useEntryActions({
} else {
await handleDeleteProperty(typeEntry.path, 'sidebar label')
}
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry])
onFrontmatterPersisted?.()
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry, createTypeEntry, onFrontmatterPersisted])
const handleToggleTypeVisibility = useCallback(async (typeName: string) => {
let typeEntry = findTypeEntry(entries, typeName)
@@ -95,7 +99,8 @@ export function useEntryActions({
updateEntry(typeEntry.path, { visible: false })
await handleUpdateFrontmatter(typeEntry.path, 'visible', false)
}
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry, createTypeEntry])
onFrontmatterPersisted?.()
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry, createTypeEntry, onFrontmatterPersisted])
return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate, handleRenameSection, handleToggleTypeVisibility }
}

View File

@@ -3,12 +3,13 @@ import { sendShortcut } from './helpers'
test.describe('AI chat wikilink rendering', () => {
test.beforeEach(async ({ page }) => {
// Block vault API so mock entries are used (ensures "Build Laputa App" exists)
await page.route('**/api/vault/ping', route => route.fulfill({ status: 503 }))
await page.goto('/')
await page.waitForTimeout(500)
})
test('[[Note]] in AI response renders as clickable wikilink', async ({ page }) => {
// Select a note first so the AI panel has context
// Select a note so the AI panel has context
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
await noteItem.click()
await page.waitForTimeout(500)
@@ -17,14 +18,17 @@ test.describe('AI chat wikilink rendering', () => {
await sendShortcut(page, 'i', ['Control'])
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3000 })
// Send a message to get a mock response containing wikilinks
// Send a message to trigger mock response with [[Build Laputa App]] and [[Matteo Cellini]]
const input = page.locator('input[placeholder*="Ask"]')
await input.fill('Tell me about this note')
await page.getByTestId('agent-send').click()
// Wait for mock response (contains [[Build Laputa App]] and [[Matteo Cellini]])
// Wait for wikilinks to render
await expect(page.locator('.chat-wikilink').first()).toBeVisible({ timeout: 5000 })
})
test('[[Note]] in AI response renders as clickable wikilink', async ({ page }) => {
const wikilink = page.locator('.chat-wikilink').first()
await expect(wikilink).toBeVisible({ timeout: 5000 })
// Verify wikilink text and attributes
await expect(wikilink).toHaveText('Build Laputa App')
@@ -36,11 +40,23 @@ test.describe('AI chat wikilink rendering', () => {
await expect(secondWikilink).toHaveText('Matteo Cellini')
// Verify multiple wikilinks rendered
const allWikilinks = page.locator('.chat-wikilink')
await expect(allWikilinks).toHaveCount(2)
await expect(page.locator('.chat-wikilink')).toHaveCount(2)
// Verify wikilink has pointer cursor (is styled as clickable)
const cursor = await wikilink.evaluate(el => getComputedStyle(el).cursor)
expect(cursor).toBe('pointer')
})
test('clicking a wikilink opens the note in a tab', async ({ page }) => {
const wikilink = page.locator('.chat-wikilink').first()
await expect(wikilink).toHaveText('Build Laputa App')
// Click the wikilink
await wikilink.click()
await page.waitForTimeout(500)
// Verify the note opened in a tab — the editor breadcrumb shows the active note title
const breadcrumb = page.locator('.app__editor span.truncate.font-medium', { hasText: 'Build Laputa App' })
await expect(breadcrumb).toBeVisible({ timeout: 3000 })
})
})