Compare commits
12 Commits
v0.2026030
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcfd37d481 | ||
|
|
f27ebe05c4 | ||
|
|
7470e4f4a7 | ||
|
|
0e503cb179 | ||
|
|
d83f04c6ff | ||
|
|
9ffa6930c5 | ||
|
|
88b20b83dc | ||
|
|
548e5694ac | ||
|
|
0cf8f55a8d | ||
|
|
72b88cef43 | ||
|
|
8db9f61d5c | ||
|
|
fb2067ec79 |
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
@@ -337,6 +339,9 @@ fn build_vault_menu(app: &App) -> MenuResult {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,8 +13,11 @@ interface AiPanelProps {
|
||||
onOpenNote?: (path: string) => void
|
||||
onFileCreated?: (relativePath: string) => void
|
||||
onFileModified?: (relativePath: string) => void
|
||||
onVaultChanged?: () => void
|
||||
vaultPath: string
|
||||
activeEntry?: VaultEntry | null
|
||||
/** Direct content of the active note from the editor tab. */
|
||||
activeNoteContent?: string | null
|
||||
entries?: VaultEntry[]
|
||||
allContent?: Record<string, string>
|
||||
openTabs?: VaultEntry[]
|
||||
@@ -109,7 +112,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, activeNoteContent, entries, allContent, openTabs, noteList, noteListFilter }: AiPanelProps) {
|
||||
const [input, setInput] = useState('')
|
||||
const [pendingRefs, setPendingRefs] = useState<NoteReference[]>([])
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -121,22 +124,24 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, va
|
||||
}, [activeEntry, entries])
|
||||
|
||||
const contextPrompt = useMemo(() => {
|
||||
if (!activeEntry || !allContent || !entries) return undefined
|
||||
if (!activeEntry || !entries) return undefined
|
||||
return buildContextSnapshot({
|
||||
activeEntry,
|
||||
allContent,
|
||||
allContent: allContent ?? {},
|
||||
activeNoteContent: activeNoteContent ?? undefined,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
entries,
|
||||
references: pendingRefs.length > 0 ? pendingRefs : undefined,
|
||||
})
|
||||
}, [activeEntry, allContent, openTabs, noteList, noteListFilter, entries, pendingRefs])
|
||||
}, [activeEntry, activeNoteContent, allContent, openTabs, noteList, noteListFilter, entries, pendingRefs])
|
||||
|
||||
const fileCallbacks = useMemo<AgentFileCallbacks>(() => ({
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
}), [onFileCreated, onFileModified])
|
||||
onVaultChanged,
|
||||
}), [onFileCreated, onFileModified, onVaultChanged])
|
||||
|
||||
const agent = useAiAgent(vaultPath, contextPrompt, fileCallbacks)
|
||||
const hasContext = !!activeEntry
|
||||
|
||||
@@ -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,8 +48,10 @@ export function EditorRightPanel({
|
||||
onOpenNote={onOpenNote}
|
||||
onFileCreated={onFileCreated}
|
||||
onFileModified={onFileModified}
|
||||
onVaultChanged={onVaultChanged}
|
||||
vaultPath={vaultPath}
|
||||
activeEntry={inspectorEntry}
|
||||
activeNoteContent={inspectorContent}
|
||||
entries={entries}
|
||||
allContent={allContent}
|
||||
openTabs={openTabs}
|
||||
|
||||
@@ -13,14 +13,13 @@ 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 text, then done
|
||||
const callbacks = args[3]
|
||||
setTimeout(() => {
|
||||
callbacks.onInit?.('test-session')
|
||||
callbacks.onText('mock response')
|
||||
callbacks.onDone()
|
||||
}, 10)
|
||||
return Promise.resolve('test-session')
|
||||
return Promise.resolve('')
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -39,58 +38,37 @@ afterEach(() => {
|
||||
describe('useAIChat', () => {
|
||||
const emptyContent: Record<string, string> = {}
|
||||
|
||||
it('sends first message without history', async () => {
|
||||
it('sends first message as raw text without history', 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 history wrapping, no session_id
|
||||
expect(message).toBe('hello')
|
||||
expect(sessionId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes conversation history in second message', async () => {
|
||||
it('embeds conversation history in second message', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
|
||||
// Send first message
|
||||
act(() => { result.current.sendMessage('What is Rust?') })
|
||||
// Wait for mock response
|
||||
// First exchange
|
||||
act(() => { result.current.sendMessage('What is 2+2?') })
|
||||
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
|
||||
act(() => { result.current.sendMessage('Tell me more') })
|
||||
// Second message — should include history from first exchange
|
||||
act(() => { result.current.sendMessage('What is that times 3?') })
|
||||
|
||||
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] = streamClaudeChatMock.mock.calls[1]
|
||||
expect(message).toContain('<conversation_history>')
|
||||
expect(message).toContain('What is 2+2?')
|
||||
expect(message).toContain('mock response')
|
||||
expect(message).toContain('What is that times 3?')
|
||||
})
|
||||
|
||||
it('resets history on clearConversation', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
|
||||
// Send a message and get response
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Clear conversation
|
||||
act(() => { result.current.clearConversation() })
|
||||
expect(result.current.messages).toHaveLength(0)
|
||||
|
||||
// Send new message — should have no history
|
||||
act(() => { result.current.sendMessage('fresh start') })
|
||||
|
||||
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
|
||||
expect(lastCall[0]).toBe('fresh start')
|
||||
})
|
||||
|
||||
it('accumulates multiple exchanges in history', async () => {
|
||||
it('accumulates history across multiple exchanges', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
|
||||
// Exchange 1
|
||||
@@ -105,26 +83,89 @@ 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 history
|
||||
expect(streamClaudeChatMock.mock.calls[0][0]).toBe('Q1')
|
||||
// Second call: history from first exchange
|
||||
const secondMsg = streamClaudeChatMock.mock.calls[1][0]
|
||||
expect(secondMsg).toContain('Q1')
|
||||
expect(secondMsg).toContain('Q2')
|
||||
// Third call: history from both exchanges
|
||||
const thirdMsg = streamClaudeChatMock.mock.calls[2][0]
|
||||
expect(thirdMsg).toContain('Q1')
|
||||
expect(thirdMsg).toContain('Q2')
|
||||
expect(thirdMsg).toContain('Q3')
|
||||
})
|
||||
|
||||
it('does not pass session_id to avoid --resume (history is in prompt)', async () => {
|
||||
it('never passes session_id (no --resume)', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
|
||||
act(() => { result.current.sendMessage('Q1') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
act(() => { result.current.sendMessage('Q2') })
|
||||
|
||||
// All calls should have undefined session_id
|
||||
for (const call of streamClaudeChatMock.mock.calls) {
|
||||
expect(call[2]).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('resets history after clearConversation', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
|
||||
// Build up some history
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Clear
|
||||
act(() => { result.current.clearConversation() })
|
||||
expect(result.current.messages).toHaveLength(0)
|
||||
|
||||
// Next message should have no history
|
||||
act(() => { result.current.sendMessage('fresh start') })
|
||||
|
||||
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
|
||||
expect(lastCall[0]).toBe('fresh start') // raw text, no history wrapping
|
||||
expect(lastCall[2]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes system prompt on every message when context notes exist', async () => {
|
||||
const content = { 'note.md': 'Some note content' }
|
||||
const notes = [{ path: 'note.md', title: 'Test Note' }] as import('../types').VaultEntry[]
|
||||
|
||||
const { result } = renderHook(() => useAIChat(content, notes))
|
||||
|
||||
// First message
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Second message — session_id should still be undefined (no --resume)
|
||||
// Second message
|
||||
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()
|
||||
// Both calls should have system prompt
|
||||
const firstSystemPrompt = streamClaudeChatMock.mock.calls[0][1]
|
||||
expect(firstSystemPrompt).toBeTruthy()
|
||||
expect(firstSystemPrompt).toContain('Test Note')
|
||||
|
||||
const secondSystemPrompt = streamClaudeChatMock.mock.calls[1][1]
|
||||
expect(secondSystemPrompt).toBeTruthy()
|
||||
expect(secondSystemPrompt).toContain('Test Note')
|
||||
})
|
||||
|
||||
it('retries with correct history (excludes retried exchange)', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
|
||||
// First exchange
|
||||
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) })
|
||||
|
||||
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
|
||||
// Should re-send the user message with no history (retrying first exchange)
|
||||
expect(lastCall[0]).toBe('hello')
|
||||
expect(lastCall[2]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,55 @@
|
||||
/**
|
||||
* Custom hook encapsulating AI chat state and message handling.
|
||||
* Uses Claude CLI subprocess via Tauri for streaming responses.
|
||||
*
|
||||
* Conversation continuity embeds prior exchanges in each prompt
|
||||
* (each CLI invocation is a fresh subprocess with no memory).
|
||||
* History is trimmed to MAX_HISTORY_TOKENS, dropping oldest first.
|
||||
*/
|
||||
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>
|
||||
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 = {
|
||||
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[],
|
||||
@@ -19,47 +59,33 @@ export function useAIChat(
|
||||
const [streamingContent, setStreamingContent] = useState('')
|
||||
const abortRef = useRef(false)
|
||||
|
||||
const sendMessage = useCallback((text: string) => {
|
||||
/** Internal: send text with explicit history context. */
|
||||
const doSend = useCallback((text: string, history: ChatMessage[]) => {
|
||||
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 = ''
|
||||
// Always include system prompt (each request is a fresh subprocess).
|
||||
const systemPrompt = 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)
|
||||
},
|
||||
// Embed conversation history in the prompt for continuity.
|
||||
const trimmedHistory = trimHistory(history, MAX_HISTORY_TOKENS)
|
||||
const formattedMessage = formatMessageWithHistory(trimmedHistory, text.trim())
|
||||
|
||||
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, setMessages, setStreamingContent, setIsStreaming,
|
||||
})
|
||||
}, [isStreaming, allContent, contextNotes, messages])
|
||||
|
||||
streamClaudeChat(formattedMessage, systemPrompt, undefined, callbacks)
|
||||
.catch(() => { /* errors forwarded via onError */ })
|
||||
}, [isStreaming, allContent, contextNotes])
|
||||
|
||||
const sendMessage = useCallback((text: string) => {
|
||||
doSend(text, messages)
|
||||
}, [doSend, messages])
|
||||
|
||||
const clearConversation = useCallback(() => {
|
||||
abortRef.current = true
|
||||
@@ -74,9 +100,10 @@ export function useAIChat(
|
||||
const userMsg = messages[userMsgIndex]
|
||||
if (userMsg.role !== 'user') return
|
||||
|
||||
setMessages(prev => prev.slice(0, msgIndex))
|
||||
sendMessage(userMsg.content)
|
||||
}, [messages, sendMessage])
|
||||
const historyForRetry = messages.slice(0, userMsgIndex)
|
||||
setMessages(prev => prev.slice(0, userMsgIndex))
|
||||
doSend(userMsg.content, historyForRetry)
|
||||
}, [messages, doSend])
|
||||
|
||||
return { messages, isStreaming, streamingContent, sendMessage, clearConversation, retryMessage }
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
69
src/hooks/useCodeMirror.test.ts
Normal file
69
src/hooks/useCodeMirror.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useCodeMirror, type CodeMirrorCallbacks } from './useCodeMirror'
|
||||
|
||||
const noop = () => {}
|
||||
const noopCallbacks: CodeMirrorCallbacks = {
|
||||
onDocChange: noop,
|
||||
onCursorActivity: noop,
|
||||
onSave: noop,
|
||||
onEscape: () => false,
|
||||
}
|
||||
|
||||
describe('useCodeMirror', () => {
|
||||
let container: HTMLDivElement
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(container)
|
||||
})
|
||||
|
||||
it('creates an EditorView in the container', () => {
|
||||
const ref = { current: container }
|
||||
const { result } = renderHook(() =>
|
||||
useCodeMirror(ref, 'hello world', false, noopCallbacks),
|
||||
)
|
||||
expect(result.current.current).not.toBeNull()
|
||||
expect(container.querySelector('.cm-editor')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls requestMeasure when laputa-zoom-change event fires', () => {
|
||||
const ref = { current: container }
|
||||
const { result } = renderHook(() =>
|
||||
useCodeMirror(ref, 'hello', false, noopCallbacks),
|
||||
)
|
||||
const view = result.current.current!
|
||||
const spy = vi.spyOn(view, 'requestMeasure')
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('laputa-zoom-change'))
|
||||
})
|
||||
|
||||
expect(spy).toHaveBeenCalled()
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('stops listening for zoom changes after unmount', () => {
|
||||
const ref = { current: container }
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useCodeMirror(ref, 'hello', false, noopCallbacks),
|
||||
)
|
||||
const view = result.current.current!
|
||||
const spy = vi.spyOn(view, 'requestMeasure')
|
||||
|
||||
unmount()
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('laputa-zoom-change'))
|
||||
})
|
||||
|
||||
// After unmount, the listener should be removed — requestMeasure should NOT be called.
|
||||
// (The view is also destroyed on unmount, so this verifies cleanup.)
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -111,7 +111,19 @@ export function useCodeMirror(
|
||||
|
||||
const view = new EditorView({ state, parent })
|
||||
viewRef.current = view
|
||||
return () => { view.destroy(); viewRef.current = null }
|
||||
|
||||
// When CSS zoom changes on the document, CodeMirror's cached measurements
|
||||
// (scaleX/scaleY, line heights, character widths) become stale because
|
||||
// ResizeObserver doesn't fire for ancestor zoom changes. Force a re-measure
|
||||
// so cursor placement stays accurate at any zoom level.
|
||||
const handleZoomChange = () => { view.requestMeasure() }
|
||||
window.addEventListener('laputa-zoom-change', handleZoomChange)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('laputa-zoom-change', handleZoomChange)
|
||||
view.destroy()
|
||||
viewRef.current = null
|
||||
}
|
||||
// Re-create editor when isDark changes (theme is baked into extensions)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isDark])
|
||||
|
||||
@@ -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
|
||||
@@ -260,6 +262,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
{ id: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: true, execute: () => onCheckForUpdates?.() },
|
||||
{ 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,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -109,4 +109,46 @@ describe('useZoom', () => {
|
||||
const { result } = renderHook(() => useZoom())
|
||||
expect(result.current.zoomLevel).toBe(100)
|
||||
})
|
||||
|
||||
it('dispatches laputa-zoom-change event on zoomIn', () => {
|
||||
const handler = vi.fn()
|
||||
window.addEventListener('laputa-zoom-change', handler)
|
||||
const { result } = renderHook(() => useZoom())
|
||||
handler.mockClear() // clear any init-phase dispatches
|
||||
act(() => result.current.zoomIn())
|
||||
expect(handler).toHaveBeenCalled()
|
||||
window.removeEventListener('laputa-zoom-change', handler)
|
||||
})
|
||||
|
||||
it('dispatches laputa-zoom-change event on zoomOut', () => {
|
||||
const handler = vi.fn()
|
||||
window.addEventListener('laputa-zoom-change', handler)
|
||||
const { result } = renderHook(() => useZoom())
|
||||
handler.mockClear()
|
||||
act(() => result.current.zoomOut())
|
||||
expect(handler).toHaveBeenCalled()
|
||||
window.removeEventListener('laputa-zoom-change', handler)
|
||||
})
|
||||
|
||||
it('dispatches laputa-zoom-change event on zoomReset', () => {
|
||||
resetVaultConfigStore()
|
||||
bindVaultConfigStore({ ...DEFAULT_VC, zoom: 1.2 }, vi.fn())
|
||||
const handler = vi.fn()
|
||||
window.addEventListener('laputa-zoom-change', handler)
|
||||
const { result } = renderHook(() => useZoom())
|
||||
handler.mockClear()
|
||||
act(() => result.current.zoomReset())
|
||||
expect(handler).toHaveBeenCalled()
|
||||
window.removeEventListener('laputa-zoom-change', handler)
|
||||
})
|
||||
|
||||
it('applies CSS zoom synchronously during initialization', () => {
|
||||
resetVaultConfigStore()
|
||||
bindVaultConfigStore({ ...DEFAULT_VC, zoom: 1.2 }, vi.fn())
|
||||
const spy = vi.spyOn(document.documentElement.style, 'setProperty')
|
||||
renderHook(() => useZoom())
|
||||
// Zoom should be applied during state init (setProperty called with zoom value)
|
||||
expect(spy).toHaveBeenCalledWith('zoom', '120%')
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,6 +29,7 @@ function loadPersistedZoom(): number {
|
||||
|
||||
function applyZoomToDocument(level: number): void {
|
||||
document.documentElement.style.setProperty('zoom', `${level}%`)
|
||||
window.dispatchEvent(new Event('laputa-zoom-change'))
|
||||
}
|
||||
|
||||
function persistZoom(level: number): void {
|
||||
@@ -36,12 +37,13 @@ function persistZoom(level: number): void {
|
||||
}
|
||||
|
||||
export function useZoom() {
|
||||
const [zoomLevel, setZoomLevel] = useState(loadPersistedZoom)
|
||||
|
||||
// Apply persisted zoom on mount
|
||||
useEffect(() => {
|
||||
applyZoomToDocument(zoomLevel)
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps -- only on mount
|
||||
const [zoomLevel, setZoomLevel] = useState(() => {
|
||||
const level = loadPersistedZoom()
|
||||
// Apply zoom synchronously during init so child components (e.g. CodeMirror)
|
||||
// measure the correct scale factor in their own effects.
|
||||
document.documentElement.style.setProperty('zoom', `${level}%`)
|
||||
return level
|
||||
})
|
||||
|
||||
// Re-sync when vault config becomes available
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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,41 @@ describe('buildContextSnapshot', () => {
|
||||
expect(json.noteList).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses activeNoteContent for body when allContent is empty (Tauri mode)', () => {
|
||||
const emptyAllContent: Record<string, string> = {}
|
||||
const result = buildContextSnapshot({
|
||||
activeEntry: active,
|
||||
allContent: emptyAllContent,
|
||||
entries,
|
||||
activeNoteContent: '---\ntitle: Alpha\n---\n\n# Alpha\nProject content from tab.',
|
||||
})
|
||||
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
|
||||
expect(json.activeNote.body).toContain('Project content from tab.')
|
||||
})
|
||||
|
||||
it('prefers activeNoteContent over allContent when both present', () => {
|
||||
const result = buildContextSnapshot({
|
||||
activeEntry: active,
|
||||
allContent,
|
||||
entries,
|
||||
activeNoteContent: 'Fresh editor content',
|
||||
})
|
||||
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
|
||||
expect(json.activeNote.body).toBe('Fresh editor content')
|
||||
})
|
||||
|
||||
it('falls back to allContent when activeNoteContent is undefined', () => {
|
||||
const result = buildContextSnapshot({ activeEntry: active, allContent, entries })
|
||||
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
|
||||
expect(json.activeNote.body).toBe('# Alpha\nProject content.')
|
||||
})
|
||||
|
||||
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',
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface NoteListItem {
|
||||
export interface ContextSnapshotParams {
|
||||
activeEntry: VaultEntry
|
||||
allContent: Record<string, string>
|
||||
/** Direct content of the active note from the editor tab (most reliable source). */
|
||||
activeNoteContent?: string
|
||||
openTabs?: VaultEntry[]
|
||||
noteList?: NoteListItem[]
|
||||
noteListFilter?: { type: string | null; query: string }
|
||||
@@ -94,7 +96,7 @@ const MAX_NOTE_LIST_ITEMS = 100
|
||||
|
||||
/** Build a structured context snapshot as a system prompt for Claude. */
|
||||
export function buildContextSnapshot(params: ContextSnapshotParams): string {
|
||||
const { activeEntry, allContent, openTabs, noteList, noteListFilter, entries, references } = params
|
||||
const { activeEntry, allContent, activeNoteContent, openTabs, noteList, noteListFilter, entries, references } = params
|
||||
|
||||
const snapshot: Record<string, unknown> = {
|
||||
activeNote: {
|
||||
@@ -102,7 +104,7 @@ export function buildContextSnapshot(params: ContextSnapshotParams): string {
|
||||
title: activeEntry.title,
|
||||
type: activeEntry.isA ?? 'Note',
|
||||
frontmatter: entryFrontmatter(activeEntry),
|
||||
body: allContent[activeEntry.path] ?? '',
|
||||
body: activeNoteContent ?? allContent[activeEntry.path] ?? '',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -152,6 +154,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
|
||||
}
|
||||
@@ -48,15 +48,19 @@ test.describe('AI chat wikilink rendering', () => {
|
||||
})
|
||||
|
||||
test('clicking a wikilink opens the note in a tab', async ({ page }) => {
|
||||
const wikilink = page.locator('.chat-wikilink').first()
|
||||
await expect(wikilink).toHaveText('Build Laputa App')
|
||||
// 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 the note opened in a tab — the editor breadcrumb shows the active note title
|
||||
const breadcrumb = page.locator('.app__editor span.truncate.font-medium', { hasText: 'Build Laputa App' })
|
||||
await expect(breadcrumb).toBeVisible({ timeout: 3000 })
|
||||
// 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