Compare commits

...

17 Commits

Author SHA1 Message Date
Test
cfb047cb22 feat: wikilink pills in message bubbles, noteList context injection
- Render [[wikilink]] reference pills inside sent message bubbles
  with type-colored badges; clicking a pill opens the note
- Add noteList (filtered note list titles, max 100) and noteListFilter
  to the structured context snapshot sent to the AI
- Thread noteList/noteListFilter from App → Editor → EditorRightPanel → AiPanel
- Store references in AiAgentMessage for display in chat history
- Add tests for reference pill rendering and noteList context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 19:53:46 +01:00
Test
55ff9e6f5d refactor: remove dead useMcpRegistration hook, add design file
useMcpRegistration is fully replaced by useMcpStatus which combines
detection + registration. Added design placeholder for MCP status bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 19:36:36 +01:00
Test
d3ea632673 feat: detect MCP server status and show warning in status bar
Add check_mcp_status Tauri command that detects whether the MCP server
is installed, Claude CLI is missing, or config needs setup. The status
bar shows a warning badge (MCP ⚠) when not installed, clickable to
trigger install. Also available via command palette "Install MCP Server".

Replaces useMcpRegistration with useMcpStatus which combines detection
and registration in a single hook.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:11:58 +01:00
Test
6d3d752fd5 fix: pass initial value to useRef for strict TS build
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:21:40 +01:00
Test
26181b57b6 test: add tests for WikilinkChatInput and buildContextSnapshot
- WikilinkChatInput: 18 tests covering menu trigger, filtering, pill creation,
  dedup, removal, keyboard nav, Enter select, send with refs, disabled state
- buildContextSnapshot: 10 tests for structured context JSON output
  including activeNote, openTabs, vault summary, references, frontmatter

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:19:23 +01:00
Test
7efcaa11c4 feat: add wikilink autocomplete, animated border, structured context wiring
- WikilinkChatInput: [[ trigger, debounced dropdown, colored type pills, keyboard nav
- AiPanel: uses buildContextSnapshot, WikilinkChatInput, animated blue border
- EditorRightPanel/Editor: thread openTabs to AI panel
- CSS: ai-border-pulse + typing-bounce animations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:15:06 +01:00
Test
197aad0e97 feat: add reasoning streaming, markdown response, structured context snapshot
- Rust: add ThinkingDelta event to ClaudeStreamEvent for reasoning chunks
- ai-agent.ts: forward ThinkingDelta events via onThinking callback
- useAiAgent: stream reasoning live, accumulate response internally,
  reveal as complete block on done
- AiMessage: auto-collapse reasoning when done, use MarkdownContent
  for response rendering, update tests for new behavior
- ai-context: add buildContextSnapshot() for structured JSON context
  with activeNote, openTabs, noteListFilter, vault summary

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:09:02 +01:00
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
Test
790f2ea85c style: apply rustfmt to theme.rs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:35:49 +01:00
Test
ed5e6d6820 fix: auto-provision theme files on vault open for any vault
seed_vault_themes now writes individual missing/empty files instead of
skipping when the theme/ directory already exists. A new
ensure_vault_themes Tauri command is called by useThemeManager on every
vault open so vaults without a theme/ folder get default.md and dark.md
seeded automatically.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:34:51 +01:00
Test
feb97caa87 fix: show 'Installing search...' when qmd missing instead of 'Indexing...'
On fresh installs without qmd, show the accurate "Installing search..."
phase instead of briefly flashing "Indexing..." before switching to
unavailable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:45:24 +01:00
Test
5bcd344d5f fix: replace 'Index error' with graceful handling on fresh installs
Separate 'unavailable' (qmd not installed) from 'error' (indexing failed)
phases. Unavailable state is hidden from the status bar instead of showing
a persistent orange error. Actual errors show "Index failed — retry" with
click-to-retry. Both phases auto-dismiss after a timeout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:36:10 +01:00
Test
816e3ca8bd style: apply cargo fmt to test assertions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:21:47 +01:00
Test
ef148be94e fix: apply rustfmt to claude_cli.rs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:20:56 +01:00
Test
ceee8b04ea feat: make AI chat tool use blocks expandable with input/output details
Tool call blocks in AI Chat are now clickable and expandable to show
tool name, input parameters (pretty-printed JSON), and output/result.
Collapsed by default, keyboard accessible (Tab/Enter/Space/Esc).

Backend: Rust stream events now carry tool input (accumulated from
input_json_delta chunks) and tool output (from tool_result events).
Frontend: AiActionCard is a disclosure widget with aria-expanded,
error output shown in red, long content truncated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:08:48 +01:00
40 changed files with 2656 additions and 465 deletions

View File

@@ -0,0 +1 @@
{"children":[],"variables":{}}

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

@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::BufRead;
use std::path::PathBuf;
use std::process::{Command, Stdio};
@@ -18,10 +19,21 @@ pub enum ClaudeStreamEvent {
Init { session_id: String },
/// Incremental text chunk.
TextDelta { text: String },
/// Incremental thinking/reasoning chunk.
ThinkingDelta { text: String },
/// A tool call started (agent mode only).
ToolStart { tool_name: String, tool_id: String },
ToolStart {
tool_name: String,
tool_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
input: Option<String>,
},
/// A tool call finished (agent mode only).
ToolDone { tool_id: String },
ToolDone {
tool_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
output: Option<String>,
},
/// Final result text + session ID.
Result { text: String, session_id: String },
/// Something went wrong.
@@ -207,6 +219,15 @@ fn build_mcp_config(vault_path: &str) -> Result<String, String> {
serde_json::to_string(&config).map_err(|e| format!("Failed to serialise MCP config: {e}"))
}
/// Mutable state accumulated across the JSON stream for a single subprocess.
struct StreamState {
session_id: String,
/// Accumulates `input_json_delta` chunks keyed by tool_use id.
tool_inputs: HashMap<String, String>,
/// The tool_use id of the block currently being streamed.
current_tool_id: Option<String>,
}
/// Core subprocess runner shared by chat and agent modes.
fn run_claude_subprocess<F>(bin: &PathBuf, args: &[String], emit: &mut F) -> Result<String, String>
where
@@ -223,7 +244,11 @@ where
let stdout = child.stdout.take().ok_or("No stdout handle")?;
let reader = std::io::BufReader::new(stdout);
let mut session_id = String::new();
let mut state = StreamState {
session_id: String::new(),
tool_inputs: HashMap::new(),
current_tool_id: None,
};
for line in reader.lines() {
let line = match line {
@@ -245,7 +270,7 @@ where
Err(_) => continue, // skip non-JSON lines
};
dispatch_event(&json, &mut session_id, emit);
dispatch_event(&json, &mut state, emit);
}
// Read stderr for potential error messages.
@@ -257,7 +282,7 @@ where
let status = child.wait().map_err(|e| format!("Wait failed: {e}"))?;
if !status.success() && session_id.is_empty() {
if !status.success() && state.session_id.is_empty() {
let msg = if stderr_output.contains("not logged in")
|| stderr_output.contains("authentication")
|| stderr_output.contains("auth")
@@ -273,11 +298,11 @@ where
emit(ClaudeStreamEvent::Done);
Ok(session_id)
Ok(state.session_id)
}
/// Parse a single JSON line from the stream and emit the appropriate event.
fn dispatch_event<F>(json: &serde_json::Value, session_id: &mut String, emit: &mut F)
fn dispatch_event<F>(json: &serde_json::Value, state: &mut StreamState, emit: &mut F)
where
F: FnMut(ClaudeStreamEvent),
{
@@ -287,7 +312,7 @@ where
// --- System init → capture session_id ---
"system" if json["subtype"].as_str() == Some("init") => {
if let Some(sid) = json["session_id"].as_str() {
*session_id = sid.to_string();
state.session_id = sid.to_string();
emit(ClaudeStreamEvent::Init {
session_id: sid.to_string(),
});
@@ -296,7 +321,7 @@ where
// --- Streaming partial events (text deltas, tool_use starts) ---
"stream_event" => {
dispatch_stream_event(json, emit);
dispatch_stream_event(json, state, emit);
}
// --- Tool progress (agent mode) ---
@@ -307,6 +332,18 @@ where
emit(ClaudeStreamEvent::ToolStart {
tool_name: name.to_string(),
tool_id: id.to_string(),
input: None,
});
}
}
// --- Tool result (agent mode) ---
"tool_result" => {
if let Some(id) = json["tool_use_id"].as_str() {
let output = extract_tool_result_text(json);
emit(ClaudeStreamEvent::ToolDone {
tool_id: id.to_string(),
output,
});
}
}
@@ -315,7 +352,7 @@ where
"result" => {
let sid = json["session_id"].as_str().unwrap_or("").to_string();
if !sid.is_empty() {
*session_id = sid.clone();
state.session_id = sid.clone();
}
let text = json["result"].as_str().unwrap_or("").to_string();
emit(ClaudeStreamEvent::Result {
@@ -332,9 +369,11 @@ where
if let (Some(id), Some(name)) =
(block["id"].as_str(), block["name"].as_str())
{
let input = format_tool_input(&block["input"], state, id);
emit(ClaudeStreamEvent::ToolStart {
tool_name: name.to_string(),
tool_id: id.to_string(),
input,
});
}
}
@@ -347,7 +386,7 @@ where
}
/// Handle a `stream_event` (partial assistant message).
fn dispatch_stream_event<F>(json: &serde_json::Value, emit: &mut F)
fn dispatch_stream_event<F>(json: &serde_json::Value, state: &mut StreamState, emit: &mut F)
where
F: FnMut(ClaudeStreamEvent),
{
@@ -357,29 +396,91 @@ where
match event_type {
"content_block_delta" => {
let delta = &event["delta"];
if delta["type"].as_str() == Some("text_delta") {
if let Some(text) = delta["text"].as_str() {
emit(ClaudeStreamEvent::TextDelta {
text: text.to_string(),
});
match delta["type"].as_str() {
Some("text_delta") => {
if let Some(text) = delta["text"].as_str() {
emit(ClaudeStreamEvent::TextDelta {
text: text.to_string(),
});
}
}
Some("thinking_delta") => {
if let Some(text) = delta["thinking"].as_str() {
emit(ClaudeStreamEvent::ThinkingDelta {
text: text.to_string(),
});
}
}
Some("input_json_delta") => {
if let (Some(partial), Some(ref tid)) =
(delta["partial_json"].as_str(), &state.current_tool_id)
{
state
.tool_inputs
.entry(tid.clone())
.or_default()
.push_str(partial);
}
}
_ => {}
}
}
"content_block_start" => {
let block = &event["content_block"];
if block["type"].as_str() == Some("tool_use") {
if let (Some(id), Some(name)) = (block["id"].as_str(), block["name"].as_str()) {
state.current_tool_id = Some(id.to_string());
state.tool_inputs.entry(id.to_string()).or_default();
emit(ClaudeStreamEvent::ToolStart {
tool_name: name.to_string(),
tool_id: id.to_string(),
input: None,
});
}
}
}
"content_block_stop" => {
state.current_tool_id = None;
}
_ => {}
}
}
/// Build the tool input string, preferring accumulated delta chunks over the
/// block's `input` field (which may be empty at stream start).
fn format_tool_input(
block_input: &serde_json::Value,
state: &StreamState,
tool_id: &str,
) -> Option<String> {
if let Some(accumulated) = state.tool_inputs.get(tool_id) {
if !accumulated.is_empty() {
return Some(accumulated.clone());
}
}
if !block_input.is_null() && block_input.as_object().is_some_and(|o| !o.is_empty()) {
return Some(block_input.to_string());
}
None
}
/// Extract displayable text from a `tool_result` event.
fn extract_tool_result_text(json: &serde_json::Value) -> Option<String> {
// String content field
if let Some(s) = json["content"].as_str() {
return Some(s.to_string());
}
// Array of content blocks (Claude format)
if let Some(arr) = json["content"].as_array() {
let texts: Vec<&str> = arr.iter().filter_map(|b| b["text"].as_str()).collect();
if !texts.is_empty() {
return Some(texts.join("\n"));
}
}
// Fallback: "output" field
json["output"].as_str().map(|s| s.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -408,12 +509,20 @@ mod tests {
// --- dispatch_event / dispatch_stream_event ---
fn new_state() -> StreamState {
StreamState {
session_id: String::new(),
tool_inputs: HashMap::new(),
current_tool_id: None,
}
}
/// Run dispatch_event on the given JSON and return (session_id, events).
fn run_dispatch(json: serde_json::Value) -> (String, Vec<ClaudeStreamEvent>) {
let mut sid = String::new();
let mut state = new_state();
let mut events = vec![];
dispatch_event(&json, &mut sid, &mut |e| events.push(e));
(sid, events)
dispatch_event(&json, &mut state, &mut |e| events.push(e));
(state.session_id, events)
}
/// Run dispatch_event with a pre-set session_id.
@@ -421,10 +530,23 @@ mod tests {
json: serde_json::Value,
initial_sid: &str,
) -> (String, Vec<ClaudeStreamEvent>) {
let mut sid = initial_sid.to_string();
let mut state = new_state();
state.session_id = initial_sid.to_string();
let mut events = vec![];
dispatch_event(&json, &mut sid, &mut |e| events.push(e));
(sid, events)
dispatch_event(&json, &mut state, &mut |e| events.push(e));
(state.session_id, events)
}
/// Run multiple dispatch_event calls sharing state (for multi-event sequences).
fn run_dispatch_sequence(
events_json: Vec<serde_json::Value>,
) -> (StreamState, Vec<ClaudeStreamEvent>) {
let mut state = new_state();
let mut events = vec![];
for json in &events_json {
dispatch_event(json, &mut state, &mut |e| events.push(e));
}
(state, events)
}
#[test]
@@ -468,7 +590,7 @@ mod tests {
"event": { "type": "content_block_start", "index": 1, "content_block": { "type": "tool_use", "id": "tool_abc", "name": "read_note", "input": {} } }
}));
assert!(
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id } if tool_name == "read_note" && tool_id == "tool_abc")
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "read_note" && tool_id == "tool_abc")
);
}
@@ -501,7 +623,7 @@ mod tests {
"type": "tool_progress", "tool_name": "search_notes", "tool_use_id": "tool_xyz"
}));
assert!(
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id } if tool_name == "search_notes" && tool_id == "tool_xyz")
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "search_notes" && tool_id == "tool_xyz")
);
}
@@ -523,7 +645,7 @@ mod tests {
}));
assert_eq!(events.len(), 1);
assert!(
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id } if tool_name == "search_notes" && tool_id == "tu_1")
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "search_notes" && tool_id == "tu_1")
);
}
@@ -541,7 +663,8 @@ mod tests {
}
#[test]
fn dispatch_stream_event_non_text_delta_is_ignored() {
fn dispatch_stream_event_input_json_delta_accumulates_silently() {
// input_json_delta doesn't emit events directly — it accumulates in state
let (_, events) = run_dispatch(serde_json::json!({
"type": "stream_event",
"event": { "type": "content_block_delta", "index": 0, "delta": { "type": "input_json_delta", "partial_json": "{}" } }
@@ -566,6 +689,112 @@ mod tests {
assert!(events.is_empty());
}
#[test]
fn dispatch_event_handles_tool_result_string_content() {
let (_, events) = run_dispatch(serde_json::json!({
"type": "tool_result",
"tool_use_id": "tool_abc",
"content": "Found 3 notes matching query"
}));
assert_eq!(events.len(), 1);
assert!(
matches!(&events[0], ClaudeStreamEvent::ToolDone { tool_id, output }
if tool_id == "tool_abc" && output.as_deref() == Some("Found 3 notes matching query"))
);
}
#[test]
fn dispatch_event_handles_tool_result_array_content() {
let (_, events) = run_dispatch(serde_json::json!({
"type": "tool_result",
"tool_use_id": "tool_def",
"content": [{ "type": "text", "text": "Line 1" }, { "type": "text", "text": "Line 2" }]
}));
assert!(
matches!(&events[0], ClaudeStreamEvent::ToolDone { output, .. }
if output.as_deref() == Some("Line 1\nLine 2"))
);
}
#[test]
fn dispatch_event_tool_result_missing_tool_id_is_ignored() {
let (_, events) = run_dispatch(serde_json::json!({
"type": "tool_result", "content": "result text"
}));
assert!(events.is_empty());
}
#[test]
fn dispatch_accumulates_input_json_deltas() {
let (_, events) = run_dispatch_sequence(vec![
// Start tool_use block
serde_json::json!({
"type": "stream_event",
"event": { "type": "content_block_start", "content_block": { "type": "tool_use", "id": "t1", "name": "search_notes", "input": {} } }
}),
// Input delta chunks
serde_json::json!({
"type": "stream_event",
"event": { "type": "content_block_delta", "delta": { "type": "input_json_delta", "partial_json": "{\"query\":" } }
}),
serde_json::json!({
"type": "stream_event",
"event": { "type": "content_block_delta", "delta": { "type": "input_json_delta", "partial_json": "\"test\"}" } }
}),
// Stop block
serde_json::json!({
"type": "stream_event",
"event": { "type": "content_block_stop" }
}),
// Assistant message triggers ToolStart with accumulated input
serde_json::json!({
"type": "assistant",
"message": { "content": [
{ "type": "tool_use", "id": "t1", "name": "search_notes", "input": { "query": "test" } }
] }
}),
]);
// First event: ToolStart with no input (from content_block_start)
assert!(matches!(
&events[0],
ClaudeStreamEvent::ToolStart { input: None, .. }
));
// Second event: ToolStart with accumulated input (from assistant)
assert!(
matches!(&events[1], ClaudeStreamEvent::ToolStart { input: Some(inp), .. }
if inp == "{\"query\":\"test\"}")
);
}
#[test]
fn dispatch_assistant_uses_block_input_when_no_deltas() {
let (_, events) = run_dispatch(serde_json::json!({
"type": "assistant",
"message": { "content": [
{ "type": "tool_use", "id": "tu_x", "name": "create_note", "input": { "title": "Hello", "content": "world" } }
] }
}));
assert!(
matches!(&events[0], ClaudeStreamEvent::ToolStart { input: Some(inp), .. }
if inp.contains("title") && inp.contains("Hello"))
);
}
#[test]
fn content_block_stop_clears_current_tool() {
let (state, _) = run_dispatch_sequence(vec![
serde_json::json!({
"type": "stream_event",
"event": { "type": "content_block_start", "content_block": { "type": "tool_use", "id": "t1", "name": "x", "input": {} } }
}),
serde_json::json!({
"type": "stream_event",
"event": { "type": "content_block_stop" }
}),
]);
assert!(state.current_tool_id.is_none());
}
// --- run_claude_subprocess with mock scripts ---
#[cfg(unix)]

View File

@@ -391,11 +391,11 @@ async fn start_indexing(app_handle: tauri::AppHandle, vault_path: String) -> Res
match indexing::auto_install_qmd() {
Ok(_) => log::info!("qmd auto-installed successfully"),
Err(e) => {
log::warn!("qmd auto-install failed: {e}");
log::info!("qmd not available (search disabled): {e}");
let _ = app_handle.emit(
"indexing-progress",
IndexingProgress {
phase: "error".to_string(),
phase: "unavailable".to_string(),
current: 0,
total: 0,
done: true,
@@ -433,6 +433,13 @@ async fn register_mcp_tools(vault_path: String) -> Result<String, String> {
.map_err(|e| format!("Registration task failed: {e}"))?
}
#[tauri::command]
async fn check_mcp_status() -> Result<mcp::McpStatus, String> {
tokio::task::spawn_blocking(mcp::check_mcp_status)
.await
.map_err(|e| format!("MCP status check failed: {e}"))
}
#[tauri::command]
fn list_themes(vault_path: String) -> Result<Vec<ThemeFile>, String> {
let vault_path = expand_tilde(&vault_path);
@@ -475,6 +482,12 @@ fn create_vault_theme(vault_path: String, name: Option<String>) -> Result<String
theme::create_vault_theme(&vault_path, name.as_deref())
}
#[tauri::command]
fn ensure_vault_themes(vault_path: String) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
theme::ensure_vault_themes(&vault_path)
}
fn log_startup_result(label: &str, result: Result<usize, String>) {
match result {
Ok(n) if n > 0 => log::info!("{}: {} files", label, n),
@@ -664,13 +677,15 @@ pub fn run() {
check_vault_exists,
get_default_vault_path,
register_mcp_tools,
check_mcp_status,
list_themes,
get_theme,
get_vault_settings,
save_vault_settings,
set_active_theme,
create_theme,
create_vault_theme
create_vault_theme,
ensure_vault_themes
])
.build(tauri::generate_context!())
.expect("error while building tauri application")

View File

@@ -1,6 +1,19 @@
use serde::Serialize;
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
/// Status of the MCP server installation.
#[derive(Debug, Serialize, Clone, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum McpStatus {
/// MCP is registered in Claude config and server files exist.
Installed,
/// MCP server files or config are missing but can be installed.
NotInstalled,
/// Claude CLI is not installed — must install that first.
NoClaudeCli,
}
/// Find the `node` binary path at runtime.
pub(crate) fn find_node() -> Result<PathBuf, String> {
let output = Command::new("which")
@@ -144,6 +157,57 @@ fn upsert_mcp_config(config_path: &Path, entry: &serde_json::Value) -> Result<bo
Ok(was_update)
}
/// Check whether the MCP server is properly installed and registered.
///
/// Returns `Installed` when the laputa entry exists in `~/.claude/mcp.json`
/// and the referenced index.js file is present. Returns `NoClaudeCli` when
/// the Claude CLI binary cannot be found. Otherwise returns `NotInstalled`.
pub fn check_mcp_status() -> McpStatus {
// Check Claude CLI first — no point installing MCP if Claude isn't available
if crate::claude_cli::find_claude_binary().is_err() {
return McpStatus::NoClaudeCli;
}
let config_path = match dirs::home_dir() {
Some(h) => h.join(".claude").join("mcp.json"),
None => return McpStatus::NotInstalled,
};
if !config_path.exists() {
return McpStatus::NotInstalled;
}
let raw = match std::fs::read_to_string(&config_path) {
Ok(r) => r,
Err(_) => return McpStatus::NotInstalled,
};
let config: serde_json::Value = match serde_json::from_str(&raw) {
Ok(c) => c,
Err(_) => return McpStatus::NotInstalled,
};
let entry = &config["mcpServers"]["laputa"];
if entry.is_null() {
return McpStatus::NotInstalled;
}
// Verify the referenced index.js actually exists on disk
if let Some(index_js) = entry["args"]
.as_array()
.and_then(|a| a.first())
.and_then(|v| v.as_str())
{
if !Path::new(index_js).exists() {
return McpStatus::NotInstalled;
}
} else {
return McpStatus::NotInstalled;
}
McpStatus::Installed
}
#[cfg(test)]
mod tests {
use super::*;
@@ -314,4 +378,29 @@ mod tests {
// With empty config list, there were no updates, so status should be "registered"
assert_eq!(status, "registered");
}
#[test]
fn check_mcp_status_returns_valid_variant() {
// On a dev machine with Claude CLI and MCP registered, this should be Installed.
// On CI without Claude it might be NoClaudeCli. Either way it must not panic.
let status = check_mcp_status();
assert!(
matches!(
status,
McpStatus::Installed | McpStatus::NotInstalled | McpStatus::NoClaudeCli
),
"unexpected status: {:?}",
status
);
}
#[test]
fn mcp_status_serializes_to_snake_case() {
let json = serde_json::to_string(&McpStatus::Installed).unwrap();
assert_eq!(json, r#""installed""#);
let json = serde_json::to_string(&McpStatus::NotInstalled).unwrap();
assert_eq!(json, r#""not_installed""#);
let json = serde_json::to_string(&McpStatus::NoClaudeCli).unwrap();
assert_eq!(json, r#""no_claude_cli""#);
}
}

View File

@@ -145,17 +145,50 @@ pub fn seed_default_themes(vault_path: &str) {
}
/// Seed the vault `theme/` directory with built-in vault-based theme notes.
/// Safe to call multiple times — only writes files that are missing.
/// Per-file idempotent: creates the directory if missing, writes each default
/// file only when it doesn't exist or is empty (corrupt). Never overwrites
/// existing files that have content.
pub fn seed_vault_themes(vault_path: &str) {
seed_dir_with_files(
&Path::new(vault_path).join("theme"),
&[
("default.md", DEFAULT_VAULT_THEME),
("dark.md", DARK_VAULT_THEME),
("minimal.md", MINIMAL_VAULT_THEME),
],
"Seeded theme/ with built-in vault themes",
);
let theme_dir = Path::new(vault_path).join("theme");
if fs::create_dir_all(&theme_dir).is_err() {
return;
}
let defaults: &[(&str, &str)] = &[
("default.md", DEFAULT_VAULT_THEME),
("dark.md", DARK_VAULT_THEME),
("minimal.md", MINIMAL_VAULT_THEME),
];
let mut seeded = false;
for (name, content) in defaults {
let path = theme_dir.join(name);
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
if needs_write {
let _ = fs::write(&path, content);
seeded = true;
}
}
if seeded {
log::info!("Seeded theme/ with built-in vault themes");
}
}
/// Ensure vault theme files exist. Returns an error if the theme directory
/// cannot be created (e.g. read-only filesystem).
pub fn ensure_vault_themes(vault_path: &str) -> Result<(), String> {
let theme_dir = Path::new(vault_path).join("theme");
fs::create_dir_all(&theme_dir).map_err(|e| format!("Failed to create theme directory: {e}"))?;
let defaults: &[(&str, &str)] = &[
("default.md", DEFAULT_VAULT_THEME),
("dark.md", DARK_VAULT_THEME),
];
for (name, content) in defaults {
let path = theme_dir.join(name);
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
if needs_write {
fs::write(&path, content).map_err(|e| format!("Failed to write theme/{name}: {e}"))?;
}
}
Ok(())
}
/// Create a new vault theme note in `theme/` directory.
@@ -856,4 +889,94 @@ mod tests {
assert!(content.contains("accent-blue:"));
assert!(content.contains("editor-font-size:"));
}
#[test]
fn test_seed_vault_themes_writes_missing_files_in_existing_dir() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let theme_dir = vault.join("theme");
fs::create_dir_all(&theme_dir).unwrap();
// Only default exists — dark and minimal should be seeded
fs::write(theme_dir.join("default.md"), DEFAULT_VAULT_THEME).unwrap();
let vp = vault.to_str().unwrap();
seed_vault_themes(vp);
assert!(theme_dir.join("dark.md").exists());
assert!(theme_dir.join("minimal.md").exists());
}
#[test]
fn test_seed_vault_themes_reseeds_empty_files() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let theme_dir = vault.join("theme");
fs::create_dir_all(&theme_dir).unwrap();
// Create empty file — should be re-seeded
fs::write(theme_dir.join("default.md"), "").unwrap();
let vp = vault.to_str().unwrap();
seed_vault_themes(vp);
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
assert!(content.contains("Is A: Theme"));
}
#[test]
fn test_seed_vault_themes_preserves_existing_content() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let theme_dir = vault.join("theme");
fs::create_dir_all(&theme_dir).unwrap();
let custom = "---\nIs A: Theme\nbackground: \"#FF0000\"\n---\n# Custom\n";
fs::write(theme_dir.join("default.md"), custom).unwrap();
let vp = vault.to_str().unwrap();
seed_vault_themes(vp);
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
assert!(
content.contains("#FF0000"),
"existing content must be preserved"
);
}
#[test]
fn test_ensure_vault_themes_creates_dir_and_defaults() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
ensure_vault_themes(vp).unwrap();
assert!(vault.join("theme").is_dir());
assert!(vault.join("theme").join("default.md").exists());
assert!(vault.join("theme").join("dark.md").exists());
}
#[test]
fn test_ensure_vault_themes_reseeds_empty_files() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let theme_dir = vault.join("theme");
fs::create_dir_all(&theme_dir).unwrap();
fs::write(theme_dir.join("default.md"), "").unwrap();
let vp = vault.to_str().unwrap();
ensure_vault_themes(vp).unwrap();
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
assert!(content.contains("Is A: Theme"));
}
#[test]
fn test_ensure_vault_themes_preserves_custom_themes() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let theme_dir = vault.join("theme");
fs::create_dir_all(&theme_dir).unwrap();
let custom = "---\nIs A: Theme\nbackground: \"#123456\"\n---\n";
fs::write(theme_dir.join("default.md"), custom).unwrap();
let vp = vault.to_str().unwrap();
ensure_vault_themes(vp).unwrap();
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
assert!(content.contains("#123456"));
}
}

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

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Sidebar } from './components/Sidebar'
import { NoteList } from './components/NoteList'
import { Editor } from './components/Editor'
@@ -13,7 +13,7 @@ import { StatusBar } from './components/StatusBar'
import { SettingsPanel } from './components/SettingsPanel'
import { GitHubVaultModal } from './components/GitHubVaultModal'
import { WelcomeScreen } from './components/WelcomeScreen'
import { useMcpRegistration } from './hooks/useMcpRegistration'
import { useMcpStatus } from './hooks/useMcpStatus'
import { useVaultLoader } from './hooks/useVaultLoader'
import { useSettings } from './hooks/useSettings'
import { useNoteActions } from './hooks/useNoteActions'
@@ -35,11 +35,14 @@ 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'
import { isTauri, mockInvoke } from './mock-tauri'
import type { SidebarSelection } from './types'
import type { NoteListItem } from './utils/ai-context'
import { filterEntries } from './utils/noteListHelpers'
import './App.css'
// Type declaration for mock content storage
@@ -102,7 +105,7 @@ function App() {
const { settings, saveSettings } = useSettings()
const themeManager = useThemeManager(resolvedPath, vault.entries, vault.allContent)
useMcpRegistration(resolvedPath, setToastMessage)
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
const autoSync = useAutoSync({
vaultPath: resolvedPath,
@@ -208,6 +211,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()
@@ -367,10 +386,25 @@ function App() {
onRestoreGettingStarted: vaultSwitcher.restoreGettingStarted,
isGettingStartedHidden: vaultSwitcher.isGettingStartedHidden,
vaultCount: vaultSwitcher.allVaults.length,
mcpStatus,
onInstallMcp: installMcp,
})
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
const aiNoteList = useMemo<NoteListItem[]>(() => {
return filterEntries(vault.entries, selection).map(e => ({
path: e.path, title: e.title, type: e.isA ?? 'Note',
}))
}, [vault.entries, selection])
const aiNoteListFilter = useMemo(() => {
if (selection.kind === 'sectionGroup') return { type: selection.type, query: '' }
if (selection.kind === 'topic') return { type: null, query: selection.entry.title }
if (selection.kind === 'entity') return { type: null, query: selection.entry.title }
return { type: null, query: '' }
}, [selection])
// Show welcome/onboarding screen when vault doesn't exist
if (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing') {
const defaultPath = onboarding.state.defaultPath
@@ -413,13 +447,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}
@@ -446,6 +480,8 @@ function App() {
showAIChat={dialogs.showAIChat}
onToggleAIChat={dialogs.toggleAIChat}
vaultPath={resolvedPath}
noteList={aiNoteList}
noteListFilter={aiNoteListFilter}
onTrashNote={entryActions.handleTrashNote}
onRestoreNote={entryActions.handleRestoreNote}
onDeleteNote={handleDeleteNote}
@@ -467,7 +503,7 @@ function App() {
</div>
</div>
<UpdateBanner status={updateStatus} actions={updateActions} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} indexingProgress={indexing.progress} onRemoveVault={vaultSwitcher.removeVault} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} indexingProgress={indexing.progress} onRetryIndexing={indexing.retryIndexing} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />

View File

@@ -2,61 +2,166 @@ import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { AiActionCard } from './AiActionCard'
const defaults = {
tool: 'create_note',
label: 'Creating note... (abc123)',
status: 'done' as const,
expanded: false,
onToggle: vi.fn(),
}
describe('AiActionCard', () => {
it('renders label text', () => {
render(<AiActionCard tool="create_note" label="Created test.md" status="done" />)
render(<AiActionCard {...defaults} label="Created test.md" />)
expect(screen.getByText('Created test.md')).toBeTruthy()
})
it('shows pending spinner', () => {
render(<AiActionCard tool="search_notes" label="Searching..." status="pending" />)
render(<AiActionCard {...defaults} tool="search_notes" label="Searching..." status="pending" />)
expect(screen.getByTestId('status-pending')).toBeTruthy()
})
it('shows done check', () => {
render(<AiActionCard tool="create_note" label="Created" status="done" />)
render(<AiActionCard {...defaults} status="done" />)
expect(screen.getByTestId('status-done')).toBeTruthy()
})
it('shows error icon', () => {
render(<AiActionCard tool="delete_note" label="Failed" status="error" />)
render(<AiActionCard {...defaults} tool="delete_note" label="Failed" status="error" />)
expect(screen.getByTestId('status-error')).toBeTruthy()
})
it('is clickable when path and onOpenNote provided', () => {
it('navigates to note when no details and path+onOpenNote provided', () => {
const onOpenNote = vi.fn()
render(<AiActionCard tool="create_note" label="Open note" path="/vault/test.md" status="done" onOpenNote={onOpenNote} />)
fireEvent.click(screen.getByTestId('ai-action-card'))
const toggle = vi.fn()
render(
<AiActionCard {...defaults} path="/vault/test.md" onOpenNote={onOpenNote} onToggle={toggle} />,
)
fireEvent.click(screen.getByTestId('action-card-header'))
expect(onOpenNote).toHaveBeenCalledWith('/vault/test.md')
expect(toggle).not.toHaveBeenCalled()
})
it('has button role when clickable', () => {
render(<AiActionCard tool="create_note" label="Open note" path="/vault/test.md" status="done" onOpenNote={vi.fn()} />)
expect(screen.getByRole('button')).toBeTruthy()
})
it('is not clickable without path', () => {
it('toggles expand instead of navigating when details exist', () => {
const onOpenNote = vi.fn()
render(<AiActionCard tool="create_note" label="Created" status="done" onOpenNote={onOpenNote} />)
fireEvent.click(screen.getByTestId('ai-action-card'))
const toggle = vi.fn()
render(
<AiActionCard
{...defaults}
path="/vault/test.md"
onOpenNote={onOpenNote}
onToggle={toggle}
input='{"title":"test"}'
/>,
)
fireEvent.click(screen.getByTestId('action-card-header'))
expect(toggle).toHaveBeenCalled()
expect(onOpenNote).not.toHaveBeenCalled()
})
it('is not clickable without onOpenNote', () => {
render(<AiActionCard tool="create_note" label="Created" path="/vault/test.md" status="done" />)
const card = screen.getByTestId('ai-action-card')
expect(card.getAttribute('role')).toBeNull()
it('header has button role and is focusable', () => {
render(<AiActionCard {...defaults} />)
const header = screen.getByTestId('action-card-header')
expect(header.getAttribute('role')).toBe('button')
expect(header.getAttribute('tabindex')).toBe('0')
})
it('uses lighter background for ui_ tools', () => {
render(<AiActionCard tool="ui_open_tab" label="Opened tab" status="done" />)
render(<AiActionCard {...defaults} tool="ui_open_tab" label="Opened tab" />)
const card = screen.getByTestId('ai-action-card')
expect(card.style.background).toContain('0.06')
})
it('uses standard background for vault tools', () => {
render(<AiActionCard tool="create_note" label="Created" status="done" />)
render(<AiActionCard {...defaults} />)
const card = screen.getByTestId('ai-action-card')
expect(card.style.background).toContain('0.1')
})
// --- Expand / collapse ---
it('does not show details when collapsed', () => {
render(<AiActionCard {...defaults} input='{"q":"test"}' output="found 3" expanded={false} />)
expect(screen.queryByTestId('action-card-details')).toBeNull()
})
it('shows details when expanded with input and output', () => {
render(<AiActionCard {...defaults} input='{"q":"test"}' output="found 3" expanded />)
expect(screen.getByTestId('action-card-details')).toBeTruthy()
expect(screen.getByTestId('detail-input')).toBeTruthy()
expect(screen.getByTestId('detail-output')).toBeTruthy()
})
it('shows only input when no output', () => {
render(<AiActionCard {...defaults} input='{"q":"test"}' expanded />)
expect(screen.getByTestId('detail-input')).toBeTruthy()
expect(screen.queryByTestId('detail-output')).toBeNull()
})
it('shows only output when no input', () => {
render(<AiActionCard {...defaults} output="result text" expanded />)
expect(screen.queryByTestId('detail-input')).toBeNull()
expect(screen.getByTestId('detail-output')).toBeTruthy()
})
it('does not show details when expanded but no input or output', () => {
render(<AiActionCard {...defaults} expanded />)
expect(screen.queryByTestId('action-card-details')).toBeNull()
})
it('formats JSON input prettily', () => {
render(<AiActionCard {...defaults} input='{"title":"Hello","content":"world"}' expanded />)
const inputBlock = screen.getByTestId('detail-input')
expect(inputBlock.textContent).toContain('"title": "Hello"')
})
it('truncates very long output', () => {
const longOutput = 'x'.repeat(1000)
render(<AiActionCard {...defaults} output={longOutput} expanded />)
const outputBlock = screen.getByTestId('detail-output')
expect(outputBlock.textContent!.length).toBeLessThan(1000)
})
// --- Keyboard accessibility ---
it('expands on Enter key', () => {
const toggle = vi.fn()
render(<AiActionCard {...defaults} onToggle={toggle} input='{"a":1}' />)
fireEvent.keyDown(screen.getByTestId('action-card-header'), { key: 'Enter' })
expect(toggle).toHaveBeenCalled()
})
it('expands on Space key', () => {
const toggle = vi.fn()
render(<AiActionCard {...defaults} onToggle={toggle} input='{"a":1}' />)
fireEvent.keyDown(screen.getByTestId('action-card-header'), { key: ' ' })
expect(toggle).toHaveBeenCalled()
})
it('collapses on Escape key when expanded', () => {
const toggle = vi.fn()
render(<AiActionCard {...defaults} onToggle={toggle} expanded input='{"a":1}' />)
fireEvent.keyDown(screen.getByTestId('action-card-header'), { key: 'Escape' })
expect(toggle).toHaveBeenCalled()
})
it('does not collapse on Escape when already collapsed', () => {
const toggle = vi.fn()
render(<AiActionCard {...defaults} onToggle={toggle} expanded={false} input='{"a":1}' />)
fireEvent.keyDown(screen.getByTestId('action-card-header'), { key: 'Escape' })
expect(toggle).not.toHaveBeenCalled()
})
it('sets aria-expanded attribute', () => {
const { rerender } = render(<AiActionCard {...defaults} expanded={false} />)
expect(screen.getByTestId('action-card-header').getAttribute('aria-expanded')).toBe('false')
rerender(<AiActionCard {...defaults} expanded />)
expect(screen.getByTestId('action-card-header').getAttribute('aria-expanded')).toBe('true')
})
it('shows error output in red for error status', () => {
render(<AiActionCard {...defaults} status="error" output="Permission denied" expanded />)
const outputBlock = screen.getByTestId('detail-output')
expect(outputBlock.style.color).toContain('destructive')
})
})

View File

@@ -1,7 +1,7 @@
import type { ReactNode } from 'react'
import { type KeyboardEvent, type ReactNode, useCallback } from 'react'
import {
PencilSimple, MagnifyingGlass, Link, Trash, ChartBar, Eye, Sparkle,
CircleNotch, CheckCircle, XCircle,
CircleNotch, CheckCircle, XCircle, CaretRight, CaretDown,
} from '@phosphor-icons/react'
export type AiActionStatus = 'pending' | 'done' | 'error'
@@ -11,9 +11,15 @@ export interface AiActionCardProps {
label: string
path?: string
status: AiActionStatus
input?: string
output?: string
expanded: boolean
onToggle: () => void
onOpenNote?: (path: string) => void
}
const MAX_DETAIL_LENGTH = 800
type IconRenderer = (size: number) => ReactNode
const TOOL_ICON_MAP: Record<string, IconRenderer> = {
@@ -43,27 +49,118 @@ function StatusIndicator({ status }: { status: AiActionStatus }) {
return <XCircle size={14} weight="fill" style={{ color: 'var(--destructive)' }} data-testid="status-error" />
}
export function AiActionCard({ tool, label, path, status, onOpenNote }: AiActionCardProps) {
const renderIcon = TOOL_ICON_MAP[tool] ?? DEFAULT_ICON
const isClickable = !!path && !!onOpenNote
const isUiTool = tool.startsWith('ui_')
function truncateText(text: string): { text: string; truncated: boolean } {
if (text.length <= MAX_DETAIL_LENGTH) return { text, truncated: false }
return { text: text.slice(0, MAX_DETAIL_LENGTH), truncated: true }
}
function formatInputForDisplay(raw: string): string {
try {
return JSON.stringify(JSON.parse(raw), null, 2)
} catch {
return raw
}
}
function DetailBlock({ label, content, isError }: {
label: string; content: string; isError?: boolean
}) {
const { text, truncated } = truncateText(content)
return (
<div
className="flex items-center gap-2 rounded"
style={{
padding: '6px 10px',
fontSize: 12,
background: isUiTool ? 'rgba(74, 158, 255, 0.06)' : 'rgba(74, 158, 255, 0.1)',
cursor: isClickable ? 'pointer' : 'default',
}}
onClick={isClickable ? () => onOpenNote(path) : undefined}
role={isClickable ? 'button' : undefined}
data-testid="ai-action-card"
>
<span className="shrink-0 text-muted-foreground">{renderIcon(14)}</span>
<span className="flex-1 truncate">{label}</span>
<StatusIndicator status={status} />
<div style={{ marginTop: 6 }}>
<div
className="text-muted-foreground"
style={{ fontSize: 10, fontWeight: 600, textTransform: 'uppercase', marginBottom: 2 }}
>
{label}
</div>
<pre
data-testid={`detail-${label.toLowerCase()}`}
style={{
fontSize: 11,
lineHeight: 1.4,
margin: 0,
padding: '4px 6px',
borderRadius: 4,
background: 'var(--muted)',
color: isError ? 'var(--destructive)' : 'var(--foreground)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: 200,
overflow: 'auto',
}}
>
{text}{truncated && <span className="text-muted-foreground">{'…'}</span>}
</pre>
</div>
)
}
export function AiActionCard({
tool, label, path, status, input, output, expanded, onToggle, onOpenNote,
}: AiActionCardProps) {
const renderIcon = TOOL_ICON_MAP[tool] ?? DEFAULT_ICON
const isUiTool = tool.startsWith('ui_')
const hasDetails = !!(input || output)
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
} else if (e.key === 'Escape' && expanded) {
e.preventDefault()
onToggle()
}
}, [onToggle, expanded])
const handleClick = useCallback(() => {
if (path && onOpenNote && !hasDetails) {
onOpenNote(path)
} else {
onToggle()
}
}, [path, onOpenNote, hasDetails, onToggle])
const formattedInput = input ? formatInputForDisplay(input) : undefined
return (
<div
data-testid="ai-action-card"
className="rounded"
style={{
fontSize: 12,
background: isUiTool ? 'rgba(74, 158, 255, 0.06)' : 'rgba(74, 158, 255, 0.1)',
}}
>
<div
className="flex items-center gap-2"
style={{ padding: '6px 10px', cursor: 'pointer' }}
role="button"
tabIndex={0}
aria-expanded={expanded}
onClick={handleClick}
onKeyDown={handleKeyDown}
data-testid="action-card-header"
>
<span className="shrink-0 text-muted-foreground" style={{ width: 14, display: 'flex' }}>
{hasDetails
? (expanded ? <CaretDown size={12} /> : <CaretRight size={12} />)
: renderIcon(14)}
</span>
<span className="flex-1 truncate">{label}</span>
<StatusIndicator status={status} />
</div>
{expanded && hasDetails && (
<div
data-testid="action-card-details"
style={{ padding: '0 10px 8px 10px' }}
>
{formattedInput && <DetailBlock label="Input" content={formattedInput} />}
{output && (
<DetailBlock label="Output" content={output} isError={status === 'error'} />
)}
</div>
)}
</div>
)
}

View File

@@ -2,15 +2,20 @@ import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { AiMessage } from './AiMessage'
vi.mock('./MarkdownContent', () => ({
MarkdownContent: ({ content }: { content: string }) => <div data-testid="markdown-content">{content}</div>,
}))
describe('AiMessage', () => {
it('renders user message', () => {
render(<AiMessage userMessage="Hello AI" actions={[]} />)
expect(screen.getByText('Hello AI')).toBeTruthy()
})
it('renders response text', () => {
render(<AiMessage userMessage="Ask" actions={[]} response="Here is the answer" />)
expect(screen.getByText('Here is the answer')).toBeTruthy()
it('renders response as markdown', () => {
render(<AiMessage userMessage="Ask" actions={[]} response="Here is the **answer**" />)
expect(screen.getByTestId('markdown-content')).toBeTruthy()
expect(screen.getByText('Here is the **answer**')).toBeTruthy()
})
it('shows undo button with response', () => {
@@ -18,23 +23,31 @@ describe('AiMessage', () => {
expect(screen.getByTestId('undo-button')).toBeTruthy()
})
it('renders reasoning toggle collapsed by default', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking about it..." actions={[]} />)
it('shows reasoning expanded while streaming (reasoningDone=false)', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking about it..." reasoningDone={false} actions={[]} />)
expect(screen.getByTestId('reasoning-toggle')).toBeTruthy()
expect(screen.queryByTestId('reasoning-content')).toBeNull()
})
it('expands reasoning on toggle click', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking about it..." actions={[]} />)
fireEvent.click(screen.getByTestId('reasoning-toggle'))
expect(screen.getByTestId('reasoning-content')).toBeTruthy()
expect(screen.getByText('Thinking about it...')).toBeTruthy()
})
it('collapses reasoning on second click', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking..." actions={[]} />)
it('auto-collapses reasoning when reasoningDone=true', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking..." reasoningDone actions={[]} />)
expect(screen.getByTestId('reasoning-toggle')).toBeTruthy()
expect(screen.queryByTestId('reasoning-content')).toBeNull()
})
it('expands collapsed reasoning on toggle click', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking..." reasoningDone actions={[]} />)
// Starts collapsed (reasoningDone=true)
expect(screen.queryByTestId('reasoning-content')).toBeNull()
fireEvent.click(screen.getByTestId('reasoning-toggle'))
expect(screen.getByTestId('reasoning-content')).toBeTruthy()
})
it('collapses expanded reasoning on toggle click', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking..." reasoningDone={false} actions={[]} />)
// Starts expanded (reasoningDone=false)
expect(screen.getByTestId('reasoning-content')).toBeTruthy()
fireEvent.click(screen.getByTestId('reasoning-toggle'))
expect(screen.queryByTestId('reasoning-content')).toBeNull()
})
@@ -44,8 +57,8 @@ describe('AiMessage', () => {
<AiMessage
userMessage="Do something"
actions={[
{ tool: 'create_note', label: 'Created test.md', status: 'done' },
{ tool: 'search_notes', label: 'Searched', status: 'pending' },
{ tool: 'create_note', toolId: 't1', label: 'Created test.md', status: 'done' },
{ tool: 'search_notes', toolId: 't2', label: 'Searched', status: 'pending' },
]}
/>,
)
@@ -57,11 +70,11 @@ describe('AiMessage', () => {
render(
<AiMessage
userMessage="Do"
actions={[{ tool: 'create_note', label: 'Open', path: '/vault/note.md', status: 'done' }]}
actions={[{ tool: 'create_note', toolId: 't1', label: 'Open', path: '/vault/note.md', status: 'done' }]}
onOpenNote={onOpenNote}
/>,
)
fireEvent.click(screen.getByTestId('ai-action-card'))
fireEvent.click(screen.getByTestId('action-card-header'))
expect(onOpenNote).toHaveBeenCalledWith('/vault/note.md')
})
@@ -88,4 +101,69 @@ describe('AiMessage', () => {
render(<AiMessage userMessage="Ask" actions={[]} />)
expect(screen.queryByTestId('ai-action-card')).toBeNull()
})
it('renders reference pills in user bubble', () => {
render(
<AiMessage
userMessage="Tell me about this"
references={[
{ title: 'Marco', path: 'person/marco.md', type: 'Person' },
{ title: 'Project X', path: 'project/x.md', type: 'Project' },
]}
actions={[]}
/>,
)
const pills = screen.getAllByTestId('message-reference-pill')
expect(pills).toHaveLength(2)
expect(pills[0].textContent).toBe('Marco')
expect(pills[1].textContent).toBe('Project X')
})
it('does not render pills when no references', () => {
render(<AiMessage userMessage="Hello" actions={[]} />)
expect(screen.queryAllByTestId('message-reference-pill')).toHaveLength(0)
})
it('does not render pills when references array is empty', () => {
render(<AiMessage userMessage="Hello" references={[]} actions={[]} />)
expect(screen.queryAllByTestId('message-reference-pill')).toHaveLength(0)
})
it('calls onOpenNote when a reference pill is clicked', () => {
const onOpenNote = vi.fn()
render(
<AiMessage
userMessage="Check this"
references={[{ title: 'Alpha', path: 'note/alpha.md', type: 'Note' }]}
actions={[]}
onOpenNote={onOpenNote}
/>,
)
fireEvent.click(screen.getByTestId('message-reference-pill'))
expect(onOpenNote).toHaveBeenCalledWith('note/alpha.md')
})
it('expands and collapses action cards independently', () => {
render(
<AiMessage
userMessage="Do"
actions={[
{ tool: 'search_notes', toolId: 't1', label: 'Searched', status: 'done', input: '{"q":"test"}', output: 'Found 3' },
{ tool: 'create_note', toolId: 't2', label: 'Created', status: 'done', input: '{"title":"x"}' },
]}
/>,
)
const headers = screen.getAllByTestId('action-card-header')
// Both collapsed initially
expect(screen.queryByTestId('action-card-details')).toBeNull()
// Expand first card
fireEvent.click(headers[0])
expect(screen.getAllByTestId('action-card-details')).toHaveLength(1)
// Expand second card too
fireEvent.click(headers[1])
expect(screen.getAllByTestId('action-card-details')).toHaveLength(2)
// Collapse first card
fireEvent.click(headers[0])
expect(screen.getAllByTestId('action-card-details')).toHaveLength(1)
})
})

View File

@@ -1,24 +1,63 @@
import { useState } from 'react'
import { useState, useCallback, useEffect, useRef } from 'react'
import { CaretRight, CaretDown, Brain, ArrowCounterClockwise } from '@phosphor-icons/react'
import { AiActionCard, type AiActionStatus } from './AiActionCard'
import { MarkdownContent } from './MarkdownContent'
import type { NoteReference } from '../utils/ai-context'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
export interface AiAction {
tool: string
toolId: string
label: string
path?: string
status: AiActionStatus
input?: string
output?: string
}
export interface AiMessageProps {
userMessage: string
references?: NoteReference[]
reasoning?: string
reasoningDone?: boolean
actions: AiAction[]
response?: string
isStreaming?: boolean
onOpenNote?: (path: string) => void
}
function UserBubble({ content }: { content: string }) {
function ReferencePill({ reference, onClick }: {
reference: NoteReference
onClick?: (path: string) => void
}) {
const color = getTypeColor(reference.type)
const lightColor = getTypeLightColor(reference.type)
return (
<button
className="inline-flex items-center border-none cursor-pointer transition-opacity hover:opacity-80"
style={{
background: lightColor,
color,
borderRadius: 9999,
padding: '1px 8px',
fontSize: 11,
fontWeight: 500,
fontFamily: 'inherit',
lineHeight: 1.4,
}}
onClick={() => onClick?.(reference.path)}
data-testid="message-reference-pill"
>
{reference.title}
</button>
)
}
function UserBubble({ content, references, onOpenNote }: {
content: string
references?: NoteReference[]
onOpenNote?: (path: string) => void
}) {
return (
<div className="flex justify-end" style={{ marginBottom: 8 }}>
<div
@@ -32,6 +71,13 @@ function UserBubble({ content }: { content: string }) {
lineHeight: 1.5,
}}
>
{references && references.length > 0 && (
<div className="flex flex-wrap gap-1" style={{ marginBottom: 4 }}>
{references.map(ref => (
<ReferencePill key={ref.path} reference={ref} onClick={onOpenNote} />
))}
</div>
)}
{content}
</div>
</div>
@@ -41,6 +87,14 @@ function UserBubble({ content }: { content: string }) {
function ReasoningBlock({ text, expanded, onToggle }: {
text: string; expanded: boolean; onToggle: () => void
}) {
const contentRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (expanded && contentRef.current) {
contentRef.current.scrollTop = contentRef.current.scrollHeight
}
}, [expanded, text])
return (
<div style={{ marginBottom: 8 }}>
<button
@@ -55,8 +109,9 @@ function ReasoningBlock({ text, expanded, onToggle }: {
</button>
{expanded && (
<div
ref={contentRef}
className="text-muted-foreground"
style={{ fontSize: 12, lineHeight: 1.5, padding: '4px 0 4px 20px' }}
style={{ fontSize: 12, lineHeight: 1.5, padding: '4px 0 4px 20px', maxHeight: 200, overflowY: 'auto' }}
data-testid="reasoning-content"
>
{text}
@@ -66,18 +121,25 @@ function ReasoningBlock({ text, expanded, onToggle }: {
)
}
function ActionCardsList({ actions, onOpenNote }: {
actions: AiAction[]; onOpenNote?: (path: string) => void
function ActionCardsList({ actions, onOpenNote, expandedIds, onToggleExpand }: {
actions: AiAction[]
onOpenNote?: (path: string) => void
expandedIds: Set<string>
onToggleExpand: (toolId: string) => void
}) {
return (
<div className="flex flex-col gap-1" style={{ marginBottom: 8 }}>
{actions.map((action, i) => (
{actions.map((action) => (
<AiActionCard
key={`${action.tool}-${i}`}
key={action.toolId}
tool={action.tool}
label={action.label}
path={action.path}
status={action.status}
input={action.input}
output={action.output}
expanded={expandedIds.has(action.toolId)}
onToggle={() => onToggleExpand(action.toolId)}
onOpenNote={onOpenNote}
/>
))}
@@ -88,7 +150,7 @@ function ActionCardsList({ actions, onOpenNote }: {
function ResponseBlock({ text }: { text: string }) {
return (
<div style={{ marginBottom: 4 }}>
<div style={{ fontSize: 13, lineHeight: 1.6 }}>{text}</div>
<MarkdownContent content={text} />
<button
className="flex items-center gap-1 border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
style={{ fontSize: 11, marginTop: 4 }}
@@ -113,20 +175,43 @@ function StreamingIndicator() {
)
}
export function AiMessage({ userMessage, reasoning, actions, response, isStreaming, onOpenNote }: AiMessageProps) {
const [reasoningExpanded, setReasoningExpanded] = useState(false)
export function AiMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote }: AiMessageProps) {
// Manual override: null = follow auto behavior, true/false = user forced
const [userOverride, setUserOverride] = useState(false)
const [expandedActions, setExpandedActions] = useState<Set<string>>(new Set())
// Auto: expanded while reasoning streams, collapsed once done
// User can manually toggle to override the auto state
const autoExpanded = !reasoningDone
const reasoningExpanded = userOverride ? !autoExpanded : autoExpanded
const toggleAction = useCallback((toolId: string) => {
setExpandedActions(prev => {
const next = new Set(prev)
if (next.has(toolId)) next.delete(toolId)
else next.add(toolId)
return next
})
}, [])
return (
<div data-testid="ai-message" style={{ marginBottom: 16 }}>
<UserBubble content={userMessage} />
<UserBubble content={userMessage} references={references} onOpenNote={onOpenNote} />
{reasoning && (
<ReasoningBlock
text={reasoning}
expanded={reasoningExpanded}
onToggle={() => setReasoningExpanded(!reasoningExpanded)}
onToggle={() => setUserOverride(prev => !prev)}
/>
)}
{actions.length > 0 && (
<ActionCardsList
actions={actions}
onOpenNote={onOpenNote}
expandedIds={expandedActions}
onToggleExpand={toggleAction}
/>
)}
{actions.length > 0 && <ActionCardsList actions={actions} onOpenNote={onOpenNote} />}
{response && <ResponseBlock text={response} />}
{isStreaming && !response && <StreamingIndicator />}
</div>

View File

@@ -1,8 +1,9 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
import { AiMessage } from './AiMessage'
import { WikilinkChatInput } from './WikilinkChatInput'
import { useAiAgent, type AiAgentMessage } from '../hooks/useAiAgent'
import { collectLinkedEntries, buildContextualPrompt } from '../utils/ai-context'
import { collectLinkedEntries, buildContextSnapshot, type NoteReference, type NoteListItem } from '../utils/ai-context'
import type { VaultEntry } from '../types'
export type { AiAgentMessage } from '../hooks/useAiAgent'
@@ -14,6 +15,9 @@ interface AiPanelProps {
activeEntry?: VaultEntry | null
entries?: VaultEntry[]
allContent?: Record<string, string>
openTabs?: VaultEntry[]
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
}
function PanelHeader({ onClose, onClear }: { onClose: () => void; onClear: () => void }) {
@@ -103,54 +107,9 @@ function MessageHistory({ messages, isActive, onOpenNote, 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; inputRef: React.RefObject<HTMLInputElement | null>
}) {
const sendDisabled = isActive || !input.trim()
return (
<div
className="flex shrink-0 flex-col border-t border-border"
style={{ padding: '8px 12px' }}
>
<div className="flex items-end gap-2">
<input
ref={inputRef}
value={input}
onChange={e => onInputChange(e.target.value)}
onKeyDown={onKeyDown}
className="flex-1 border border-border bg-transparent text-foreground"
style={{
fontSize: 13, borderRadius: 8, padding: '8px 10px',
outline: 'none', fontFamily: 'inherit',
}}
placeholder={hasContext ? 'Ask about this note...' : 'Ask the AI agent...'}
disabled={isActive}
data-testid="agent-input"
/>
<button
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
style={{
background: sendDisabled ? 'var(--muted)' : 'var(--primary)',
color: sendDisabled ? 'var(--muted-foreground)' : 'white',
borderRadius: 8, width: 32, height: 34,
cursor: sendDisabled ? 'not-allowed' : 'pointer',
}}
onClick={onSend}
disabled={sendDisabled}
title="Send message"
data-testid="agent-send"
>
<PaperPlaneRight size={16} />
</button>
</div>
</div>
)
}
export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries, allContent }: AiPanelProps) {
export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries, allContent, openTabs, noteList, noteListFilter }: AiPanelProps) {
const [input, setInput] = useState('')
const [pendingRefs, setPendingRefs] = useState<NoteReference[]>([])
const inputRef = useRef<HTMLInputElement>(null)
const panelRef = useRef<HTMLElement>(null)
@@ -160,9 +119,17 @@ export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries,
}, [activeEntry, entries])
const contextPrompt = useMemo(() => {
if (!activeEntry || !allContent) return undefined
return buildContextualPrompt(activeEntry, linkedEntries, allContent)
}, [activeEntry, linkedEntries, allContent])
if (!activeEntry || !allContent || !entries) return undefined
return buildContextSnapshot({
activeEntry,
allContent,
openTabs,
noteList,
noteListFilter,
entries,
references: pendingRefs.length > 0 ? pendingRefs : undefined,
})
}, [activeEntry, allContent, openTabs, noteList, noteListFilter, entries, pendingRefs])
const agent = useAiAgent(vaultPath, contextPrompt)
const hasContext = !!activeEntry
@@ -193,26 +160,28 @@ export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries,
return () => window.removeEventListener('keydown', handleEscape)
}, [handleEscape])
const handleSend = () => {
if (!input.trim() || isActive) return
agent.sendMessage(input)
const handleSend = useCallback((text: string, references: NoteReference[]) => {
if (!text.trim() || isActive) return
setPendingRefs(references)
agent.sendMessage(text, references)
setInput('')
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
}, [isActive, agent])
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' }}
className="flex flex-1 flex-col overflow-hidden bg-background text-foreground"
style={{
outline: 'none',
borderLeft: isActive
? '2px solid var(--accent-blue, #3b82f6)'
: '1px solid var(--border)',
animation: isActive ? 'ai-border-pulse 2s ease-in-out infinite' : undefined,
transition: 'border-color 0.3s ease',
}}
data-testid="ai-panel"
data-ai-active={isActive || undefined}
>
<PanelHeader onClose={onClose} onClear={agent.clearConversation} />
{activeEntry && (
@@ -224,15 +193,39 @@ export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries,
onOpenNote={onOpenNote}
hasContext={hasContext}
/>
<InputBar
input={input}
onInputChange={setInput}
onSend={handleSend}
onKeyDown={handleKeyDown}
isActive={isActive}
hasContext={hasContext}
inputRef={inputRef}
/>
<div
className="flex shrink-0 flex-col border-t border-border"
style={{ padding: '8px 12px' }}
>
<div className="flex items-end gap-2">
<div className="flex-1">
<WikilinkChatInput
entries={entries ?? []}
value={input}
onChange={setInput}
onSend={handleSend}
disabled={isActive}
placeholder={hasContext ? 'Ask about this note...' : 'Ask the AI agent...'}
inputRef={inputRef}
/>
</div>
<button
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
style={{
background: (isActive || !input.trim()) ? 'var(--muted)' : 'var(--primary)',
color: (isActive || !input.trim()) ? 'var(--muted-foreground)' : 'white',
borderRadius: 8, width: 32, height: 34,
cursor: (isActive || !input.trim()) ? 'not-allowed' : 'pointer',
}}
onClick={() => handleSend(input, pendingRefs)}
disabled={isActive || !input.trim()}
title="Send message"
data-testid="agent-send"
>
<PaperPlaneRight size={16} />
</button>
</div>
</div>
</aside>
)
}

View File

@@ -5,6 +5,7 @@ import { useCreateBlockNote } from '@blocknote/react'
import '@blocknote/mantine/style.css'
import { uploadImageFile } from '../hooks/useImageDrop'
import type { VaultEntry, GitCommit, NoteStatus } from '../types'
import type { NoteListItem } from '../utils/ai-context'
import type { FrontmatterValue } from './Inspector'
import { ResizeHandle } from './ResizeHandle'
import { TabBar } from './TabBar'
@@ -48,6 +49,8 @@ interface EditorProps {
showAIChat?: boolean
onToggleAIChat?: () => void
vaultPath?: string
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
onTrashNote?: (path: string) => void
onRestoreNote?: (path: string) => void
onDeleteNote?: (path: string) => void
@@ -117,7 +120,7 @@ export const Editor = memo(function Editor({
inspectorEntry, inspectorContent, allContent, gitHistory,
onUpdateFrontmatter, onDeleteProperty, onAddProperty,
showAIChat, onToggleAIChat,
vaultPath,
vaultPath, noteList, noteListFilter,
onTrashNote, onRestoreNote, onDeleteNote, onArchiveNote, onUnarchiveNote,
onRenameTab, onContentChange, onSave, onTitleSync,
canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed,
@@ -228,6 +231,9 @@ export const Editor = memo(function Editor({
allContent={allContent}
gitHistory={gitHistory}
vaultPath={vaultPath ?? ''}
openTabs={tabs.map(t => t.entry)}
noteList={noteList}
noteListFilter={noteListFilter}
onToggleInspector={onToggleInspector}
onToggleAIChat={onToggleAIChat}
onNavigateWikilink={onNavigateWikilink}

View File

@@ -1,4 +1,5 @@
import type { VaultEntry, GitCommit } from '../types'
import type { NoteListItem } from '../utils/ai-context'
import { Inspector, type FrontmatterValue } from './Inspector'
import { AiPanel } from './AiPanel'
@@ -12,6 +13,9 @@ interface EditorRightPanelProps {
allContent: Record<string, string>
gitHistory: GitCommit[]
vaultPath: string
openTabs?: VaultEntry[]
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
onToggleInspector: () => void
onToggleAIChat?: () => void
onNavigateWikilink: (target: string) => void
@@ -24,7 +28,8 @@ interface EditorRightPanelProps {
export function EditorRightPanel({
showAIChat, inspectorCollapsed, inspectorWidth,
inspectorEntry, inspectorContent, entries, allContent, gitHistory, vaultPath,
inspectorEntry, inspectorContent, entries, allContent, gitHistory, vaultPath, openTabs,
noteList, noteListFilter,
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onOpenNote,
}: EditorRightPanelProps) {
@@ -41,6 +46,9 @@ export function EditorRightPanel({
activeEntry={inspectorEntry}
entries={entries}
allContent={allContent}
openTabs={openTabs}
noteList={noteList}
noteListFilter={noteListFilter}
/>
</div>
)

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

@@ -265,17 +265,46 @@ describe('StatusBar', () => {
expect(screen.getByText('Index ready')).toBeInTheDocument()
})
it('shows error state in indexing badge', () => {
it('shows error state in indexing badge with retry label', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'error', current: 0, total: 0, done: true, error: 'qmd not available' }}
indexingProgress={{ phase: 'error', current: 0, total: 0, done: true, error: 'qmd update failed' }}
/>
)
expect(screen.getByText('Index error')).toBeInTheDocument()
expect(screen.getByText('Index failed — retry')).toBeInTheDocument()
})
it('hides indexing badge when phase is unavailable', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'unavailable', current: 0, total: 0, done: true, error: 'qmd not available' }}
/>
)
expect(screen.queryByTestId('status-indexing')).not.toBeInTheDocument()
})
it('calls onRetryIndexing when clicking error badge', () => {
const onRetryIndexing = vi.fn()
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'error', current: 0, total: 0, done: true, error: 'qmd update failed' }}
onRetryIndexing={onRetryIndexing}
/>
)
fireEvent.click(screen.getByTestId('status-indexing'))
expect(onRetryIndexing).toHaveBeenCalledOnce()
})
it('hides indexing badge when phase is idle', () => {
@@ -310,4 +339,59 @@ describe('StatusBar', () => {
)
expect(screen.getByText('Installing search…')).toBeInTheDocument()
})
it('shows MCP warning badge when status is not_installed', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} mcpStatus="not_installed" />
)
expect(screen.getByTestId('status-mcp')).toBeInTheDocument()
expect(screen.getByTitle('MCP server not installed — click to install')).toBeInTheDocument()
})
it('shows MCP warning badge when status is no_claude_cli', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} mcpStatus="no_claude_cli" />
)
expect(screen.getByTestId('status-mcp')).toBeInTheDocument()
expect(screen.getByTitle('Claude CLI not found — install it first')).toBeInTheDocument()
})
it('hides MCP badge when status is installed', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} mcpStatus="installed" />
)
expect(screen.queryByTestId('status-mcp')).not.toBeInTheDocument()
})
it('hides MCP badge when status is checking', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} mcpStatus="checking" />
)
expect(screen.queryByTestId('status-mcp')).not.toBeInTheDocument()
})
it('hides MCP badge when no mcpStatus prop provided', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />
)
expect(screen.queryByTestId('status-mcp')).not.toBeInTheDocument()
})
it('calls onInstallMcp when clicking MCP badge with not_installed status', () => {
const onInstallMcp = vi.fn()
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} mcpStatus="not_installed" onInstallMcp={onInstallMcp} />
)
fireEvent.click(screen.getByTestId('status-mcp'))
expect(onInstallMcp).toHaveBeenCalledOnce()
})
it('does not call onInstallMcp when clicking MCP badge with no_claude_cli status', () => {
const onInstallMcp = vi.fn()
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} mcpStatus="no_claude_cli" onInstallMcp={onInstallMcp} />
)
fireEvent.click(screen.getByTestId('status-mcp'))
expect(onInstallMcp).not.toHaveBeenCalled()
})
})

View File

@@ -1,7 +1,8 @@
import { useState, useRef, useEffect } from 'react'
import { Package, RefreshCw, 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, Cpu } from 'lucide-react'
import type { LastCommitInfo, SyncStatus } from '../types'
import type { IndexingProgress } from '../hooks/useIndexing'
import type { McpStatus } from '../hooks/useMcpStatus'
import { openExternalUrl } from '../utils/url'
export interface VaultOption {
@@ -32,7 +33,10 @@ interface StatusBarProps {
buildNumber?: string
onCheckForUpdates?: () => void
indexingProgress?: IndexingProgress
onRetryIndexing?: () => void
onRemoveVault?: (path: string) => void
mcpStatus?: McpStatus
onInstallMcp?: () => void
}
function VaultMenuIcon({ isActive, unavailable }: { isActive: boolean; unavailable: boolean }) {
@@ -237,23 +241,31 @@ const INDEXING_LABELS: Record<string, string> = {
scanning: 'Indexing…',
embedding: 'Embedding…',
complete: 'Index ready',
error: 'Index error',
error: 'Index failed — retry',
unavailable: 'Search unavailable',
}
function IndexingBadge({ progress }: { progress: IndexingProgress }) {
if (progress.phase === 'idle') return null
function IndexingBadge({ progress, onRetry }: { progress: IndexingProgress; onRetry?: () => void }) {
if (progress.phase === 'idle' || progress.phase === 'unavailable') return null
const label = INDEXING_LABELS[progress.phase] ?? progress.phase
const isActive = !progress.done
const isError = progress.phase === 'error'
const showCount = progress.total > 0 && isActive
const displayText = showCount
? `${label} ${progress.current.toLocaleString()}/${progress.total.toLocaleString()}`
: label
const color = progress.phase === 'error' ? 'var(--accent-orange)' : 'var(--accent-blue, #3b82f6)'
const color = isError ? 'var(--accent-orange)' : 'var(--accent-blue, #3b82f6)'
return (
<>
<span style={SEP_STYLE}>|</span>
<span style={{ ...ICON_STYLE, color }} data-testid="status-indexing">
<span
role={isError && onRetry ? 'button' : undefined}
onClick={isError && onRetry ? onRetry : undefined}
style={{ ...ICON_STYLE, color, cursor: isError && onRetry ? 'pointer' : 'default' }}
title={isError ? 'Click to retry indexing' : undefined}
data-testid="status-indexing"
>
{isActive
? <Loader2 size={13} className="animate-spin" />
: <Search size={13} />
@@ -282,7 +294,42 @@ function PendingBadge({ count, onClick }: { count: number; onClick?: () => void
)
}
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, indexingProgress, onRemoveVault }: StatusBarProps) {
const MCP_TOOLTIPS: Record<string, string> = {
not_installed: 'MCP server not installed — click to install',
no_claude_cli: 'Claude CLI not found — install it first',
}
function McpBadge({ status, onInstall }: { status: McpStatus; onInstall?: () => void }) {
if (status === 'installed' || status === 'checking') return null
const tooltip = MCP_TOOLTIPS[status] ?? 'MCP status unknown'
const clickable = status === 'not_installed' && !!onInstall
return (
<>
<span style={SEP_STYLE}>|</span>
<span
role={clickable ? 'button' : undefined}
onClick={clickable ? onInstall : undefined}
style={{
...ICON_STYLE,
color: 'var(--accent-orange)',
cursor: clickable ? 'pointer' : 'default',
padding: '2px 4px',
borderRadius: 3,
background: 'transparent',
}}
title={tooltip}
data-testid="status-mcp"
onMouseEnter={clickable ? (e) => { e.currentTarget.style.background = 'var(--hover)' } : undefined}
onMouseLeave={clickable ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
>
<Cpu size={13} />MCP
<AlertTriangle size={10} style={{ marginLeft: 2 }} />
</span>
</>
)
}
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, indexingProgress, onRetryIndexing, onRemoveVault, mcpStatus, onInstallMcp }: StatusBarProps) {
const [, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 30_000)
@@ -308,7 +355,8 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
{lastCommitInfo && <CommitBadge info={lastCommitInfo} />}
<ConflictBadge count={conflictCount} onClick={onOpenConflictResolver} />
<PendingBadge count={modifiedCount} onClick={onClickPending} />
{indexingProgress && <IndexingBadge progress={indexingProgress} />}
{indexingProgress && <IndexingBadge progress={indexingProgress} onRetry={onRetryIndexing} />}
{mcpStatus && <McpBadge status={mcpStatus} onInstall={onInstallMcp} />}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={ICON_STYLE}><FileText size={13} />{noteCount.toLocaleString()} notes</span>

View File

@@ -0,0 +1,283 @@
import { useState } from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent, act } from '@testing-library/react'
import { WikilinkChatInput } from './WikilinkChatInput'
import type { VaultEntry } from '../types'
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
path: '/vault/note/test.md',
filename: 'test.md',
title: 'Test Note',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: 1700000000,
fileSize: 100,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
outgoingLinks: [],
...overrides,
})
const entries: VaultEntry[] = [
makeEntry({ path: '/vault/alpha.md', title: 'Alpha', filename: 'alpha.md', isA: 'Project' }),
makeEntry({ path: '/vault/beta.md', title: 'Beta', filename: 'beta.md', isA: 'Person' }),
makeEntry({ path: '/vault/gamma.md', title: 'Gamma', filename: 'gamma.md' }),
makeEntry({ path: '/vault/trashed.md', title: 'Trashed', filename: 'trashed.md', trashed: true }),
makeEntry({ path: '/vault/archived.md', title: 'Archived', filename: 'archived.md', archived: true }),
]
/** Wrapper that manages controlled value state so onChange/selectSuggestion work correctly. */
function Controlled({ onSend, ...props }: {
entries: VaultEntry[]
onSend?: (text: string, refs: Array<{ title: string; path: string; type: string | null }>) => void
disabled?: boolean
placeholder?: string
}) {
const [value, setValue] = useState('')
return (
<WikilinkChatInput
entries={props.entries}
value={value}
onChange={setValue}
onSend={onSend ?? vi.fn()}
disabled={props.disabled}
placeholder={props.placeholder}
/>
)
}
/** Helper: type in the input and wait for debounce to flush. */
async function typeAndWait(text: string) {
fireEvent.change(screen.getByTestId('agent-input'), {
target: { value: text, selectionStart: text.length },
})
await act(() => { vi.advanceTimersByTime(150) })
}
describe('WikilinkChatInput', () => {
it('renders input with placeholder', () => {
render(<Controlled entries={entries} placeholder="Ask something..." />)
const input = screen.getByTestId('agent-input') as HTMLInputElement
expect(input.placeholder).toBe('Ask something...')
})
it('calls onChange when typing', () => {
const onChange = vi.fn()
render(
<WikilinkChatInput entries={entries} value="" onChange={onChange} onSend={vi.fn()} />,
)
fireEvent.change(screen.getByTestId('agent-input'), { target: { value: 'hello' } })
expect(onChange).toHaveBeenCalledWith('hello')
})
it('shows wikilink menu when [[ is typed', async () => {
vi.useFakeTimers()
render(<Controlled entries={entries} />)
await typeAndWait('[[a')
expect(screen.getByTestId('wikilink-menu')).toBeTruthy()
vi.useRealTimers()
})
it('filters suggestions matching query', async () => {
vi.useFakeTimers()
render(<Controlled entries={entries} />)
await typeAndWait('[[alp')
const menu = screen.getByTestId('wikilink-menu')
expect(menu.textContent).toContain('Alpha')
expect(menu.textContent).not.toContain('Beta')
vi.useRealTimers()
})
it('excludes trashed and archived entries from suggestions', async () => {
vi.useFakeTimers()
render(<Controlled entries={entries} />)
await typeAndWait('[[t')
const menu = screen.queryByTestId('wikilink-menu')
if (menu) {
expect(menu.textContent).not.toContain('Trashed')
expect(menu.textContent).not.toContain('Archived')
}
vi.useRealTimers()
})
it('selects suggestion on click and creates pill', async () => {
vi.useFakeTimers()
render(<Controlled entries={entries} />)
await typeAndWait('[[a')
const menu = screen.getByTestId('wikilink-menu')
const items = menu.querySelectorAll('[class*="cursor-pointer"]')
expect(items.length).toBeGreaterThan(0)
fireEvent.click(items[0])
const pills = screen.queryAllByTestId('reference-pill')
expect(pills.length).toBe(1)
vi.useRealTimers()
})
it('does not duplicate pills for same note', async () => {
vi.useFakeTimers()
render(<Controlled entries={entries} />)
// First selection
await typeAndWait('[[alp')
fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0])
expect(screen.queryAllByTestId('reference-pill').length).toBe(1)
// Second selection of same note
await typeAndWait('[[alp')
fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0])
expect(screen.queryAllByTestId('reference-pill').length).toBe(1) // No duplicate
vi.useRealTimers()
})
it('removes pill when x button is clicked', async () => {
vi.useFakeTimers()
render(<Controlled entries={entries} />)
await typeAndWait('[[alp')
fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0])
expect(screen.queryAllByTestId('reference-pill').length).toBe(1)
const pill = screen.getByTestId('reference-pill')
fireEvent.click(pill.querySelector('button')!)
expect(screen.queryAllByTestId('reference-pill').length).toBe(0)
vi.useRealTimers()
})
it('navigates suggestions with keyboard', async () => {
vi.useFakeTimers()
render(<Controlled entries={entries} />)
await typeAndWait('[[a')
const input = screen.getByTestId('agent-input')
fireEvent.keyDown(input, { key: 'ArrowDown' })
fireEvent.keyDown(input, { key: 'ArrowUp' })
// Escape closes menu
fireEvent.keyDown(input, { key: 'Escape' })
expect(screen.queryByTestId('wikilink-menu')).toBeNull()
vi.useRealTimers()
})
it('selects suggestion with Enter key', async () => {
vi.useFakeTimers()
render(<Controlled entries={entries} />)
await typeAndWait('[[alp')
expect(screen.getByTestId('wikilink-menu')).toBeTruthy()
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' })
expect(screen.queryAllByTestId('reference-pill').length).toBe(1)
vi.useRealTimers()
})
it('calls onSend with text and references on Enter without menu', async () => {
vi.useFakeTimers()
const onSend = vi.fn()
render(<Controlled entries={entries} onSend={onSend} />)
// Type non-wikilink text
fireEvent.change(screen.getByTestId('agent-input'), {
target: { value: 'hello', selectionStart: 5 },
})
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' })
expect(onSend).toHaveBeenCalledWith('hello', [])
vi.useRealTimers()
})
it('does not send on Enter+Shift', () => {
const onSend = vi.fn()
render(
<WikilinkChatInput entries={entries} value="hello" onChange={vi.fn()} onSend={onSend} />,
)
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter', shiftKey: true })
expect(onSend).not.toHaveBeenCalled()
})
it('does not send on Enter when input is empty', () => {
const onSend = vi.fn()
render(
<WikilinkChatInput entries={entries} value="" onChange={vi.fn()} onSend={onSend} />,
)
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' })
expect(onSend).not.toHaveBeenCalled()
})
it('disables input when disabled prop is true', () => {
render(<Controlled entries={entries} disabled />)
expect((screen.getByTestId('agent-input') as HTMLInputElement).disabled).toBe(true)
})
it('closes menu when ]] is typed', async () => {
vi.useFakeTimers()
render(<Controlled entries={entries} />)
await typeAndWait('[[alp')
expect(screen.getByTestId('wikilink-menu')).toBeTruthy()
// Type ]] to close
fireEvent.change(screen.getByTestId('agent-input'), {
target: { value: '[[alp]]', selectionStart: 7 },
})
expect(screen.queryByTestId('wikilink-menu')).toBeNull()
vi.useRealTimers()
})
it('shows type badge for non-Note types in suggestions', async () => {
vi.useFakeTimers()
render(<Controlled entries={entries} />)
await typeAndWait('[[alp')
const menu = screen.getByTestId('wikilink-menu')
expect(menu.textContent).toContain('Project')
vi.useRealTimers()
})
it('matches by alias', async () => {
vi.useFakeTimers()
const entriesWithAlias = [
...entries,
makeEntry({ path: '/vault/delta.md', title: 'Delta', filename: 'delta.md', aliases: ['DLT'] }),
]
render(<Controlled entries={entriesWithAlias} />)
await typeAndWait('[[DLT')
const menu = screen.getByTestId('wikilink-menu')
expect(menu.textContent).toContain('Delta')
vi.useRealTimers()
})
it('sends references with onSend after selecting pills', async () => {
vi.useFakeTimers()
const onSend = vi.fn()
render(<Controlled entries={entries} onSend={onSend} />)
// Select a pill first
await typeAndWait('[[alp')
fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0])
expect(screen.queryAllByTestId('reference-pill').length).toBe(1)
// Type a message and send
fireEvent.change(screen.getByTestId('agent-input'), {
target: { value: 'tell me about it', selectionStart: 16 },
})
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' })
expect(onSend).toHaveBeenCalledOnce()
const [text, refs] = onSend.mock.calls[0]
expect(text).toBe('tell me about it')
expect(refs).toHaveLength(1)
expect(refs[0].title).toBe('Alpha')
expect(refs[0].path).toBe('/vault/alpha.md')
vi.useRealTimers()
})
})

View File

@@ -0,0 +1,264 @@
/**
* Chat input with [[wikilink]] autocomplete.
*
* When the user types `[[`, a dropdown appears with vault note suggestions.
* Selecting a note inserts a colored pill in the input and records a reference.
*/
import { useState, useRef, useMemo, useEffect } from 'react'
import type { VaultEntry } from '../types'
import type { NoteReference } from '../utils/ai-context'
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors'
const MAX_SUGGESTIONS = 20
const MIN_QUERY_LENGTH = 1
const DEBOUNCE_MS = 100
interface WikilinkChatInputProps {
entries: VaultEntry[]
value: string
onChange: (value: string) => void
onSend: (text: string, references: NoteReference[]) => void
disabled?: boolean
placeholder?: string
inputRef?: React.RefObject<HTMLInputElement | null>
}
interface Pill {
title: string
path: string
type: string | null
color?: string
lightColor?: string
}
interface SuggestionEntry {
title: string
path: string
isA: string | null
color?: string
lightColor?: string
}
function matchEntries(
entries: VaultEntry[],
query: string,
typeEntryMap: Record<string, VaultEntry>,
): SuggestionEntry[] {
if (query.length < MIN_QUERY_LENGTH) return []
const lower = query.toLowerCase()
const matches = entries.filter(e =>
!e.trashed && !e.archived && (
e.title.toLowerCase().includes(lower) ||
e.aliases.some(a => a.toLowerCase().includes(lower))
),
)
return matches.slice(0, MAX_SUGGESTIONS).map(e => {
const te = typeEntryMap[e.isA ?? '']
return {
title: e.title,
path: e.path,
isA: e.isA,
color: e.isA ? getTypeColor(e.isA, te?.color) : undefined,
lightColor: e.isA ? getTypeLightColor(e.isA, te?.color) : undefined,
}
})
}
export function WikilinkChatInput({
entries, value, onChange, onSend, disabled, placeholder, inputRef: externalRef,
}: WikilinkChatInputProps) {
const [pills, setPills] = useState<Pill[]>([])
const [showMenu, setShowMenu] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(0)
const [debouncedQuery, setDebouncedQuery] = useState('')
const internalRef = useRef<HTMLInputElement>(null)
const inputRefToUse = externalRef ?? internalRef
const menuRef = useRef<HTMLDivElement>(null)
const debounceTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const suggestions = useMemo(
() => showMenu ? matchEntries(entries, debouncedQuery, typeEntryMap) : [],
[entries, debouncedQuery, typeEntryMap, showMenu],
)
// Clamp selection to valid range
const clampedIndex = suggestions.length > 0
? Math.min(selectedIndex, suggestions.length - 1)
: 0
useEffect(() => {
if (!menuRef.current || clampedIndex < 0) return
const el = menuRef.current.children[clampedIndex] as HTMLElement | undefined
el?.scrollIntoView?.({ block: 'nearest' })
}, [clampedIndex])
function selectSuggestion(suggestion: SuggestionEntry) {
const cursor = inputRefToUse.current?.selectionStart ?? value.length
const textBefore = value.slice(0, cursor)
const bracketIdx = textBefore.lastIndexOf('[[')
if (bracketIdx < 0) return
const textAfter = value.slice(cursor)
const newValue = textBefore.slice(0, bracketIdx) + textAfter
onChange(newValue)
setPills(prev => {
if (prev.some(p => p.path === suggestion.path)) return prev
return [...prev, {
title: suggestion.title,
path: suggestion.path,
type: suggestion.isA,
color: suggestion.color,
lightColor: suggestion.lightColor,
}]
})
setShowMenu(false)
setTimeout(() => inputRefToUse.current?.focus(), 0)
}
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const newValue = e.target.value
onChange(newValue)
const cursor = e.target.selectionStart ?? newValue.length
const textBefore = newValue.slice(0, cursor)
const bracketIdx = textBefore.lastIndexOf('[[')
if (bracketIdx >= 0 && !textBefore.slice(bracketIdx).includes(']]')) {
const query = textBefore.slice(bracketIdx + 2)
setShowMenu(true)
setSelectedIndex(0)
clearTimeout(debounceTimer.current)
debounceTimer.current = setTimeout(() => setDebouncedQuery(query), DEBOUNCE_MS)
} else {
setShowMenu(false)
}
}
function handleKeyDown(e: React.KeyboardEvent) {
if (showMenu && suggestions.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault()
setSelectedIndex(i => (i + 1) % suggestions.length)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
setSelectedIndex(i => (i <= 0 ? suggestions.length - 1 : i - 1))
return
}
if (e.key === 'Enter') {
e.preventDefault()
selectSuggestion(suggestions[clampedIndex])
return
}
if (e.key === 'Escape') {
e.preventDefault()
setShowMenu(false)
return
}
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
if (!value.trim() && pills.length === 0) return
const references: NoteReference[] = pills.map(p => ({
title: p.title,
path: p.path,
type: p.type,
}))
onSend(value, references)
setPills([])
}
}
return (
<div style={{ position: 'relative' }}>
{pills.length > 0 && (
<div className="flex flex-wrap gap-1" style={{ marginBottom: 4 }}>
{pills.map(pill => (
<span
key={pill.path}
className="inline-flex items-center gap-1 text-xs"
style={{
background: pill.lightColor ?? 'var(--muted)',
color: pill.color ?? 'var(--foreground)',
borderRadius: 9999,
padding: '1px 8px 1px 6px',
fontWeight: 500,
}}
data-testid="reference-pill"
>
{pill.title}
<button
className="border-none bg-transparent p-0 cursor-pointer"
style={{ color: 'inherit', opacity: 0.6, fontSize: 10, lineHeight: 1 }}
onClick={() => setPills(prev => prev.filter(p => p.path !== pill.path))}
tabIndex={-1}
>
×
</button>
</span>
))}
</div>
)}
<input
ref={inputRefToUse}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
className="flex-1 border border-border bg-transparent text-foreground"
style={{
fontSize: 13, borderRadius: 8, padding: '8px 10px',
outline: 'none', fontFamily: 'inherit', width: '100%', boxSizing: 'border-box',
}}
placeholder={placeholder}
disabled={disabled}
data-testid="agent-input"
/>
{showMenu && suggestions.length > 0 && (
<div
ref={menuRef}
className="wikilink-menu"
style={{
position: 'absolute', bottom: '100%', left: 0, right: 0,
marginBottom: 4, maxHeight: 260, overflowY: 'auto',
}}
data-testid="wikilink-menu"
>
{suggestions.map((s, i) => (
<div
key={s.path}
className="flex items-center justify-between gap-2 cursor-pointer transition-colors"
style={{
padding: '6px 10px',
fontSize: 13,
background: i === clampedIndex ? 'var(--accent)' : undefined,
}}
onMouseDown={e => e.preventDefault()}
onClick={() => selectSuggestion(s)}
onMouseEnter={() => setSelectedIndex(i)}
>
<span className="truncate">{s.title}</span>
{s.isA && s.isA !== 'Note' && (
<span
className="shrink-0 text-xs"
style={{
color: s.color,
backgroundColor: s.lightColor,
borderRadius: 9999,
padding: '1px 8px',
}}
>
{s.isA}
</span>
)}
</div>
))}
</div>
)}
</div>
)
}

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

@@ -3,9 +3,13 @@
* Uses Claude CLI subprocess with MCP tools via Tauri.
*
* States: idle -> thinking -> tool-executing -> done/error
*
* Reasoning streams live while Claude thinks, then auto-collapses.
* Response text accumulates internally and is revealed as a complete block on done.
*/
import { useState, useCallback, useRef, useEffect } from 'react'
import type { AiAction } from '../components/AiMessage'
import type { NoteReference } from '../utils/ai-context'
import { streamClaudeAgent, buildAgentSystemPrompt } from '../utils/ai-agent'
import { nextMessageId } from '../utils/ai-chat'
@@ -13,7 +17,9 @@ export type AgentStatus = 'idle' | 'thinking' | 'tool-executing' | 'done' | 'err
export interface AiAgentMessage {
userMessage: string
references?: NoteReference[]
reasoning?: string
reasoningDone?: boolean
actions: AiAction[]
response?: string
isStreaming?: boolean
@@ -25,16 +31,19 @@ export function useAiAgent(vaultPath: string, contextPrompt?: string) {
const [status, setStatus] = useState<AgentStatus>('idle')
const abortRef = useRef({ aborted: false })
const contextRef = useRef(contextPrompt)
const responseAccRef = useRef('')
useEffect(() => {
contextRef.current = contextPrompt
}, [contextPrompt])
const sendMessage = useCallback(async (text: string) => {
const sendMessage = useCallback(async (text: string, references?: NoteReference[]) => {
if (!text.trim() || status === 'thinking' || status === 'tool-executing') return
const refs = references && references.length > 0 ? references : undefined
if (!vaultPath) {
setMessages(prev => [...prev, {
userMessage: text.trim(), actions: [],
userMessage: text.trim(), references: refs, actions: [],
response: 'No vault loaded. Open a vault first.',
id: nextMessageId(),
}])
@@ -42,10 +51,11 @@ export function useAiAgent(vaultPath: string, contextPrompt?: string) {
}
abortRef.current = { aborted: false }
responseAccRef.current = ''
const messageId = nextMessageId()
setMessages(prev => [...prev, {
userMessage: text.trim(), actions: [], isStreaming: true, id: messageId,
userMessage: text.trim(), references: refs, actions: [], isStreaming: true, id: messageId,
}])
setStatus('thinking')
@@ -53,41 +63,85 @@ export function useAiAgent(vaultPath: string, contextPrompt?: string) {
setMessages(prev => prev.map(m => m.id === messageId ? fn(m) : m))
}
// When a contextual prompt is provided (from buildContextualPrompt),
// use it directly — it already includes the system preamble.
const markReasoningDone = () => {
update(m => m.reasoningDone ? m : { ...m, reasoningDone: true })
}
const systemPrompt = contextRef.current ?? buildAgentSystemPrompt()
await streamClaudeAgent(text.trim(), systemPrompt, vaultPath, {
onText: (text) => {
onThinking: (chunk) => {
if (abortRef.current.aborted) return
update(m => ({ ...m, response: (m.response ?? '') + text }))
update(m => ({ ...m, reasoning: (m.reasoning ?? '') + chunk }))
},
onToolStart: (toolName, toolId) => {
onText: (chunk) => {
if (abortRef.current.aborted) return
markReasoningDone()
responseAccRef.current += chunk
},
onToolStart: (toolName, toolId, input) => {
if (abortRef.current.aborted) return
markReasoningDone()
setStatus('tool-executing')
update(m => {
const existing = m.actions.find(a => a.toolId === toolId)
if (existing) {
return {
...m,
actions: m.actions.map(a =>
a.toolId === toolId ? { ...a, input: input ?? a.input } : a,
),
}
}
return {
...m,
actions: [...m.actions, {
tool: toolName,
toolId,
label: formatToolLabel(toolName, toolId),
status: 'pending' as const,
input,
}],
}
})
},
onToolDone: (toolId, output) => {
if (abortRef.current.aborted) return
update(m => ({
...m,
actions: [...m.actions, {
tool: toolName,
label: formatToolLabel(toolName, toolId),
status: 'pending' as const,
}],
actions: m.actions.map(a =>
a.toolId === toolId ? { ...a, status: 'done' as const, output } : a,
),
}))
},
onError: (error) => {
if (abortRef.current.aborted) return
setStatus('error')
update(m => ({ ...m, isStreaming: false, response: (m.response ?? '') + `\nError: ${error}` }))
const partial = responseAccRef.current
update(m => ({
...m,
isStreaming: false,
reasoningDone: true,
response: partial ? `${partial}\n\nError: ${error}` : `Error: ${error}`,
actions: m.actions.map(a =>
a.status === 'pending' ? { ...a, status: 'error' as const } : a,
),
}))
},
onDone: () => {
if (abortRef.current.aborted) return
setStatus('done')
const finalResponse = responseAccRef.current || undefined
update(m => ({
...m,
isStreaming: false,
reasoningDone: true,
response: finalResponse,
actions: m.actions.map(a => a.status === 'pending' ? { ...a, status: 'done' as const } : a),
}))
},
@@ -96,6 +150,7 @@ export function useAiAgent(vaultPath: string, contextPrompt?: string) {
const clearConversation = useCallback(() => {
abortRef.current.aborted = true
responseAccRef.current = ''
setMessages([])
setStatus('idle')
}, [])

View File

@@ -64,6 +64,8 @@ interface AppCommandsConfig {
onRestoreGettingStarted?: () => void
isGettingStartedHidden?: boolean
vaultCount?: number
mcpStatus?: string
onInstallMcp?: () => void
}
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
@@ -172,6 +174,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onRestoreGettingStarted: config.onRestoreGettingStarted,
isGettingStartedHidden: config.isGettingStartedHidden,
vaultCount: config.vaultCount,
mcpStatus: config.mcpStatus,
onInstallMcp: config.onInstallMcp,
})
useKeyboardNavigation({

View File

@@ -19,6 +19,8 @@ interface CommandRegistryConfig {
entries: VaultEntry[]
modifiedCount: number
conflictCount?: number
mcpStatus?: string
onInstallMcp?: () => void
onQuickOpen: () => void
onCreateNote: () => void
@@ -193,6 +195,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
onCheckForUpdates,
onCreateType,
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
mcpStatus, onInstallMcp,
} = config
const hasActiveNote = activeTabPath !== null
@@ -252,6 +255,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
{ id: 'remove-vault', label: 'Remove Vault from List', group: 'Settings', keywords: ['vault', 'remove', 'disconnect', 'hide'], enabled: (vaultCount ?? 0) > 1 && !!onRemoveActiveVault, execute: () => onRemoveActiveVault?.() },
{ id: 'restore-getting-started', label: 'Restore Getting Started Vault', group: 'Settings', keywords: ['vault', 'restore', 'demo', 'getting started', 'reset'], enabled: !!isGettingStartedHidden && !!onRestoreGettingStarted, execute: () => onRestoreGettingStarted?.() },
{ id: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: true, execute: () => onCheckForUpdates?.() },
{ id: 'install-mcp', label: 'Install MCP Server', group: 'Settings', keywords: ['mcp', 'claude', 'ai', 'tools', 'install'], enabled: mcpStatus === 'not_installed' && !!onInstallMcp, execute: () => onInstallMcp?.() },
// Type-aware: "New [Type]" and "List [Type]"
...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect),
@@ -269,5 +273,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
onGoBack, onGoForward, canGoBack, canGoForward,
vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme,
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
mcpStatus, onInstallMcp,
])
}

View File

@@ -1,23 +1,18 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useIndexing } from './useIndexing'
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
vi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn().mockResolvedValue(vi.fn()) }))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: vi.fn().mockImplementation((cmd: string) => {
if (cmd === 'get_index_status') {
return Promise.resolve({
available: true,
qmd_installed: true,
collection_exists: true,
indexed_count: 100,
embedded_count: 80,
pending_embed: 0,
})
}
if (cmd === 'start_indexing') return Promise.resolve(null)
if (cmd === 'trigger_incremental_index') return Promise.resolve(null)
return Promise.resolve(null)
mockInvoke: vi.fn().mockResolvedValue({
available: true,
qmd_installed: true,
collection_exists: true,
indexed_count: 100,
embedded_count: 80,
pending_embed: 0,
}),
}))
@@ -25,135 +20,60 @@ const { mockInvoke } = await import('../mock-tauri') as { mockInvoke: ReturnType
describe('useIndexing', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
mockInvoke.mockResolvedValue({
available: true,
qmd_installed: true,
collection_exists: true,
indexed_count: 100,
embedded_count: 80,
pending_embed: 0,
})
})
it('starts with idle progress', () => {
const { result } = renderHook(() => useIndexing('/vault'))
afterEach(() => {
vi.useRealTimers()
})
it('starts with idle phase', () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
expect(result.current.progress.phase).toBe('idle')
expect(result.current.progress.done).toBe(false)
})
it('checks index status on mount', async () => {
renderHook(() => useIndexing('/vault'))
await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith('get_index_status', { vaultPath: '/vault' })
})
it('auto-dismisses error phase after 15 seconds', async () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
// Simulate setting error state via retryIndexing
mockInvoke.mockRejectedValueOnce(new Error('qmd update failed'))
await act(async () => { await result.current.retryIndexing() })
expect(result.current.progress.phase).toBe('error')
act(() => { vi.advanceTimersByTime(15000) })
expect(result.current.progress.phase).toBe('idle')
})
it('does not start indexing when collection exists and no pending embeds', async () => {
renderHook(() => useIndexing('/vault'))
await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith('get_index_status', { vaultPath: '/vault' })
})
expect(mockInvoke).not.toHaveBeenCalledWith('start_indexing', expect.anything())
it('sets unavailable phase for missing qmd errors', async () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
mockInvoke.mockRejectedValueOnce(new Error('bun not installed'))
await act(async () => { await result.current.retryIndexing() })
expect(result.current.progress.phase).toBe('unavailable')
})
it('starts indexing when collection is missing', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'get_index_status') {
return Promise.resolve({
available: true,
qmd_installed: true,
collection_exists: false,
indexed_count: 0,
embedded_count: 0,
pending_embed: 0,
})
}
return Promise.resolve(null)
})
it('auto-dismisses unavailable phase after 8 seconds', async () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
const { result } = renderHook(() => useIndexing('/vault'))
await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith('start_indexing', { vaultPath: '/vault' })
})
expect(result.current.progress.phase).not.toBe('idle')
mockInvoke.mockRejectedValueOnce(new Error('bun not installed'))
await act(async () => { await result.current.retryIndexing() })
expect(result.current.progress.phase).toBe('unavailable')
act(() => { vi.advanceTimersByTime(8000) })
expect(result.current.progress.phase).toBe('idle')
})
it('starts indexing when qmd is not installed', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'get_index_status') {
return Promise.resolve({
available: false,
qmd_installed: false,
collection_exists: false,
indexed_count: 0,
embedded_count: 0,
pending_embed: 0,
})
}
return Promise.resolve(null)
})
renderHook(() => useIndexing('/vault'))
await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith('start_indexing', { vaultPath: '/vault' })
})
})
it('starts indexing when pending embeds exist', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'get_index_status') {
return Promise.resolve({
available: true,
qmd_installed: true,
collection_exists: true,
indexed_count: 100,
embedded_count: 80,
pending_embed: 20,
})
}
return Promise.resolve(null)
})
renderHook(() => useIndexing('/vault'))
await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith('start_indexing', { vaultPath: '/vault' })
})
})
it('triggerIncrementalIndex calls the command', async () => {
const { result } = renderHook(() => useIndexing('/vault'))
await act(async () => {
await result.current.triggerIncrementalIndex()
})
expect(mockInvoke).toHaveBeenCalledWith('trigger_incremental_index', { vaultPath: '/vault' })
})
it('handles index status check failure gracefully', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'get_index_status') return Promise.reject(new Error('network error'))
return Promise.resolve(null)
})
const { result } = renderHook(() => useIndexing('/vault'))
// Should remain idle — not crash
await waitFor(() => {
expect(result.current.progress.phase).toBe('idle')
})
})
it('sets error phase when start_indexing fails', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'get_index_status') {
return Promise.resolve({
available: false,
qmd_installed: false,
collection_exists: false,
indexed_count: 0,
embedded_count: 0,
pending_embed: 0,
})
}
if (cmd === 'start_indexing') return Promise.reject(new Error('install failed'))
return Promise.resolve(null)
})
const { result } = renderHook(() => useIndexing('/vault'))
await waitFor(() => {
expect(result.current.progress.phase).toBe('error')
})
expect(result.current.progress.error).toContain('install failed')
it('exposes retryIndexing function', () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
expect(typeof result.current.retryIndexing).toBe('function')
})
})

View File

@@ -3,7 +3,7 @@ import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
export interface IndexingProgress {
phase: 'idle' | 'installing' | 'scanning' | 'embedding' | 'complete' | 'error'
phase: 'idle' | 'installing' | 'scanning' | 'embedding' | 'complete' | 'error' | 'unavailable'
current: number
total: number
done: boolean
@@ -66,7 +66,7 @@ export function useIndexing(vaultPath: string) {
if (needsIndexing && !indexingRef.current) {
indexingRef.current = true
setProgress({
phase: 'scanning',
phase: status.qmd_installed ? 'scanning' : 'installing',
current: 0,
total: status.indexed_count,
done: false,
@@ -74,16 +74,17 @@ export function useIndexing(vaultPath: string) {
})
// Fire and forget — progress updates come via events
invokeCmd('start_indexing', { vaultPath }).catch((err) => {
if (!cancelled) {
setProgress({
phase: 'error',
current: 0,
total: 0,
done: true,
error: String(err),
})
indexingRef.current = false
}
if (cancelled) return
const msg = String(err)
const isUnavailable = msg.includes('not installed') || msg.includes('not available')
setProgress({
phase: isUnavailable ? 'unavailable' : 'error',
current: 0,
total: 0,
done: true,
error: msg,
})
indexingRef.current = false
})
}
} catch {
@@ -95,12 +96,20 @@ export function useIndexing(vaultPath: string) {
return () => { cancelled = true }
}, [vaultPath])
// Auto-dismiss the "complete" status after 5 seconds
// Auto-dismiss transient statuses after a delay
useEffect(() => {
if (progress.phase === 'complete') {
const timer = setTimeout(() => setProgress(IDLE), 5000)
return () => clearTimeout(timer)
}
if (progress.phase === 'unavailable') {
const timer = setTimeout(() => setProgress(IDLE), 8000)
return () => clearTimeout(timer)
}
if (progress.phase === 'error') {
const timer = setTimeout(() => setProgress(IDLE), 15000)
return () => clearTimeout(timer)
}
}, [progress.phase])
const triggerIncrementalIndex = useCallback(async () => {
@@ -112,5 +121,20 @@ export function useIndexing(vaultPath: string) {
}
}, [])
return { progress, triggerIncrementalIndex }
const retryIndexing = useCallback(async () => {
if (indexingRef.current || !vaultPathRef.current) return
indexingRef.current = true
setProgress({ phase: 'scanning', current: 0, total: 0, done: false, error: null })
try {
await invokeCmd('start_indexing', { vaultPath: vaultPathRef.current })
} catch (err) {
const msg = String(err)
const isUnavailable = msg.includes('not installed') || msg.includes('not available')
setProgress({ phase: isUnavailable ? 'unavailable' : 'error', current: 0, total: 0, done: true, error: msg })
} finally {
indexingRef.current = false
}
}, [])
return { progress, triggerIncrementalIndex, retryIndexing }
}

View File

@@ -1,33 +0,0 @@
import { useEffect, useRef } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
/**
* Registers Laputa as an MCP server in Claude Code and Cursor config files.
* Fires once per vault path (skips duplicates).
*/
export function useMcpRegistration(
vaultPath: string,
onToast: (msg: string) => void,
) {
const registeredRef = useRef<string | null>(null)
useEffect(() => {
if (registeredRef.current === vaultPath) return
registeredRef.current = vaultPath
tauriCall<string>('register_mcp_tools', { vaultPath })
.then((status) => {
if (status === 'registered') {
onToast('Laputa registered as MCP tool for Claude Code')
}
})
.catch(() => {
// Silently ignore — not critical for app operation
})
}, [vaultPath]) // eslint-disable-line react-hooks/exhaustive-deps -- onToast is stable via ref
}

View File

@@ -0,0 +1,151 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { useMcpStatus } from './useMcpStatus'
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn(),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: vi.fn(),
}))
const { mockInvoke } = await import('../mock-tauri') as { mockInvoke: ReturnType<typeof vi.fn> }
describe('useMcpStatus', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('starts in checking state and resolves to installed', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_mcp_status') return Promise.resolve('installed')
if (cmd === 'register_mcp_tools') return Promise.resolve('updated')
return Promise.resolve(null)
})
const onToast = vi.fn()
const { result } = renderHook(() => useMcpStatus('/vault', onToast))
expect(result.current.mcpStatus).toBe('checking')
await waitFor(() => {
expect(result.current.mcpStatus).toBe('installed')
})
})
it('resolves to not_installed when check returns not_installed', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_mcp_status') return Promise.resolve('not_installed')
if (cmd === 'register_mcp_tools') return Promise.reject(new Error('fail'))
return Promise.resolve(null)
})
const onToast = vi.fn()
const { result } = renderHook(() => useMcpStatus('/vault', onToast))
await waitFor(() => {
expect(result.current.mcpStatus).toBe('not_installed')
})
})
it('resolves to no_claude_cli when check returns no_claude_cli', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_mcp_status') return Promise.resolve('no_claude_cli')
if (cmd === 'register_mcp_tools') return Promise.reject(new Error('no cli'))
return Promise.resolve(null)
})
const onToast = vi.fn()
const { result } = renderHook(() => useMcpStatus('/vault', onToast))
await waitFor(() => {
expect(result.current.mcpStatus).toBe('no_claude_cli')
})
})
it('install action calls register_mcp_tools and updates status', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_mcp_status') return Promise.resolve('not_installed')
if (cmd === 'register_mcp_tools') return Promise.resolve('registered')
return Promise.resolve(null)
})
const onToast = vi.fn()
const { result } = renderHook(() => useMcpStatus('/vault', onToast))
await waitFor(() => {
expect(result.current.mcpStatus).toBe('installed')
})
// Reset to test install action directly
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_mcp_status') return Promise.resolve('not_installed')
if (cmd === 'register_mcp_tools') return Promise.resolve('registered')
return Promise.resolve(null)
})
await act(async () => {
await result.current.installMcp()
})
expect(result.current.mcpStatus).toBe('installed')
expect(onToast).toHaveBeenCalledWith('MCP server installed successfully')
})
it('install action shows error toast on failure', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_mcp_status') return Promise.resolve('not_installed')
if (cmd === 'register_mcp_tools') return Promise.reject(new Error('disk full'))
return Promise.resolve(null)
})
const onToast = vi.fn()
const { result } = renderHook(() => useMcpStatus('/vault', onToast))
await waitFor(() => {
expect(result.current.mcpStatus).toBe('not_installed')
})
await act(async () => {
await result.current.installMcp()
})
expect(result.current.mcpStatus).toBe('not_installed')
expect(onToast).toHaveBeenCalledWith(expect.stringContaining('MCP install failed'))
})
it('shows toast when registered for the first time', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_mcp_status') return Promise.resolve('installed')
if (cmd === 'register_mcp_tools') return Promise.resolve('registered')
return Promise.resolve(null)
})
const onToast = vi.fn()
renderHook(() => useMcpStatus('/vault', onToast))
await waitFor(() => {
expect(onToast).toHaveBeenCalledWith('Laputa registered as MCP tool for Claude Code')
})
})
it('does not show toast when already registered', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_mcp_status') return Promise.resolve('installed')
if (cmd === 'register_mcp_tools') return Promise.resolve('updated')
return Promise.resolve(null)
})
const onToast = vi.fn()
renderHook(() => useMcpStatus('/vault', onToast))
await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith('register_mcp_tools', { vaultPath: '/vault' })
})
// 'updated' should not trigger a toast
expect(onToast).not.toHaveBeenCalledWith('Laputa registered as MCP tool for Claude Code')
})
})

72
src/hooks/useMcpStatus.ts Normal file
View File

@@ -0,0 +1,72 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
export type McpStatus = 'checking' | 'installed' | 'not_installed' | 'no_claude_cli'
function tauriCall<T>(command: string, args?: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
/**
* Detects MCP server status on vault open and provides an install action.
*
* Combines the old `useMcpRegistration` functionality (auto-register on vault
* switch) with new status detection for the status bar indicator.
*/
export function useMcpStatus(
vaultPath: string,
onToast: (msg: string) => void,
) {
const [status, setStatus] = useState<McpStatus>('checking')
const registeredRef = useRef<string | null>(null)
const onToastRef = useRef(onToast)
useEffect(() => { onToastRef.current = onToast })
// Check MCP status on vault open / vault switch
useEffect(() => {
let cancelled = false
setStatus('checking') // eslint-disable-line react-hooks/set-state-in-effect -- reset to checking on vault switch
tauriCall<string>('check_mcp_status')
.then((result) => {
if (!cancelled) setStatus(result as McpStatus)
})
.catch(() => {
if (!cancelled) setStatus('not_installed')
})
return () => { cancelled = true }
}, [vaultPath])
// Auto-register on vault switch (preserves old useMcpRegistration behavior)
useEffect(() => {
if (registeredRef.current === vaultPath) return
registeredRef.current = vaultPath
tauriCall<string>('register_mcp_tools', { vaultPath })
.then((result) => {
if (result === 'registered') {
onToastRef.current('Laputa registered as MCP tool for Claude Code')
}
setStatus('installed')
})
.catch(() => {
// Non-critical — status check will show the right state
})
}, [vaultPath])
const install = useCallback(async () => {
setStatus('checking')
try {
await tauriCall<string>('register_mcp_tools', { vaultPath })
setStatus('installed')
onToastRef.current('MCP server installed successfully')
} catch (e) {
setStatus('not_installed')
onToastRef.current(`MCP install failed: ${e}`)
}
}, [vaultPath])
return { mcpStatus: status, installMcp: install }
}

View File

@@ -478,4 +478,17 @@ describe('useThemeManager', () => {
// Light theme isDark should be false (default state is false, so this is stable)
expect(result.current.isDark).toBe(false)
})
it('calls ensure_vault_themes on mount with vaultPath', async () => {
renderHook(() => useThemeManager('/vault', entries, allContent))
await waitFor(() => {
expect(mockInvokeFn).toHaveBeenCalledWith('ensure_vault_themes', { vaultPath: '/vault' })
})
})
it('does not call ensure_vault_themes when vaultPath is null', async () => {
renderHook(() => useThemeManager(null, entries, allContent))
await new Promise(r => setTimeout(r, 50))
expect(mockInvokeFn).not.toHaveBeenCalledWith('ensure_vault_themes', expect.anything())
})
})

View File

@@ -196,6 +196,11 @@ export function useThemeManager(
entries: VaultEntry[],
allContent: Record<string, string>,
): ThemeManager {
// Ensure default theme files exist on vault open (creates theme/ dir + defaults if missing)
useEffect(() => {
if (vaultPath) tauriCall('ensure_vault_themes', { vaultPath }).catch(() => {})
}, [vaultPath])
const { activeThemeId, setActiveThemeId, reload } = useThemeSetting(vaultPath)
const cachedThemeContent = activeThemeId ? allContent[activeThemeId] : undefined
const { clearDom: clearTheme, isDark } = useThemeApplier(activeThemeId, cachedThemeContent)

View File

@@ -289,3 +289,26 @@
.ai-markdown .hljs-attribute { color: #005cc5; }
.ai-markdown .hljs-deletion { color: #b31d28; background: #ffeef0; }
.ai-markdown .hljs-meta { color: #6a737d; }
/* --- AI panel working border animation --- */
@keyframes ai-border-pulse {
0%, 100% { border-color: var(--accent-blue, #3b82f6); opacity: 1; }
50% { border-color: var(--accent-blue, #93c5fd); opacity: 0.6; }
}
/* --- Typing indicator dots --- */
.typing-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--muted-foreground);
animation: typing-bounce 1.2s ease-in-out infinite;
}
@keyframes typing-bounce {
0%, 60%, 100% { opacity: 0.3; transform: translateY(0); }
30% { opacity: 1; transform: translateY(-3px); }
}

View File

@@ -264,6 +264,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
},
create_getting_started_vault: () => '/Users/mock/Documents/Getting Started',
register_mcp_tools: () => 'registered',
check_mcp_status: () => 'installed',
get_index_status: () => ({ available: true, qmd_installed: true, collection_exists: true, indexed_count: 100, embedded_count: 80, pending_embed: 0 }),
start_indexing: () => null,
trigger_incremental_index: () => null,
@@ -309,6 +310,7 @@ line-height-base: 1.6
syncWindowContent()
return path
},
ensure_vault_themes: (): null => null,
}
export function addMockEntry(_entry: VaultEntry, content: string): void {

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

View File

@@ -24,15 +24,18 @@ export function buildAgentSystemPrompt(vaultContext?: string): string {
type ClaudeAgentStreamEvent =
| { kind: 'Init'; session_id: string }
| { kind: 'TextDelta'; text: string }
| { kind: 'ToolStart'; tool_name: string; tool_id: string }
| { kind: 'ToolDone'; tool_id: string }
| { kind: 'ThinkingDelta'; text: string }
| { kind: 'ToolStart'; tool_name: string; tool_id: string; input?: string }
| { kind: 'ToolDone'; tool_id: string; output?: string }
| { kind: 'Result'; text: string; session_id: string }
| { kind: 'Error'; message: string }
| { kind: 'Done' }
export interface AgentStreamCallbacks {
onText: (text: string) => void
onToolStart: (toolName: string, toolId: string) => void
onThinking: (text: string) => void
onToolStart: (toolName: string, toolId: string, input?: string) => void
onToolDone: (toolId: string, output?: string) => void
onError: (message: string) => void
onDone: () => void
}
@@ -64,8 +67,14 @@ export async function streamClaudeAgent(
case 'TextDelta':
callbacks.onText(data.text)
break
case 'ThinkingDelta':
callbacks.onThinking(data.text)
break
case 'ToolStart':
callbacks.onToolStart(data.tool_name, data.tool_id)
callbacks.onToolStart(data.tool_name, data.tool_id, data.input)
break
case 'ToolDone':
callbacks.onToolDone(data.tool_id, data.output)
break
case 'Error':
callbacks.onError(data.message)

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { resolveTarget, collectLinkedEntries, buildContextualPrompt } from './ai-context'
import { resolveTarget, collectLinkedEntries, buildContextualPrompt, buildContextSnapshot } from './ai-context'
import type { VaultEntry } from '../types'
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
@@ -183,3 +183,165 @@ describe('buildContextualPrompt', () => {
expect(prompt).toContain('AI assistant integrated into Laputa')
})
})
describe('buildContextSnapshot', () => {
const active = makeEntry({ path: '/vault/a.md', title: 'Alpha', isA: 'Project', status: 'active', owner: 'Alice' })
const entries = [
active,
makeEntry({ path: '/vault/b.md', title: 'Beta', isA: 'Person' }),
makeEntry({ path: '/vault/c.md', title: 'Gamma', isA: 'Note' }),
]
const allContent: Record<string, string> = {
'/vault/a.md': '# Alpha\nProject content.',
'/vault/b.md': '# Beta\nPerson content.',
'/vault/c.md': '# Gamma\nNote content.',
}
it('includes activeNote with body and frontmatter', () => {
const result = buildContextSnapshot({ activeEntry: active, allContent, entries })
expect(result).toContain('Alpha')
expect(result).toContain('Project content.')
expect(result).toContain('"type": "Project"')
expect(result).toContain('"status": "active"')
expect(result).toContain('"owner": "Alice"')
})
it('includes system preamble', () => {
const result = buildContextSnapshot({ activeEntry: active, allContent, entries })
expect(result).toContain('AI assistant integrated into Laputa')
expect(result).toContain('Context Snapshot')
})
it('includes vault summary with types and totalNotes', () => {
const result = buildContextSnapshot({ activeEntry: active, allContent, entries })
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.vault.totalNotes).toBe(3)
expect(json.vault.types).toContain('Project')
expect(json.vault.types).toContain('Person')
expect(json.vault.types).toContain('Note')
})
it('includes openTabs excluding active note', () => {
const tab = makeEntry({ path: '/vault/b.md', title: 'Beta', isA: 'Person' })
const result = buildContextSnapshot({
activeEntry: active, allContent, entries,
openTabs: [active, tab],
})
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.openTabs).toHaveLength(1)
expect(json.openTabs[0].title).toBe('Beta')
})
it('omits openTabs when none besides active', () => {
const result = buildContextSnapshot({
activeEntry: active, allContent, entries,
openTabs: [active],
})
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.openTabs).toBeUndefined()
})
it('includes noteListFilter when present', () => {
const result = buildContextSnapshot({
activeEntry: active, allContent, entries,
noteListFilter: { type: 'Project', query: 'search' },
})
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.noteListFilter.type).toBe('Project')
expect(json.noteListFilter.query).toBe('search')
})
it('omits noteListFilter when empty', () => {
const result = buildContextSnapshot({
activeEntry: active, allContent, entries,
noteListFilter: { type: null, query: '' },
})
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.noteListFilter).toBeUndefined()
})
it('includes referencedNotes with bodies', () => {
const result = buildContextSnapshot({
activeEntry: active, allContent, entries,
references: [
{ title: 'Beta', path: '/vault/b.md', type: 'Person' },
{ title: 'Gamma', path: '/vault/c.md', type: null },
],
})
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.referencedNotes).toHaveLength(2)
expect(json.referencedNotes[0].title).toBe('Beta')
expect(json.referencedNotes[0].body).toContain('Person content.')
expect(json.referencedNotes[1].type).toBe('Note') // null fallback
})
it('filters out references with no content', () => {
const result = buildContextSnapshot({
activeEntry: active, allContent, entries,
references: [
{ title: 'Beta', path: '/vault/b.md', type: 'Person' },
{ title: 'Missing', path: '/vault/missing.md', type: null },
],
})
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.referencedNotes).toHaveLength(1)
expect(json.referencedNotes[0].title).toBe('Beta')
})
it('omits referencedNotes when no references provided', () => {
const result = buildContextSnapshot({ activeEntry: active, allContent, entries })
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.referencedNotes).toBeUndefined()
})
it('includes noteList when provided', () => {
const noteList = [
{ path: '/vault/a.md', title: 'Alpha', type: 'Project' },
{ path: '/vault/b.md', title: 'Beta', type: 'Person' },
]
const result = buildContextSnapshot({
activeEntry: active, allContent, entries,
noteList,
})
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.noteList).toHaveLength(2)
expect(json.noteList[0].title).toBe('Alpha')
expect(json.noteList[1].type).toBe('Person')
})
it('truncates noteList at 100 items', () => {
const noteList = Array.from({ length: 150 }, (_, i) => ({
path: `/vault/note-${i}.md`, title: `Note ${i}`, type: 'Note',
}))
const result = buildContextSnapshot({
activeEntry: active, allContent, entries,
noteList,
})
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.noteList).toHaveLength(100)
expect(json.noteListTruncated).toEqual({ shown: 100, total: 150 })
})
it('omits noteList when empty', () => {
const result = buildContextSnapshot({
activeEntry: active, allContent, entries,
noteList: [],
})
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.noteList).toBeUndefined()
})
it('includes belongsTo and relatedTo in frontmatter', () => {
const entryWithRels = makeEntry({
path: '/vault/a.md', title: 'Alpha',
belongsTo: ['[[Parent]]'],
relatedTo: ['[[Sibling]]'],
relationships: { people: ['[[Alice]]'] },
})
const result = buildContextSnapshot({ activeEntry: entryWithRels, allContent, entries })
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.activeNote.frontmatter.belongsTo).toEqual(['[[Parent]]'])
expect(json.activeNote.frontmatter.relatedTo).toEqual(['[[Sibling]]'])
expect(json.activeNote.frontmatter.relationships).toEqual({ people: ['[[Alice]]'] })
})
})

View File

@@ -1,6 +1,6 @@
/**
* AI contextual chat — builds the context note list from the active note
* and its first-degree linked notes (outgoingLinks + relationships).
* AI contextual chat — builds a structured context snapshot from the active note,
* open tabs, vault metadata, and optional explicit note references.
*/
import type { VaultEntry } from '../types'
@@ -34,19 +34,16 @@ export function collectLinkedEntries(
}
}
// outgoingLinks are raw targets (no [[ ]] wrapper)
for (const target of active.outgoingLinks) {
addTarget(target)
}
// relationships values are wikilink references like [[target]]
for (const refs of Object.values(active.relationships)) {
for (const ref of refs) {
addTarget(wikilinkTarget(ref))
}
}
// belongsTo and relatedTo are also wikilink references
for (const ref of active.belongsTo) {
addTarget(wikilinkTarget(ref))
}
@@ -57,7 +54,110 @@ export function collectLinkedEntries(
return linked
}
/** Build a contextual system prompt from the active note and its linked notes. */
/** A note reference from the user's [[wikilink]] selection in the chat input. */
export interface NoteReference {
title: string
path: string
type: string | null
}
/** Lightweight note summary for the context snapshot. */
export interface NoteListItem {
path: string
title: string
type: string
}
/** Parameters for building the structured context snapshot. */
export interface ContextSnapshotParams {
activeEntry: VaultEntry
allContent: Record<string, string>
openTabs?: VaultEntry[]
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
entries: VaultEntry[]
references?: NoteReference[]
}
function entryFrontmatter(e: VaultEntry): Record<string, unknown> {
const fm: Record<string, unknown> = {}
if (e.isA) fm.type = e.isA
if (e.status) fm.status = e.status
if (e.owner) fm.owner = e.owner
if (e.belongsTo.length > 0) fm.belongsTo = e.belongsTo
if (e.relatedTo.length > 0) fm.relatedTo = e.relatedTo
if (Object.keys(e.relationships).length > 0) fm.relationships = e.relationships
return fm
}
const MAX_NOTE_LIST_ITEMS = 100
/** Build a structured context snapshot as a system prompt for Claude. */
export function buildContextSnapshot(params: ContextSnapshotParams): string {
const { activeEntry, allContent, openTabs, noteList, noteListFilter, entries, references } = params
const snapshot: Record<string, unknown> = {
activeNote: {
path: activeEntry.path,
title: activeEntry.title,
type: activeEntry.isA ?? 'Note',
frontmatter: entryFrontmatter(activeEntry),
body: allContent[activeEntry.path] ?? '',
},
}
const otherTabs = openTabs?.filter(t => t.path !== activeEntry.path)
if (otherTabs && otherTabs.length > 0) {
snapshot.openTabs = otherTabs.map(t => ({
path: t.path,
title: t.title,
type: t.isA ?? 'Note',
frontmatter: entryFrontmatter(t),
}))
}
if (noteList && noteList.length > 0) {
const items = noteList.slice(0, MAX_NOTE_LIST_ITEMS)
snapshot.noteList = items
if (noteList.length > MAX_NOTE_LIST_ITEMS) {
snapshot.noteListTruncated = { shown: MAX_NOTE_LIST_ITEMS, total: noteList.length }
}
}
if (noteListFilter && (noteListFilter.type || noteListFilter.query)) {
snapshot.noteListFilter = noteListFilter
}
const types = new Set<string>()
for (const e of entries) {
if (e.isA) types.add(e.isA)
}
snapshot.vault = {
types: [...types].sort(),
totalNotes: entries.length,
}
if (references && references.length > 0) {
snapshot.referencedNotes = references
.filter(ref => allContent[ref.path] !== undefined)
.map(ref => ({
path: ref.path,
title: ref.title,
type: ref.type ?? 'Note',
body: allContent[ref.path] ?? '',
}))
}
const preamble = [
'You are an AI assistant integrated into Laputa, a personal knowledge management app.',
'The user is viewing a specific note. Use the structured context below to answer questions accurately.',
'You can also use MCP tools to search, read, create, or edit notes in the vault.',
].join('\n')
return `${preamble}\n\n## Context Snapshot\n\`\`\`json\n${JSON.stringify(snapshot, null, 2)}\n\`\`\``
}
/** Legacy: Build a contextual system prompt (text-based). */
export function buildContextualPrompt(
active: VaultEntry,
linkedEntries: VaultEntry[],