Compare commits
3 Commits
v0.2026030
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db47ffe454 | ||
|
|
008f067bf7 | ||
|
|
55a2509658 |
@@ -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}` }] }
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
11
src/App.css
11
src/App.css
@@ -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;
|
||||
}
|
||||
|
||||
21
src/App.tsx
21
src/App.tsx
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user