Compare commits
5 Commits
v0.2026030
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3fa296b99 | ||
|
|
6370b66e05 | ||
|
|
1ec27dd264 | ||
|
|
10e6d7b366 | ||
|
|
0c87e51037 |
@@ -32,10 +32,16 @@ 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)
|
||||
|
||||
// Start the UI bridge so stdio-based MCP tools can broadcast UI actions
|
||||
const uiBridge = startUiBridge(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
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
* Protocol (UI bridge):
|
||||
* Server broadcasts: { "type": "ui_action", "action": "open_note", "path": "..." }
|
||||
*/
|
||||
import { createServer } from 'node:http'
|
||||
import { WebSocketServer } from 'ws'
|
||||
import {
|
||||
readNote, createNote, searchNotes, appendToNote,
|
||||
@@ -80,15 +81,34 @@ async function handleMessage(data) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to start the UI bridge WebSocket server.
|
||||
* Returns a Promise that resolves to the WebSocketServer or null if the port
|
||||
* is unavailable (e.g. another Laputa instance owns it).
|
||||
*/
|
||||
export function startUiBridge(port = WS_UI_PORT) {
|
||||
uiBridge = new WebSocketServer({ port })
|
||||
return new Promise((resolve) => {
|
||||
const httpServer = createServer()
|
||||
|
||||
uiBridge.on('connection', () => {
|
||||
console.error(`[ws-bridge] UI client connected on port ${port}`)
|
||||
httpServer.on('error', (err) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.error(`[ws-bridge] UI bridge port ${port} already in use, disabling bridge`)
|
||||
} else {
|
||||
console.error(`[ws-bridge] UI bridge error: ${err.message}`)
|
||||
}
|
||||
resolve(null)
|
||||
})
|
||||
|
||||
httpServer.listen(port, () => {
|
||||
const wss = new WebSocketServer({ server: httpServer })
|
||||
wss.on('connection', () => {
|
||||
console.error(`[ws-bridge] UI client connected on port ${port}`)
|
||||
})
|
||||
uiBridge = wss
|
||||
console.error(`[ws-bridge] UI bridge listening on ws://localhost:${port}`)
|
||||
resolve(wss)
|
||||
})
|
||||
})
|
||||
|
||||
console.error(`[ws-bridge] UI bridge listening on ws://localhost:${port}`)
|
||||
return uiBridge
|
||||
}
|
||||
|
||||
export function startBridge(port = WS_PORT) {
|
||||
@@ -116,6 +136,5 @@ export function startBridge(port = WS_PORT) {
|
||||
// Run directly if invoked as main module
|
||||
const isMain = process.argv[1]?.endsWith('ws-bridge.js')
|
||||
if (isMain) {
|
||||
startUiBridge()
|
||||
startBridge()
|
||||
startUiBridge().then(() => startBridge())
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ const NOTE_TRASH: &str = "note-trash";
|
||||
const EDIT_FIND_IN_VAULT: &str = "edit-find-in-vault";
|
||||
const VIEW_GO_BACK: &str = "view-go-back";
|
||||
const VIEW_GO_FORWARD: &str = "view-go-forward";
|
||||
const APP_CHECK_FOR_UPDATES: &str = "app-check-for-updates";
|
||||
|
||||
const CUSTOM_IDS: &[&str] = &[
|
||||
APP_SETTINGS,
|
||||
@@ -44,6 +45,7 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
VIEW_ZOOM_RESET,
|
||||
VIEW_GO_BACK,
|
||||
VIEW_GO_FORWARD,
|
||||
APP_CHECK_FOR_UPDATES,
|
||||
];
|
||||
|
||||
/// IDs of menu items that should be disabled when no note tab is active.
|
||||
@@ -56,10 +58,15 @@ fn build_app_menu(app: &App) -> MenuResult {
|
||||
.id(APP_SETTINGS)
|
||||
.accelerator("CmdOrCtrl+,")
|
||||
.build(app)?;
|
||||
let check_updates_item = MenuItemBuilder::new("Check for Updates...")
|
||||
.id(APP_CHECK_FOR_UPDATES)
|
||||
.build(app)?;
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "Laputa")
|
||||
.about(None)
|
||||
.separator()
|
||||
.item(&check_updates_item)
|
||||
.separator()
|
||||
.item(&settings_item)
|
||||
.separator()
|
||||
.services()
|
||||
@@ -269,6 +276,7 @@ mod tests {
|
||||
"view-zoom-reset",
|
||||
"view-go-back",
|
||||
"view-go-forward",
|
||||
"app-check-for-updates",
|
||||
];
|
||||
for id in &expected {
|
||||
assert!(CUSTOM_IDS.contains(id), "missing custom ID: {id}");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { AiPanel } from './AiPanel'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
@@ -133,4 +133,33 @@ describe('AiPanel', () => {
|
||||
const input = screen.getByTestId('agent-input') as HTMLInputElement
|
||||
expect(input.placeholder).toBe('Ask the AI agent...')
|
||||
})
|
||||
|
||||
it('auto-focuses input on mount', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
await act(() => { vi.advanceTimersByTime(1) })
|
||||
const input = screen.getByTestId('agent-input')
|
||||
expect(document.activeElement).toBe(input)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('calls onClose when Escape is pressed while panel has focus', async () => {
|
||||
vi.useFakeTimers()
|
||||
const onClose = vi.fn()
|
||||
render(<AiPanel onClose={onClose} vaultPath="/tmp/vault" />)
|
||||
await act(() => { vi.advanceTimersByTime(1) })
|
||||
// Input is focused inside the panel, so Escape should trigger onClose
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('calls onClose when Escape is pressed on panel element', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<AiPanel onClose={onClose} vaultPath="/tmp/vault" />)
|
||||
const panel = screen.getByTestId('ai-panel')
|
||||
panel.focus()
|
||||
fireEvent.keyDown(panel, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
|
||||
import { AiMessage } from './AiMessage'
|
||||
import { useAiAgent, type AiAgentMessage } from '../hooks/useAiAgent'
|
||||
@@ -103,10 +103,10 @@ function MessageHistory({ messages, isActive, onOpenNote, hasContext }: {
|
||||
)
|
||||
}
|
||||
|
||||
function InputBar({ input, onInputChange, onSend, onKeyDown, isActive, hasContext }: {
|
||||
function InputBar({ input, onInputChange, onSend, onKeyDown, isActive, hasContext, inputRef }: {
|
||||
input: string; onInputChange: (v: string) => void
|
||||
onSend: () => void; onKeyDown: (e: React.KeyboardEvent) => void
|
||||
isActive: boolean; hasContext: boolean
|
||||
isActive: boolean; hasContext: boolean; inputRef: React.RefObject<HTMLInputElement | null>
|
||||
}) {
|
||||
const sendDisabled = isActive || !input.trim()
|
||||
return (
|
||||
@@ -116,6 +116,7 @@ function InputBar({ input, onInputChange, onSend, onKeyDown, isActive, hasContex
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={e => onInputChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
@@ -150,6 +151,8 @@ function InputBar({ input, onInputChange, onSend, onKeyDown, isActive, hasContex
|
||||
|
||||
export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries, allContent }: AiPanelProps) {
|
||||
const [input, setInput] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const panelRef = useRef<HTMLElement>(null)
|
||||
|
||||
const linkedEntries = useMemo(() => {
|
||||
if (!activeEntry || !entries) return []
|
||||
@@ -163,9 +166,33 @@ export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries,
|
||||
|
||||
const agent = useAiAgent(vaultPath, contextPrompt)
|
||||
const hasContext = !!activeEntry
|
||||
|
||||
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
panelRef.current?.focus()
|
||||
} else {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}, [isActive])
|
||||
|
||||
const handleEscape = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && panelRef.current?.contains(document.activeElement)) {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleEscape)
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [handleEscape])
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() || isActive) return
|
||||
agent.sendMessage(input)
|
||||
@@ -181,7 +208,10 @@ export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries,
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
className="flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground"
|
||||
style={{ outline: 'none' }}
|
||||
data-testid="ai-panel"
|
||||
>
|
||||
<PanelHeader onClose={onClose} onClear={agent.clearConversation} />
|
||||
@@ -201,6 +231,7 @@ export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries,
|
||||
onKeyDown={handleKeyDown}
|
||||
isActive={isActive}
|
||||
hasContext={hasContext}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Package, RefreshCw, Sparkles, FileText, Bell, Settings, FolderOpen, Check, Github, CircleDot, AlertTriangle, Loader2, GitCommitHorizontal, Search, X } from 'lucide-react'
|
||||
import { Package, RefreshCw, FileText, Bell, Settings, FolderOpen, Check, Github, CircleDot, AlertTriangle, Loader2, GitCommitHorizontal, Search, X } from 'lucide-react'
|
||||
import type { LastCommitInfo, SyncStatus } from '../types'
|
||||
import type { IndexingProgress } from '../hooks/useIndexing'
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
@@ -302,7 +302,6 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
|
||||
{indexingProgress && <IndexingBadge progress={indexingProgress} />}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={ICON_STYLE}><Sparkles size={13} style={{ color: 'var(--accent-purple)' }} />Claude Sonnet 4</span>
|
||||
<span style={ICON_STYLE}><FileText size={13} />{noteCount.toLocaleString()} notes</span>
|
||||
{zoomLevel !== 100 && (
|
||||
<span
|
||||
|
||||
@@ -122,6 +122,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onSearch: config.onSearch,
|
||||
onGoBack: config.onGoBack,
|
||||
onGoForward: config.onGoForward,
|
||||
onCheckForUpdates: config.onCheckForUpdates,
|
||||
activeTabPathRef: config.activeTabPathRef,
|
||||
handleCloseTabRef: config.handleCloseTabRef,
|
||||
activeTabPath: config.activeTabPath,
|
||||
|
||||
@@ -19,6 +19,7 @@ function makeHandlers(): MenuEventHandlers {
|
||||
onSearch: vi.fn(),
|
||||
onGoBack: vi.fn(),
|
||||
onGoForward: vi.fn(),
|
||||
onCheckForUpdates: vi.fn(),
|
||||
activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject<string | null>,
|
||||
handleCloseTabRef: { current: vi.fn() } as React.MutableRefObject<(path: string) => void>,
|
||||
activeTabPath: '/vault/test.md',
|
||||
@@ -161,6 +162,12 @@ describe('dispatchMenuEvent', () => {
|
||||
expect(h.onGoForward).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('app-check-for-updates triggers check for updates', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('app-check-for-updates', h)
|
||||
expect(h.onCheckForUpdates).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('unknown event ID does nothing', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('unknown-event', h)
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface MenuEventHandlers {
|
||||
onSearch: () => void
|
||||
onGoBack?: () => void
|
||||
onGoForward?: () => void
|
||||
onCheckForUpdates?: () => void
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
|
||||
activeTabPath: string | null
|
||||
@@ -58,6 +59,7 @@ function dispatchActiveTabEvent(id: string, h: MenuEventHandlers): boolean {
|
||||
function dispatchOptionalEvent(id: string, h: MenuEventHandlers): boolean {
|
||||
if (id === 'view-go-back') { h.onGoBack?.(); return true }
|
||||
if (id === 'view-go-forward') { h.onGoForward?.(); return true }
|
||||
if (id === 'app-check-for-updates') { h.onCheckForUpdates?.(); return true }
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user