Compare commits

...

3 Commits

Author SHA1 Message Date
Test
bcfd37d481 fix: CodeMirror cursor placement at non-100% zoom levels
Root cause: CSS zoom on document.documentElement caused stale CodeMirror
measurements. Two issues:

1. Race condition: zoom was applied in useEffect (parent), but CodeMirror
   was created in useEffect (child) — child effects run first, so CM
   measured at zoom=1 before zoom was actually applied.

2. No re-measure on zoom change: CSS zoom changes don't trigger
   ResizeObserver on descendant elements, so CodeMirror never updated
   its cached scaleX/scaleY, line heights, or character widths.

Fix:
- Apply zoom synchronously during useState init (before child effects)
- Dispatch 'laputa-zoom-change' event when zoom changes
- Listen for this event in useCodeMirror and call view.requestMeasure()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 14:54:50 +01:00
Test
f27ebe05c4 fix: pass active note content directly to AI context builder
In Tauri mode, allContent is {} (empty) until a note is explicitly
saved. The previous mergeTabContent fix enriched allContent with tab
content, but the indirection was fragile. This fix passes the active
tab's content directly to buildContextSnapshot as activeNoteContent,
which takes priority over allContent[path]. This ensures the AI always
receives the note body regardless of allContent state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:42:09 +01:00
Test
7470e4f4a7 fix: embed conversation history in prompt instead of broken --resume
Each CLI invocation is a fresh subprocess — --resume depends on session
persistence that doesn't reliably work with -p mode. Switch to prompt-
embedded history: prior exchanges are formatted into each request using
formatMessageWithHistory + trimHistory (already existed as dead code).

- Remove sessionIdRef and --resume session tracking
- Always send system prompt (each request is stateless)
- Split sendMessage into doSend(text, history) to handle retry correctly
- Retry now passes correct history (excludes the retried exchange)
- Tests verify history accumulation, clear, retry, and system prompt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:06:58 +01:00
10 changed files with 265 additions and 96 deletions

View File

@@ -16,6 +16,8 @@ interface AiPanelProps {
onVaultChanged?: () => void
vaultPath: string
activeEntry?: VaultEntry | null
/** Direct content of the active note from the editor tab. */
activeNoteContent?: string | null
entries?: VaultEntry[]
allContent?: Record<string, string>
openTabs?: VaultEntry[]
@@ -110,7 +112,7 @@ function MessageHistory({ messages, isActive, onOpenNote, onNavigateWikilink, ha
)
}
export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, onVaultChanged, vaultPath, activeEntry, entries, allContent, openTabs, noteList, noteListFilter }: AiPanelProps) {
export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, onVaultChanged, vaultPath, activeEntry, activeNoteContent, entries, allContent, openTabs, noteList, noteListFilter }: AiPanelProps) {
const [input, setInput] = useState('')
const [pendingRefs, setPendingRefs] = useState<NoteReference[]>([])
const inputRef = useRef<HTMLInputElement>(null)
@@ -122,17 +124,18 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, on
}, [activeEntry, entries])
const contextPrompt = useMemo(() => {
if (!activeEntry || !allContent || !entries) return undefined
if (!activeEntry || !entries) return undefined
return buildContextSnapshot({
activeEntry,
allContent,
allContent: allContent ?? {},
activeNoteContent: activeNoteContent ?? undefined,
openTabs,
noteList,
noteListFilter,
entries,
references: pendingRefs.length > 0 ? pendingRefs : undefined,
})
}, [activeEntry, allContent, openTabs, noteList, noteListFilter, entries, pendingRefs])
}, [activeEntry, activeNoteContent, allContent, openTabs, noteList, noteListFilter, entries, pendingRefs])
const fileCallbacks = useMemo<AgentFileCallbacks>(() => ({
onFileCreated,

View File

@@ -51,6 +51,7 @@ export function EditorRightPanel({
onVaultChanged={onVaultChanged}
vaultPath={vaultPath}
activeEntry={inspectorEntry}
activeNoteContent={inspectorContent}
entries={entries}
allContent={allContent}
openTabs={openTabs}

View File

@@ -13,14 +13,13 @@ vi.mock('../utils/ai-chat', async () => {
...actual,
streamClaudeChat: (...args: Parameters<typeof actual.streamClaudeChat>) => {
streamClaudeChatMock(...args)
// Simulate async: emit Init with session_id, then text, then done
// Simulate async: emit text, then done
const callbacks = args[3]
setTimeout(() => {
callbacks.onInit?.('session-001')
callbacks.onText('mock response')
callbacks.onDone()
}, 10)
return Promise.resolve('session-001')
return Promise.resolve('')
},
}
})
@@ -39,61 +38,37 @@ afterEach(() => {
describe('useAIChat', () => {
const emptyContent: Record<string, string> = {}
it('sends first message without session_id (new session)', async () => {
it('sends first message as raw text without history', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
act(() => { result.current.sendMessage('hello') })
expect(streamClaudeChatMock).toHaveBeenCalledTimes(1)
const [message, , sessionId] = streamClaudeChatMock.mock.calls[0]
// First message: raw text, no session_id
// First message: raw text, no history wrapping, no session_id
expect(message).toBe('hello')
expect(sessionId).toBeUndefined()
})
it('resumes session on second message via --resume', async () => {
it('embeds conversation history in second message', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
// Send first message
act(() => { result.current.sendMessage('What is Rust?') })
// Wait for mock response (which fires onInit with session-001)
// First exchange
act(() => { result.current.sendMessage('What is 2+2?') })
await act(async () => { vi.advanceTimersByTime(50) })
expect(result.current.messages).toHaveLength(2)
// Send second message — should resume with session_id
act(() => { result.current.sendMessage('Tell me more') })
// Second message — should include history from first exchange
act(() => { result.current.sendMessage('What is that times 3?') })
expect(streamClaudeChatMock).toHaveBeenCalledTimes(2)
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()
const [message] = streamClaudeChatMock.mock.calls[1]
expect(message).toContain('<conversation_history>')
expect(message).toContain('What is 2+2?')
expect(message).toContain('mock response')
expect(message).toContain('What is that times 3?')
})
it('resets session on clearConversation', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
// Send a message and get response
act(() => { result.current.sendMessage('hello') })
await act(async () => { vi.advanceTimersByTime(50) })
// Clear conversation
act(() => { result.current.clearConversation() })
expect(result.current.messages).toHaveLength(0)
// 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('resumes session across multiple exchanges', async () => {
it('accumulates history across multiple exchanges', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
// Exchange 1
@@ -109,43 +84,78 @@ describe('useAIChat', () => {
expect(streamClaudeChatMock).toHaveBeenCalledTimes(3)
// 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)
// First call: no 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')
// Second call: history from first exchange
const secondMsg = streamClaudeChatMock.mock.calls[1][0]
expect(secondMsg).toContain('Q1')
expect(secondMsg).toContain('Q2')
// Third call: history from both exchanges
const thirdMsg = streamClaudeChatMock.mock.calls[2][0]
expect(thirdMsg).toContain('Q1')
expect(thirdMsg).toContain('Q2')
expect(thirdMsg).toContain('Q3')
})
it('includes system prompt only on first message of a session', async () => {
it('never passes session_id (no --resume)', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
act(() => { result.current.sendMessage('Q1') })
await act(async () => { vi.advanceTimersByTime(50) })
act(() => { result.current.sendMessage('Q2') })
// All calls should have undefined session_id
for (const call of streamClaudeChatMock.mock.calls) {
expect(call[2]).toBeUndefined()
}
})
it('resets history after clearConversation', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
// Build up some history
act(() => { result.current.sendMessage('hello') })
await act(async () => { vi.advanceTimersByTime(50) })
// Clear
act(() => { result.current.clearConversation() })
expect(result.current.messages).toHaveLength(0)
// Next message should have no history
act(() => { result.current.sendMessage('fresh start') })
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
expect(lastCall[0]).toBe('fresh start') // raw text, no history wrapping
expect(lastCall[2]).toBeUndefined()
})
it('includes system prompt on every message when context notes exist', async () => {
const content = { 'note.md': 'Some note content' }
const notes = [{ path: 'note.md', title: 'Test Note' }] as import('../types').VaultEntry[]
const { result } = renderHook(() => useAIChat(content, notes))
// First message — system prompt included
// First message
act(() => { result.current.sendMessage('hello') })
await act(async () => { vi.advanceTimersByTime(50) })
// Second message
act(() => { result.current.sendMessage('follow up') })
// Both calls should have system prompt
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') })
const secondSystemPrompt = streamClaudeChatMock.mock.calls[1][1]
expect(secondSystemPrompt).toBeUndefined()
expect(secondSystemPrompt).toBeTruthy()
expect(secondSystemPrompt).toContain('Test Note')
})
it('resets session on retry', async () => {
it('retries with correct history (excludes retried exchange)', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
// Send message and get response
// First exchange
act(() => { result.current.sendMessage('hello') })
await act(async () => { vi.advanceTimersByTime(50) })
expect(result.current.messages).toHaveLength(2)
@@ -153,9 +163,9 @@ describe('useAIChat', () => {
// 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
// Should re-send the user message with no history (retrying first exchange)
expect(lastCall[0]).toBe('hello')
expect(lastCall[2]).toBeUndefined()
})
})

View File

@@ -2,21 +2,20 @@
* 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).
* Conversation continuity embeds prior exchanges in each prompt
* (each CLI invocation is a fresh subprocess with no memory).
* History is trimmed to MAX_HISTORY_TOKENS, dropping oldest first.
*/
import { useState, useCallback, useRef } from 'react'
import type { VaultEntry } from '../types'
import {
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>>
@@ -28,7 +27,6 @@ function makeStreamCallbacks(
): { callbacks: ChatStreamCallbacks; getAccumulated: () => string } {
let accumulated = ''
const callbacks: ChatStreamCallbacks = {
onInit: (sid) => { refs.sessionIdRef.current = sid },
onText: (chunk) => {
if (refs.abortRef.current) return
accumulated += chunk
@@ -60,9 +58,9 @@ 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) => {
/** Internal: send text with explicit history context. */
const doSend = useCallback((text: string, history: ChatMessage[]) => {
if (!text.trim() || isStreaming) return
setMessages(prev => [...prev, { role: 'user', content: text.trim(), id: nextMessageId() }])
@@ -70,29 +68,30 @@ export function useAIChat(
setStreamingContent('')
abortRef.current = false
const currentSessionId = sessionIdRef.current
// System prompt only on first message (new session).
const systemPrompt = currentSessionId
? undefined
: (buildSystemPrompt(contextNotes, allContent).prompt || undefined)
// Always include system prompt (each request is a fresh subprocess).
const systemPrompt = buildSystemPrompt(contextNotes, allContent).prompt || undefined
// Embed conversation history in the prompt for continuity.
const trimmedHistory = trimHistory(history, MAX_HISTORY_TOKENS)
const formattedMessage = formatMessageWithHistory(trimmedHistory, text.trim())
const { callbacks } = makeStreamCallbacks({
abortRef, sessionIdRef, setMessages, setStreamingContent, setIsStreaming,
abortRef, setMessages, setStreamingContent, setIsStreaming,
})
streamClaudeChat(text.trim(), systemPrompt, currentSessionId, callbacks)
.then((sid) => {
if (sid && !sessionIdRef.current) sessionIdRef.current = sid
})
streamClaudeChat(formattedMessage, systemPrompt, undefined, callbacks)
.catch(() => { /* errors forwarded via onError */ })
}, [isStreaming, allContent, contextNotes])
const sendMessage = useCallback((text: string) => {
doSend(text, messages)
}, [doSend, messages])
const clearConversation = useCallback(() => {
abortRef.current = true
setMessages([])
setIsStreaming(false)
setStreamingContent('')
sessionIdRef.current = undefined
}, [])
const retryMessage = useCallback((msgIndex: number) => {
@@ -101,10 +100,10 @@ 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])
const historyForRetry = messages.slice(0, userMsgIndex)
setMessages(prev => prev.slice(0, userMsgIndex))
doSend(userMsg.content, historyForRetry)
}, [messages, doSend])
return { messages, isStreaming, streamingContent, sendMessage, clearConversation, retryMessage }
}

View File

@@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useCodeMirror, type CodeMirrorCallbacks } from './useCodeMirror'
const noop = () => {}
const noopCallbacks: CodeMirrorCallbacks = {
onDocChange: noop,
onCursorActivity: noop,
onSave: noop,
onEscape: () => false,
}
describe('useCodeMirror', () => {
let container: HTMLDivElement
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
})
afterEach(() => {
document.body.removeChild(container)
})
it('creates an EditorView in the container', () => {
const ref = { current: container }
const { result } = renderHook(() =>
useCodeMirror(ref, 'hello world', false, noopCallbacks),
)
expect(result.current.current).not.toBeNull()
expect(container.querySelector('.cm-editor')).toBeInTheDocument()
})
it('calls requestMeasure when laputa-zoom-change event fires', () => {
const ref = { current: container }
const { result } = renderHook(() =>
useCodeMirror(ref, 'hello', false, noopCallbacks),
)
const view = result.current.current!
const spy = vi.spyOn(view, 'requestMeasure')
act(() => {
window.dispatchEvent(new Event('laputa-zoom-change'))
})
expect(spy).toHaveBeenCalled()
spy.mockRestore()
})
it('stops listening for zoom changes after unmount', () => {
const ref = { current: container }
const { result, unmount } = renderHook(() =>
useCodeMirror(ref, 'hello', false, noopCallbacks),
)
const view = result.current.current!
const spy = vi.spyOn(view, 'requestMeasure')
unmount()
act(() => {
window.dispatchEvent(new Event('laputa-zoom-change'))
})
// After unmount, the listener should be removed — requestMeasure should NOT be called.
// (The view is also destroyed on unmount, so this verifies cleanup.)
expect(spy).not.toHaveBeenCalled()
spy.mockRestore()
})
})

View File

@@ -111,7 +111,19 @@ export function useCodeMirror(
const view = new EditorView({ state, parent })
viewRef.current = view
return () => { view.destroy(); viewRef.current = null }
// When CSS zoom changes on the document, CodeMirror's cached measurements
// (scaleX/scaleY, line heights, character widths) become stale because
// ResizeObserver doesn't fire for ancestor zoom changes. Force a re-measure
// so cursor placement stays accurate at any zoom level.
const handleZoomChange = () => { view.requestMeasure() }
window.addEventListener('laputa-zoom-change', handleZoomChange)
return () => {
window.removeEventListener('laputa-zoom-change', handleZoomChange)
view.destroy()
viewRef.current = null
}
// Re-create editor when isDark changes (theme is baked into extensions)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDark])

View File

@@ -109,4 +109,46 @@ describe('useZoom', () => {
const { result } = renderHook(() => useZoom())
expect(result.current.zoomLevel).toBe(100)
})
it('dispatches laputa-zoom-change event on zoomIn', () => {
const handler = vi.fn()
window.addEventListener('laputa-zoom-change', handler)
const { result } = renderHook(() => useZoom())
handler.mockClear() // clear any init-phase dispatches
act(() => result.current.zoomIn())
expect(handler).toHaveBeenCalled()
window.removeEventListener('laputa-zoom-change', handler)
})
it('dispatches laputa-zoom-change event on zoomOut', () => {
const handler = vi.fn()
window.addEventListener('laputa-zoom-change', handler)
const { result } = renderHook(() => useZoom())
handler.mockClear()
act(() => result.current.zoomOut())
expect(handler).toHaveBeenCalled()
window.removeEventListener('laputa-zoom-change', handler)
})
it('dispatches laputa-zoom-change event on zoomReset', () => {
resetVaultConfigStore()
bindVaultConfigStore({ ...DEFAULT_VC, zoom: 1.2 }, vi.fn())
const handler = vi.fn()
window.addEventListener('laputa-zoom-change', handler)
const { result } = renderHook(() => useZoom())
handler.mockClear()
act(() => result.current.zoomReset())
expect(handler).toHaveBeenCalled()
window.removeEventListener('laputa-zoom-change', handler)
})
it('applies CSS zoom synchronously during initialization', () => {
resetVaultConfigStore()
bindVaultConfigStore({ ...DEFAULT_VC, zoom: 1.2 }, vi.fn())
const spy = vi.spyOn(document.documentElement.style, 'setProperty')
renderHook(() => useZoom())
// Zoom should be applied during state init (setProperty called with zoom value)
expect(spy).toHaveBeenCalledWith('zoom', '120%')
spy.mockRestore()
})
})

View File

@@ -29,6 +29,7 @@ function loadPersistedZoom(): number {
function applyZoomToDocument(level: number): void {
document.documentElement.style.setProperty('zoom', `${level}%`)
window.dispatchEvent(new Event('laputa-zoom-change'))
}
function persistZoom(level: number): void {
@@ -36,12 +37,13 @@ function persistZoom(level: number): void {
}
export function useZoom() {
const [zoomLevel, setZoomLevel] = useState(loadPersistedZoom)
// Apply persisted zoom on mount
useEffect(() => {
applyZoomToDocument(zoomLevel)
}, []) // eslint-disable-line react-hooks/exhaustive-deps -- only on mount
const [zoomLevel, setZoomLevel] = useState(() => {
const level = loadPersistedZoom()
// Apply zoom synchronously during init so child components (e.g. CodeMirror)
// measure the correct scale factor in their own effects.
document.documentElement.style.setProperty('zoom', `${level}%`)
return level
})
// Re-sync when vault config becomes available
useEffect(() => {

View File

@@ -331,6 +331,35 @@ describe('buildContextSnapshot', () => {
expect(json.noteList).toBeUndefined()
})
it('uses activeNoteContent for body when allContent is empty (Tauri mode)', () => {
const emptyAllContent: Record<string, string> = {}
const result = buildContextSnapshot({
activeEntry: active,
allContent: emptyAllContent,
entries,
activeNoteContent: '---\ntitle: Alpha\n---\n\n# Alpha\nProject content from tab.',
})
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.activeNote.body).toContain('Project content from tab.')
})
it('prefers activeNoteContent over allContent when both present', () => {
const result = buildContextSnapshot({
activeEntry: active,
allContent,
entries,
activeNoteContent: 'Fresh editor content',
})
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.activeNote.body).toBe('Fresh editor content')
})
it('falls back to allContent when activeNoteContent is undefined', () => {
const result = buildContextSnapshot({ activeEntry: active, allContent, entries })
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.activeNote.body).toBe('# Alpha\nProject content.')
})
it('includes wikilink instruction in preamble', () => {
const result = buildContextSnapshot({ activeEntry: active, allContent, entries })
expect(result).toContain('[[Note Title]]')

View File

@@ -72,6 +72,8 @@ export interface NoteListItem {
export interface ContextSnapshotParams {
activeEntry: VaultEntry
allContent: Record<string, string>
/** Direct content of the active note from the editor tab (most reliable source). */
activeNoteContent?: string
openTabs?: VaultEntry[]
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
@@ -94,7 +96,7 @@ const MAX_NOTE_LIST_ITEMS = 100
/** Build a structured context snapshot as a system prompt for Claude. */
export function buildContextSnapshot(params: ContextSnapshotParams): string {
const { activeEntry, allContent, openTabs, noteList, noteListFilter, entries, references } = params
const { activeEntry, allContent, activeNoteContent, openTabs, noteList, noteListFilter, entries, references } = params
const snapshot: Record<string, unknown> = {
activeNote: {
@@ -102,7 +104,7 @@ export function buildContextSnapshot(params: ContextSnapshotParams): string {
title: activeEntry.title,
type: activeEntry.isA ?? 'Note',
frontmatter: entryFrontmatter(activeEntry),
body: allContent[activeEntry.path] ?? '',
body: activeNoteContent ?? allContent[activeEntry.path] ?? '',
},
}