fix: AI chat wikilinks — system prompt + integration tests
Root cause: buildContextSnapshot (the system prompt used when a note is active — the common case) did NOT instruct the AI to use [[wikilinks]]. Only buildAgentSystemPrompt (the fallback when no context) had it. So the AI almost never produced clickable wikilinks. Fix: Add the [[Note Title]] wikilink instruction to buildContextSnapshot's preamble. The click handler chain was already correct (verified with new integration tests): MarkdownContent → AiMessage → AiPanel → notes.handleNavigateWikilink → findWikilinkTarget → handleSelectNote. Tests added: - Unit: buildContextSnapshot includes wikilink instruction - Integration: clicking wikilinks in AiPanel calls onOpenNote - Playwright: wikilink renders, click opens note in tab Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,10 +4,12 @@ import { AiPanel } from './AiPanel'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
// Mock the hooks and utils to isolate component tests
|
||||
let mockMessages: ReturnType<typeof import('../hooks/useAiAgent').useAiAgent>['messages'] = []
|
||||
let mockStatus: ReturnType<typeof import('../hooks/useAiAgent').useAiAgent>['status'] = 'idle'
|
||||
vi.mock('../hooks/useAiAgent', () => ({
|
||||
useAiAgent: () => ({
|
||||
messages: [],
|
||||
status: 'idle',
|
||||
messages: mockMessages,
|
||||
status: mockStatus,
|
||||
sendMessage: vi.fn(),
|
||||
clearConversation: vi.fn(),
|
||||
}),
|
||||
@@ -45,6 +47,11 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
})
|
||||
|
||||
describe('AiPanel', () => {
|
||||
beforeEach(() => {
|
||||
mockMessages = []
|
||||
mockStatus = 'idle'
|
||||
})
|
||||
|
||||
it('renders panel with AI Chat header', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
expect(screen.getByText('AI Chat')).toBeTruthy()
|
||||
@@ -162,4 +169,41 @@ describe('AiPanel', () => {
|
||||
fireEvent.keyDown(panel, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('clicking a wikilink in AI response calls onOpenNote with the target', () => {
|
||||
mockMessages = [{
|
||||
userMessage: 'Tell me about notes',
|
||||
actions: [],
|
||||
response: 'Check out [[Build Laputa App]] for details.',
|
||||
id: 'msg-1',
|
||||
}]
|
||||
const onOpenNote = vi.fn()
|
||||
const { container } = render(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" onOpenNote={onOpenNote} />,
|
||||
)
|
||||
const wikilink = container.querySelector('.chat-wikilink')
|
||||
expect(wikilink).toBeTruthy()
|
||||
expect(wikilink!.textContent).toBe('Build Laputa App')
|
||||
fireEvent.click(wikilink!)
|
||||
expect(onOpenNote).toHaveBeenCalledWith('Build Laputa App')
|
||||
})
|
||||
|
||||
it('renders wikilinks with special characters and clicking works', () => {
|
||||
mockMessages = [{
|
||||
userMessage: 'Tell me about meetings',
|
||||
actions: [],
|
||||
response: 'See [[Meeting — 2024/01/15]] and [[Pasta Carbonara]].',
|
||||
id: 'msg-2',
|
||||
}]
|
||||
const onOpenNote = vi.fn()
|
||||
const { container } = render(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" onOpenNote={onOpenNote} />,
|
||||
)
|
||||
const wikilinks = container.querySelectorAll('.chat-wikilink')
|
||||
expect(wikilinks).toHaveLength(2)
|
||||
fireEvent.click(wikilinks[0])
|
||||
expect(onOpenNote).toHaveBeenCalledWith('Meeting — 2024/01/15')
|
||||
fireEvent.click(wikilinks[1])
|
||||
expect(onOpenNote).toHaveBeenCalledWith('Pasta Carbonara')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -331,6 +331,12 @@ describe('buildContextSnapshot', () => {
|
||||
expect(json.noteList).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes wikilink instruction in preamble', () => {
|
||||
const result = buildContextSnapshot({ activeEntry: active, allContent, entries })
|
||||
expect(result).toContain('[[Note Title]]')
|
||||
expect(result).toContain('wikilink')
|
||||
})
|
||||
|
||||
it('includes belongsTo and relatedTo in frontmatter', () => {
|
||||
const entryWithRels = makeEntry({
|
||||
path: '/vault/a.md', title: 'Alpha',
|
||||
|
||||
@@ -152,6 +152,7 @@ export function buildContextSnapshot(params: ContextSnapshotParams): string {
|
||||
'You are an AI assistant integrated into Laputa, 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.',
|
||||
'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')
|
||||
|
||||
return `${preamble}\n\n## Context Snapshot\n\`\`\`json\n${JSON.stringify(snapshot, null, 2)}\n\`\`\``
|
||||
|
||||
@@ -48,15 +48,19 @@ test.describe('AI chat wikilink rendering', () => {
|
||||
})
|
||||
|
||||
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 second wikilink ("Matteo Cellini") which is NOT already open in a tab
|
||||
const wikilink = page.locator('.chat-wikilink').nth(1)
|
||||
await expect(wikilink).toHaveText('Matteo Cellini')
|
||||
|
||||
// Verify "Matteo Cellini" is not yet in any tab
|
||||
const tabsBefore = await page.locator('span.truncate:has-text("Matteo Cellini")').count()
|
||||
|
||||
// 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 })
|
||||
// Verify a new tab appeared with the note title
|
||||
const tabsAfter = await page.locator('span.truncate:has-text("Matteo Cellini")').count()
|
||||
expect(tabsAfter).toBeGreaterThan(tabsBefore)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user