Compare commits

...

7 Commits

Author SHA1 Message Date
Test
d29f919182 test: add Playwright smoke test for AI note visibility and tab opening
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 03:56:37 +01:00
Test
42e37e035c test: add unit tests for detectFileOperation and parseBashFileCreation
24 tests covering Write/Edit/Bash file detection, edge cases
(malformed JSON, files outside vault, non-md files, undefined input),
and the parseBashFileCreation helper for redirect/tee patterns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 03:41:06 +01:00
Test
0ad0fa9b6b fix: AI-created notes now trigger vault refresh and auto-open in tab
Root causes:
- toolInputMapRef in useAiAgent was overwritten by tool_progress events
  (which arrive with input=undefined AFTER the assistant message set
  the full input), causing detectFileOperation to receive undefined
  and skip file creation detection entirely.
- MCP open_note only broadcast open_tab without vault_changed, so
  the note list didn't refresh when Claude Code called open_note.
- detectFileOperation only handled Write/Edit but not Bash commands
  that create .md files via redirects.

Fixes:
- Preserve accumulated input in toolInputMapRef (input ?? prev?.input)
- MCP open_note now broadcasts vault_changed before open_tab
- detectFileOperation now detects Bash redirect patterns (>, >>, tee)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 03:39:34 +01:00
Test
c37c03d6a9 fix: remove --resume from AI chat to fix conversation history
The AI chat was using both --resume (CLI session resumption) AND formatted
conversation history in the prompt simultaneously. This dual-context approach
confused the model — it saw the conversation twice (from session + from prompt
markup), leading to "I don't have context" responses on follow-ups.

Fix: remove --resume entirely from chat mode. Each CLI call is now independent,
with full conversation history formatted into the prompt via
<conversation_history> markup. trimHistory handles graceful truncation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 03:08:24 +01:00
Test
fcc264d7dc feat: restore MCP UI-steering tools (highlight_editor, refresh_vault)
Add highlight_editor and refresh_vault tools to the MCP stdio server
so Claude Code can visually highlight UI elements and trigger vault
rescans. Also fix outdated test.js imports after the ai-agent-full-shell
simplification removed write operations from vault.js.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:03:21 +01:00
Test
382ba0a6d4 test: add Playwright smoke test for wikilink rendering in AI chat
Update mock agent response to include [[wikilinks]] for testing.
Add smoke test verifying wikilinks render as clickable elements
with correct text, attributes, and styling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:58:08 +01:00
Test
4719810b10 feat: render [[wikilinks]] as clickable links in AI chat
- System prompts instruct AI to use [[Note Title]] wikilink syntax
- preprocessWikilinks converts [[Target]] to markdown links
- Custom urlTransform allows wikilink:// scheme through sanitizer
- Click handler resolves target via findEntryByTarget and opens note
- Styled as colored chips matching primary accent
- Works in both AiPanel (agent) and AIChatPanel (legacy chat)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:40:44 +01:00
23 changed files with 698 additions and 186 deletions

View File

@@ -1 +1 @@
66070
88289

View File

@@ -10,6 +10,8 @@
* - get_vault_context: vault structure overview (types, note count, folders)
* - get_note: parsed frontmatter + content (convenience over raw cat)
* - open_note: signal Laputa UI to open a note as a tab
* - highlight_editor: visually highlight a UI element (editor, tab, etc.)
* - refresh_vault: trigger vault rescan so new/modified files appear
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
@@ -94,6 +96,28 @@ const TOOLS = [
required: ['path'],
},
},
{
name: 'highlight_editor',
description: 'Visually highlight a UI element in Laputa (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.',
inputSchema: {
type: 'object',
properties: {
element: { type: 'string', enum: ['editor', 'tab', 'properties', 'notelist'], description: 'Which UI element to highlight' },
path: { type: 'string', description: 'Optional note path to associate with the highlight' },
},
required: ['element'],
},
},
{
name: 'refresh_vault',
description: 'Trigger a vault rescan so new or modified files appear immediately in the Laputa note list.',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Optional specific note path that changed' },
},
},
},
]
const TOOL_HANDLERS = {
@@ -101,6 +125,8 @@ const TOOL_HANDLERS = {
get_vault_context: handleVaultContext,
get_note: handleGetNote,
open_note: handleOpenNote,
highlight_editor: handleHighlightEditor,
refresh_vault: handleRefreshVault,
}
async function handleSearchNotes(args) {
@@ -122,14 +148,27 @@ async function handleGetNote(args) {
}
function handleOpenNote(args) {
// Refresh vault first so the new/modified note appears in the note list,
// then signal the UI to open it in a tab.
broadcastUiAction('vault_changed', { path: args.path })
broadcastUiAction('open_tab', { path: args.path })
return { content: [{ type: 'text', text: `Opening ${args.path} in Laputa` }] }
}
function handleHighlightEditor(args) {
broadcastUiAction('highlight', { element: args.element, path: args.path })
return { content: [{ type: 'text', text: `Highlighting ${args.element}` }] }
}
function handleRefreshVault(args) {
broadcastUiAction('vault_changed', { path: args?.path })
return { content: [{ type: 'text', text: 'Vault refresh triggered' }] }
}
// --- Server setup ---
const server = new Server(
{ name: 'laputa-mcp-server', version: '0.2.0' },
{ name: 'laputa-mcp-server', version: '0.3.0' },
{ capabilities: { tools: {} } },
)

View File

@@ -4,8 +4,7 @@ import fs from 'node:fs/promises'
import path from 'node:path'
import os from 'node:os'
import {
readNote, createNote, searchNotes, appendToNote, findMarkdownFiles,
editNoteFrontmatter, deleteNote, linkNotes, listNotes, vaultContext,
findMarkdownFiles, getNote, searchNotes, vaultContext,
} from './vault.js'
let tmpDir
@@ -65,39 +64,23 @@ describe('findMarkdownFiles', () => {
})
})
describe('readNote', () => {
it('should read a note by relative path', async () => {
const content = await readNote(tmpDir, 'project/test-project.md')
assert.ok(content.includes('Test Project'))
assert.ok(content.includes('is_a: Project'))
describe('getNote', () => {
it('should read a note with parsed frontmatter', async () => {
const note = await getNote(tmpDir, 'project/test-project.md')
assert.equal(note.path, 'project/test-project.md')
assert.equal(note.frontmatter.title, 'Test Project')
assert.equal(note.frontmatter.is_a, 'Project')
assert.ok(note.content.includes('test project for the MCP server'))
})
it('should throw for missing notes', async () => {
await assert.rejects(
() => readNote(tmpDir, 'nonexistent.md'),
() => getNote(tmpDir, 'nonexistent.md'),
{ code: 'ENOENT' }
)
})
})
describe('createNote', () => {
it('should create a note with frontmatter', async () => {
const absPath = await createNote(tmpDir, 'note/new-note.md', 'My New Note', { is_a: 'Note' })
assert.ok(absPath.endsWith('new-note.md'))
const content = await fs.readFile(absPath, 'utf-8')
assert.ok(content.includes('title: My New Note'))
assert.ok(content.includes('is_a: Note'))
assert.ok(content.includes('# My New Note'))
})
it('should create parent directories', async () => {
const absPath = await createNote(tmpDir, 'deep/nested/dir/note.md', 'Deep Note')
const content = await fs.readFile(absPath, 'utf-8')
assert.ok(content.includes('# Deep Note'))
})
})
describe('searchNotes', () => {
it('should find notes matching title', async () => {
const results = await searchNotes(tmpDir, 'Test Project')
@@ -121,123 +104,6 @@ describe('searchNotes', () => {
})
})
describe('appendToNote', () => {
it('should append text to a note', async () => {
await appendToNote(tmpDir, 'note/daily-log.md', '## Evening Update\nFinished testing.')
const content = await readNote(tmpDir, 'note/daily-log.md')
assert.ok(content.includes('## Evening Update'))
assert.ok(content.includes('Finished testing.'))
})
})
describe('editNoteFrontmatter', () => {
it('should merge a patch into frontmatter', async () => {
const updated = await editNoteFrontmatter(tmpDir, 'project/test-project.md', { status: 'Completed', priority: 'High' })
assert.equal(updated.status, 'Completed')
assert.equal(updated.priority, 'High')
assert.equal(updated.title, 'Test Project')
})
it('should preserve existing frontmatter fields', async () => {
const content = await readNote(tmpDir, 'project/test-project.md')
assert.ok(content.includes('is_a: Project'))
assert.ok(content.includes('status: Completed'))
})
it('should throw for missing file', async () => {
await assert.rejects(
() => editNoteFrontmatter(tmpDir, 'nonexistent.md', { foo: 'bar' }),
{ code: 'ENOENT' }
)
})
})
describe('deleteNote', () => {
it('should delete an existing note', async () => {
const delPath = 'note/to-delete.md'
await createNote(tmpDir, delPath, 'To Delete')
const absPath = path.join(tmpDir, delPath)
// Verify it exists
await fs.access(absPath)
await deleteNote(tmpDir, delPath)
await assert.rejects(
() => fs.access(absPath),
{ code: 'ENOENT' }
)
})
it('should throw for missing file', async () => {
await assert.rejects(
() => deleteNote(tmpDir, 'nonexistent.md'),
{ code: 'ENOENT' }
)
})
})
describe('linkNotes', () => {
it('should add a target to an array property', async () => {
const linkPath = 'project/link-test.md'
await createNote(tmpDir, linkPath, 'Link Test', { is_a: 'Project' })
const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]')
assert.deepEqual(result, ['[[note/daily-log]]'])
})
it('should not duplicate existing links', async () => {
const linkPath = 'project/link-test.md'
await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]')
const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]')
assert.equal(result.length, 1)
})
it('should add multiple distinct links', async () => {
const linkPath = 'project/link-test.md'
await linkNotes(tmpDir, linkPath, 'related_to', '[[project/test-project]]')
const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[project/test-project]]')
// Should have daily-log and test-project
assert.ok(result.includes('[[note/daily-log]]'))
assert.ok(result.includes('[[project/test-project]]'))
assert.equal(result.length, 2)
})
})
describe('listNotes', () => {
it('should list all notes sorted by title', async () => {
const notes = await listNotes(tmpDir)
assert.ok(notes.length >= 3)
// Verify sorted by title
for (let i = 1; i < notes.length; i++) {
assert.ok(notes[i - 1].title.localeCompare(notes[i].title) <= 0)
}
})
it('should filter by type', async () => {
const projects = await listNotes(tmpDir, 'Project')
assert.ok(projects.length >= 1)
for (const n of projects) {
assert.equal(n.type, 'Project')
}
})
it('should return empty for unknown type', async () => {
const notes = await listNotes(tmpDir, 'UnknownType12345')
assert.equal(notes.length, 0)
})
it('should support mtime sorting', async () => {
const notes = await listNotes(tmpDir, undefined, 'mtime')
assert.ok(notes.length >= 1)
// Just verify it returns results without crashing
assert.ok(notes[0].path)
assert.ok(notes[0].title)
})
})
describe('vaultContext', () => {
it('should return types, recent notes, and vault path', async () => {
const ctx = await vaultContext(tmpDir)
@@ -264,4 +130,15 @@ describe('vaultContext', () => {
assert.ok(note.title)
}
})
it('should include folders', async () => {
const ctx = await vaultContext(tmpDir)
assert.ok(ctx.folders.includes('project/'))
assert.ok(ctx.folders.includes('note/'))
})
it('should report correct note count', async () => {
const ctx = await vaultContext(tmpDir)
assert.equal(ctx.noteCount, 3)
})
})

View File

@@ -46,10 +46,12 @@ const TOOL_HANDLERS = {
read_note: (args) => getNote(VAULT_PATH, args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
search_notes: (args) => searchNotes(VAULT_PATH, args.query, args.limit),
vault_context: () => vaultContext(VAULT_PATH),
ui_open_note: (args) => { broadcastUiAction('open_note', { path: args.path }); return { ok: true } },
ui_open_tab: (args) => { broadcastUiAction('open_tab', { path: args.path }); return { ok: true } },
ui_open_note: (args) => { broadcastUiAction('vault_changed', { path: args.path }); broadcastUiAction('open_note', { path: args.path }); return { ok: true } },
ui_open_tab: (args) => { broadcastUiAction('vault_changed', { path: args.path }); broadcastUiAction('open_tab', { path: args.path }); return { ok: true } },
ui_highlight: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
ui_set_filter: (args) => { broadcastUiAction('set_filter', { filterType: args.type }); return { ok: true } },
highlight_editor: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
refresh_vault: (args) => { broadcastUiAction('vault_changed', { path: args?.path }); return { ok: true } },
}
async function handleMessage(data) {

View File

@@ -73,6 +73,7 @@
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.0.18",
"esbuild": "^0.27.3",
@@ -86,6 +87,7 @@
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1",
"vitest": "^4.0.18"
"vitest": "^4.0.18",
"ws": "^8.19.0"
}
}

13
pnpm-lock.yaml generated
View File

@@ -168,6 +168,9 @@ importers:
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
'@vitejs/plugin-react':
specifier: ^5.1.1
version: 5.1.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2))
@@ -210,6 +213,9 @@ importers:
vitest:
specifier: ^4.0.18
version: 4.0.18(@types/node@24.10.13)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2)
ws:
specifier: ^8.19.0
version: 8.19.0
mcp-server:
dependencies:
@@ -2043,6 +2049,9 @@ packages:
'@types/use-sync-external-store@1.5.0':
resolution: {integrity: sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@typescript-eslint/eslint-plugin@8.55.0':
resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -6011,6 +6020,10 @@ snapshots:
'@types/use-sync-external-store@1.5.0': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 24.10.13
'@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2

View File

@@ -18,6 +18,7 @@ interface AIChatPanelProps {
allContent: Record<string, string>
entries?: VaultEntry[]
onClose: () => void
onNavigateWikilink?: (target: string) => void
}
function TypingIndicator() {
@@ -99,10 +100,10 @@ function ContextSearchDropdown({
)
}
function AssistantMessage({ msg, onRetry }: { msg: ChatMessage; onRetry: () => void }) {
function AssistantMessage({ msg, onRetry, onNavigateWikilink }: { msg: ChatMessage; onRetry: () => void; onNavigateWikilink?: (target: string) => void }) {
return (
<div>
<MarkdownContent content={msg.content} />
<MarkdownContent content={msg.content} onWikilinkClick={onNavigateWikilink} />
<div className="flex items-center gap-3" style={{ marginTop: 4 }}>
<button className="border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:underline"
style={{ fontSize: 11 }} onClick={() => navigator.clipboard.writeText(msg.content)}>
@@ -121,10 +122,10 @@ function AssistantMessage({ msg, onRetry }: { msg: ChatMessage; onRetry: () => v
)
}
function StreamingContent({ content }: { content: string }) {
function StreamingContent({ content, onNavigateWikilink }: { content: string; onNavigateWikilink?: (target: string) => void }) {
return (
<div style={{ marginBottom: 12 }}>
<MarkdownContent content={content} />
<MarkdownContent content={content} onWikilinkClick={onNavigateWikilink} />
</div>
)
}
@@ -176,7 +177,7 @@ function useContextNotes(entry: VaultEntry | null) {
// --- Main component ---
export function AIChatPanel({ entry, allContent, entries = [], onClose }: AIChatPanelProps) {
export function AIChatPanel({ entry, allContent, entries = [], onClose, onNavigateWikilink }: AIChatPanelProps) {
const [input, setInput] = useState('')
const [showSearch, setShowSearch] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
@@ -213,7 +214,7 @@ export function AIChatPanel({ entry, allContent, entries = [], onClose }: AIChat
<MessageList
messages={chat.messages} isStreaming={chat.isStreaming}
streamingContent={chat.streamingContent} onRetry={chat.retryMessage}
messagesEndRef={messagesEndRef}
messagesEndRef={messagesEndRef} onNavigateWikilink={onNavigateWikilink}
/>
<QuickActionsBar actions={QUICK_ACTIONS} disabled={chat.isStreaming}
@@ -284,10 +285,11 @@ function ContextBar({
}
function MessageList({
messages, isStreaming, streamingContent, onRetry, messagesEndRef,
messages, isStreaming, streamingContent, onRetry, messagesEndRef, onNavigateWikilink,
}: {
messages: ChatMessage[]; isStreaming: boolean; streamingContent: string
onRetry: (idx: number) => void; messagesEndRef: React.RefObject<HTMLDivElement | null>
onNavigateWikilink?: (target: string) => void
}) {
return (
<div className="flex-1 overflow-y-auto" style={{ padding: 12 }}>
@@ -302,10 +304,10 @@ function MessageList({
<div key={msg.id} style={{ marginBottom: 12 }}>
{msg.role === 'user'
? <UserBubble content={msg.content} />
: <AssistantMessage msg={msg} onRetry={() => onRetry(idx)} />}
: <AssistantMessage msg={msg} onRetry={() => onRetry(idx)} onNavigateWikilink={onNavigateWikilink} />}
</div>
))}
{isStreaming && streamingContent && <StreamingContent content={streamingContent} />}
{isStreaming && streamingContent && <StreamingContent content={streamingContent} onNavigateWikilink={onNavigateWikilink} />}
{isStreaming && !streamingContent && <TypingIndicator />}
<div ref={messagesEndRef} />
</div>

View File

@@ -24,6 +24,7 @@ export interface AiMessageProps {
response?: string
isStreaming?: boolean
onOpenNote?: (path: string) => void
onNavigateWikilink?: (target: string) => void
}
function ReferencePill({ reference, onClick }: {
@@ -147,10 +148,10 @@ function ActionCardsList({ actions, onOpenNote, expandedIds, onToggleExpand }: {
)
}
function ResponseBlock({ text }: { text: string }) {
function ResponseBlock({ text, onNavigateWikilink }: { text: string; onNavigateWikilink?: (target: string) => void }) {
return (
<div style={{ marginBottom: 4 }}>
<MarkdownContent content={text} />
<MarkdownContent content={text} onWikilinkClick={onNavigateWikilink} />
<button
className="flex items-center gap-1 border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
style={{ fontSize: 11, marginTop: 4 }}
@@ -175,7 +176,7 @@ function StreamingIndicator() {
)
}
export function AiMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote }: AiMessageProps) {
export function AiMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote, onNavigateWikilink }: AiMessageProps) {
// Manual override: null = follow auto behavior, true/false = user forced
const [userOverride, setUserOverride] = useState(false)
const [expandedActions, setExpandedActions] = useState<Set<string>>(new Set())
@@ -212,7 +213,7 @@ export function AiMessage({ userMessage, references, reasoning, reasoningDone, a
onToggleExpand={toggleAction}
/>
)}
{response && <ResponseBlock text={response} />}
{response && <ResponseBlock text={response} onNavigateWikilink={onNavigateWikilink} />}
{isStreaming && !response && <StreamingIndicator />}
</div>
)

View File

@@ -4,6 +4,7 @@ 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'
@@ -89,8 +90,8 @@ function EmptyState({ hasContext }: { hasContext: boolean }) {
)
}
function MessageHistory({ messages, isActive, onOpenNote, hasContext }: {
messages: AiAgentMessage[]; isActive: boolean; onOpenNote?: (path: string) => void; hasContext: boolean
function MessageHistory({ messages, isActive, onOpenNote, onNavigateWikilink, hasContext }: {
messages: AiAgentMessage[]; isActive: boolean; onOpenNote?: (path: string) => void; onNavigateWikilink?: (target: string) => void; hasContext: boolean
}) {
const endRef = useRef<HTMLDivElement>(null)
@@ -102,7 +103,7 @@ function MessageHistory({ messages, isActive, onOpenNote, hasContext }: {
<div className="flex-1 overflow-y-auto" style={{ padding: 12 }}>
{messages.length === 0 && !isActive && <EmptyState hasContext={hasContext} />}
{messages.map((msg, i) => (
<AiMessage key={msg.id ?? i} {...msg} onOpenNote={onOpenNote} />
<AiMessage key={msg.id ?? i} {...msg} onOpenNote={onOpenNote} onNavigateWikilink={onNavigateWikilink} />
))}
<div ref={endRef} />
</div>
@@ -167,6 +168,12 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, va
return () => window.removeEventListener('keydown', handleEscape)
}, [handleEscape])
const handleNavigateWikilink = useCallback((target: string) => {
if (!entries) return
const entry = findEntryByTarget(entries, target)
if (entry) onOpenNote?.(entry.path)
}, [entries, onOpenNote])
const handleSend = useCallback((text: string, references: NoteReference[]) => {
if (!text.trim() || isActive) return
setPendingRefs(references)
@@ -198,6 +205,7 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, va
messages={agent.messages}
isActive={isActive}
onOpenNote={onOpenNote}
onNavigateWikilink={handleNavigateWikilink}
hasContext={hasContext}
/>
<div

View File

@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { MarkdownContent } from './MarkdownContent'
import { preprocessWikilinks } from '../utils/chatWikilinks'
describe('MarkdownContent', () => {
it('renders bold text', () => {
@@ -72,4 +73,110 @@ describe('MarkdownContent', () => {
expect(bq).toBeTruthy()
expect(bq!.textContent).toContain('A quote')
})
describe('wikilinks', () => {
it('preprocessWikilinks converts [[Target]] to markdown links', () => {
expect(preprocessWikilinks('See [[My Note]]')).toBe('See [My Note](wikilink://My%20Note)')
expect(preprocessWikilinks('[[A]] and [[B]]')).toBe('[A](wikilink://A) and [B](wikilink://B)')
expect(preprocessWikilinks('`[[code]]`')).toBe('`[[code]]`')
})
it('renders [[Note Title]] as a clickable wikilink chip', () => {
const onClick = vi.fn()
const { container } = render(
<MarkdownContent content="Check out [[My Note]]" onWikilinkClick={onClick} />,
)
const wikilink = container.querySelector('.chat-wikilink')
expect(wikilink).toBeTruthy()
expect(wikilink!.textContent).toBe('My Note')
expect(wikilink!.getAttribute('data-wikilink-target')).toBe('My Note')
})
it('fires onWikilinkClick when a wikilink is clicked', () => {
const onClick = vi.fn()
const { container } = render(
<MarkdownContent content="See [[Daily Log]]" onWikilinkClick={onClick} />,
)
const wikilink = container.querySelector('.chat-wikilink')!
fireEvent.click(wikilink)
expect(onClick).toHaveBeenCalledWith('Daily Log')
})
it('renders multiple wikilinks in the same paragraph', () => {
const onClick = vi.fn()
const { container } = render(
<MarkdownContent content="See [[Note A]] and [[Note B]]" onWikilinkClick={onClick} />,
)
const wikilinks = container.querySelectorAll('.chat-wikilink')
expect(wikilinks).toHaveLength(2)
expect(wikilinks[0].textContent).toBe('Note A')
expect(wikilinks[1].textContent).toBe('Note B')
})
it('handles pipe syntax [[target|display]]', () => {
const onClick = vi.fn()
const { container } = render(
<MarkdownContent content="See [[path/to/note|My Display]]" onWikilinkClick={onClick} />,
)
const wikilink = container.querySelector('.chat-wikilink')!
expect(wikilink.textContent).toBe('My Display')
expect(wikilink.getAttribute('data-wikilink-target')).toBe('path/to/note')
fireEvent.click(wikilink)
expect(onClick).toHaveBeenCalledWith('path/to/note')
})
it('does not render wikilinks inside inline code', () => {
const onClick = vi.fn()
const { container } = render(
<MarkdownContent content="Use `[[Not a link]]` syntax" onWikilinkClick={onClick} />,
)
expect(container.querySelector('.chat-wikilink')).toBeNull()
})
it('does not render wikilinks inside code blocks', () => {
const onClick = vi.fn()
const { container } = render(
<MarkdownContent content={'```\n[[Not a link]]\n```'} onWikilinkClick={onClick} />,
)
expect(container.querySelector('.chat-wikilink')).toBeNull()
})
it('handles notes with special characters in title', () => {
const onClick = vi.fn()
const { container } = render(
<MarkdownContent content="Check [[Meeting — 2024/01/15]]" onWikilinkClick={onClick} />,
)
const wikilink = container.querySelector('.chat-wikilink')!
expect(wikilink.textContent).toBe('Meeting — 2024/01/15')
fireEvent.click(wikilink)
expect(onClick).toHaveBeenCalledWith('Meeting — 2024/01/15')
})
it('does not transform wikilinks when onWikilinkClick is not provided', () => {
const { container } = render(
<MarkdownContent content="See [[Some Note]]" />,
)
expect(container.querySelector('.chat-wikilink')).toBeNull()
expect(container.textContent).toContain('[[Some Note]]')
})
it('renders wikilinks inside list items', () => {
const onClick = vi.fn()
const { container } = render(
<MarkdownContent content={'- First [[Note A]]\n- Second [[Note B]]'} onWikilinkClick={onClick} />,
)
const wikilinks = container.querySelectorAll('.chat-wikilink')
expect(wikilinks).toHaveLength(2)
})
it('has role="link" and tabIndex for accessibility', () => {
const onClick = vi.fn()
const { container } = render(
<MarkdownContent content="See [[Accessible Note]]" onWikilinkClick={onClick} />,
)
const wikilink = container.querySelector('.chat-wikilink')!
expect(wikilink.getAttribute('role')).toBe('link')
expect(wikilink.getAttribute('tabindex')).toBe('0')
})
})
})

View File

@@ -1,18 +1,63 @@
import { memo, useMemo } from 'react'
import Markdown from 'react-markdown'
import { memo, useMemo, useCallback, type MouseEvent } from 'react'
import Markdown, { defaultUrlTransform } from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import { preprocessWikilinks, WIKILINK_SCHEME } from '../utils/chatWikilinks'
const REMARK_PLUGINS = [remarkGfm]
const REHYPE_PLUGINS = [rehypeHighlight]
export const MarkdownContent = memo(function MarkdownContent({ content }: { content: string }) {
const rendered = useMemo(() => (
<div className="ai-markdown">
<Markdown remarkPlugins={REMARK_PLUGINS} rehypePlugins={REHYPE_PLUGINS}>
{content}
function wikilinkUrlTransform(url: string): string {
if (url.startsWith(WIKILINK_SCHEME)) return url
return defaultUrlTransform(url)
}
interface MarkdownContentProps {
content: string
onWikilinkClick?: (target: string) => void
}
export const MarkdownContent = memo(function MarkdownContent({ content, onWikilinkClick }: MarkdownContentProps) {
const processedContent = useMemo(
() => onWikilinkClick ? preprocessWikilinks(content) : content,
[content, onWikilinkClick],
)
const handleClick = useCallback((e: MouseEvent) => {
const el = (e.target as HTMLElement).closest<HTMLElement>('[data-wikilink-target]')
if (el) {
e.preventDefault()
onWikilinkClick?.(el.dataset.wikilinkTarget!)
}
}, [onWikilinkClick])
const components = useMemo(() => {
if (!onWikilinkClick) return undefined
return {
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => {
if (href?.startsWith(WIKILINK_SCHEME)) {
const target = decodeURIComponent(href.slice(WIKILINK_SCHEME.length))
return (
<span className="chat-wikilink" data-wikilink-target={target} role="link" tabIndex={0}>
{children}
</span>
)
}
return <a href={href}>{children}</a>
},
}
}, [onWikilinkClick])
return (
<div className="ai-markdown" onClick={onWikilinkClick ? handleClick : undefined} role="presentation">
<Markdown
remarkPlugins={REMARK_PLUGINS}
rehypePlugins={REHYPE_PLUGINS}
components={components}
urlTransform={onWikilinkClick ? wikilinkUrlTransform : undefined}
>
{processedContent}
</Markdown>
</div>
), [content])
return rendered
)
})

View File

@@ -111,4 +111,20 @@ describe('useAIChat', () => {
expect(thirdMessage).toContain('Q2')
expect(thirdMessage).toContain('Q3')
})
it('does not pass session_id to avoid --resume (history is in prompt)', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
// First message
act(() => { result.current.sendMessage('hello') })
await act(async () => { vi.advanceTimersByTime(50) })
// Second message — session_id should still be undefined (no --resume)
act(() => { result.current.sendMessage('follow up') })
expect(streamClaudeChatMock).toHaveBeenCalledTimes(2)
// Third argument is sessionId — must be undefined for both calls
expect(streamClaudeChatMock.mock.calls[0][2]).toBeUndefined()
expect(streamClaudeChatMock.mock.calls[1][2]).toBeUndefined()
})
})

View File

@@ -18,7 +18,6 @@ export function useAIChat(
const [isStreaming, setIsStreaming] = useState(false)
const [streamingContent, setStreamingContent] = useState('')
const abortRef = useRef(false)
const sessionIdRef = useRef<string | undefined>(undefined)
const sendMessage = useCallback((text: string) => {
if (!text.trim() || isStreaming) return
@@ -34,9 +33,10 @@ export function useAIChat(
const messageWithHistory = formatMessageWithHistory(history, text.trim())
let accumulated = ''
streamClaudeChat(messageWithHistory, systemPrompt || undefined, sessionIdRef.current, {
onInit: (sid) => { sessionIdRef.current = sid },
// No session_id: each call is independent. Context is provided via
// formatted history in the prompt, avoiding --resume which causes
// double-context confusion when combined with in-prompt history.
streamClaudeChat(messageWithHistory, systemPrompt || undefined, undefined, {
onText: (chunk) => {
if (abortRef.current) return
accumulated += chunk
@@ -58,8 +58,6 @@ export function useAIChat(
setStreamingContent('')
setIsStreaming(false)
},
}).then(sid => {
if (sid) sessionIdRef.current = sid
})
}, [isStreaming, allContent, contextNotes, messages])
@@ -68,7 +66,6 @@ export function useAIChat(
setMessages([])
setIsStreaming(false)
setStreamingContent('')
sessionIdRef.current = undefined
}, [])
const retryMessage = useCallback((msgIndex: number) => {

View File

@@ -0,0 +1,148 @@
import { describe, it, expect, vi } from 'vitest'
import { detectFileOperation, parseBashFileCreation } from './useAiAgent'
import type { AgentFileCallbacks } from './useAiAgent'
const VAULT = '/Users/luca/Laputa'
function makeCallbacks() {
return {
onFileCreated: vi.fn(),
onFileModified: vi.fn(),
} satisfies AgentFileCallbacks
}
describe('detectFileOperation', () => {
it('calls onFileCreated for Write tool with .md in vault', () => {
const cb = makeCallbacks()
detectFileOperation('Write', JSON.stringify({ file_path: `${VAULT}/note/test.md` }), VAULT, cb)
expect(cb.onFileCreated).toHaveBeenCalledWith('note/test.md')
expect(cb.onFileModified).not.toHaveBeenCalled()
})
it('calls onFileModified for Edit tool with .md in vault', () => {
const cb = makeCallbacks()
detectFileOperation('Edit', JSON.stringify({ file_path: `${VAULT}/note/test.md` }), VAULT, cb)
expect(cb.onFileModified).toHaveBeenCalledWith('note/test.md')
expect(cb.onFileCreated).not.toHaveBeenCalled()
})
it('ignores non-md files', () => {
const cb = makeCallbacks()
detectFileOperation('Write', JSON.stringify({ file_path: `${VAULT}/image.png` }), VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
})
it('ignores files outside vault', () => {
const cb = makeCallbacks()
detectFileOperation('Write', JSON.stringify({ file_path: '/tmp/other.md' }), VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
})
it('ignores unknown tool names', () => {
const cb = makeCallbacks()
detectFileOperation('Read', JSON.stringify({ file_path: `${VAULT}/note/test.md` }), VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
expect(cb.onFileModified).not.toHaveBeenCalled()
})
it('handles undefined input gracefully', () => {
const cb = makeCallbacks()
detectFileOperation('Write', undefined, VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
})
it('handles malformed JSON input gracefully', () => {
const cb = makeCallbacks()
detectFileOperation('Write', 'not-json', VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
})
it('handles undefined callbacks gracefully', () => {
expect(() => detectFileOperation('Write', JSON.stringify({ file_path: `${VAULT}/note/test.md` }), VAULT, undefined)).not.toThrow()
})
it('detects Bash redirect creating .md file in vault', () => {
const cb = makeCallbacks()
detectFileOperation('Bash', JSON.stringify({ command: `echo "# Note" > ${VAULT}/note/new.md` }), VAULT, cb)
expect(cb.onFileCreated).toHaveBeenCalledWith('note/new.md')
})
it('detects Bash append redirect creating .md file', () => {
const cb = makeCallbacks()
detectFileOperation('Bash', JSON.stringify({ command: `cat content.txt >> ${VAULT}/daily/2026-03-07.md` }), VAULT, cb)
expect(cb.onFileCreated).toHaveBeenCalledWith('daily/2026-03-07.md')
})
it('detects Bash tee command creating .md file', () => {
const cb = makeCallbacks()
detectFileOperation('Bash', JSON.stringify({ command: `echo "content" | tee ${VAULT}/note/tee-note.md` }), VAULT, cb)
expect(cb.onFileCreated).toHaveBeenCalledWith('note/tee-note.md')
})
it('ignores Bash commands without file creation', () => {
const cb = makeCallbacks()
detectFileOperation('Bash', JSON.stringify({ command: 'ls -la' }), VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
})
it('ignores Bash creating non-md files', () => {
const cb = makeCallbacks()
detectFileOperation('Bash', JSON.stringify({ command: `echo "data" > ${VAULT}/config.json` }), VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
})
it('ignores Bash creating .md files outside vault', () => {
const cb = makeCallbacks()
detectFileOperation('Bash', JSON.stringify({ command: 'echo "text" > /tmp/other.md' }), VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
})
it('uses path field when file_path is missing', () => {
const cb = makeCallbacks()
detectFileOperation('Write', JSON.stringify({ path: `${VAULT}/note/alt.md` }), VAULT, cb)
expect(cb.onFileCreated).toHaveBeenCalledWith('note/alt.md')
})
})
describe('parseBashFileCreation', () => {
it('returns null for undefined input', () => {
expect(parseBashFileCreation(undefined, VAULT)).toBeNull()
})
it('returns null for malformed JSON', () => {
expect(parseBashFileCreation('not json', VAULT)).toBeNull()
})
it('returns null when no command field', () => {
expect(parseBashFileCreation(JSON.stringify({ other: 'value' }), VAULT)).toBeNull()
})
it('returns null when command is not a string', () => {
expect(parseBashFileCreation(JSON.stringify({ command: 42 }), VAULT)).toBeNull()
})
it('parses simple redirect', () => {
const input = JSON.stringify({ command: `echo "# Title" > ${VAULT}/note.md` })
expect(parseBashFileCreation(input, VAULT)).toBe('note.md')
})
it('parses append redirect', () => {
const input = JSON.stringify({ command: `echo "line" >> ${VAULT}/sub/note.md` })
expect(parseBashFileCreation(input, VAULT)).toBe('sub/note.md')
})
it('parses tee command', () => {
const input = JSON.stringify({ command: `echo "data" | tee ${VAULT}/new.md` })
expect(parseBashFileCreation(input, VAULT)).toBe('new.md')
})
it('parses tee -a (append) command', () => {
const input = JSON.stringify({ command: `echo "extra" | tee -a ${VAULT}/new.md` })
expect(parseBashFileCreation(input, VAULT)).toBe('new.md')
})
it('returns null for non-md redirect', () => {
const input = JSON.stringify({ command: `echo "x" > ${VAULT}/file.txt` })
expect(parseBashFileCreation(input, VAULT)).toBeNull()
})
})

View File

@@ -105,7 +105,10 @@ export function useAiAgent(
if (abortRef.current.aborted) return
markReasoningDone()
setStatus('tool-executing')
toolInputMapRef.current.set(toolId, { tool: toolName, input })
// Preserve accumulated input — tool_progress events arrive with
// input=undefined AFTER the assistant message set the full input.
const prev = toolInputMapRef.current.get(toolId)
toolInputMapRef.current.set(toolId, { tool: toolName, input: input ?? prev?.input })
update(m => {
const existing = m.actions.find(a => a.toolId === toolId)
if (existing) {
@@ -205,13 +208,21 @@ function toVaultRelative(filePath: string, vaultPath: string): string | null {
}
/** Detect file operations from completed tool calls and notify callbacks. */
function detectFileOperation(
export function detectFileOperation(
toolName: string,
input: string | undefined,
vaultPath: string,
callbacks: AgentFileCallbacks | undefined,
) {
if (!callbacks) return
// Handle Bash commands that create/write .md files
if (toolName === 'Bash') {
const mdPath = parseBashFileCreation(input, vaultPath)
if (mdPath) callbacks.onFileCreated?.(mdPath)
return
}
if (toolName !== 'Write' && toolName !== 'Edit') return
const filePath = parseFilePath(input)
if (!filePath || !filePath.endsWith('.md')) return
@@ -224,6 +235,23 @@ function detectFileOperation(
}
}
/** Detect .md file creation from a Bash command string. */
export function parseBashFileCreation(input: string | undefined, vaultPath: string): string | null {
if (!input) return null
try {
const parsed = JSON.parse(input)
const cmd = parsed.command ?? parsed.cmd
if (typeof cmd !== 'string') return null
// Match redirect patterns: > file.md, >> file.md, tee file.md, cat > file.md
const match = cmd.match(/(?:>|>>|tee\s+(?:-a\s+)?)\s*["']?([^\s"'|;]+\.md)["']?/)
if (!match) return null
const filePath = match[1]
return toVaultRelative(filePath, vaultPath)
} catch {
return null
}
}
/** Generate a human-readable label for a tool call. */
function formatToolLabel(toolName: string, input?: string): string {
// Native Claude Code tools

View File

@@ -246,6 +246,22 @@
}
.ai-markdown a:hover { text-decoration: underline; }
.ai-markdown .chat-wikilink {
display: inline;
color: var(--primary);
background: color-mix(in srgb, var(--primary) 10%, transparent);
border-radius: 4px;
padding: 0 4px;
cursor: pointer;
font-weight: 500;
text-decoration: none;
transition: background 0.15s ease;
}
.ai-markdown .chat-wikilink:hover {
background: color-mix(in srgb, var(--primary) 20%, transparent);
text-decoration: underline;
}
.ai-markdown hr {
border: none;
border-top: 1px solid var(--border);

View File

@@ -23,6 +23,12 @@ describe('buildAgentSystemPrompt', () => {
expect(prompt).toContain('Vault context:')
expect(prompt).toContain('Recent notes: foo, bar')
})
it('instructs AI to use wikilink syntax', () => {
const prompt = buildAgentSystemPrompt()
expect(prompt).toContain('[[')
expect(prompt).toMatch(/wikilink/i)
})
})
// --- streamClaudeAgent ---
@@ -44,7 +50,7 @@ describe('streamClaudeAgent', () => {
// Wait for the setTimeout mock response
await new Promise(r => setTimeout(r, 400))
expect(onText).toHaveBeenCalledWith(expect.stringContaining('Claude CLI'))
expect(onText).toHaveBeenCalledWith(expect.stringContaining('Build Laputa App'))
expect(onDone).toHaveBeenCalled()
expect(onError).not.toHaveBeenCalled()
expect(onToolStart).not.toHaveBeenCalled()

View File

@@ -17,6 +17,7 @@ You have full shell access. Use bash for file operations, search, bulk edits.
Use the provided MCP tools for: full-text search (search_notes), vault orientation (get_vault_context), parsed note reading (get_note), and opening notes in the UI (open_note).
When you create or edit a note, call open_note(path) so the user sees it in Laputa.
When you mention or reference a note by name, always use [[Note Title]] wikilink syntax so the user can click to open it.
Be concise and helpful. When you've completed a task, briefly summarize what you did.`
export function buildAgentSystemPrompt(vaultContext?: string): string {
@@ -57,7 +58,7 @@ export async function streamClaudeAgent(
): Promise<void> {
if (!isTauri()) {
setTimeout(() => {
callbacks.onText('AI Agent requires the Claude CLI. Install it and run the native app.')
callbacks.onText('This note is related to [[Build Laputa App]] and [[Matteo Cellini]]. The AI Agent requires the Claude CLI for full functionality.')
callbacks.onDone()
}, 300)
return

View File

@@ -52,6 +52,14 @@ describe('buildSystemPrompt', () => {
expect(result.prompt).toContain('Hello world')
expect(result.totalTokens).toBeGreaterThan(0)
})
it('instructs AI to use wikilink syntax', () => {
const notes = [makeEntry('/test.md', 'Test Note')]
const content = { '/test.md': 'content' }
const result = buildSystemPrompt(notes, content)
expect(result.prompt).toContain('[[')
expect(result.prompt).toMatch(/wikilink/i)
})
})
// --- nextMessageId ---

View File

@@ -34,6 +34,7 @@ export function buildSystemPrompt(
const preamble = [
'You are a helpful AI assistant integrated into Laputa, a personal knowledge management app.',
'The user has selected the following notes as context. Use them to answer questions accurately.',
'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')

View File

@@ -0,0 +1,35 @@
const WIKILINK_SCHEME = 'wikilink://'
export { WIKILINK_SCHEME }
function encodeTarget(target: string): string {
return encodeURIComponent(target).replace(/\(/g, '%28').replace(/\)/g, '%29')
}
function escapeLinkText(text: string): string {
return text.replace(/[[\]]/g, '\\$&')
}
function replaceWikilinksInText(text: string): string {
return text.replace(/\[\[([^\]]+)\]\]/g, (_, inner: string) => {
const pipeIdx = inner.indexOf('|')
const target = pipeIdx !== -1 ? inner.slice(0, pipeIdx) : inner
const display = pipeIdx !== -1 ? inner.slice(pipeIdx + 1) : inner
return `[${escapeLinkText(display)}](${WIKILINK_SCHEME}${encodeTarget(target)})`
})
}
/** Convert [[Target]] to markdown links, but skip code blocks and inline code. */
export function preprocessWikilinks(md: string): string {
const segments: string[] = []
const codeRegex = /(```[\s\S]*?```|`[^`\n]+`)/g
let lastIndex = 0
let match
while ((match = codeRegex.exec(md)) !== null) {
segments.push(replaceWikilinksInText(md.slice(lastIndex, match.index)))
segments.push(match[0])
lastIndex = match.index + match[0].length
}
segments.push(replaceWikilinksInText(md.slice(lastIndex)))
return segments.join('')
}

View File

@@ -0,0 +1,46 @@
import { test, expect } from '@playwright/test'
import { sendShortcut } from './helpers'
test.describe('AI chat wikilink rendering', () => {
test.beforeEach(async ({ page }) => {
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
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
await noteItem.click()
await page.waitForTimeout(500)
// Open AI Chat with Ctrl+I
await sendShortcut(page, 'i', ['Control'])
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3000 })
// Send a message to get a mock response containing wikilinks
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]])
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')
await expect(wikilink).toHaveAttribute('data-wikilink-target', 'Build Laputa App')
await expect(wikilink).toHaveAttribute('role', 'link')
// Verify second wikilink
const secondWikilink = page.locator('.chat-wikilink').nth(1)
await expect(secondWikilink).toHaveText('Matteo Cellini')
// Verify multiple wikilinks rendered
const allWikilinks = page.locator('.chat-wikilink')
await expect(allWikilinks).toHaveCount(2)
// Verify wikilink has pointer cursor (is styled as clickable)
const cursor = await wikilink.evaluate(el => getComputedStyle(el).cursor)
expect(cursor).toBe('pointer')
})
})

View File

@@ -0,0 +1,114 @@
import { test, expect } from '@playwright/test'
import { WebSocketServer } from 'ws'
const NEW_NOTE_PATH = '/Users/luca/Laputa/note/ai-created-note.md'
const NEW_NOTE_TITLE = 'AI Created Note'
const NEW_NOTE_CONTENT = `---
title: ${NEW_NOTE_TITLE}
type: Note
---
# ${NEW_NOTE_TITLE}
This note was created by the AI agent.
`
function broadcast(wss: WebSocketServer, data: Record<string, unknown>) {
const msg = JSON.stringify(data)
for (const client of wss.clients) {
if (client.readyState === 1) client.send(msg)
}
}
/** Wait until at least one WebSocket client connects. */
function waitForClient(wss: WebSocketServer, timeoutMs = 10000): Promise<void> {
if (wss.clients.size > 0) return Promise.resolve()
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('No WS client connected')), timeoutMs)
wss.on('connection', () => { clearTimeout(timer); resolve() })
})
}
test.describe('AI-created note visibility', () => {
let wss: WebSocketServer
/** When true, the intercepted list_vault response includes the new note. */
let injectNote = false
test.beforeEach(async () => {
wss = new WebSocketServer({ port: 9711 })
})
test.afterEach(async () => {
wss.close()
await new Promise(r => setTimeout(r, 200))
})
test('vault_changed + open_tab from MCP makes note visible and opens tab', async ({ page }) => {
// Intercept list_vault API calls. On reload (after vault_changed),
// the injected note will be included in the response.
await page.route('**/api/vault/list*', async (route) => {
const response = await route.fetch()
if (!injectNote) {
await route.fulfill({ response })
return
}
const entries = await response.json()
const now = Date.now()
entries.push({
path: NEW_NOTE_PATH, filename: 'ai-created-note.md',
title: NEW_NOTE_TITLE, isA: null,
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
cadence: null, archived: false, trashed: false, trashedAt: null,
modifiedAt: now, createdAt: now, fileSize: NEW_NOTE_CONTENT.length,
snippet: 'This note was created by the AI agent.', wordCount: 10,
relationships: {}, icon: null, color: null, order: null,
sidebarLabel: null, template: null, sort: null, view: null,
visible: null, outgoingLinks: [], properties: {},
})
await route.fulfill({ json: entries })
})
// Intercept content fetch for the new note
await page.route('**/api/vault/content*ai-created-note*', async (route) => {
await route.fulfill({ json: { content: NEW_NOTE_CONTENT } })
})
// Intercept all-content to include new note
await page.route('**/api/vault/all-content*', async (route) => {
const response = await route.fetch()
if (!injectNote) {
await route.fulfill({ response })
return
}
const data = await response.json()
data[NEW_NOTE_PATH] = NEW_NOTE_CONTENT
await route.fulfill({ json: data })
})
await page.goto('/')
// Wait for note list to render
const noteList = page.locator('.app__note-list')
await expect(noteList).toBeVisible()
await expect(noteList.getByText(NEW_NOTE_TITLE)).not.toBeVisible()
// Enable injection for subsequent reloads
injectNote = true
// Wait for the frontend's useAiActivity WS to connect to our bridge
await waitForClient(wss)
// Simulate MCP broadcasting vault_changed then open_tab
// (This is what happens when Claude Code calls open_note via MCP)
broadcast(wss, { type: 'ui_action', action: 'vault_changed', path: 'note/ai-created-note.md' })
// Verify: note appears in note list after vault reload
await expect(noteList.getByText(NEW_NOTE_TITLE)).toBeVisible({ timeout: 8000 })
// Send open_tab after vault is reloaded so the entry is in entriesByPath
broadcast(wss, { type: 'ui_action', action: 'open_tab', path: NEW_NOTE_PATH })
// Verify: note content is displayed in the editor (proves tab opened)
await expect(page.getByText('This note was created by the AI agent.')).toBeVisible({ timeout: 8000 })
})
})