Compare commits

...

4 Commits

Author SHA1 Message Date
Test
0e503cb179 fix: AI chat receives note body from open tabs instead of empty allContent
allContent is empty ({}) in Tauri mode because loadVaultData never
populates it — note content only enters allContent on explicit save.
mergeTabContent() enriches allContent with open tab content so the AI
context snapshot always includes the active note's body.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 12:32:52 +01:00
Test
d83f04c6ff fix: AI-created notes now visible — onVaultChanged fallback for missed file ops
detectFileOperation silently failed when tool input was undefined (timing
issues with NDJSON event ordering). Added onVaultChanged callback as fallback:
when Write/Edit/Bash tools complete but specific file can't be determined,
vault.reloadVault() is triggered. Also added safety net in onDone handler.

Threaded onVaultChanged through AiPanel → EditorRightPanel → Editor → App.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 14:19:41 +01:00
Test
9ffa6930c5 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>
2026-03-07 13:42:32 +01:00
Test
88b20b83dc fix: use --resume for AI chat conversation continuity
The previous approach embedded conversation history as formatted text
in the prompt (<conversation_history> tags). The claude CLI's -p flag
treats this as a single user turn, losing turn boundaries — so the
model had no real multi-turn context.

Switch to using --resume with the session_id returned by the CLI's
init event. This lets the CLI manage conversation state natively:
- First message starts a new session (with system prompt)
- Subsequent messages resume via --resume (no system prompt needed)
- Clear and retry reset the session_id for a fresh start

Extract makeStreamCallbacks() to keep useAIChat complexity below
CodeScene threshold.

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

View File

@@ -287,6 +287,10 @@ function App() {
}
}, [vault, notes, resolvedPath])
const handleAgentVaultChanged = useCallback(() => {
vault.reloadVault()
}, [vault])
const { triggerIncrementalIndex } = indexing
const onAfterSave = useCallback(() => {
vault.loadModifiedFiles()
@@ -599,6 +603,7 @@ function App() {
isDarkTheme={themeManager.isDark}
onFileCreated={handleAgentFileCreated}
onFileModified={handleAgentFileModified}
onVaultChanged={handleAgentVaultChanged}
/>
</div>
</div>

View File

@@ -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')
})
})

View File

@@ -13,6 +13,7 @@ interface AiPanelProps {
onOpenNote?: (path: string) => void
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
onVaultChanged?: () => void
vaultPath: string
activeEntry?: VaultEntry | null
entries?: VaultEntry[]
@@ -109,7 +110,7 @@ function MessageHistory({ messages, isActive, onOpenNote, onNavigateWikilink, ha
)
}
export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, vaultPath, activeEntry, entries, allContent, openTabs, noteList, noteListFilter }: AiPanelProps) {
export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, onVaultChanged, vaultPath, activeEntry, entries, allContent, openTabs, noteList, noteListFilter }: AiPanelProps) {
const [input, setInput] = useState('')
const [pendingRefs, setPendingRefs] = useState<NoteReference[]>([])
const inputRef = useRef<HTMLInputElement>(null)
@@ -136,7 +137,8 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, va
const fileCallbacks = useMemo<AgentFileCallbacks>(() => ({
onFileCreated,
onFileModified,
}), [onFileCreated, onFileModified])
onVaultChanged,
}), [onFileCreated, onFileModified, onVaultChanged])
const agent = useAiAgent(vaultPath, contextPrompt, fileCallbacks)
const hasContext = !!activeEntry

View File

@@ -1,4 +1,4 @@
import { useRef, useEffect, useCallback, memo } from 'react'
import { useRef, useEffect, useCallback, memo, useMemo } from 'react'
import { useEditorTabSwap, getH1TextFromBlocks } from '../hooks/useEditorTabSwap'
import { useHeadingTitleSync } from '../hooks/useHeadingTitleSync'
import { useCreateBlockNote } from '@blocknote/react'
@@ -15,6 +15,7 @@ import { useEditorFocus } from '../hooks/useEditorFocus'
import { EditorRightPanel } from './EditorRightPanel'
import { EditorContent } from './EditorContent'
import { schema } from './editorSchema'
import { mergeTabContent } from '../utils/mergeTabContent'
import './Editor.css'
import './EditorTheme.css'
@@ -73,6 +74,7 @@ interface EditorProps {
diffToggleRef?: React.MutableRefObject<() => void>
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
onVaultChanged?: () => void
}
function useEditorModeExclusion({
@@ -131,6 +133,7 @@ export const Editor = memo(function Editor({
diffToggleRef,
onFileCreated,
onFileModified,
onVaultChanged,
}: EditorProps) {
const vaultPathRef = useRef(vaultPath)
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
@@ -170,6 +173,11 @@ export const Editor = memo(function Editor({
diffMode, rawMode, handleToggleDiff, handleToggleRaw, rawToggleRef, diffToggleRef,
})
const enrichedAllContent = useMemo(
() => mergeTabContent(allContent, tabs),
[allContent, tabs],
)
const isLoadingNewTab = activeTabPath !== null && !activeTab
const activeStatus = activeTab ? getNoteStatus?.(activeTab.entry.path) ?? 'clean' : 'clean'
const showDiffToggle = !!(activeTab && (diffMode || activeStatus === 'modified'))
@@ -232,7 +240,7 @@ export const Editor = memo(function Editor({
inspectorEntry={inspectorEntry}
inspectorContent={inspectorContent}
entries={entries}
allContent={allContent}
allContent={enrichedAllContent}
gitHistory={gitHistory}
vaultPath={vaultPath ?? ''}
openTabs={tabs.map(t => t.entry)}
@@ -248,6 +256,7 @@ export const Editor = memo(function Editor({
onOpenNote={onNavigateWikilink}
onFileCreated={onFileCreated}
onFileModified={onFileModified}
onVaultChanged={onVaultChanged}
/>
</div>
</div>

View File

@@ -26,6 +26,7 @@ interface EditorRightPanelProps {
onOpenNote?: (path: string) => void
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
onVaultChanged?: () => void
}
export function EditorRightPanel({
@@ -34,7 +35,7 @@ export function EditorRightPanel({
noteList, noteListFilter,
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onOpenNote,
onFileCreated, onFileModified,
onFileCreated, onFileModified, onVaultChanged,
}: EditorRightPanelProps) {
if (showAIChat) {
return (
@@ -47,6 +48,7 @@ export function EditorRightPanel({
onOpenNote={onOpenNote}
onFileCreated={onFileCreated}
onFileModified={onFileModified}
onVaultChanged={onVaultChanged}
vaultPath={vaultPath}
activeEntry={inspectorEntry}
entries={entries}

View File

@@ -13,14 +13,14 @@ vi.mock('../utils/ai-chat', async () => {
...actual,
streamClaudeChat: (...args: Parameters<typeof actual.streamClaudeChat>) => {
streamClaudeChatMock(...args)
// Simulate async: call onDone after a tick
// Simulate async: emit Init with session_id, then text, then done
const callbacks = args[3]
setTimeout(() => {
callbacks.onInit?.('test-session')
callbacks.onInit?.('session-001')
callbacks.onText('mock response')
callbacks.onDone()
}, 10)
return Promise.resolve('test-session')
return Promise.resolve('session-001')
},
}
})
@@ -39,40 +39,42 @@ afterEach(() => {
describe('useAIChat', () => {
const emptyContent: Record<string, string> = {}
it('sends first message without history', async () => {
it('sends first message without session_id (new session)', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
act(() => { result.current.sendMessage('hello') })
expect(streamClaudeChatMock).toHaveBeenCalledTimes(1)
const message = streamClaudeChatMock.mock.calls[0][0]
// First message: no history, so just the plain text
const [message, , sessionId] = streamClaudeChatMock.mock.calls[0]
// First message: raw text, no session_id
expect(message).toBe('hello')
expect(sessionId).toBeUndefined()
})
it('includes conversation history in second message', async () => {
it('resumes session on second message via --resume', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
// Send first message
act(() => { result.current.sendMessage('What is Rust?') })
// Wait for mock response
// Wait for mock response (which fires onInit with session-001)
await act(async () => { vi.advanceTimersByTime(50) })
// Now messages state has: [user: What is Rust?, assistant: mock response]
expect(result.current.messages).toHaveLength(2)
// Send second message
// Send second message — should resume with session_id
act(() => { result.current.sendMessage('Tell me more') })
expect(streamClaudeChatMock).toHaveBeenCalledTimes(2)
const secondMessage = streamClaudeChatMock.mock.calls[1][0]
// Should contain conversation history
expect(secondMessage).toContain('What is Rust?')
expect(secondMessage).toContain('mock response')
expect(secondMessage).toContain('Tell me more')
const [message, systemPrompt, sessionId] = streamClaudeChatMock.mock.calls[1]
// Raw message only (no embedded history)
expect(message).toBe('Tell me more')
// Session resumed via --resume
expect(sessionId).toBe('session-001')
// System prompt omitted on resumed sessions
expect(systemPrompt).toBeUndefined()
})
it('resets history on clearConversation', async () => {
it('resets session on clearConversation', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
// Send a message and get response
@@ -83,14 +85,15 @@ describe('useAIChat', () => {
act(() => { result.current.clearConversation() })
expect(result.current.messages).toHaveLength(0)
// Send new message — should have no history
// Send new message — should start a fresh session (no session_id)
act(() => { result.current.sendMessage('fresh start') })
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
expect(lastCall[0]).toBe('fresh start')
expect(lastCall[2]).toBeUndefined() // no session_id
})
it('accumulates multiple exchanges in history', async () => {
it('resumes session across multiple exchanges', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
// Exchange 1
@@ -105,26 +108,54 @@ describe('useAIChat', () => {
act(() => { result.current.sendMessage('Q3') })
expect(streamClaudeChatMock).toHaveBeenCalledTimes(3)
const thirdMessage = streamClaudeChatMock.mock.calls[2][0]
// Should contain all prior exchanges
expect(thirdMessage).toContain('Q1')
expect(thirdMessage).toContain('Q2')
expect(thirdMessage).toContain('Q3')
// First call: no session
expect(streamClaudeChatMock.mock.calls[0][2]).toBeUndefined()
// Second and third calls: resume session
expect(streamClaudeChatMock.mock.calls[1][2]).toBe('session-001')
expect(streamClaudeChatMock.mock.calls[2][2]).toBe('session-001')
// All messages are raw text (no embedded history)
expect(streamClaudeChatMock.mock.calls[0][0]).toBe('Q1')
expect(streamClaudeChatMock.mock.calls[1][0]).toBe('Q2')
expect(streamClaudeChatMock.mock.calls[2][0]).toBe('Q3')
})
it('does not pass session_id to avoid --resume (history is in prompt)', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
it('includes system prompt only on first message of a session', async () => {
const content = { 'note.md': 'Some note content' }
const notes = [{ path: 'note.md', title: 'Test Note' }] as import('../types').VaultEntry[]
// First message
const { result } = renderHook(() => useAIChat(content, notes))
// First message — system prompt included
act(() => { result.current.sendMessage('hello') })
await act(async () => { vi.advanceTimersByTime(50) })
// Second message — session_id should still be undefined (no --resume)
const firstSystemPrompt = streamClaudeChatMock.mock.calls[0][1]
expect(firstSystemPrompt).toBeTruthy()
expect(firstSystemPrompt).toContain('Test Note')
// Second message — system prompt omitted (session already has it)
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()
const secondSystemPrompt = streamClaudeChatMock.mock.calls[1][1]
expect(secondSystemPrompt).toBeUndefined()
})
it('resets session on retry', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
// Send message and get response
act(() => { result.current.sendMessage('hello') })
await act(async () => { vi.advanceTimersByTime(50) })
expect(result.current.messages).toHaveLength(2)
// Retry the assistant response (index 1)
act(() => { result.current.retryMessage(1) })
// Should start a fresh session
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
expect(lastCall[2]).toBeUndefined() // no session_id — fresh session
expect(lastCall[0]).toBe('hello') // re-sends the user message
})
})

View File

@@ -1,15 +1,57 @@
/**
* Custom hook encapsulating AI chat state and message handling.
* Uses Claude CLI subprocess via Tauri for streaming responses.
*
* Conversation continuity uses the CLI's --resume flag: the first message
* starts a new session; subsequent messages resume it via session_id.
* This avoids embedding history as text in the prompt (which the CLI's
* -p mode treats as a single user turn, losing turn boundaries).
*/
import { useState, useCallback, useRef } from 'react'
import type { VaultEntry } from '../types'
import {
type ChatMessage, nextMessageId,
type ChatMessage, type ChatStreamCallbacks, nextMessageId,
buildSystemPrompt, streamClaudeChat,
trimHistory, formatMessageWithHistory, MAX_HISTORY_TOKENS,
} from '../utils/ai-chat'
interface ChatStreamRefs {
abortRef: React.RefObject<boolean>
sessionIdRef: React.MutableRefObject<string | undefined>
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>
setStreamingContent: React.Dispatch<React.SetStateAction<string>>
setIsStreaming: React.Dispatch<React.SetStateAction<boolean>>
}
/** Create stream callbacks that accumulate text and update React state. */
function makeStreamCallbacks(
refs: ChatStreamRefs,
): { callbacks: ChatStreamCallbacks; getAccumulated: () => string } {
let accumulated = ''
const callbacks: ChatStreamCallbacks = {
onInit: (sid) => { refs.sessionIdRef.current = sid },
onText: (chunk) => {
if (refs.abortRef.current) return
accumulated += chunk
refs.setStreamingContent(accumulated)
},
onError: (error) => {
if (refs.abortRef.current) return
refs.setMessages(prev => [...prev, { role: 'assistant', content: `Error: ${error}`, id: nextMessageId() }])
refs.setStreamingContent('')
refs.setIsStreaming(false)
},
onDone: () => {
if (refs.abortRef.current) return
if (accumulated) {
refs.setMessages(prev => [...prev, { role: 'assistant', content: accumulated, id: nextMessageId() }])
}
refs.setStreamingContent('')
refs.setIsStreaming(false)
},
}
return { callbacks, getAccumulated: () => accumulated }
}
export function useAIChat(
allContent: Record<string, string>,
contextNotes: VaultEntry[],
@@ -18,54 +60,39 @@ 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
const userMsg: ChatMessage = { role: 'user', content: text.trim(), id: nextMessageId() }
setMessages(prev => [...prev, userMsg])
setMessages(prev => [...prev, { role: 'user', content: text.trim(), id: nextMessageId() }])
setIsStreaming(true)
setStreamingContent('')
abortRef.current = false
const { prompt: systemPrompt } = buildSystemPrompt(contextNotes, allContent)
const history = trimHistory(messages, MAX_HISTORY_TOKENS)
const messageWithHistory = formatMessageWithHistory(history, text.trim())
let accumulated = ''
const currentSessionId = sessionIdRef.current
// System prompt only on first message (new session).
const systemPrompt = currentSessionId
? undefined
: (buildSystemPrompt(contextNotes, allContent).prompt || undefined)
// 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
setStreamingContent(accumulated)
},
onError: (error) => {
if (abortRef.current) return
setMessages(prev => [...prev, { role: 'assistant', content: `Error: ${error}`, id: nextMessageId() }])
setStreamingContent('')
setIsStreaming(false)
},
onDone: () => {
if (abortRef.current) return
if (accumulated) {
setMessages(prev => [...prev, { role: 'assistant', content: accumulated, id: nextMessageId() }])
}
setStreamingContent('')
setIsStreaming(false)
},
const { callbacks } = makeStreamCallbacks({
abortRef, sessionIdRef, setMessages, setStreamingContent, setIsStreaming,
})
}, [isStreaming, allContent, contextNotes, messages])
streamClaudeChat(text.trim(), systemPrompt, currentSessionId, callbacks)
.then((sid) => {
if (sid && !sessionIdRef.current) sessionIdRef.current = sid
})
.catch(() => { /* errors forwarded via onError */ })
}, [isStreaming, allContent, contextNotes])
const clearConversation = useCallback(() => {
abortRef.current = true
setMessages([])
setIsStreaming(false)
setStreamingContent('')
sessionIdRef.current = undefined
}, [])
const retryMessage = useCallback((msgIndex: number) => {
@@ -74,6 +101,7 @@ export function useAIChat(
const userMsg = messages[userMsgIndex]
if (userMsg.role !== 'user') return
sessionIdRef.current = undefined
setMessages(prev => prev.slice(0, msgIndex))
sendMessage(userMsg.content)
}, [messages, sendMessage])

View File

@@ -8,6 +8,7 @@ function makeCallbacks() {
return {
onFileCreated: vi.fn(),
onFileModified: vi.fn(),
onVaultChanged: vi.fn(),
} satisfies AgentFileCallbacks
}
@@ -45,16 +46,45 @@ describe('detectFileOperation', () => {
expect(cb.onFileModified).not.toHaveBeenCalled()
})
it('handles undefined input gracefully', () => {
it('calls onVaultChanged when Write input is undefined', () => {
const cb = makeCallbacks()
detectFileOperation('Write', undefined, VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
expect(cb.onVaultChanged).toHaveBeenCalled()
})
it('handles malformed JSON input gracefully', () => {
it('calls onVaultChanged when Edit input is undefined', () => {
const cb = makeCallbacks()
detectFileOperation('Edit', undefined, VAULT, cb)
expect(cb.onFileModified).not.toHaveBeenCalled()
expect(cb.onVaultChanged).toHaveBeenCalled()
})
it('calls onVaultChanged when Write has malformed JSON input', () => {
const cb = makeCallbacks()
detectFileOperation('Write', 'not-json', VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
expect(cb.onVaultChanged).toHaveBeenCalled()
})
it('does not call onVaultChanged when Write detects specific file', () => {
const cb = makeCallbacks()
detectFileOperation('Write', JSON.stringify({ file_path: `${VAULT}/note/test.md` }), VAULT, cb)
expect(cb.onFileCreated).toHaveBeenCalledWith('note/test.md')
expect(cb.onVaultChanged).not.toHaveBeenCalled()
})
it('does not call onVaultChanged for Read tool', () => {
const cb = makeCallbacks()
detectFileOperation('Read', undefined, VAULT, cb)
expect(cb.onVaultChanged).not.toHaveBeenCalled()
})
it('calls onVaultChanged when Bash input is undefined', () => {
const cb = makeCallbacks()
detectFileOperation('Bash', undefined, VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
expect(cb.onVaultChanged).toHaveBeenCalled()
})
it('handles undefined callbacks gracefully', () => {

View File

@@ -32,6 +32,8 @@ export interface AiAgentMessage {
export interface AgentFileCallbacks {
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
/** Fallback: vault may have changed but we can't determine the specific file. */
onVaultChanged?: () => void
}
export function useAiAgent(
@@ -172,6 +174,8 @@ export function useAiAgent(
response: finalResponse,
actions: m.actions.map(a => a.status === 'pending' ? { ...a, status: 'done' as const } : a),
}))
// Safety net: refresh vault after agent completes in case file changes were missed
fileCallbacksRef.current?.onVaultChanged?.()
},
})
}, [status, vaultPath])
@@ -219,20 +223,26 @@ export function detectFileOperation(
// Handle Bash commands that create/write .md files
if (toolName === 'Bash') {
const mdPath = parseBashFileCreation(input, vaultPath)
if (mdPath) callbacks.onFileCreated?.(mdPath)
if (mdPath) { callbacks.onFileCreated?.(mdPath); return }
// Bash ran but we couldn't detect a specific .md file — still may have changed vault
callbacks.onVaultChanged?.()
return
}
if (toolName !== 'Write' && toolName !== 'Edit') return
const filePath = parseFilePath(input)
if (!filePath || !filePath.endsWith('.md')) return
const rel = toVaultRelative(filePath, vaultPath)
if (!rel) return
if (toolName === 'Write') {
callbacks.onFileCreated?.(rel)
} else {
callbacks.onFileModified?.(rel)
if (filePath && filePath.endsWith('.md')) {
const rel = toVaultRelative(filePath, vaultPath)
if (rel) {
if (toolName === 'Write') callbacks.onFileCreated?.(rel)
else callbacks.onFileModified?.(rel)
return
}
}
// Write/Edit completed but couldn't determine target file — trigger vault refresh
callbacks.onVaultChanged?.()
}
/** Detect .md file creation from a Bash command string. */

View File

@@ -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',

View File

@@ -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\`\`\``

View File

@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest'
import { mergeTabContent } from './mergeTabContent'
describe('mergeTabContent', () => {
it('adds tab content when allContent is empty', () => {
const result = mergeTabContent({}, [
{ entry: { path: '/vault/a.md' }, content: '# Hello\nBody text' },
])
expect(result['/vault/a.md']).toBe('# Hello\nBody text')
})
it('overrides allContent with tab content (editor state is fresher)', () => {
const result = mergeTabContent(
{ '/vault/a.md': 'old saved content' },
[{ entry: { path: '/vault/a.md' }, content: 'current editor content' }],
)
expect(result['/vault/a.md']).toBe('current editor content')
})
it('preserves allContent for paths without open tabs', () => {
const result = mergeTabContent(
{ '/vault/b.md': 'other note content' },
[{ entry: { path: '/vault/a.md' }, content: '# Hello' }],
)
expect(result['/vault/b.md']).toBe('other note content')
expect(result['/vault/a.md']).toBe('# Hello')
})
it('returns original object when no tabs have content to merge', () => {
const original = { '/vault/a.md': 'content' }
expect(mergeTabContent(original, [])).toBe(original)
})
it('skips tabs with empty content', () => {
const original = { '/vault/a.md': 'content' }
const result = mergeTabContent(original, [
{ entry: { path: '/vault/b.md' }, content: '' },
])
expect(result).toBe(original)
expect(result['/vault/b.md']).toBeUndefined()
})
it('handles multiple tabs', () => {
const result = mergeTabContent({}, [
{ entry: { path: '/vault/a.md' }, content: 'Note A' },
{ entry: { path: '/vault/b.md' }, content: 'Note B' },
])
expect(result['/vault/a.md']).toBe('Note A')
expect(result['/vault/b.md']).toBe('Note B')
})
it('does not mutate the original allContent object', () => {
const original = { '/vault/a.md': 'old' }
const result = mergeTabContent(original, [
{ entry: { path: '/vault/a.md' }, content: 'new' },
])
expect(original['/vault/a.md']).toBe('old')
expect(result['/vault/a.md']).toBe('new')
expect(result).not.toBe(original)
})
})

View File

@@ -0,0 +1,18 @@
/**
* Merge open tab content into the allContent dictionary.
* Tab content takes priority (it reflects the current editor state).
* Returns the original object if no changes are needed (stable reference for useMemo).
*/
export function mergeTabContent(
allContent: Record<string, string>,
tabs: ReadonlyArray<{ entry: { path: string }; content: string }>,
): Record<string, string> {
let merged: Record<string, string> | null = null
for (const tab of tabs) {
if (!tab.content) continue
if (allContent[tab.entry.path] === tab.content) continue
if (!merged) merged = { ...allContent }
merged[tab.entry.path] = tab.content
}
return merged ?? allContent
}

View File

@@ -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)
})
})