Compare commits

...

14 Commits

Author SHA1 Message Date
Test
0e503cb179 fix: AI chat receives note body from open tabs instead of empty allContent
allContent is empty ({}) in Tauri mode because loadVaultData never
populates it — note content only enters allContent on explicit save.
mergeTabContent() enriches allContent with open tab content so the AI
context snapshot always includes the active note's body.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 12:32:52 +01:00
Test
d83f04c6ff fix: AI-created notes now visible — onVaultChanged fallback for missed file ops
detectFileOperation silently failed when tool input was undefined (timing
issues with NDJSON event ordering). Added onVaultChanged callback as fallback:
when Write/Edit/Bash tools complete but specific file can't be determined,
vault.reloadVault() is triggered. Also added safety net in onDone handler.

Threaded onVaultChanged through AiPanel → EditorRightPanel → Editor → App.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 14:19:41 +01:00
Test
9ffa6930c5 fix: AI chat wikilinks — system prompt + integration tests
Root cause: buildContextSnapshot (the system prompt used when a note is
active — the common case) did NOT instruct the AI to use [[wikilinks]].
Only buildAgentSystemPrompt (the fallback when no context) had it.
So the AI almost never produced clickable wikilinks.

Fix: Add the [[Note Title]] wikilink instruction to
buildContextSnapshot's preamble.

The click handler chain was already correct (verified with new
integration tests): MarkdownContent → AiMessage → AiPanel →
notes.handleNavigateWikilink → findWikilinkTarget → handleSelectNote.

Tests added:
- Unit: buildContextSnapshot includes wikilink instruction
- Integration: clicking wikilinks in AiPanel calls onOpenNote
- Playwright: wikilink renders, click opens note in tab

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 13:42:32 +01:00
Test
88b20b83dc fix: use --resume for AI chat conversation continuity
The previous approach embedded conversation history as formatted text
in the prompt (<conversation_history> tags). The claude CLI's -p flag
treats this as a single user turn, losing turn boundaries — so the
model had no real multi-turn context.

Switch to using --resume with the session_id returned by the CLI's
init event. This lets the CLI manage conversation state natively:
- First message starts a new session (with system prompt)
- Subsequent messages resume via --resume (no system prompt needed)
- Clear and retry reset the session_id for a fresh start

Extract makeStreamCallbacks() to keep useAIChat complexity below
CodeScene threshold.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 13:11:34 +01:00
Test
548e5694ac style: cargo fmt config_seed.rs and getting_started.rs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:46:43 +01:00
Test
0cf8f55a8d fix: clippy doc_lazy_continuation in config_seed.rs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:45:09 +01:00
Test
72b88cef43 docs: add config/ vault type to architecture and abstractions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:42:56 +01:00
Test
8db9f61d5c feat: add Repair Vault command and MCP configFiles
- Add "Repair Vault" to command palette (Cmd+K → "Repair Vault")
- Add "Repair Vault" to macOS Vault menu bar
- Wire repair_vault Tauri command through App → useAppCommands → registry
- Add menu event handler for vault-repair
- Update MCP get_vault_context to include configFiles.agents content
- Add repair_vault mock handler for browser testing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:39:55 +01:00
Test
fb2067ec79 feat: add config/ vault type with agents.md migration
- Add config_seed module: seed_config_files, migrate_agents_md, repair_config_files
- New vaults seed config/agents.md + root AGENTS.md stub (Codex compat)
- Existing vaults auto-migrate root AGENTS.md → config/agents.md on open
- Add type/config.md (icon: gear-six, sidebar label: Config)
- Add "config" → "Config" folder type mapping
- Add repair_vault Tauri command (themes + config files)
- 24 new tests covering seeding, migration, repair, idempotency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:35:52 +01:00
Test
b60bdb685d fix: MCP install command always visible in Cmd+K regardless of mcpStatus
The command was gated on `mcpStatus !== 'checking'` which meant it was
hidden during the initial async status check. Changed enabled to always
be true so users can find and run the command immediately on app start.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 11:58:46 +01:00
Test
83009d8fb9 feat: make MCP restore command always available in Cmd+K
The "Install MCP Server" command was only enabled when status was
"not_installed", preventing users from re-registering when MCP got
removed or broken. Now the command is always available:
- Shows "Install MCP Server" when not installed
- Shows "Restore MCP Server" when already installed
- Added restore/fix/repair keywords for discoverability
- Context-aware toast: "installed" vs "restored"
- Menu bar label updated to "Restore MCP Server"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 11:33:18 +01:00
Test
068d434344 fix: auto-save structural UI changes to disk immediately
Structural changes (sidebar visibility, label, order, template, icon/color)
now auto-create missing Type entries and call onFrontmatterPersisted to
refresh git status, so changes appear in git diff without user intervention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 11:06:10 +01:00
Test
6b2a1b0659 fix: scope qmd update/embed to current vault collection only
Previously called 'qmd update' and 'qmd embed' without arguments, which
updated all global qmd collections. If any other collection had a broken
path, the entire indexing would fail.

Now passes the vault name explicitly:
  qmd update <vault_name>
  qmd embed -c <vault_name>

This makes reindex independent of other qmd collections on the system.
2026-03-07 10:08:39 +01:00
Test
bf5f5521af fix: wikilink clicks in AI chat now open note in tab
AiPanel.handleNavigateWikilink was double-resolving: it resolved the
wikilink target to an entry path, then passed the path to onOpenNote
which is notes.handleNavigateWikilink (expects a title-based target).
The path didn't match any entry's title, so navigation silently failed.

Fix: pass the wikilink target string directly to onOpenNote, letting
the parent's handleNavigateWikilink do the resolution.

Also enhanced the Playwright smoke test to verify click → tab opens.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 07:14:34 +01:00
33 changed files with 988 additions and 182 deletions

View File

@@ -56,7 +56,8 @@ Entity type is inferred from the folder structure. The vault is organized by typ
├── journal/ → "Journal"
├── essay/ → "Essay"
├── evergreen/ → "Evergreen"
── theme/ → "Theme" ← vault-based themes
── theme/ → "Theme" ← vault-based themes
└── config/ → "Config" ← meta-configuration files (agents.md, etc.)
```
Mapping logic lives in `vault/mod.rs:parse_md_file()`. If a folder doesn't match any known type, the folder name is capitalized and used as-is.

View File

@@ -183,7 +183,7 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
| `delete_note` | `path` | Delete a note file from the vault |
| `link_notes` | `source_path, property, target_title` | Add a target to an array property in frontmatter |
| `list_notes` | `[type_filter], [sort]` | List all notes, optionally filtered by type |
| `vault_context` | — | Get vault summary: entity types + 20 recent notes |
| `vault_context` | — | Get vault summary: entity types + 20 recent notes + configFiles |
| `ui_open_note` | `path` | Open a note in the Laputa UI editor |
| `ui_open_tab` | `path` | Open a note in a new UI tab |
| `ui_highlight` | `element, [path]` | Highlight a UI element (editor, tab, properties, notelist) |
@@ -391,7 +391,7 @@ Backend: `get_vault_pulse` Tauri command parses `git log` with `--name-status`.
```
1. Tauri setup:
a. run_startup_tasks() → purge trash, migrate frontmatter, seed themes, register MCP
a. run_startup_tasks() → purge trash, migrate frontmatter, seed themes, migrate AGENTS.md, seed config files, register MCP
b. spawn_ws_bridge() → start MCP WebSocket bridge (ports 9710, 9711)
2. App mounts
3. useOnboarding checks vault exists → WelcomeScreen if not
@@ -454,6 +454,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `rename.rs` | `rename_note` — renames files and updates wikilinks across the vault |
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames |
| `migration.rs` | Frontmatter migration utilities |
| `config_seed.rs` | Seeds `config/` folder, migrates `AGENTS.md`, repairs missing config files |
| `getting_started.rs` | Creates the Getting Started demo vault |
## Rust Backend Modules
@@ -551,6 +552,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `create_vault_theme` | Create markdown theme note |
| `ensure_vault_themes` | Seed default themes if missing |
| `restore_default_themes` | Restore all default themes |
| `repair_vault` | Restore default themes + missing config files |
### AI & MCP

View File

@@ -107,11 +107,22 @@ export async function vaultContext(vaultPath) {
notesWithMtime.sort((a, b) => b.mtime - a.mtime)
const recentNotes = notesWithMtime.slice(0, 20).map(({ mtime: _mtime, ...rest }) => rest)
// Read config files for AI agent context
const configFiles = {}
try {
const agentsPath = path.join(vaultPath, 'config', 'agents.md')
const agentsContent = await fs.readFile(agentsPath, 'utf-8')
configFiles.agents = agentsContent
} catch {
// config/agents.md may not exist yet
}
return {
types: [...typesSet].sort(),
noteCount: files.length,
folders: [...foldersSet].sort(),
recentNotes,
configFiles,
vaultPath,
}
}

View File

@@ -511,6 +511,16 @@ pub fn restore_default_themes(vault_path: String) -> Result<String, String> {
theme::restore_default_themes(&vault_path)
}
#[tauri::command]
pub fn repair_vault(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
// Repair themes
theme::restore_default_themes(&vault_path)?;
// Repair config files (config/agents.md, type/config.md, AGENTS.md stub)
vault::repair_config_files(&vault_path)?;
Ok("Vault repaired".to_string())
}
// ── Settings & config commands ──────────────────────────────────────────────
#[tauri::command]

View File

@@ -464,7 +464,9 @@ where
ensure_collection(vault_path)?;
// Phase 1: update (scan files)
let vault_name = vault_dir_name(vault_path);
// Phase 1: update (scan files) — scoped to this vault's collection only
on_progress(IndexingProgress {
phase: "scanning".to_string(),
current: 0,
@@ -475,7 +477,7 @@ where
let update_output = qmd
.command()
.args(["update"])
.args(["update", &vault_name])
.output()
.map_err(|e| format!("qmd update failed: {e}"))?;
@@ -504,7 +506,7 @@ where
error: None,
});
// Phase 2: embed (generate vectors)
// Phase 2: embed (generate vectors) — scoped to this vault's collection only
on_progress(IndexingProgress {
phase: "embedding".to_string(),
current: 0,
@@ -515,7 +517,7 @@ where
let embed_output = qmd
.command()
.args(["embed"])
.args(["embed", "-c", &vault_name])
.output()
.map_err(|e| format!("qmd embed failed: {e}"))?;
@@ -579,7 +581,7 @@ pub fn run_incremental_update(vault_path: &str) -> Result<(), String> {
let output = qmd
.command()
.args(["update"])
.args(["update", &vault_name])
.output()
.map_err(|e| format!("qmd incremental update failed: {e}"))?;

View File

@@ -56,6 +56,11 @@ fn run_startup_tasks() {
// Seed type/theme.md so the Theme type has an icon in the sidebar
let _ = theme::ensure_theme_type_definition(vp_str);
// Migrate root AGENTS.md → config/agents.md (one-time, idempotent)
vault::migrate_agents_md(vp_str);
// Seed config/ with default config files if missing
vault::seed_config_files(vp_str);
// Register Laputa MCP server in Claude Code and Cursor configs
match mcp::register_mcp(vp_str) {
Ok(status) => log::info!("MCP registration: {status}"),
@@ -167,6 +172,7 @@ pub fn run() {
commands::create_vault_theme,
commands::ensure_vault_themes,
commands::restore_default_themes,
commands::repair_vault,
commands::get_vault_config,
commands::save_vault_config
])

View File

@@ -49,6 +49,7 @@ const VAULT_RESOLVE_CONFLICTS: &str = "vault-resolve-conflicts";
const VAULT_VIEW_CHANGES: &str = "vault-view-changes";
const VAULT_INSTALL_MCP: &str = "vault-install-mcp";
const VAULT_REINDEX: &str = "vault-reindex";
const VAULT_REPAIR: &str = "vault-repair";
const CUSTOM_IDS: &[&str] = &[
APP_SETTINGS,
@@ -90,6 +91,7 @@ const CUSTOM_IDS: &[&str] = &[
VAULT_VIEW_CHANGES,
VAULT_INSTALL_MCP,
VAULT_REINDEX,
VAULT_REPAIR,
];
/// IDs of menu items that should be disabled when no note tab is active.
@@ -331,12 +333,15 @@ fn build_vault_menu(app: &App) -> MenuResult {
let view_changes = MenuItemBuilder::new("View Pending Changes")
.id(VAULT_VIEW_CHANGES)
.build(app)?;
let install_mcp = MenuItemBuilder::new("Install MCP Server")
let install_mcp = MenuItemBuilder::new("Restore MCP Server")
.id(VAULT_INSTALL_MCP)
.build(app)?;
let reindex = MenuItemBuilder::new("Reindex Vault")
.id(VAULT_REINDEX)
.build(app)?;
let repair = MenuItemBuilder::new("Repair Vault")
.id(VAULT_REPAIR)
.build(app)?;
Ok(SubmenuBuilder::new(app, "Vault")
.item(&open_vault)
@@ -351,6 +356,7 @@ fn build_vault_menu(app: &App) -> MenuResult {
.item(&view_changes)
.separator()
.item(&reindex)
.item(&repair)
.item(&install_mcp)
.build()?)
}

View File

@@ -0,0 +1,355 @@
use std::fs;
use std::path::Path;
use super::getting_started::AGENTS_MD;
/// Content for `type/config.md` — gives the Config type a sidebar icon and label.
const CONFIG_TYPE_DEFINITION: &str = "\
---
Is A: Type
icon: gear-six
color: gray
order: 90
sidebar label: Config
---
# Config
Vault configuration files. These control how AI agents, tools, and other integrations interact with this vault.
";
/// Minimal root `AGENTS.md` stub that redirects to `config/agents.md`.
const AGENTS_MD_STUB: &str = "\
# Agent Instructions
See config/agents.md for vault instructions.
";
/// Seed `config/agents.md` if missing or empty (idempotent, per-file).
/// Also seeds `type/config.md` for sidebar visibility.
pub fn seed_config_files(vault_path: &str) {
let vault = Path::new(vault_path);
let config_dir = vault.join("config");
if fs::create_dir_all(&config_dir).is_err() {
return;
}
let agents_path = config_dir.join("agents.md");
let needs_write =
!agents_path.exists() || fs::metadata(&agents_path).map_or(true, |m| m.len() == 0);
if needs_write {
let _ = fs::write(&agents_path, AGENTS_MD);
log::info!("Seeded config/agents.md");
}
ensure_config_type_definition(vault_path);
}
/// Ensure `type/config.md` exists (gives Config type a sidebar icon/color).
fn ensure_config_type_definition(vault_path: &str) {
let type_dir = Path::new(vault_path).join("type");
if fs::create_dir_all(&type_dir).is_err() {
return;
}
let path = type_dir.join("config.md");
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
if needs_write {
let _ = fs::write(&path, CONFIG_TYPE_DEFINITION);
}
}
/// Migrate root `AGENTS.md` → `config/agents.md` for existing vaults.
///
/// - If root `AGENTS.md` exists and `config/agents.md` does not: move content, write stub.
/// - If root `AGENTS.md` exists and `config/agents.md` also exists: just replace root with stub.
/// - If root `AGENTS.md` doesn't exist: write the stub anyway (for Codex discoverability).
///
/// Always idempotent and silent.
pub fn migrate_agents_md(vault_path: &str) {
let vault = Path::new(vault_path);
let root_agents = vault.join("AGENTS.md");
let config_dir = vault.join("config");
let config_agents = config_dir.join("agents.md");
// Ensure config/ directory exists
if fs::create_dir_all(&config_dir).is_err() {
return;
}
// If root AGENTS.md has real content (not already a stub), migrate it
if root_agents.exists() {
let content = fs::read_to_string(&root_agents).unwrap_or_default();
let is_stub = content.contains("See config/agents.md");
if !is_stub {
// Only move content if config/agents.md doesn't exist yet
let config_needs_write = !config_agents.exists()
|| fs::metadata(&config_agents).map_or(true, |m| m.len() == 0);
if config_needs_write {
let _ = fs::write(&config_agents, &content);
log::info!("Migrated AGENTS.md content to config/agents.md");
}
// Replace root with stub
let _ = fs::write(&root_agents, AGENTS_MD_STUB);
log::info!("Replaced root AGENTS.md with stub pointing to config/agents.md");
}
} else {
// No root AGENTS.md — write stub for Codex discoverability
let _ = fs::write(&root_agents, AGENTS_MD_STUB);
}
}
/// Repair config files: re-create missing `config/agents.md` and `type/config.md`.
/// Called by the "Repair Vault" command. Returns a status message.
pub fn repair_config_files(vault_path: &str) -> Result<String, String> {
let vault = Path::new(vault_path);
// Ensure config/ directory
let config_dir = vault.join("config");
fs::create_dir_all(&config_dir)
.map_err(|e| format!("Failed to create config directory: {e}"))?;
let agents_path = config_dir.join("agents.md");
let root_agents = vault.join("AGENTS.md");
// Step 1: Migrate root AGENTS.md content → config/agents.md if needed
if root_agents.exists() {
let root_content = fs::read_to_string(&root_agents).unwrap_or_default();
let is_stub = root_content.contains("See config/agents.md");
if !is_stub && !root_content.is_empty() {
let config_needs_write =
!agents_path.exists() || fs::metadata(&agents_path).map_or(true, |m| m.len() == 0);
if config_needs_write {
fs::write(&agents_path, &root_content)
.map_err(|e| format!("Failed to migrate AGENTS.md: {e}"))?;
}
fs::write(&root_agents, AGENTS_MD_STUB)
.map_err(|e| format!("Failed to write AGENTS.md stub: {e}"))?;
}
}
// Step 2: Seed config/agents.md with defaults if still missing or empty
let needs_write =
!agents_path.exists() || fs::metadata(&agents_path).map_or(true, |m| m.len() == 0);
if needs_write {
fs::write(&agents_path, AGENTS_MD)
.map_err(|e| format!("Failed to write config/agents.md: {e}"))?;
}
// Step 3: Ensure type/config.md
let type_dir = vault.join("type");
fs::create_dir_all(&type_dir).map_err(|e| format!("Failed to create type directory: {e}"))?;
let config_type_path = type_dir.join("config.md");
let type_needs_write = !config_type_path.exists()
|| fs::metadata(&config_type_path).map_or(true, |m| m.len() == 0);
if type_needs_write {
fs::write(&config_type_path, CONFIG_TYPE_DEFINITION)
.map_err(|e| format!("Failed to write type/config.md: {e}"))?;
}
// Step 4: Ensure root AGENTS.md stub exists
let stub_needs_write = !root_agents.exists()
|| fs::read_to_string(&root_agents).map_or(true, |c| !c.contains("See config/agents.md"));
if stub_needs_write {
fs::write(&root_agents, AGENTS_MD_STUB)
.map_err(|e| format!("Failed to write AGENTS.md stub: {e}"))?;
}
Ok("Config files repaired".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_seed_config_files_creates_dir_and_agents() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
seed_config_files(vault.to_str().unwrap());
assert!(vault.join("config").is_dir());
assert!(vault.join("config/agents.md").exists());
let content = fs::read_to_string(vault.join("config/agents.md")).unwrap();
assert!(content.contains("Vault Instructions for AI Agents"));
}
#[test]
fn test_seed_config_files_creates_type_definition() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
seed_config_files(vault.to_str().unwrap());
assert!(vault.join("type/config.md").exists());
let content = fs::read_to_string(vault.join("type/config.md")).unwrap();
assert!(content.contains("Is A: Type"));
assert!(content.contains("icon: gear-six"));
}
#[test]
fn test_seed_config_files_is_idempotent() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
seed_config_files(vault.to_str().unwrap());
// Customize the file
let custom = "---\nIs A: Config\n---\n# Custom Agents\nMy custom instructions\n";
fs::write(vault.join("config/agents.md"), custom).unwrap();
seed_config_files(vault.to_str().unwrap());
let content = fs::read_to_string(vault.join("config/agents.md")).unwrap();
assert!(
content.contains("Custom Agents"),
"must preserve existing content"
);
}
#[test]
fn test_seed_config_files_reseeds_empty() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let config_dir = vault.join("config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(config_dir.join("agents.md"), "").unwrap();
seed_config_files(vault.to_str().unwrap());
let content = fs::read_to_string(config_dir.join("agents.md")).unwrap();
assert!(content.contains("Vault Instructions for AI Agents"));
}
#[test]
fn test_migrate_agents_md_moves_content() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
fs::write(vault.join("AGENTS.md"), AGENTS_MD).unwrap();
migrate_agents_md(vault.to_str().unwrap());
// config/agents.md should have the original content
let config_content = fs::read_to_string(vault.join("config/agents.md")).unwrap();
assert!(config_content.contains("Vault Instructions for AI Agents"));
// Root AGENTS.md should be a stub
let root_content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
assert!(root_content.contains("See config/agents.md"));
assert!(!root_content.contains("## Structure"));
}
#[test]
fn test_migrate_agents_md_preserves_existing_config() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let config_dir = vault.join("config");
fs::create_dir_all(&config_dir).unwrap();
let custom = "# Custom agent instructions\n";
fs::write(config_dir.join("agents.md"), custom).unwrap();
fs::write(vault.join("AGENTS.md"), AGENTS_MD).unwrap();
migrate_agents_md(vault.to_str().unwrap());
// config/agents.md should preserve custom content
let content = fs::read_to_string(config_dir.join("agents.md")).unwrap();
assert!(content.contains("Custom agent instructions"));
// Root should be a stub
let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
assert!(root.contains("See config/agents.md"));
}
#[test]
fn test_migrate_agents_md_idempotent_on_stub() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
fs::write(vault.join("AGENTS.md"), AGENTS_MD_STUB).unwrap();
migrate_agents_md(vault.to_str().unwrap());
// Stub should remain unchanged
let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
assert!(root.contains("See config/agents.md"));
}
#[test]
fn test_migrate_agents_md_writes_stub_when_no_root() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
migrate_agents_md(vault.to_str().unwrap());
assert!(vault.join("AGENTS.md").exists());
let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
assert!(root.contains("See config/agents.md"));
}
#[test]
fn test_repair_config_files_creates_all() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let msg = repair_config_files(vault.to_str().unwrap()).unwrap();
assert_eq!(msg, "Config files repaired");
assert!(vault.join("config/agents.md").exists());
assert!(vault.join("type/config.md").exists());
assert!(vault.join("AGENTS.md").exists());
let agents = fs::read_to_string(vault.join("config/agents.md")).unwrap();
assert!(agents.contains("Vault Instructions for AI Agents"));
let stub = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
assert!(stub.contains("See config/agents.md"));
}
#[test]
fn test_repair_config_files_preserves_custom_content() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let config_dir = vault.join("config");
fs::create_dir_all(&config_dir).unwrap();
let custom = "# My custom agent config\nDo not overwrite me\n";
fs::write(config_dir.join("agents.md"), custom).unwrap();
fs::write(
vault.join("AGENTS.md"),
"# Agent Instructions\nSee config/agents.md for vault instructions.\n",
)
.unwrap();
repair_config_files(vault.to_str().unwrap()).unwrap();
let content = fs::read_to_string(config_dir.join("agents.md")).unwrap();
assert!(
content.contains("My custom agent config"),
"must preserve existing content"
);
}
#[test]
fn test_repair_config_files_migrates_root_agents() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let original = "# My vault agents instructions\nCustom content here\n";
fs::write(vault.join("AGENTS.md"), original).unwrap();
repair_config_files(vault.to_str().unwrap()).unwrap();
// Root should be a stub
let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
assert!(root.contains("See config/agents.md"));
// config/agents.md should have the original content
let config = fs::read_to_string(vault.join("config/agents.md")).unwrap();
assert!(config.contains("My vault agents instructions"));
}
}

View File

@@ -18,10 +18,10 @@ struct SampleFile {
content: &'static str,
}
/// Content for the AGENTS.md file written to the vault root.
/// Content for config/agents.md — vault instructions for AI agents.
/// This file has no YAML frontmatter — it is a convention file for AI agents,
/// not a vault note. The vault scanner will still pick it up as a regular entry.
const AGENTS_MD: &str = r#"# AGENTS.md — Vault Instructions for AI Agents
pub(super) const AGENTS_MD: &str = r#"# AGENTS.md — Vault Instructions for AI Agents
This is a [Laputa](https://github.com/refactoring-ai/laputa) vault — a folder of markdown files with YAML frontmatter that form a personal knowledge graph.
@@ -137,6 +137,10 @@ const SAMPLE_FILES: &[SampleFile] = &[
rel_path: "type/theme.md",
content: "---\nIs A: Type\nicon: palette\ncolor: purple\norder: 50\n---\n\n# Theme\n\nA visual theme for Laputa. Each theme defines CSS custom properties that control colors, typography, and spacing.\n",
},
SampleFile {
rel_path: "type/config.md",
content: "---\nIs A: Type\nicon: gear-six\ncolor: gray\norder: 90\nsidebar label: Config\n---\n\n# Config\n\nVault configuration files. These control how AI agents, tools, and other integrations interact with this vault.\n",
},
SampleFile {
rel_path: "note/welcome-to-laputa.md",
content: r#"---
@@ -384,9 +388,19 @@ pub fn create_getting_started_vault(target_path: &str) -> Result<String, String>
fs::create_dir_all(vault_dir)
.map_err(|e| format!("Failed to create vault directory: {}", e))?;
// Write AGENTS.md at the vault root
fs::write(vault_dir.join("AGENTS.md"), AGENTS_MD)
.map_err(|e| format!("Failed to write AGENTS.md: {}", e))?;
// Write config/agents.md with vault instructions for AI agents
let config_dir = vault_dir.join("config");
fs::create_dir_all(&config_dir)
.map_err(|e| format!("Failed to create config directory: {}", e))?;
fs::write(config_dir.join("agents.md"), AGENTS_MD)
.map_err(|e| format!("Failed to write config/agents.md: {}", e))?;
// Write root AGENTS.md stub for Codex discoverability
fs::write(
vault_dir.join("AGENTS.md"),
"# Agent Instructions\n\nSee config/agents.md for vault instructions.\n",
)
.map_err(|e| format!("Failed to write AGENTS.md stub: {}", e))?;
for sample in SAMPLE_FILES {
let file_path = vault_dir.join(sample.rel_path);
@@ -462,6 +476,7 @@ mod tests {
assert!(result.is_ok());
// Verify key files exist
assert!(vault_path.join("config/agents.md").exists());
assert!(vault_path.join("AGENTS.md").exists());
assert!(vault_path.join("note/welcome-to-laputa.md").exists());
assert!(vault_path.join("note/editor-basics.md").exists());
@@ -476,6 +491,7 @@ mod tests {
assert!(vault_path.join("type/note.md").exists());
assert!(vault_path.join("type/person.md").exists());
assert!(vault_path.join("type/topic.md").exists());
assert!(vault_path.join("type/config.md").exists());
}
#[test]
@@ -530,57 +546,63 @@ mod tests {
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
let entries = crate::vault::scan_vault(&vault_path).unwrap();
// SAMPLE_FILES + AGENTS.md + 3 vault theme notes (theme/default.md, dark.md, minimal.md)
assert_eq!(entries.len(), SAMPLE_FILES.len() + 1 + 3);
// SAMPLE_FILES + config/agents.md + AGENTS.md stub + 3 vault theme notes
assert_eq!(entries.len(), SAMPLE_FILES.len() + 2 + 3);
}
#[test]
fn test_agents_md_present_after_vault_creation() {
fn test_config_agents_md_present_after_vault_creation() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().join("agents-vault");
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
let agents_path = vault_path.join("AGENTS.md");
assert!(agents_path.exists(), "AGENTS.md should exist at vault root");
let agents_path = vault_path.join("config/agents.md");
assert!(
agents_path.exists(),
"config/agents.md should exist in vault"
);
let content = fs::read_to_string(&agents_path).unwrap();
assert!(content.contains("Vault Instructions for AI Agents"));
assert!(content.contains("## Structure"));
assert!(content.contains("## Frontmatter"));
assert!(content.contains("## Wikilinks"));
assert!(content.contains("## Type definitions"));
assert!(content.contains("## Conventions"));
}
#[test]
fn test_root_agents_md_is_stub_after_vault_creation() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().join("stub-vault");
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
let root_path = vault_path.join("AGENTS.md");
assert!(root_path.exists(), "Root AGENTS.md stub should exist");
let content = fs::read_to_string(&root_path).unwrap();
assert!(
content.contains("Vault Instructions for AI Agents"),
"AGENTS.md should contain instructions header"
content.contains("See config/agents.md"),
"Root AGENTS.md should redirect to config/agents.md"
);
assert!(
content.contains("## Structure"),
"AGENTS.md should describe vault structure"
);
assert!(
content.contains("## Frontmatter"),
"AGENTS.md should describe frontmatter"
);
assert!(
content.contains("## Wikilinks"),
"AGENTS.md should describe wikilinks"
);
assert!(
content.contains("## Type definitions"),
"AGENTS.md should describe type definitions"
);
assert!(
content.contains("## Conventions"),
"AGENTS.md should describe conventions"
!content.contains("## Structure"),
"Root AGENTS.md should not contain full instructions"
);
}
#[test]
fn test_agents_md_parseable_as_vault_entry() {
fn test_config_agents_md_parseable_as_vault_entry() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().join("agents-parse-vault");
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
let entry = crate::vault::parse_md_file(&vault_path.join("AGENTS.md")).unwrap();
let entry = crate::vault::parse_md_file(&vault_path.join("config/agents.md")).unwrap();
assert_eq!(
entry.title,
"AGENTS.md \u{2014} Vault Instructions for AI Agents"
);
assert_eq!(entry.is_a.as_deref(), Some("Config"));
}
#[test]

View File

@@ -1,4 +1,5 @@
mod cache;
mod config_seed;
mod getting_started;
mod image;
mod migration;
@@ -7,6 +8,7 @@ mod rename;
mod trash;
pub use cache::scan_vault_cached;
pub use config_seed::{migrate_agents_md, repair_config_files, seed_config_files};
pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists};
pub use image::{copy_image_to_vault, save_image};
pub use migration::migrate_is_a_to_type;
@@ -277,6 +279,7 @@ fn infer_type_from_folder(folder: &str) -> String {
"target" => "Target",
"journal" => "Journal",
"month" => "Month",
"config" => "Config",
"essay" => "Essay",
"evergreen" => "Evergreen",
_ => return title_case_folder(folder),

View File

@@ -287,6 +287,10 @@ function App() {
}
}, [vault, notes, resolvedPath])
const handleAgentVaultChanged = useCallback(() => {
vault.reloadVault()
}, [vault])
const { triggerIncrementalIndex } = indexing
const onAfterSave = useCallback(() => {
vault.loadModifiedFiles()
@@ -412,6 +416,19 @@ function App() {
}
}, [resolvedPath, vault, themeManager, setToastMessage])
const handleRepairVault = useCallback(async () => {
if (!resolvedPath) return
try {
const tauriInvoke = isTauri() ? invoke : mockInvoke
const msg = await tauriInvoke<string>('repair_vault', { vaultPath: resolvedPath })
await vault.reloadVault()
await themeManager.reloadThemes()
setToastMessage(msg)
} catch (err) {
setToastMessage(`Failed to repair vault: ${err}`)
}
}, [resolvedPath, vault, themeManager, setToastMessage])
const commands = useAppCommands({
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
handleCloseTabRef: notes.handleCloseTabRef, tabs: notes.tabs,
@@ -468,6 +485,7 @@ function App() {
mcpStatus,
onInstallMcp: installMcp,
onReindexVault: indexing.triggerFullReindex,
onRepairVault: handleRepairVault,
})
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
@@ -585,6 +603,7 @@ function App() {
isDarkTheme={themeManager.isDark}
onFileCreated={handleAgentFileCreated}
onFileModified={handleAgentFileModified}
onVaultChanged={handleAgentVaultChanged}
/>
</div>
</div>

View File

@@ -4,10 +4,12 @@ import { AiPanel } from './AiPanel'
import type { VaultEntry } from '../types'
// Mock the hooks and utils to isolate component tests
let mockMessages: ReturnType<typeof import('../hooks/useAiAgent').useAiAgent>['messages'] = []
let mockStatus: ReturnType<typeof import('../hooks/useAiAgent').useAiAgent>['status'] = 'idle'
vi.mock('../hooks/useAiAgent', () => ({
useAiAgent: () => ({
messages: [],
status: 'idle',
messages: mockMessages,
status: mockStatus,
sendMessage: vi.fn(),
clearConversation: vi.fn(),
}),
@@ -45,6 +47,11 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
})
describe('AiPanel', () => {
beforeEach(() => {
mockMessages = []
mockStatus = 'idle'
})
it('renders panel with AI Chat header', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.getByText('AI Chat')).toBeTruthy()
@@ -162,4 +169,41 @@ describe('AiPanel', () => {
fireEvent.keyDown(panel, { key: 'Escape' })
expect(onClose).toHaveBeenCalledOnce()
})
it('clicking a wikilink in AI response calls onOpenNote with the target', () => {
mockMessages = [{
userMessage: 'Tell me about notes',
actions: [],
response: 'Check out [[Build Laputa App]] for details.',
id: 'msg-1',
}]
const onOpenNote = vi.fn()
const { container } = render(
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" onOpenNote={onOpenNote} />,
)
const wikilink = container.querySelector('.chat-wikilink')
expect(wikilink).toBeTruthy()
expect(wikilink!.textContent).toBe('Build Laputa App')
fireEvent.click(wikilink!)
expect(onOpenNote).toHaveBeenCalledWith('Build Laputa App')
})
it('renders wikilinks with special characters and clicking works', () => {
mockMessages = [{
userMessage: 'Tell me about meetings',
actions: [],
response: 'See [[Meeting — 2024/01/15]] and [[Pasta Carbonara]].',
id: 'msg-2',
}]
const onOpenNote = vi.fn()
const { container } = render(
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" onOpenNote={onOpenNote} />,
)
const wikilinks = container.querySelectorAll('.chat-wikilink')
expect(wikilinks).toHaveLength(2)
fireEvent.click(wikilinks[0])
expect(onOpenNote).toHaveBeenCalledWith('Meeting — 2024/01/15')
fireEvent.click(wikilinks[1])
expect(onOpenNote).toHaveBeenCalledWith('Pasta Carbonara')
})
})

View File

@@ -4,7 +4,6 @@ import { AiMessage } from './AiMessage'
import { WikilinkChatInput } from './WikilinkChatInput'
import { useAiAgent, type AiAgentMessage, type AgentFileCallbacks } from '../hooks/useAiAgent'
import { collectLinkedEntries, buildContextSnapshot, type NoteReference, type NoteListItem } from '../utils/ai-context'
import { findEntryByTarget } from '../utils/wikilinkColors'
import type { VaultEntry } from '../types'
export type { AiAgentMessage } from '../hooks/useAiAgent'
@@ -14,6 +13,7 @@ interface AiPanelProps {
onOpenNote?: (path: string) => void
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
onVaultChanged?: () => void
vaultPath: string
activeEntry?: VaultEntry | null
entries?: VaultEntry[]
@@ -110,7 +110,7 @@ function MessageHistory({ messages, isActive, onOpenNote, onNavigateWikilink, ha
)
}
export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, vaultPath, activeEntry, entries, allContent, openTabs, noteList, noteListFilter }: AiPanelProps) {
export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, onVaultChanged, vaultPath, activeEntry, entries, allContent, openTabs, noteList, noteListFilter }: AiPanelProps) {
const [input, setInput] = useState('')
const [pendingRefs, setPendingRefs] = useState<NoteReference[]>([])
const inputRef = useRef<HTMLInputElement>(null)
@@ -137,7 +137,8 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, va
const fileCallbacks = useMemo<AgentFileCallbacks>(() => ({
onFileCreated,
onFileModified,
}), [onFileCreated, onFileModified])
onVaultChanged,
}), [onFileCreated, onFileModified, onVaultChanged])
const agent = useAiAgent(vaultPath, contextPrompt, fileCallbacks)
const hasContext = !!activeEntry
@@ -169,10 +170,8 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, va
}, [handleEscape])
const handleNavigateWikilink = useCallback((target: string) => {
if (!entries) return
const entry = findEntryByTarget(entries, target)
if (entry) onOpenNote?.(entry.path)
}, [entries, onOpenNote])
onOpenNote?.(target)
}, [onOpenNote])
const handleSend = useCallback((text: string, references: NoteReference[]) => {
if (!text.trim() || isActive) return

View File

@@ -1,4 +1,4 @@
import { useRef, useEffect, useCallback, memo } from 'react'
import { useRef, useEffect, useCallback, memo, useMemo } from 'react'
import { useEditorTabSwap, getH1TextFromBlocks } from '../hooks/useEditorTabSwap'
import { useHeadingTitleSync } from '../hooks/useHeadingTitleSync'
import { useCreateBlockNote } from '@blocknote/react'
@@ -15,6 +15,7 @@ import { useEditorFocus } from '../hooks/useEditorFocus'
import { EditorRightPanel } from './EditorRightPanel'
import { EditorContent } from './EditorContent'
import { schema } from './editorSchema'
import { mergeTabContent } from '../utils/mergeTabContent'
import './Editor.css'
import './EditorTheme.css'
@@ -73,6 +74,7 @@ interface EditorProps {
diffToggleRef?: React.MutableRefObject<() => void>
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
onVaultChanged?: () => void
}
function useEditorModeExclusion({
@@ -131,6 +133,7 @@ export const Editor = memo(function Editor({
diffToggleRef,
onFileCreated,
onFileModified,
onVaultChanged,
}: EditorProps) {
const vaultPathRef = useRef(vaultPath)
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
@@ -170,6 +173,11 @@ export const Editor = memo(function Editor({
diffMode, rawMode, handleToggleDiff, handleToggleRaw, rawToggleRef, diffToggleRef,
})
const enrichedAllContent = useMemo(
() => mergeTabContent(allContent, tabs),
[allContent, tabs],
)
const isLoadingNewTab = activeTabPath !== null && !activeTab
const activeStatus = activeTab ? getNoteStatus?.(activeTab.entry.path) ?? 'clean' : 'clean'
const showDiffToggle = !!(activeTab && (diffMode || activeStatus === 'modified'))
@@ -232,7 +240,7 @@ export const Editor = memo(function Editor({
inspectorEntry={inspectorEntry}
inspectorContent={inspectorContent}
entries={entries}
allContent={allContent}
allContent={enrichedAllContent}
gitHistory={gitHistory}
vaultPath={vaultPath ?? ''}
openTabs={tabs.map(t => t.entry)}
@@ -248,6 +256,7 @@ export const Editor = memo(function Editor({
onOpenNote={onNavigateWikilink}
onFileCreated={onFileCreated}
onFileModified={onFileModified}
onVaultChanged={onVaultChanged}
/>
</div>
</div>

View File

@@ -26,6 +26,7 @@ interface EditorRightPanelProps {
onOpenNote?: (path: string) => void
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
onVaultChanged?: () => void
}
export function EditorRightPanel({
@@ -34,7 +35,7 @@ export function EditorRightPanel({
noteList, noteListFilter,
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onOpenNote,
onFileCreated, onFileModified,
onFileCreated, onFileModified, onVaultChanged,
}: EditorRightPanelProps) {
if (showAIChat) {
return (
@@ -47,6 +48,7 @@ export function EditorRightPanel({
onOpenNote={onOpenNote}
onFileCreated={onFileCreated}
onFileModified={onFileModified}
onVaultChanged={onVaultChanged}
vaultPath={vaultPath}
activeEntry={inspectorEntry}
entries={entries}

View File

@@ -13,14 +13,14 @@ vi.mock('../utils/ai-chat', async () => {
...actual,
streamClaudeChat: (...args: Parameters<typeof actual.streamClaudeChat>) => {
streamClaudeChatMock(...args)
// Simulate async: call onDone after a tick
// Simulate async: emit Init with session_id, then text, then done
const callbacks = args[3]
setTimeout(() => {
callbacks.onInit?.('test-session')
callbacks.onInit?.('session-001')
callbacks.onText('mock response')
callbacks.onDone()
}, 10)
return Promise.resolve('test-session')
return Promise.resolve('session-001')
},
}
})
@@ -39,40 +39,42 @@ afterEach(() => {
describe('useAIChat', () => {
const emptyContent: Record<string, string> = {}
it('sends first message without history', async () => {
it('sends first message without session_id (new session)', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
act(() => { result.current.sendMessage('hello') })
expect(streamClaudeChatMock).toHaveBeenCalledTimes(1)
const message = streamClaudeChatMock.mock.calls[0][0]
// First message: no history, so just the plain text
const [message, , sessionId] = streamClaudeChatMock.mock.calls[0]
// First message: raw text, no session_id
expect(message).toBe('hello')
expect(sessionId).toBeUndefined()
})
it('includes conversation history in second message', async () => {
it('resumes session on second message via --resume', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
// Send first message
act(() => { result.current.sendMessage('What is Rust?') })
// Wait for mock response
// Wait for mock response (which fires onInit with session-001)
await act(async () => { vi.advanceTimersByTime(50) })
// Now messages state has: [user: What is Rust?, assistant: mock response]
expect(result.current.messages).toHaveLength(2)
// Send second message
// Send second message — should resume with session_id
act(() => { result.current.sendMessage('Tell me more') })
expect(streamClaudeChatMock).toHaveBeenCalledTimes(2)
const secondMessage = streamClaudeChatMock.mock.calls[1][0]
// Should contain conversation history
expect(secondMessage).toContain('What is Rust?')
expect(secondMessage).toContain('mock response')
expect(secondMessage).toContain('Tell me more')
const [message, systemPrompt, sessionId] = streamClaudeChatMock.mock.calls[1]
// Raw message only (no embedded history)
expect(message).toBe('Tell me more')
// Session resumed via --resume
expect(sessionId).toBe('session-001')
// System prompt omitted on resumed sessions
expect(systemPrompt).toBeUndefined()
})
it('resets history on clearConversation', async () => {
it('resets session on clearConversation', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
// Send a message and get response
@@ -83,14 +85,15 @@ describe('useAIChat', () => {
act(() => { result.current.clearConversation() })
expect(result.current.messages).toHaveLength(0)
// Send new message — should have no history
// Send new message — should start a fresh session (no session_id)
act(() => { result.current.sendMessage('fresh start') })
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
expect(lastCall[0]).toBe('fresh start')
expect(lastCall[2]).toBeUndefined() // no session_id
})
it('accumulates multiple exchanges in history', async () => {
it('resumes session across multiple exchanges', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
// Exchange 1
@@ -105,26 +108,54 @@ describe('useAIChat', () => {
act(() => { result.current.sendMessage('Q3') })
expect(streamClaudeChatMock).toHaveBeenCalledTimes(3)
const thirdMessage = streamClaudeChatMock.mock.calls[2][0]
// Should contain all prior exchanges
expect(thirdMessage).toContain('Q1')
expect(thirdMessage).toContain('Q2')
expect(thirdMessage).toContain('Q3')
// First call: no session
expect(streamClaudeChatMock.mock.calls[0][2]).toBeUndefined()
// Second and third calls: resume session
expect(streamClaudeChatMock.mock.calls[1][2]).toBe('session-001')
expect(streamClaudeChatMock.mock.calls[2][2]).toBe('session-001')
// All messages are raw text (no embedded history)
expect(streamClaudeChatMock.mock.calls[0][0]).toBe('Q1')
expect(streamClaudeChatMock.mock.calls[1][0]).toBe('Q2')
expect(streamClaudeChatMock.mock.calls[2][0]).toBe('Q3')
})
it('does not pass session_id to avoid --resume (history is in prompt)', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
it('includes system prompt only on first message of a session', async () => {
const content = { 'note.md': 'Some note content' }
const notes = [{ path: 'note.md', title: 'Test Note' }] as import('../types').VaultEntry[]
// First message
const { result } = renderHook(() => useAIChat(content, notes))
// First message — system prompt included
act(() => { result.current.sendMessage('hello') })
await act(async () => { vi.advanceTimersByTime(50) })
// Second message — session_id should still be undefined (no --resume)
const firstSystemPrompt = streamClaudeChatMock.mock.calls[0][1]
expect(firstSystemPrompt).toBeTruthy()
expect(firstSystemPrompt).toContain('Test Note')
// Second message — system prompt omitted (session already has it)
act(() => { result.current.sendMessage('follow up') })
expect(streamClaudeChatMock).toHaveBeenCalledTimes(2)
// Third argument is sessionId — must be undefined for both calls
expect(streamClaudeChatMock.mock.calls[0][2]).toBeUndefined()
expect(streamClaudeChatMock.mock.calls[1][2]).toBeUndefined()
const secondSystemPrompt = streamClaudeChatMock.mock.calls[1][1]
expect(secondSystemPrompt).toBeUndefined()
})
it('resets session on retry', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
// Send message and get response
act(() => { result.current.sendMessage('hello') })
await act(async () => { vi.advanceTimersByTime(50) })
expect(result.current.messages).toHaveLength(2)
// Retry the assistant response (index 1)
act(() => { result.current.retryMessage(1) })
// Should start a fresh session
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
expect(lastCall[2]).toBeUndefined() // no session_id — fresh session
expect(lastCall[0]).toBe('hello') // re-sends the user message
})
})

View File

@@ -1,15 +1,57 @@
/**
* Custom hook encapsulating AI chat state and message handling.
* Uses Claude CLI subprocess via Tauri for streaming responses.
*
* Conversation continuity uses the CLI's --resume flag: the first message
* starts a new session; subsequent messages resume it via session_id.
* This avoids embedding history as text in the prompt (which the CLI's
* -p mode treats as a single user turn, losing turn boundaries).
*/
import { useState, useCallback, useRef } from 'react'
import type { VaultEntry } from '../types'
import {
type ChatMessage, nextMessageId,
type ChatMessage, type ChatStreamCallbacks, nextMessageId,
buildSystemPrompt, streamClaudeChat,
trimHistory, formatMessageWithHistory, MAX_HISTORY_TOKENS,
} from '../utils/ai-chat'
interface ChatStreamRefs {
abortRef: React.RefObject<boolean>
sessionIdRef: React.MutableRefObject<string | undefined>
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>
setStreamingContent: React.Dispatch<React.SetStateAction<string>>
setIsStreaming: React.Dispatch<React.SetStateAction<boolean>>
}
/** Create stream callbacks that accumulate text and update React state. */
function makeStreamCallbacks(
refs: ChatStreamRefs,
): { callbacks: ChatStreamCallbacks; getAccumulated: () => string } {
let accumulated = ''
const callbacks: ChatStreamCallbacks = {
onInit: (sid) => { refs.sessionIdRef.current = sid },
onText: (chunk) => {
if (refs.abortRef.current) return
accumulated += chunk
refs.setStreamingContent(accumulated)
},
onError: (error) => {
if (refs.abortRef.current) return
refs.setMessages(prev => [...prev, { role: 'assistant', content: `Error: ${error}`, id: nextMessageId() }])
refs.setStreamingContent('')
refs.setIsStreaming(false)
},
onDone: () => {
if (refs.abortRef.current) return
if (accumulated) {
refs.setMessages(prev => [...prev, { role: 'assistant', content: accumulated, id: nextMessageId() }])
}
refs.setStreamingContent('')
refs.setIsStreaming(false)
},
}
return { callbacks, getAccumulated: () => accumulated }
}
export function useAIChat(
allContent: Record<string, string>,
contextNotes: VaultEntry[],
@@ -18,54 +60,39 @@ export function useAIChat(
const [isStreaming, setIsStreaming] = useState(false)
const [streamingContent, setStreamingContent] = useState('')
const abortRef = useRef(false)
const sessionIdRef = useRef<string | undefined>(undefined)
const sendMessage = useCallback((text: string) => {
if (!text.trim() || isStreaming) return
const userMsg: ChatMessage = { role: 'user', content: text.trim(), id: nextMessageId() }
setMessages(prev => [...prev, userMsg])
setMessages(prev => [...prev, { role: 'user', content: text.trim(), id: nextMessageId() }])
setIsStreaming(true)
setStreamingContent('')
abortRef.current = false
const { prompt: systemPrompt } = buildSystemPrompt(contextNotes, allContent)
const history = trimHistory(messages, MAX_HISTORY_TOKENS)
const messageWithHistory = formatMessageWithHistory(history, text.trim())
let accumulated = ''
const currentSessionId = sessionIdRef.current
// System prompt only on first message (new session).
const systemPrompt = currentSessionId
? undefined
: (buildSystemPrompt(contextNotes, allContent).prompt || undefined)
// No session_id: each call is independent. Context is provided via
// formatted history in the prompt, avoiding --resume which causes
// double-context confusion when combined with in-prompt history.
streamClaudeChat(messageWithHistory, systemPrompt || undefined, undefined, {
onText: (chunk) => {
if (abortRef.current) return
accumulated += chunk
setStreamingContent(accumulated)
},
onError: (error) => {
if (abortRef.current) return
setMessages(prev => [...prev, { role: 'assistant', content: `Error: ${error}`, id: nextMessageId() }])
setStreamingContent('')
setIsStreaming(false)
},
onDone: () => {
if (abortRef.current) return
if (accumulated) {
setMessages(prev => [...prev, { role: 'assistant', content: accumulated, id: nextMessageId() }])
}
setStreamingContent('')
setIsStreaming(false)
},
const { callbacks } = makeStreamCallbacks({
abortRef, sessionIdRef, setMessages, setStreamingContent, setIsStreaming,
})
}, [isStreaming, allContent, contextNotes, messages])
streamClaudeChat(text.trim(), systemPrompt, currentSessionId, callbacks)
.then((sid) => {
if (sid && !sessionIdRef.current) sessionIdRef.current = sid
})
.catch(() => { /* errors forwarded via onError */ })
}, [isStreaming, allContent, contextNotes])
const clearConversation = useCallback(() => {
abortRef.current = true
setMessages([])
setIsStreaming(false)
setStreamingContent('')
sessionIdRef.current = undefined
}, [])
const retryMessage = useCallback((msgIndex: number) => {
@@ -74,6 +101,7 @@ export function useAIChat(
const userMsg = messages[userMsgIndex]
if (userMsg.role !== 'user') return
sessionIdRef.current = undefined
setMessages(prev => prev.slice(0, msgIndex))
sendMessage(userMsg.content)
}, [messages, sendMessage])

View File

@@ -8,6 +8,7 @@ function makeCallbacks() {
return {
onFileCreated: vi.fn(),
onFileModified: vi.fn(),
onVaultChanged: vi.fn(),
} satisfies AgentFileCallbacks
}
@@ -45,16 +46,45 @@ describe('detectFileOperation', () => {
expect(cb.onFileModified).not.toHaveBeenCalled()
})
it('handles undefined input gracefully', () => {
it('calls onVaultChanged when Write input is undefined', () => {
const cb = makeCallbacks()
detectFileOperation('Write', undefined, VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
expect(cb.onVaultChanged).toHaveBeenCalled()
})
it('handles malformed JSON input gracefully', () => {
it('calls onVaultChanged when Edit input is undefined', () => {
const cb = makeCallbacks()
detectFileOperation('Edit', undefined, VAULT, cb)
expect(cb.onFileModified).not.toHaveBeenCalled()
expect(cb.onVaultChanged).toHaveBeenCalled()
})
it('calls onVaultChanged when Write has malformed JSON input', () => {
const cb = makeCallbacks()
detectFileOperation('Write', 'not-json', VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
expect(cb.onVaultChanged).toHaveBeenCalled()
})
it('does not call onVaultChanged when Write detects specific file', () => {
const cb = makeCallbacks()
detectFileOperation('Write', JSON.stringify({ file_path: `${VAULT}/note/test.md` }), VAULT, cb)
expect(cb.onFileCreated).toHaveBeenCalledWith('note/test.md')
expect(cb.onVaultChanged).not.toHaveBeenCalled()
})
it('does not call onVaultChanged for Read tool', () => {
const cb = makeCallbacks()
detectFileOperation('Read', undefined, VAULT, cb)
expect(cb.onVaultChanged).not.toHaveBeenCalled()
})
it('calls onVaultChanged when Bash input is undefined', () => {
const cb = makeCallbacks()
detectFileOperation('Bash', undefined, VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
expect(cb.onVaultChanged).toHaveBeenCalled()
})
it('handles undefined callbacks gracefully', () => {

View File

@@ -32,6 +32,8 @@ export interface AiAgentMessage {
export interface AgentFileCallbacks {
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
/** Fallback: vault may have changed but we can't determine the specific file. */
onVaultChanged?: () => void
}
export function useAiAgent(
@@ -172,6 +174,8 @@ export function useAiAgent(
response: finalResponse,
actions: m.actions.map(a => a.status === 'pending' ? { ...a, status: 'done' as const } : a),
}))
// Safety net: refresh vault after agent completes in case file changes were missed
fileCallbacksRef.current?.onVaultChanged?.()
},
})
}, [status, vaultPath])
@@ -219,20 +223,26 @@ export function detectFileOperation(
// Handle Bash commands that create/write .md files
if (toolName === 'Bash') {
const mdPath = parseBashFileCreation(input, vaultPath)
if (mdPath) callbacks.onFileCreated?.(mdPath)
if (mdPath) { callbacks.onFileCreated?.(mdPath); return }
// Bash ran but we couldn't detect a specific .md file — still may have changed vault
callbacks.onVaultChanged?.()
return
}
if (toolName !== 'Write' && toolName !== 'Edit') return
const filePath = parseFilePath(input)
if (!filePath || !filePath.endsWith('.md')) return
const rel = toVaultRelative(filePath, vaultPath)
if (!rel) return
if (toolName === 'Write') {
callbacks.onFileCreated?.(rel)
} else {
callbacks.onFileModified?.(rel)
if (filePath && filePath.endsWith('.md')) {
const rel = toVaultRelative(filePath, vaultPath)
if (rel) {
if (toolName === 'Write') callbacks.onFileCreated?.(rel)
else callbacks.onFileModified?.(rel)
return
}
}
// Write/Edit completed but couldn't determine target file — trigger vault refresh
callbacks.onVaultChanged?.()
}
/** Detect .md file creation from a Bash command string. */

View File

@@ -67,6 +67,7 @@ interface AppCommandsConfig {
mcpStatus?: string
onInstallMcp?: () => void
onReindexVault?: () => void
onRepairVault?: () => void
}
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
@@ -150,6 +151,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onViewChanges: viewChanges,
onInstallMcp: config.onInstallMcp,
onReindexVault: config.onReindexVault,
onRepairVault: config.onRepairVault,
activeTabPathRef: config.activeTabPathRef,
handleCloseTabRef: config.handleCloseTabRef,
activeTabPath: config.activeTabPath,
@@ -204,6 +206,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
mcpStatus: config.mcpStatus,
onInstallMcp: config.onInstallMcp,
onReindexVault: config.onReindexVault,
onRepairVault: config.onRepairVault,
})
useKeyboardNavigation({

View File

@@ -197,6 +197,64 @@ describe('groupSortKey', () => {
})
})
describe('install-mcp command', () => {
it('is enabled when mcpStatus is not_installed and handler provided', () => {
const config = makeConfig({ mcpStatus: 'not_installed', onInstallMcp: vi.fn() })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'install-mcp')
expect(cmd).toBeDefined()
expect(cmd!.enabled).toBe(true)
expect(cmd!.label).toBe('Install MCP Server')
})
it('is enabled when mcpStatus is installed and handler provided (restore use case)', () => {
const config = makeConfig({ mcpStatus: 'installed', onInstallMcp: vi.fn() })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'install-mcp')
expect(cmd!.enabled).toBe(true)
expect(cmd!.label).toBe('Restore MCP Server')
})
it('is enabled even when mcpStatus is checking', () => {
const config = makeConfig({ mcpStatus: 'checking', onInstallMcp: vi.fn() })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'install-mcp')
expect(cmd!.enabled).toBe(true)
})
it('is enabled even when no handler provided', () => {
const config = makeConfig({ mcpStatus: 'not_installed' })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'install-mcp')
expect(cmd!.enabled).toBe(true)
})
it('has restore keyword for discoverability', () => {
const config = makeConfig({ mcpStatus: 'installed', onInstallMcp: vi.fn() })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'install-mcp')
expect(cmd!.keywords).toContain('restore')
expect(cmd!.keywords).toContain('mcp')
expect(cmd!.keywords).toContain('claude')
})
it('executes onInstallMcp callback', () => {
const onInstallMcp = vi.fn()
const config = makeConfig({ mcpStatus: 'installed', onInstallMcp })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'install-mcp')
cmd!.execute()
expect(onInstallMcp).toHaveBeenCalled()
})
it('is in Settings group', () => {
const config = makeConfig({ mcpStatus: 'installed', onInstallMcp: vi.fn() })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'install-mcp')
expect(cmd!.group).toBe('Settings')
})
})
describe('buildTypeCommands', () => {
it('creates new and list commands for each type', () => {
const onCreateNoteOfType = vi.fn()

View File

@@ -21,6 +21,7 @@ interface CommandRegistryConfig {
mcpStatus?: string
onInstallMcp?: () => void
onReindexVault?: () => void
onRepairVault?: () => void
onQuickOpen: () => void
onCreateNote: () => void
@@ -198,6 +199,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
onRemoveActiveVault, onRestoreGettingStarted, onRestoreDefaultThemes, isGettingStartedHidden, vaultCount,
mcpStatus, onInstallMcp,
onReindexVault,
onRepairVault,
} = config
const hasActiveNote = activeTabPath !== null
@@ -258,8 +260,9 @@ 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?.() },
{ id: 'install-mcp', label: mcpStatus === 'installed' ? 'Restore MCP Server' : 'Install MCP Server', group: 'Settings', keywords: ['mcp', 'claude', 'ai', 'tools', 'install', 'restore', 'fix', 'repair'], enabled: true, execute: () => onInstallMcp?.() },
{ id: 'reindex-vault', label: 'Reindex Vault', group: 'Settings', keywords: ['reindex', 'index', 'search', 'rebuild', 'refresh'], enabled: !!onReindexVault, execute: () => onReindexVault?.() },
{ id: 'repair-vault', label: 'Repair Vault', group: 'Settings', keywords: ['repair', 'fix', 'restore', 'config', 'agents', 'themes', 'missing', 'reset'], enabled: !!onRepairVault, execute: () => onRepairVault?.() },
// Type-aware: "New [Type]" and "List [Type]"
...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect),
@@ -278,6 +281,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme, onRestoreDefaultThemes,
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
mcpStatus, onInstallMcp,
onReindexVault,
onReindexVault, onRepairVault,
])
}

View File

@@ -141,6 +141,7 @@ describe('useEntryActions', () => {
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'icon', 'cooking-pot')
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'color', 'green')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { icon: 'cooking-pot', color: 'green' })
expect(onFrontmatterPersisted).toHaveBeenCalled()
})
it('auto-creates type entry when not found and applies customization', async () => {
@@ -174,50 +175,52 @@ describe('useEntryActions', () => {
})
describe('handleUpdateTypeTemplate', () => {
it('updates template on the type entry', () => {
it('updates template on the type entry', async () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' })
const { result } = setup([typeEntry])
act(() => {
result.current.handleUpdateTypeTemplate('Project', '## Objective\n\n## Notes')
await act(async () => {
await result.current.handleUpdateTypeTemplate('Project', '## Objective\n\n## Notes')
})
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/project.md', 'template', '## Objective\n\n## Notes')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/project.md', { template: '## Objective\n\n## Notes' })
expect(onFrontmatterPersisted).toHaveBeenCalled()
})
it('sets template to null when empty string', () => {
it('sets template to null when empty string', async () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' })
const { result } = setup([typeEntry])
act(() => {
result.current.handleUpdateTypeTemplate('Project', '')
await act(async () => {
await result.current.handleUpdateTypeTemplate('Project', '')
})
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/project.md', 'template', '')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/project.md', { template: null })
})
it('does nothing when type entry not found', () => {
it('auto-creates type entry when not found', async () => {
const { result } = setup([])
act(() => {
result.current.handleUpdateTypeTemplate('NonExistent', '## Template')
await act(async () => {
await result.current.handleUpdateTypeTemplate('NonExistent', '## Template')
})
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
expect(updateEntry).not.toHaveBeenCalled()
expect(createTypeEntry).toHaveBeenCalledWith('NonExistent')
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/nonexistent.md', 'template', '## Template')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/nonexistent.md', { template: '## Template' })
})
})
describe('handleReorderSections', () => {
it('updates order on multiple type entries', () => {
it('updates order on multiple type entries', async () => {
const typeA = makeEntry({ isA: 'Type', title: 'Note', path: '/vault/type/note.md' })
const typeB = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' })
const { result } = setup([typeA, typeB])
act(() => {
result.current.handleReorderSections([
await act(async () => {
await result.current.handleReorderSections([
{ typeName: 'Note', order: 0 },
{ typeName: 'Project', order: 1 },
])
@@ -227,23 +230,24 @@ describe('useEntryActions', () => {
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/project.md', 'order', 1)
expect(updateEntry).toHaveBeenCalledWith('/vault/type/note.md', { order: 0 })
expect(updateEntry).toHaveBeenCalledWith('/vault/type/project.md', { order: 1 })
expect(onFrontmatterPersisted).toHaveBeenCalled()
})
it('skips types that are not found', () => {
it('auto-creates type entries when not found', async () => {
const typeA = makeEntry({ isA: 'Type', title: 'Note', path: '/vault/type/note.md' })
const { result } = setup([typeA])
act(() => {
result.current.handleReorderSections([
await act(async () => {
await result.current.handleReorderSections([
{ typeName: 'Note', order: 0 },
{ typeName: 'Missing', order: 1 },
])
})
// Only Note's order was set; Missing was skipped
expect(handleUpdateFrontmatter).toHaveBeenCalledTimes(1)
expect(createTypeEntry).toHaveBeenCalledWith('Missing')
expect(handleUpdateFrontmatter).toHaveBeenCalledTimes(2)
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/note.md', 'order', 0)
expect(updateEntry).toHaveBeenCalledTimes(1)
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/missing.md', 'order', 1)
})
})
@@ -258,6 +262,7 @@ describe('useEntryActions', () => {
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'sidebar label', 'Recipes')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { sidebarLabel: 'Recipes' })
expect(onFrontmatterPersisted).toHaveBeenCalled()
})
it('trims whitespace before saving', async () => {
@@ -285,15 +290,16 @@ describe('useEntryActions', () => {
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
})
it('does nothing when type entry not found', async () => {
it('auto-creates type entry when not found', async () => {
const { result } = setup([])
await act(async () => {
await result.current.handleRenameSection('NonExistent', 'Label')
})
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
expect(updateEntry).not.toHaveBeenCalled()
expect(createTypeEntry).toHaveBeenCalledWith('NonExistent')
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/nonexistent.md', 'sidebar label', 'Label')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/nonexistent.md', { sidebarLabel: 'Label' })
})
})
@@ -308,6 +314,7 @@ describe('useEntryActions', () => {
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/journal.md', 'visible', false)
expect(updateEntry).toHaveBeenCalledWith('/vault/type/journal.md', { visible: false })
expect(onFrontmatterPersisted).toHaveBeenCalled()
})
it('sets visible to true (deletes property) when currently hidden', async () => {
@@ -320,6 +327,7 @@ describe('useEntryActions', () => {
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/type/journal.md', 'visible')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/journal.md', { visible: null })
expect(onFrontmatterPersisted).toHaveBeenCalled()
})
it('auto-creates type entry when not found', async () => {

View File

@@ -55,27 +55,30 @@ export function useEntryActions({
updateEntry(typeEntry.path, { icon, color })
await handleUpdateFrontmatter(typeEntry.path, 'icon', icon)
await handleUpdateFrontmatter(typeEntry.path, 'color', color)
}, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry])
onFrontmatterPersisted?.()
}, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry, onFrontmatterPersisted])
const handleReorderSections = useCallback((orderedTypes: { typeName: string; order: number }[]) => {
const handleReorderSections = useCallback(async (orderedTypes: { typeName: string; order: number }[]) => {
for (const { typeName, order } of orderedTypes) {
const typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) continue
handleUpdateFrontmatter(typeEntry.path, 'order', order)
let typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
await handleUpdateFrontmatter(typeEntry.path, 'order', order)
updateEntry(typeEntry.path, { order })
}
}, [entries, handleUpdateFrontmatter, updateEntry])
onFrontmatterPersisted?.()
}, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry, onFrontmatterPersisted])
const handleUpdateTypeTemplate = useCallback((typeName: string, template: string) => {
const typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) return
handleUpdateFrontmatter(typeEntry.path, 'template', template)
const handleUpdateTypeTemplate = useCallback(async (typeName: string, template: string) => {
let typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
await handleUpdateFrontmatter(typeEntry.path, 'template', template)
updateEntry(typeEntry.path, { template: template || null })
}, [entries, handleUpdateFrontmatter, updateEntry])
onFrontmatterPersisted?.()
}, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry, onFrontmatterPersisted])
const handleRenameSection = useCallback(async (typeName: string, label: string) => {
const typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) return
let typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
const trimmed = label.trim()
updateEntry(typeEntry.path, { sidebarLabel: trimmed || null })
if (trimmed) {
@@ -83,7 +86,8 @@ export function useEntryActions({
} else {
await handleDeleteProperty(typeEntry.path, 'sidebar label')
}
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry])
onFrontmatterPersisted?.()
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry, createTypeEntry, onFrontmatterPersisted])
const handleToggleTypeVisibility = useCallback(async (typeName: string) => {
let typeEntry = findTypeEntry(entries, typeName)
@@ -95,7 +99,8 @@ export function useEntryActions({
updateEntry(typeEntry.path, { visible: false })
await handleUpdateFrontmatter(typeEntry.path, 'visible', false)
}
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry, createTypeEntry])
onFrontmatterPersisted?.()
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry, createTypeEntry, onFrontmatterPersisted])
return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate, handleRenameSection, handleToggleTypeVisibility }
}

View File

@@ -66,9 +66,10 @@ describe('useMcpStatus', () => {
})
it('install action calls register_mcp_tools and updates status', async () => {
// Auto-register fails (e.g. node not found), leaving status as not_installed
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_mcp_status') return Promise.resolve('not_installed')
if (cmd === 'register_mcp_tools') return Promise.resolve('registered')
if (cmd === 'register_mcp_tools') return Promise.reject(new Error('no node'))
return Promise.resolve(null)
})
@@ -76,12 +77,11 @@ describe('useMcpStatus', () => {
const { result } = renderHook(() => useMcpStatus('/vault', onToast))
await waitFor(() => {
expect(result.current.mcpStatus).toBe('installed')
expect(result.current.mcpStatus).toBe('not_installed')
})
// Reset to test install action directly
// Now manual install succeeds
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)
})
@@ -131,6 +131,33 @@ describe('useMcpStatus', () => {
})
})
it('install action shows restored toast when status was installed', async () => {
// First call: status already installed
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))
await waitFor(() => {
expect(result.current.mcpStatus).toBe('installed')
})
// Clear toasts from auto-register
onToast.mockClear()
// Now manually trigger install (restore)
await act(async () => {
await result.current.installMcp()
})
expect(result.current.mcpStatus).toBe('installed')
expect(onToast).toHaveBeenCalledWith('MCP server restored successfully')
})
it('does not show toast when already registered', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_mcp_status') return Promise.resolve('installed')

View File

@@ -19,9 +19,11 @@ export function useMcpStatus(
onToast: (msg: string) => void,
) {
const [status, setStatus] = useState<McpStatus>('checking')
const statusRef = useRef<McpStatus>(status)
const registeredRef = useRef<string | null>(null)
const onToastRef = useRef(onToast)
useEffect(() => { onToastRef.current = onToast })
useEffect(() => { statusRef.current = status }, [status])
// Check MCP status on vault open / vault switch
useEffect(() => {
@@ -57,11 +59,12 @@ export function useMcpStatus(
}, [vaultPath])
const install = useCallback(async () => {
const wasInstalled = statusRef.current === 'installed'
setStatus('checking')
try {
await tauriCall<string>('register_mcp_tools', { vaultPath })
setStatus('installed')
onToastRef.current('MCP server installed successfully')
onToastRef.current(wasInstalled ? 'MCP server restored successfully' : 'MCP server installed successfully')
} catch (e) {
setStatus('not_installed')
onToastRef.current(`MCP install failed: ${e}`)

View File

@@ -36,6 +36,7 @@ export interface MenuEventHandlers {
onViewChanges?: () => void
onInstallMcp?: () => void
onReindexVault?: () => void
onRepairVault?: () => void
activeTabPathRef: React.MutableRefObject<string | null>
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
activeTabPath: string | null
@@ -78,7 +79,7 @@ type OptionalHandler =
| 'onCreateType' | 'onToggleRawEditor' | 'onToggleDiff' | 'onToggleAIChat'
| 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'
| 'onCreateTheme' | 'onRestoreDefaultThemes'
| 'onCommitPush' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReindexVault'
| 'onCommitPush' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReindexVault' | 'onRepairVault'
const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
'view-go-back': 'onGoBack',
@@ -98,6 +99,7 @@ const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
'vault-view-changes': 'onViewChanges',
'vault-install-mcp': 'onInstallMcp',
'vault-reindex': 'onReindexVault',
'vault-repair': 'onRepairVault',
}
function dispatchActiveTabEvent(id: string, h: MenuEventHandlers): boolean {

View File

@@ -334,6 +334,7 @@ line-height-base: 1.6
},
ensure_vault_themes: (): null => null,
restore_default_themes: (): string => 'Default themes restored',
repair_vault: (): string => 'Vault repaired',
get_vault_config: (): VaultConfig => ({ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null }),
save_vault_config: (): null => null,
}

View File

@@ -331,6 +331,12 @@ describe('buildContextSnapshot', () => {
expect(json.noteList).toBeUndefined()
})
it('includes wikilink instruction in preamble', () => {
const result = buildContextSnapshot({ activeEntry: active, allContent, entries })
expect(result).toContain('[[Note Title]]')
expect(result).toContain('wikilink')
})
it('includes belongsTo and relatedTo in frontmatter', () => {
const entryWithRels = makeEntry({
path: '/vault/a.md', title: 'Alpha',

View File

@@ -152,6 +152,7 @@ export function buildContextSnapshot(params: ContextSnapshotParams): string {
'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.',
'When you mention or reference a note by name, always use [[Note Title]] wikilink syntax so the user can click to open it.',
].join('\n')
return `${preamble}\n\n## Context Snapshot\n\`\`\`json\n${JSON.stringify(snapshot, null, 2)}\n\`\`\``

View File

@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest'
import { mergeTabContent } from './mergeTabContent'
describe('mergeTabContent', () => {
it('adds tab content when allContent is empty', () => {
const result = mergeTabContent({}, [
{ entry: { path: '/vault/a.md' }, content: '# Hello\nBody text' },
])
expect(result['/vault/a.md']).toBe('# Hello\nBody text')
})
it('overrides allContent with tab content (editor state is fresher)', () => {
const result = mergeTabContent(
{ '/vault/a.md': 'old saved content' },
[{ entry: { path: '/vault/a.md' }, content: 'current editor content' }],
)
expect(result['/vault/a.md']).toBe('current editor content')
})
it('preserves allContent for paths without open tabs', () => {
const result = mergeTabContent(
{ '/vault/b.md': 'other note content' },
[{ entry: { path: '/vault/a.md' }, content: '# Hello' }],
)
expect(result['/vault/b.md']).toBe('other note content')
expect(result['/vault/a.md']).toBe('# Hello')
})
it('returns original object when no tabs have content to merge', () => {
const original = { '/vault/a.md': 'content' }
expect(mergeTabContent(original, [])).toBe(original)
})
it('skips tabs with empty content', () => {
const original = { '/vault/a.md': 'content' }
const result = mergeTabContent(original, [
{ entry: { path: '/vault/b.md' }, content: '' },
])
expect(result).toBe(original)
expect(result['/vault/b.md']).toBeUndefined()
})
it('handles multiple tabs', () => {
const result = mergeTabContent({}, [
{ entry: { path: '/vault/a.md' }, content: 'Note A' },
{ entry: { path: '/vault/b.md' }, content: 'Note B' },
])
expect(result['/vault/a.md']).toBe('Note A')
expect(result['/vault/b.md']).toBe('Note B')
})
it('does not mutate the original allContent object', () => {
const original = { '/vault/a.md': 'old' }
const result = mergeTabContent(original, [
{ entry: { path: '/vault/a.md' }, content: 'new' },
])
expect(original['/vault/a.md']).toBe('old')
expect(result['/vault/a.md']).toBe('new')
expect(result).not.toBe(original)
})
})

View File

@@ -0,0 +1,18 @@
/**
* Merge open tab content into the allContent dictionary.
* Tab content takes priority (it reflects the current editor state).
* Returns the original object if no changes are needed (stable reference for useMemo).
*/
export function mergeTabContent(
allContent: Record<string, string>,
tabs: ReadonlyArray<{ entry: { path: string }; content: string }>,
): Record<string, string> {
let merged: Record<string, string> | null = null
for (const tab of tabs) {
if (!tab.content) continue
if (allContent[tab.entry.path] === tab.content) continue
if (!merged) merged = { ...allContent }
merged[tab.entry.path] = tab.content
}
return merged ?? allContent
}

View File

@@ -3,12 +3,13 @@ import { sendShortcut } from './helpers'
test.describe('AI chat wikilink rendering', () => {
test.beforeEach(async ({ page }) => {
// Block vault API so mock entries are used (ensures "Build Laputa App" exists)
await page.route('**/api/vault/ping', route => route.fulfill({ status: 503 }))
await page.goto('/')
await page.waitForTimeout(500)
})
test('[[Note]] in AI response renders as clickable wikilink', async ({ page }) => {
// Select a note first so the AI panel has context
// Select a note so the AI panel has context
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
await noteItem.click()
await page.waitForTimeout(500)
@@ -17,14 +18,17 @@ test.describe('AI chat wikilink rendering', () => {
await sendShortcut(page, 'i', ['Control'])
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3000 })
// Send a message to get a mock response containing wikilinks
// Send a message to trigger mock response with [[Build Laputa App]] and [[Matteo Cellini]]
const input = page.locator('input[placeholder*="Ask"]')
await input.fill('Tell me about this note')
await page.getByTestId('agent-send').click()
// Wait for mock response (contains [[Build Laputa App]] and [[Matteo Cellini]])
// Wait for wikilinks to render
await expect(page.locator('.chat-wikilink').first()).toBeVisible({ timeout: 5000 })
})
test('[[Note]] in AI response renders as clickable wikilink', async ({ page }) => {
const wikilink = page.locator('.chat-wikilink').first()
await expect(wikilink).toBeVisible({ timeout: 5000 })
// Verify wikilink text and attributes
await expect(wikilink).toHaveText('Build Laputa App')
@@ -36,11 +40,27 @@ test.describe('AI chat wikilink rendering', () => {
await expect(secondWikilink).toHaveText('Matteo Cellini')
// Verify multiple wikilinks rendered
const allWikilinks = page.locator('.chat-wikilink')
await expect(allWikilinks).toHaveCount(2)
await expect(page.locator('.chat-wikilink')).toHaveCount(2)
// Verify wikilink has pointer cursor (is styled as clickable)
const cursor = await wikilink.evaluate(el => getComputedStyle(el).cursor)
expect(cursor).toBe('pointer')
})
test('clicking a wikilink opens the note in a tab', async ({ page }) => {
// Click the second wikilink ("Matteo Cellini") which is NOT already open in a tab
const wikilink = page.locator('.chat-wikilink').nth(1)
await expect(wikilink).toHaveText('Matteo Cellini')
// Verify "Matteo Cellini" is not yet in any tab
const tabsBefore = await page.locator('span.truncate:has-text("Matteo Cellini")').count()
// Click the wikilink
await wikilink.click()
await page.waitForTimeout(500)
// Verify a new tab appeared with the note title
const tabsAfter = await page.locator('span.truncate:has-text("Matteo Cellini")').count()
expect(tabsAfter).toBeGreaterThan(tabsBefore)
})
})