Compare commits
3 Commits
v0.2026030
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcfd37d481 | ||
|
|
f27ebe05c4 | ||
|
|
7470e4f4a7 |
@@ -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,
|
||||
|
||||
@@ -51,6 +51,7 @@ export function EditorRightPanel({
|
||||
onVaultChanged={onVaultChanged}
|
||||
vaultPath={vaultPath}
|
||||
activeEntry={inspectorEntry}
|
||||
activeNoteContent={inspectorContent}
|
||||
entries={entries}
|
||||
allContent={allContent}
|
||||
openTabs={openTabs}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
69
src/hooks/useCodeMirror.test.ts
Normal file
69
src/hooks/useCodeMirror.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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]]')
|
||||
|
||||
@@ -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] ?? '',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user