Compare commits

...

12 Commits

Author SHA1 Message Date
Test
bcfd37d481 fix: CodeMirror cursor placement at non-100% zoom levels
Root cause: CSS zoom on document.documentElement caused stale CodeMirror
measurements. Two issues:

1. Race condition: zoom was applied in useEffect (parent), but CodeMirror
   was created in useEffect (child) — child effects run first, so CM
   measured at zoom=1 before zoom was actually applied.

2. No re-measure on zoom change: CSS zoom changes don't trigger
   ResizeObserver on descendant elements, so CodeMirror never updated
   its cached scaleX/scaleY, line heights, or character widths.

Fix:
- Apply zoom synchronously during useState init (before child effects)
- Dispatch 'laputa-zoom-change' event when zoom changes
- Listen for this event in useCodeMirror and call view.requestMeasure()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 14:54:50 +01:00
Test
f27ebe05c4 fix: pass active note content directly to AI context builder
In Tauri mode, allContent is {} (empty) until a note is explicitly
saved. The previous mergeTabContent fix enriched allContent with tab
content, but the indirection was fragile. This fix passes the active
tab's content directly to buildContextSnapshot as activeNoteContent,
which takes priority over allContent[path]. This ensures the AI always
receives the note body regardless of allContent state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:42:09 +01:00
Test
7470e4f4a7 fix: embed conversation history in prompt instead of broken --resume
Each CLI invocation is a fresh subprocess — --resume depends on session
persistence that doesn't reliably work with -p mode. Switch to prompt-
embedded history: prior exchanges are formatted into each request using
formatMessageWithHistory + trimHistory (already existed as dead code).

- Remove sessionIdRef and --resume session tracking
- Always send system prompt (each request is stateless)
- Split sendMessage into doSend(text, history) to handle retry correctly
- Retry now passes correct history (excludes the retried exchange)
- Tests verify history accumulation, clear, retry, and system prompt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:06:58 +01:00
Test
0e503cb179 fix: AI chat receives note body from open tabs instead of empty allContent
allContent is empty ({}) in Tauri mode because loadVaultData never
populates it — note content only enters allContent on explicit save.
mergeTabContent() enriches allContent with open tab content so the AI
context snapshot always includes the active note's body.

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

Threaded onVaultChanged through AiPanel → EditorRightPanel → Editor → App.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:35:52 +01:00
31 changed files with 1016 additions and 157 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,6 +49,7 @@ const VAULT_RESOLVE_CONFLICTS: &str = "vault-resolve-conflicts";
const VAULT_VIEW_CHANGES: &str = "vault-view-changes";
const VAULT_INSTALL_MCP: &str = "vault-install-mcp";
const VAULT_REINDEX: &str = "vault-reindex";
const VAULT_REPAIR: &str = "vault-repair";
const CUSTOM_IDS: &[&str] = &[
APP_SETTINGS,
@@ -90,6 +91,7 @@ const CUSTOM_IDS: &[&str] = &[
VAULT_VIEW_CHANGES,
VAULT_INSTALL_MCP,
VAULT_REINDEX,
VAULT_REPAIR,
];
/// IDs of menu items that should be disabled when no note tab is active.
@@ -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()?)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

View File

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

View File

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

View File

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

View File

@@ -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 }
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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])

View File

@@ -21,6 +21,7 @@ interface CommandRegistryConfig {
mcpStatus?: string
onInstallMcp?: () => void
onReindexVault?: () => void
onRepairVault?: () => void
onQuickOpen: () => void
onCreateNote: () => void
@@ -198,6 +199,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
onRemoveActiveVault, onRestoreGettingStarted, onRestoreDefaultThemes, isGettingStartedHidden, vaultCount,
mcpStatus, onInstallMcp,
onReindexVault,
onRepairVault,
} = config
const hasActiveNote = activeTabPath !== null
@@ -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,
])
}

View File

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

View File

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

View File

@@ -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(() => {

View File

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

View File

@@ -331,6 +331,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',

View File

@@ -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\`\`\``

View File

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

View File

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

View File

@@ -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)
})
})