Root causes: - index.js tried to start its own UI bridge server on port 9711, but ws-bridge.js (spawned by Tauri) already owns it → broadcastUiAction was a no-op. Fixed by connecting index.js as a WebSocket CLIENT that sends messages through the existing bridge. - ws-bridge.js UI bridge had no relay — client messages weren't forwarded. Added relay so messages from the MCP server reach the React frontend. - useAiActivity hook existed but was never imported in App.tsx. - useAiActivity only handled highlight, not open_note/open_tab/set_filter. - No vault_changed events after write operations. - set_filter payload used `type` key which overwrote `type: 'ui_action'`. Changes: - mcp-server/index.js: connect as WS client instead of starting server; broadcast vault_changed after all write operations - mcp-server/ws-bridge.js: add message relay in UI bridge; broadcast vault_changed after write operations; fix set_filter payload key - useAiActivity: handle all UI actions (highlight, open_note, open_tab, set_filter, vault_changed); accept callbacks; auto-reconnect on close - App.tsx: wire useAiActivity into vault/notes/selection actions; apply ai-highlight CSS class to editor and note list panels - App.css: add ai-highlight-glow keyframe animation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
170 lines
5.7 KiB
TypeScript
170 lines
5.7 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import { renderHook, act } from '@testing-library/react'
|
|
import { useAiActivity } from './useAiActivity'
|
|
|
|
let lastWsInstance: MockWebSocket | null = null
|
|
|
|
class MockWebSocket {
|
|
onmessage: ((event: MessageEvent) => void) | null = null
|
|
onerror: (() => void) | null = null
|
|
onclose: (() => void) | null = null
|
|
close = vi.fn()
|
|
url: string
|
|
|
|
constructor(url: string) {
|
|
this.url = url
|
|
lastWsInstance = this // eslint-disable-line @typescript-eslint/no-this-alias
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
lastWsInstance = null
|
|
vi.stubGlobal('WebSocket', MockWebSocket)
|
|
vi.useFakeTimers()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
function sendWsMessage(data: Record<string, unknown>) {
|
|
lastWsInstance?.onmessage?.(new MessageEvent('message', { data: JSON.stringify(data) }))
|
|
}
|
|
|
|
describe('useAiActivity', () => {
|
|
it('initializes with null highlight', () => {
|
|
const { result } = renderHook(() => useAiActivity())
|
|
expect(result.current.highlightElement).toBeNull()
|
|
expect(result.current.highlightPath).toBeNull()
|
|
})
|
|
|
|
it('connects to ws://localhost:9711', () => {
|
|
renderHook(() => useAiActivity())
|
|
expect(lastWsInstance).not.toBeNull()
|
|
expect(lastWsInstance!.url).toBe('ws://localhost:9711')
|
|
})
|
|
|
|
it('sets highlight on ui_action highlight message', () => {
|
|
const { result } = renderHook(() => useAiActivity())
|
|
act(() => {
|
|
sendWsMessage({ type: 'ui_action', action: 'highlight', element: 'editor', path: '/vault/test.md' })
|
|
})
|
|
expect(result.current.highlightElement).toBe('editor')
|
|
expect(result.current.highlightPath).toBe('/vault/test.md')
|
|
})
|
|
|
|
it('auto-clears highlight after 800ms', () => {
|
|
const { result } = renderHook(() => useAiActivity())
|
|
act(() => {
|
|
sendWsMessage({ type: 'ui_action', action: 'highlight', element: 'tab', path: '/vault/note.md' })
|
|
})
|
|
expect(result.current.highlightElement).toBe('tab')
|
|
act(() => { vi.advanceTimersByTime(800) })
|
|
expect(result.current.highlightElement).toBeNull()
|
|
expect(result.current.highlightPath).toBeNull()
|
|
})
|
|
|
|
it('resets timer on repeated highlight messages', () => {
|
|
const { result } = renderHook(() => useAiActivity())
|
|
act(() => {
|
|
sendWsMessage({ type: 'ui_action', action: 'highlight', element: 'editor' })
|
|
})
|
|
act(() => { vi.advanceTimersByTime(500) })
|
|
// Second message resets the timer
|
|
act(() => {
|
|
sendWsMessage({ type: 'ui_action', action: 'highlight', element: 'notelist' })
|
|
})
|
|
expect(result.current.highlightElement).toBe('notelist')
|
|
act(() => { vi.advanceTimersByTime(500) })
|
|
// Still active — only 500ms since the second message
|
|
expect(result.current.highlightElement).toBe('notelist')
|
|
act(() => { vi.advanceTimersByTime(300) })
|
|
expect(result.current.highlightElement).toBeNull()
|
|
})
|
|
|
|
it('ignores non-ui_action messages', () => {
|
|
const { result } = renderHook(() => useAiActivity())
|
|
act(() => {
|
|
sendWsMessage({ type: 'other', action: 'highlight', element: 'editor' })
|
|
})
|
|
expect(result.current.highlightElement).toBeNull()
|
|
})
|
|
|
|
it('ignores malformed JSON', () => {
|
|
const { result } = renderHook(() => useAiActivity())
|
|
act(() => {
|
|
lastWsInstance?.onmessage?.(new MessageEvent('message', { data: 'not json' }))
|
|
})
|
|
expect(result.current.highlightElement).toBeNull()
|
|
})
|
|
|
|
it('closes WebSocket on unmount', () => {
|
|
const { unmount } = renderHook(() => useAiActivity())
|
|
unmount()
|
|
expect(lastWsInstance!.close).toHaveBeenCalled()
|
|
})
|
|
|
|
it('handles highlight with no path', () => {
|
|
const { result } = renderHook(() => useAiActivity())
|
|
act(() => {
|
|
sendWsMessage({ type: 'ui_action', action: 'highlight', element: 'properties' })
|
|
})
|
|
expect(result.current.highlightElement).toBe('properties')
|
|
expect(result.current.highlightPath).toBeNull()
|
|
})
|
|
|
|
it('calls onOpenNote callback on open_note action', () => {
|
|
const onOpenNote = vi.fn()
|
|
renderHook(() => useAiActivity({ onOpenNote }))
|
|
act(() => {
|
|
sendWsMessage({ type: 'ui_action', action: 'open_note', path: 'project/foo.md' })
|
|
})
|
|
expect(onOpenNote).toHaveBeenCalledWith('project/foo.md')
|
|
})
|
|
|
|
it('calls onOpenTab callback on open_tab action', () => {
|
|
const onOpenTab = vi.fn()
|
|
renderHook(() => useAiActivity({ onOpenTab }))
|
|
act(() => {
|
|
sendWsMessage({ type: 'ui_action', action: 'open_tab', path: 'note/bar.md' })
|
|
})
|
|
expect(onOpenTab).toHaveBeenCalledWith('note/bar.md')
|
|
})
|
|
|
|
it('calls onSetFilter callback on set_filter action', () => {
|
|
const onSetFilter = vi.fn()
|
|
renderHook(() => useAiActivity({ onSetFilter }))
|
|
act(() => {
|
|
sendWsMessage({ type: 'ui_action', action: 'set_filter', filterType: 'Project' })
|
|
})
|
|
expect(onSetFilter).toHaveBeenCalledWith('Project')
|
|
})
|
|
|
|
it('calls onVaultChanged callback on vault_changed action', () => {
|
|
const onVaultChanged = vi.fn()
|
|
renderHook(() => useAiActivity({ onVaultChanged }))
|
|
act(() => {
|
|
sendWsMessage({ type: 'ui_action', action: 'vault_changed', path: 'note/new.md' })
|
|
})
|
|
expect(onVaultChanged).toHaveBeenCalledWith('note/new.md')
|
|
})
|
|
|
|
it('does not call onOpenNote when path is missing', () => {
|
|
const onOpenNote = vi.fn()
|
|
renderHook(() => useAiActivity({ onOpenNote }))
|
|
act(() => {
|
|
sendWsMessage({ type: 'ui_action', action: 'open_note' })
|
|
})
|
|
expect(onOpenNote).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('reconnects on close after delay', () => {
|
|
renderHook(() => useAiActivity())
|
|
const firstWs = lastWsInstance
|
|
act(() => { firstWs?.onclose?.() })
|
|
act(() => { vi.advanceTimersByTime(3000) })
|
|
expect(lastWsInstance).not.toBe(firstWs)
|
|
})
|
|
})
|