Files
tolaria/src/hooks/useAiActivity.test.ts
lucaronin 93d598d04c fix: MCP UI tools (highlight, open_note) now work in real-time
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>
2026-03-04 10:05:43 +01:00

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