Compare commits

...

3 Commits

Author SHA1 Message Date
Test
db47ffe454 fix: use generic Error type in setup.ts to avoid NodeJS namespace 2026-03-04 11:20:02 +01:00
Test
008f067bf7 fix: restore drag-to-reorder for sidebar sections
Re-add useSortable listeners that were removed in the realignment
refactor. The entire section header row is now the drag target (no
visible handle icon needed). PointerSensor's distance:5 constraint
ensures clicks for collapse/expand don't conflict with drag.

Also suppress pre-existing undici WebSocket ERR_INVALID_ARG_TYPE in
test setup (jsdom Event ≠ Node Event incompatibility).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:07:56 +01:00
Test
55a2509658 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
9 changed files with 211 additions and 59 deletions

View File

@@ -23,29 +23,44 @@ import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'
import WebSocket from 'ws'
import {
readNote, createNote, searchNotes, appendToNote,
editNoteFrontmatter, deleteNote, linkNotes, listNotes, vaultContext,
} from './vault.js'
import { startUiBridge } from './ws-bridge.js'
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
const WS_UI_URL = `ws://localhost:${WS_UI_PORT}`
// Start the UI bridge so stdio-based MCP tools can broadcast UI actions.
// If the port is already in use (e.g. by the running Laputa app), continue
// without the bridge — vault tools still work via stdio MCP.
let uiBridge = null
startUiBridge(WS_UI_PORT).then((bridge) => {
uiBridge = bridge
})
// Connect as a WebSocket CLIENT to the UI bridge (run by ws-bridge.js).
// The bridge relays messages to all other clients (the React frontend).
let uiSocket = null
const RECONNECT_INTERVAL_MS = 3000
function connectUiBridge() {
try {
const ws = new WebSocket(WS_UI_URL)
ws.on('open', () => {
uiSocket = ws
console.error(`[mcp] Connected to UI bridge at ${WS_UI_URL}`)
})
ws.on('close', () => {
uiSocket = null
setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS)
})
ws.on('error', () => {
// Silent — bridge may not be running yet, will retry
})
} catch {
setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS)
}
}
connectUiBridge()
function broadcastUiAction(action, payload) {
if (!uiBridge) return
const msg = JSON.stringify({ type: 'ui_action', action, ...payload })
for (const client of uiBridge.clients) {
if (client.readyState === 1) client.send(msg)
}
if (!uiSocket || uiSocket.readyState !== WebSocket.OPEN) return
uiSocket.send(JSON.stringify({ type: 'ui_action', action, ...payload }))
}
const TOOLS = [
@@ -233,6 +248,7 @@ async function handleCreateNote(args) {
const frontmatter = {}
if (args.is_a) frontmatter.is_a = args.is_a
const absPath = await createNote(VAULT_PATH, args.path, args.title, frontmatter)
broadcastUiAction('vault_changed', { path: args.path })
return { content: [{ type: 'text', text: `Created note at ${absPath}` }] }
}
@@ -246,21 +262,25 @@ async function handleSearchNotes(args) {
async function handleAppendToNote(args) {
await appendToNote(VAULT_PATH, args.path, args.text)
broadcastUiAction('vault_changed', { path: args.path })
return { content: [{ type: 'text', text: `Appended text to ${args.path}` }] }
}
async function handleEditFrontmatter(args) {
const updated = await editNoteFrontmatter(VAULT_PATH, args.path, args.patch)
broadcastUiAction('vault_changed', { path: args.path })
return { content: [{ type: 'text', text: JSON.stringify(updated) }] }
}
async function handleDeleteNote(args) {
await deleteNote(VAULT_PATH, args.path)
broadcastUiAction('vault_changed', { path: args.path })
return { content: [{ type: 'text', text: `Deleted ${args.path}` }] }
}
async function handleLinkNotes(args) {
const arr = await linkNotes(VAULT_PATH, args.source_path, args.property, args.target_title)
broadcastUiAction('vault_changed', { path: args.source_path })
return { content: [{ type: 'text', text: `${args.property}: [${arr.join(', ')}]` }] }
}
@@ -293,7 +313,7 @@ function handleUiHighlight(args) {
}
function handleUiSetFilter(args) {
broadcastUiAction('set_filter', { type: args.type })
broadcastUiAction('set_filter', { filterType: args.type })
return { content: [{ type: 'text', text: `Filter set to ${args.type}` }] }
}

View File

@@ -50,18 +50,18 @@ function buildFrontmatter(args) {
const TOOL_HANDLERS = {
open_note: (args) => readNote(VAULT_PATH, args.path).then(text => ({ content: text })),
read_note: (args) => readNote(VAULT_PATH, args.path).then(text => ({ content: text })),
create_note: (args) => createNote(VAULT_PATH, args.path, args.title, buildFrontmatter(args)),
create_note: (args) => createNote(VAULT_PATH, args.path, args.title, buildFrontmatter(args)).then(r => { broadcastUiAction('vault_changed', { path: args.path }); return r }),
search_notes: (args) => searchNotes(VAULT_PATH, args.query, args.limit),
append_to_note: (args) => appendToNote(VAULT_PATH, args.path, args.text).then(() => ({ ok: true })),
edit_note_frontmatter: (args) => editNoteFrontmatter(VAULT_PATH, args.path, args.patch),
delete_note: (args) => deleteNote(VAULT_PATH, args.path).then(() => ({ ok: true })),
link_notes: (args) => linkNotes(VAULT_PATH, args.source_path, args.property, args.target_title),
append_to_note: (args) => appendToNote(VAULT_PATH, args.path, args.text).then(() => { broadcastUiAction('vault_changed', { path: args.path }); return { ok: true } }),
edit_note_frontmatter: (args) => editNoteFrontmatter(VAULT_PATH, args.path, args.patch).then(r => { broadcastUiAction('vault_changed', { path: args.path }); return r }),
delete_note: (args) => deleteNote(VAULT_PATH, args.path).then(() => { broadcastUiAction('vault_changed', { path: args.path }); return { ok: true } }),
link_notes: (args) => linkNotes(VAULT_PATH, args.source_path, args.property, args.target_title).then(r => { broadcastUiAction('vault_changed', { path: args.source_path }); return r }),
list_notes: (args) => listNotes(VAULT_PATH, args.type_filter, args.sort),
vault_context: () => vaultContext(VAULT_PATH),
ui_open_note: (args) => { broadcastUiAction('open_note', { path: args.path }); return { ok: true } },
ui_open_tab: (args) => { broadcastUiAction('open_tab', { path: args.path }); return { ok: true } },
ui_highlight: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
ui_set_filter: (args) => { broadcastUiAction('set_filter', { type: args.type }); return { ok: true } },
ui_set_filter: (args) => { broadcastUiAction('set_filter', { filterType: args.type }); return { ok: true } },
}
async function handleMessage(data) {
@@ -101,8 +101,15 @@ export function startUiBridge(port = WS_UI_PORT) {
httpServer.listen(port, () => {
const wss = new WebSocketServer({ server: httpServer })
wss.on('connection', () => {
wss.on('connection', (ws) => {
console.error(`[ws-bridge] UI client connected on port ${port}`)
// Relay: when a client sends a message, broadcast to all OTHER clients.
// This allows the MCP stdio server (connected as a client) to reach the frontend.
ws.on('message', (raw) => {
for (const client of wss.clients) {
if (client !== ws && client.readyState === 1) client.send(raw.toString())
}
})
})
uiBridge = wss
console.error(`[ws-bridge] UI bridge listening on ws://localhost:${port}`)

View File

@@ -61,3 +61,14 @@
.tab-status-pulse {
animation: tab-status-pulse 1.2s ease-in-out infinite;
}
/* AI highlight animation — applied by useAiActivity when MCP calls ui_highlight */
@keyframes ai-highlight-glow {
0% { box-shadow: inset 0 0 0 2px rgba(99, 102, 241, 0.8); }
50% { box-shadow: inset 0 0 8px 2px rgba(99, 102, 241, 0.4); }
100% { box-shadow: inset 0 0 0 2px rgba(99, 102, 241, 0); }
}
.ai-highlight {
animation: ai-highlight-glow 0.8s ease-out;
}

View File

@@ -35,6 +35,7 @@ import { useOnboarding } from './hooks/useOnboarding'
import { useThemeManager } from './hooks/useThemeManager'
import { useEditorSaveWithLinks } from './hooks/useEditorSaveWithLinks'
import { useNavigationGestures } from './hooks/useNavigationGestures'
import { useAiActivity } from './hooks/useAiActivity'
import { ConflictResolverModal } from './components/ConflictResolverModal'
import { UpdateBanner } from './components/UpdateBanner'
import { invoke } from '@tauri-apps/api/core'
@@ -208,6 +209,22 @@ function App() {
useNavigationGestures({ onGoBack: handleGoBack, onGoForward: handleGoForward })
// MCP UI bridge: react to AI-driven open/highlight/vault-change events
const aiActivity = useAiActivity({
onOpenNote: (path) => {
const entry = vault.entries.find(e => e.path === path || e.path === `${resolvedPath}/${path}`)
if (entry) notes.handleSelectNote(entry)
},
onOpenTab: (path) => {
const entry = vault.entries.find(e => e.path === path || e.path === `${resolvedPath}/${path}`)
if (entry) notes.handleSelectNote(entry)
},
onSetFilter: (filterType) => {
setSelection({ kind: 'sectionGroup', type: filterType })
},
onVaultChanged: () => { vault.reloadVault() },
})
const { triggerIncrementalIndex } = indexing
const onAfterSave = useCallback(() => {
vault.loadModifiedFiles()
@@ -413,13 +430,13 @@ function App() {
)}
{noteListVisible && (
<>
<div className="app__note-list" style={{ width: layout.noteListWidth }}>
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
</>
)}
<div className="app__editor">
<div className={`app__editor${aiActivity.highlightElement === 'editor' || aiActivity.highlightElement === 'tab' ? ' ai-highlight' : ''}`}>
<Editor
tabs={notes.tabs}
activeTabPath={notes.activeTabPath}

View File

@@ -165,7 +165,7 @@ function SortableSection({ group, sectionProps }: {
sectionProps: Omit<SectionContentProps, 'group' | 'items' | 'isCollapsed' | 'onToggle' | 'isRenaming' | 'renameInitialValue'>
& { entries: VaultEntry[]; collapsed: Record<string, boolean>; onToggle: (type: string) => void; renamingType: string | null; renameInitialValue: string }
}) {
const { attributes, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type })
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type })
const items = sectionProps.entries.filter((e) => e.isA === group.type && !e.archived && !e.trashed)
const isCollapsed = sectionProps.collapsed[group.type] ?? true
const isRenaming = sectionProps.renamingType === group.type
@@ -178,6 +178,7 @@ function SortableSection({ group, sectionProps }: {
onSelectNote={sectionProps.onSelectNote} onCreateType={sectionProps.onCreateType}
onCreateNewType={sectionProps.onCreateNewType} onContextMenu={sectionProps.onContextMenu}
onToggle={() => sectionProps.onToggle(group.type)}
dragHandleProps={listeners}
isRenaming={isRenaming}
renameInitialValue={isRenaming ? sectionProps.renameInitialValue : undefined}
onRenameSubmit={sectionProps.onRenameSubmit}

View File

@@ -75,6 +75,7 @@ export interface SectionContentProps {
onCreateNewType?: () => void
onContextMenu: (e: React.MouseEvent, type: string) => void
onToggle: () => void
dragHandleProps?: Record<string, unknown>
isRenaming?: boolean
renameInitialValue?: string
onRenameSubmit?: (value: string) => void
@@ -93,7 +94,7 @@ function resolveCreateHandler(type: string, onCreateType?: (type: string) => voi
export function SectionContent({
group, items, isCollapsed, selection, onSelect, onSelectNote,
onCreateType, onCreateNewType, onContextMenu, onToggle,
onCreateType, onCreateNewType, onContextMenu, onToggle, dragHandleProps,
isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel,
}: SectionContentProps) {
const { label, type, Icon, customColor } = group
@@ -113,6 +114,7 @@ export function SectionContent({
onContextMenu={(e) => onContextMenu(e, type)}
onToggle={onToggle}
onCreate={(e) => { e.stopPropagation(); onCreate?.() }}
dragHandleProps={dragHandleProps}
isRenaming={isRenaming}
renameInitialValue={renameInitialValue}
onRenameSubmit={onRenameSubmit}
@@ -182,11 +184,12 @@ function InlineRenameInput({ initialValue, onSubmit, onCancel }: {
)
}
function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive, showCreate, onSelect, onContextMenu, onToggle, onCreate, isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel }: {
function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive, showCreate, onSelect, onContextMenu, onToggle, onCreate, dragHandleProps, isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel }: {
label: string; type: string; Icon: ComponentType<IconProps>
sectionColor: string; isCollapsed: boolean; isActive: boolean; showCreate: boolean
onSelect: () => void; onContextMenu: (e: React.MouseEvent) => void
onToggle: () => void; onCreate: (e: React.MouseEvent) => void
dragHandleProps?: Record<string, unknown>
isRenaming?: boolean; renameInitialValue?: string
onRenameSubmit?: (value: string) => void; onRenameCancel?: () => void
}) {
@@ -194,6 +197,7 @@ function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive,
<div
className={cn("group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors", isActive ? "bg-secondary" : "hover:bg-accent")}
style={{ padding: '6px 8px 6px 16px', borderRadius: 4, gap: 4 }}
{...dragHandleProps}
onClick={() => {
if (isRenaming) return
if (isCollapsed) { onToggle(); onSelect() }

View File

@@ -7,6 +7,7 @@ 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
@@ -82,10 +83,10 @@ describe('useAiActivity', () => {
expect(result.current.highlightElement).toBeNull()
})
it('ignores non-highlight messages', () => {
it('ignores non-ui_action messages', () => {
const { result } = renderHook(() => useAiActivity())
act(() => {
sendWsMessage({ type: 'ui_action', action: 'other_action' })
sendWsMessage({ type: 'other', action: 'highlight', element: 'editor' })
})
expect(result.current.highlightElement).toBeNull()
})
@@ -112,4 +113,57 @@ describe('useAiActivity', () => {
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)
})
})

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef, useCallback } from 'react'
export type HighlightElement = 'editor' | 'tab' | 'properties' | 'notelist' | null
@@ -7,58 +7,89 @@ export interface AiActivity {
highlightPath: string | null
}
export interface AiActivityCallbacks {
onOpenNote?: (path: string) => void
onOpenTab?: (path: string) => void
onSetFilter?: (type: string) => void
onVaultChanged?: (path?: string) => void
}
const WS_UI_URL = 'ws://localhost:9711'
const HIGHLIGHT_DURATION_MS = 800
const RECONNECT_DELAY_MS = 3000
/**
* Listens on the UI WebSocket bridge (port 9711) for highlight events
* from the AI agent. Sets highlightElement for 800ms then auto-clears.
* Listens on the UI WebSocket bridge (port 9711) for UI action events
* from the MCP server. Handles highlight, open_note, open_tab, set_filter,
* and vault_changed actions.
*/
export function useAiActivity(): AiActivity {
export function useAiActivity(callbacks?: AiActivityCallbacks): AiActivity {
const [highlightElement, setHighlightElement] = useState<HighlightElement>(null)
const [highlightPath, setHighlightPath] = useState<string | null>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const callbacksRef = useRef(callbacks)
useEffect(() => { callbacksRef.current = callbacks })
const handleMessage = useCallback((event: MessageEvent) => {
try {
const data = JSON.parse(event.data as string)
if (data.type !== 'ui_action') return
switch (data.action) {
case 'highlight':
setHighlightElement(data.element ?? null)
setHighlightPath(data.path ?? null)
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
setHighlightElement(null)
setHighlightPath(null)
}, HIGHLIGHT_DURATION_MS)
break
case 'open_note':
if (data.path) callbacksRef.current?.onOpenNote?.(data.path)
break
case 'open_tab':
if (data.path) callbacksRef.current?.onOpenTab?.(data.path)
break
case 'set_filter':
if (data.filterType) callbacksRef.current?.onSetFilter?.(data.filterType)
break
case 'vault_changed':
callbacksRef.current?.onVaultChanged?.(data.path)
break
}
} catch {
// Ignore parse errors from malformed messages
}
}, [])
useEffect(() => {
let ws: WebSocket | null = null
let mounted = true
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
try {
ws = new WebSocket(WS_UI_URL)
ws.onmessage = (event) => {
if (!mounted) return
try {
const data = JSON.parse(event.data as string)
if (data.type === 'ui_action' && data.action === 'highlight') {
setHighlightElement(data.element ?? null)
setHighlightPath(data.path ?? null)
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
if (mounted) {
setHighlightElement(null)
setHighlightPath(null)
}
}, HIGHLIGHT_DURATION_MS)
}
} catch {
// Ignore parse errors from malformed messages
function connect() {
if (!mounted) return
try {
ws = new WebSocket(WS_UI_URL)
ws.onmessage = handleMessage
ws.onclose = () => {
if (mounted) reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS)
}
ws.onerror = () => { /* Silent — bridge may not be running */ }
} catch {
if (mounted) reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS)
}
ws.onerror = () => {
// Silent — UI bridge may not be running
}
} catch {
// WebSocket connection failed — bridge not available
}
connect()
return () => {
mounted = false
ws?.close()
if (timerRef.current) clearTimeout(timerRef.current)
if (reconnectTimer) clearTimeout(reconnectTimer)
}
}, [])
}, [handleMessage])
return { highlightElement, highlightPath }
}

View File

@@ -2,6 +2,13 @@ import '@testing-library/jest-dom/vitest'
import { vi } from 'vitest'
import { createElement, type ReactNode, type ComponentType } from 'react'
// Suppress undici WebSocket ERR_INVALID_ARG_TYPE in jsdom (jsdom Event ≠ Node Event)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(globalThis as any).process?.on?.('uncaughtException', (err: Error & { code?: string }) => {
if (err.code === 'ERR_INVALID_ARG_TYPE' && err.message?.includes('Event')) return
throw err
})
// Mock scrollIntoView for jsdom (not implemented)
Element.prototype.scrollIntoView = vi.fn()