Compare commits
14 Commits
v0.2026030
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e503cb179 | ||
|
|
d83f04c6ff | ||
|
|
9ffa6930c5 | ||
|
|
88b20b83dc | ||
|
|
548e5694ac | ||
|
|
0cf8f55a8d | ||
|
|
72b88cef43 | ||
|
|
8db9f61d5c | ||
|
|
fb2067ec79 | ||
|
|
b60bdb685d | ||
|
|
83009d8fb9 | ||
|
|
068d434344 | ||
|
|
6b2a1b0659 | ||
|
|
bf5f5521af |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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}"))?;
|
||||
|
||||
|
||||
@@ -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
|
||||
])
|
||||
|
||||
@@ -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()?)
|
||||
}
|
||||
|
||||
355
src-tauri/src/vault/config_seed.rs
Normal file
355
src-tauri/src/vault/config_seed.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
|
||||
19
src/App.tsx
19
src/App.tsx
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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\`\`\``
|
||||
|
||||
61
src/utils/mergeTabContent.test.ts
Normal file
61
src/utils/mergeTabContent.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
18
src/utils/mergeTabContent.ts
Normal file
18
src/utils/mergeTabContent.ts
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user