From 35afd09364858d2ce78d18f863b2eab223233bdd Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 7 Mar 2026 13:42:32 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20AI=20chat=20wikilinks=20=E2=80=94=20syst?= =?UTF-8?q?em=20prompt=20+=20integration=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/AiPanel.test.tsx | 48 ++++++++++++++++++- src/utils/ai-context.test.ts | 6 +++ src/utils/ai-context.ts | 1 + .../smoke/ai-chat-wikilink-clickable.spec.ts | 14 ++++-- 4 files changed, 62 insertions(+), 7 deletions(-) diff --git a/src/components/AiPanel.test.tsx b/src/components/AiPanel.test.tsx index c71ea5d6..ddaa25f5 100644 --- a/src/components/AiPanel.test.tsx +++ b/src/components/AiPanel.test.tsx @@ -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['messages'] = [] +let mockStatus: ReturnType['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 => ({ }) describe('AiPanel', () => { + beforeEach(() => { + mockMessages = [] + mockStatus = 'idle' + }) + it('renders panel with AI Chat header', () => { render() 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( + , + ) + 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( + , + ) + 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') + }) }) diff --git a/src/utils/ai-context.test.ts b/src/utils/ai-context.test.ts index 73e28e9d..ba876107 100644 --- a/src/utils/ai-context.test.ts +++ b/src/utils/ai-context.test.ts @@ -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', diff --git a/src/utils/ai-context.ts b/src/utils/ai-context.ts index 7b830987..24c19604 100644 --- a/src/utils/ai-context.ts +++ b/src/utils/ai-context.ts @@ -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\`\`\`` diff --git a/tests/smoke/ai-chat-wikilink-clickable.spec.ts b/tests/smoke/ai-chat-wikilink-clickable.spec.ts index b66fa88a..d3b98dec 100644 --- a/tests/smoke/ai-chat-wikilink-clickable.spec.ts +++ b/tests/smoke/ai-chat-wikilink-clickable.spec.ts @@ -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) }) })