feat: add vault ai guidance restore flow

This commit is contained in:
lucaronin
2026-04-14 14:16:55 +02:00
parent 2356afdc42
commit ec3f90e865
22 changed files with 1102 additions and 184 deletions

View File

@@ -146,12 +146,13 @@ Type is determined **purely** from the `type:` frontmatter field — it is never
├── weekly-review.md ← type: Procedure
├── john-doe.md ← type: Person
├── some-topic.md ← type: Topic
├── AGENTS.md ← canonical Tolaria AI guidance
├── CLAUDE.md ← compatibility shim pointing at AGENTS.md
├── ...
── type/ ← type definition documents
└── config/ ← meta-configuration files (agents.md, etc.)
── type/ ← type definition documents
```
New notes are created at the vault root: `{vault}/{slug}.md`. Changing a note's type only requires updating the `type:` field in frontmatter — the file does not move. The `type/` folder exists solely for type definition documents, and `config/` for configuration files.
New notes are created at the vault root: `{vault}/{slug}.md`. Changing a note's type only requires updating the `type:` field in frontmatter — the file does not move. The `type/` folder exists solely for type definition documents. Legacy `config/` content is still recognized during migration and repair, but Tolaria's managed AI guidance now lives at the vault root.
A `flatten_vault` migration command is available to move existing notes from type-based subfolders to the vault root.
@@ -271,7 +272,7 @@ type SidebarSelection =
1. Validates the path exists and is a directory
2. Scans root-level `.md` files (non-recursive)
3. Recursively scans protected folders: `type/`, `config/`, `attachments/`
3. Recursively scans protected folders: `type/`, legacy `config/`, `attachments/`
4. Files in non-protected subfolders are **not indexed** (flat vault enforcement)
5. For each `.md` file, calls `parse_md_file()`:
- Reads content with `fs::read_to_string()`
@@ -529,6 +530,15 @@ Per-vault settings stored locally and scoped by vault path:
- Settings: zoom, view mode, tag colors, status colors, property display modes, Inbox note-list column overrides, explicit organization workflow toggle
- One-time migration from localStorage (`configMigration.ts`)
### AI Guidance Files
Tolaria tracks managed vault-level AI guidance separately from normal note content:
- `AGENTS.md` is the canonical managed guidance file for Tolaria-aware coding agents
- `CLAUDE.md` is a compatibility shim that points Claude Code back to `AGENTS.md`
- `useVaultAiGuidanceStatus` reads `get_vault_ai_guidance_status` and normalizes the backend state into four UI cases: `managed`, `missing`, `broken`, and `custom`
- `restore_vault_ai_guidance` repairs only Tolaria-managed files; user-authored custom `AGENTS.md` / `CLAUDE.md` files are surfaced as custom and left untouched
- The status bar AI badge and command palette consume that abstraction to expose restore actions only when the managed guidance is missing or broken
### Getting Started / Onboarding
`useOnboarding` hook detects first launch:

View File

@@ -437,7 +437,7 @@ On first launch, `useOnboarding` checks if the default vault exists. If not, it
Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code and Codex are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch.
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL, delegates the clone to the git backend, then normalizes Tolaria-managed config files (`AGENTS.md`, `config.md`) so fresh starter vaults pick up the current default guidance even when the remote starter repo still carries a legacy copy.
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL, delegates the clone to the git backend, then normalizes Tolaria-managed config files (`AGENTS.md`, `CLAUDE.md`, `config.md`) so fresh starter vaults pick up the current default guidance even when the remote starter repo still carries a legacy copy. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions.
### Remote Clone & Auth Model
@@ -571,7 +571,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `rename.rs` | `rename_note` — renames files, updates `title` frontmatter, and updates wikilinks across the vault |
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames |
| `migration.rs` | `flatten_vault`, `vault_health_check`, `migrate_is_a_to_type` |
| `config_seed.rs` | Seeds `config/` folder, migrates `AGENTS.md`, repairs missing config files |
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, repairs missing config files |
| `getting_started.rs` | Creates the Getting Started demo vault |
## Rust Backend Modules
@@ -608,8 +608,10 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `reload_vault` | Invalidate cache and full rescan from filesystem → `Vec<VaultEntry>` |
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
| `check_vault_exists` | Check if vault path exists |
| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md` and `config.md` defaults |
| `create_getting_started_vault` | Clone the public Getting Started vault, refresh Tolaria-managed config defaults, and keep the cloned repo clean |
| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, and `config.md` defaults |
| `create_getting_started_vault` | Clone the public Getting Started vault, refresh Tolaria-managed guidance/config defaults, and keep the cloned repo clean |
| `get_vault_ai_guidance_status` | Report whether `AGENTS.md` and the `CLAUDE.md` shim are managed, missing, broken, or custom |
| `restore_vault_ai_guidance` | Restore any missing/broken Tolaria-managed guidance files without overwriting custom ones |
### Frontmatter

View File

@@ -186,7 +186,7 @@ tolaria/
├── playwright.config.ts # Full Playwright regression config
├── playwright.smoke.config.ts # Curated pre-push Playwright config
├── ui-design.pen # Master design file
├── AGENTS.md # Shared project instructions for coding agents
├── AGENTS.md # Canonical shared instructions for coding agents
├── CLAUDE.md # Claude Code compatibility shim importing AGENTS.md
└── docs/ # This documentation
```

View File

@@ -1,6 +1,9 @@
#[cfg(desktop)]
use crate::ai_agents::{AiAgentStreamRequest, AiAgentsStatus};
use crate::claude_cli::{AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus};
use crate::vault::VaultAiGuidanceStatus;
use super::expand_tilde;
#[cfg(desktop)]
type StreamEmitter<Event> = Box<dyn Fn(Event) + Send>;
@@ -58,6 +61,18 @@ pub fn get_ai_agents_status() -> AiAgentsStatus {
crate::ai_agents::get_ai_agents_status()
}
#[tauri::command]
pub fn get_vault_ai_guidance_status(vault_path: String) -> Result<VaultAiGuidanceStatus, String> {
let vault_path = expand_tilde(&vault_path);
crate::vault::get_ai_guidance_status(vault_path.as_ref())
}
#[tauri::command]
pub fn restore_vault_ai_guidance(vault_path: String) -> Result<VaultAiGuidanceStatus, String> {
let vault_path = expand_tilde(&vault_path);
crate::vault::restore_ai_guidance_files(vault_path.as_ref())
}
#[cfg(desktop)]
define_desktop_stream_command!(
stream_claude_chat,

View File

@@ -178,6 +178,8 @@ macro_rules! app_invoke_handler {
commands::init_git_repo,
commands::check_claude_cli,
commands::get_ai_agents_status,
commands::get_vault_ai_guidance_status,
commands::restore_vault_ai_guidance,
commands::stream_claude_chat,
commands::stream_claude_agent,
commands::stream_ai_agent,

View File

@@ -1,5 +1,6 @@
use serde::Serialize;
use std::fs;
use std::path::Path;
use std::path::{Path, PathBuf};
use super::getting_started::{AGENTS_MD, LEGACY_AGENTS_MD};
@@ -18,6 +19,33 @@ sidebar label: Config
Vault configuration files. These control how AI agents, tools, and other integrations interact with this vault.
";
const CLAUDE_MD_SHIM: &str = "@AGENTS.md
This file is a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`.
";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AiGuidanceFileState {
Managed,
Missing,
Broken,
Custom,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct VaultAiGuidanceStatus {
pub agents_state: AiGuidanceFileState,
pub claude_state: AiGuidanceFileState,
pub can_restore: bool,
}
#[derive(Debug, Default)]
struct LegacyAgentsMigrationOutcome {
copied_to_root: bool,
removed_legacy: bool,
}
/// Write a file if it doesn't exist or is empty (corrupt). Returns true if written.
fn write_if_missing(path: &Path, content: &str) -> Result<bool, String> {
let needs_write = !path.exists() || fs::metadata(path).map_or(true, |m| m.len() == 0);
@@ -27,31 +55,176 @@ fn write_if_missing(path: &Path, content: &str) -> Result<bool, String> {
Ok(needs_write)
}
fn read_file_or_empty(path: &Path) -> String {
fs::read_to_string(path).unwrap_or_default()
}
fn agents_content_can_be_replaced(content: &str) -> bool {
content.is_empty()
|| content.contains("See config/agents.md")
|| content == LEGACY_AGENTS_MD
|| content.contains("Do not add `title:` frontmatter.")
}
fn root_agents_can_be_replaced(path: &Path) -> bool {
!path.exists()
|| fs::read_to_string(path).map_or(true, |content| {
content.is_empty()
|| content.contains("See config/agents.md")
|| content == LEGACY_AGENTS_MD
|| content.contains("Do not add `title:` frontmatter.")
})
!path.exists() || agents_content_can_be_replaced(&read_file_or_empty(path))
}
fn matches_claude_shim(content: &str) -> bool {
let trimmed = content.trim();
trimmed == "@AGENTS.md" || trimmed == CLAUDE_MD_SHIM.trim()
}
fn claude_shim_can_be_replaced(path: &Path) -> bool {
!path.exists() || {
let content = read_file_or_empty(path);
content.trim().is_empty() || matches_claude_shim(&content)
}
}
fn sync_managed_file(
path: &Path,
content: &str,
can_replace: fn(&Path) -> bool,
) -> Result<bool, String> {
if !can_replace(path) {
return Ok(false);
}
fs::write(path, content).map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
Ok(true)
}
fn classify_guidance_file(
path: &Path,
matches_managed: fn(&str) -> bool,
can_replace: fn(&Path) -> bool,
) -> AiGuidanceFileState {
if !path.exists() {
return AiGuidanceFileState::Missing;
}
let content = read_file_or_empty(path);
if matches_managed(&content) {
return AiGuidanceFileState::Managed;
}
if can_replace(path) {
return AiGuidanceFileState::Broken;
}
AiGuidanceFileState::Custom
}
fn guidance_paths(vault_path: &str) -> (PathBuf, PathBuf) {
let vault = Path::new(vault_path);
(vault.join("AGENTS.md"), vault.join("CLAUDE.md"))
}
fn classify_agents_file(path: &Path) -> AiGuidanceFileState {
classify_guidance_file(
path,
|content| content == AGENTS_MD,
root_agents_can_be_replaced,
)
}
fn classify_claude_file(path: &Path) -> AiGuidanceFileState {
classify_guidance_file(path, matches_claude_shim, claude_shim_can_be_replaced)
}
fn guidance_file_needs_restore(state: AiGuidanceFileState) -> bool {
matches!(
state,
AiGuidanceFileState::Missing | AiGuidanceFileState::Broken
)
}
fn build_ai_guidance_status(vault_path: &str) -> VaultAiGuidanceStatus {
let (agents_path, claude_path) = guidance_paths(vault_path);
let agents_state = classify_agents_file(&agents_path);
let claude_state = classify_claude_file(&claude_path);
VaultAiGuidanceStatus {
agents_state,
claude_state,
can_restore: guidance_file_needs_restore(agents_state)
|| guidance_file_needs_restore(claude_state),
}
}
fn sync_claude_shim_file(vault_path: &str) -> Result<bool, String> {
let (_, claude_path) = guidance_paths(vault_path);
sync_managed_file(&claude_path, CLAUDE_MD_SHIM, claude_shim_can_be_replaced)
}
fn sync_ai_guidance_files(vault_path: &str) -> Result<bool, String> {
let wrote_agents = sync_default_agents_file(vault_path)?;
let wrote_claude = sync_claude_shim_file(vault_path)?;
Ok(wrote_agents || wrote_claude)
}
fn migrate_legacy_agents_file(
root_agents: &Path,
config_agents: &Path,
) -> Result<LegacyAgentsMigrationOutcome, String> {
let mut outcome = LegacyAgentsMigrationOutcome::default();
if !config_agents.exists() {
return Ok(outcome);
}
let config_content = read_file_or_empty(config_agents);
if !config_content.is_empty() && root_agents_can_be_replaced(root_agents) {
fs::write(root_agents, &config_content)
.map_err(|e| format!("Failed to write AGENTS.md: {e}"))?;
outcome.copied_to_root = true;
}
fs::remove_file(config_agents)
.map_err(|e| format!("Failed to remove config/agents.md: {e}"))?;
outcome.removed_legacy = true;
Ok(outcome)
}
fn cleanup_empty_config_dir(vault: &Path) -> Result<bool, String> {
let config_dir = vault.join("config");
if !config_dir.is_dir() {
return Ok(false);
}
let is_empty = fs::read_dir(&config_dir)
.map_err(|e| format!("Failed to inspect {}: {e}", config_dir.display()))?
.next()
.is_none();
if !is_empty {
return Ok(false);
}
fs::remove_dir(&config_dir)
.map_err(|e| format!("Failed to remove {}: {e}", config_dir.display()))?;
Ok(true)
}
pub(super) fn sync_default_agents_file(vault_path: &str) -> Result<bool, String> {
let agents_path = Path::new(vault_path).join("AGENTS.md");
if root_agents_can_be_replaced(&agents_path) {
fs::write(&agents_path, AGENTS_MD)
.map_err(|e| format!("Failed to write {}: {e}", agents_path.display()))?;
return Ok(true);
}
Ok(false)
let (agents_path, _) = guidance_paths(vault_path);
sync_managed_file(&agents_path, AGENTS_MD, root_agents_can_be_replaced)
}
pub fn get_ai_guidance_status(vault_path: &str) -> Result<VaultAiGuidanceStatus, String> {
Ok(build_ai_guidance_status(vault_path))
}
pub fn restore_ai_guidance_files(vault_path: &str) -> Result<VaultAiGuidanceStatus, String> {
sync_ai_guidance_files(vault_path)?;
get_ai_guidance_status(vault_path)
}
/// Seed `AGENTS.md` at vault root if missing or empty (idempotent, per-file).
/// Also seeds `config.md` type definition for sidebar visibility.
pub fn seed_config_files(vault_path: &str) {
if sync_default_agents_file(vault_path).unwrap_or(false) {
log::info!("Seeded AGENTS.md at vault root");
if sync_ai_guidance_files(vault_path).unwrap_or(false) {
log::info!("Seeded vault AI guidance files at vault root");
}
ensure_config_type_definition(vault_path);
@@ -60,10 +233,7 @@ pub fn seed_config_files(vault_path: &str) {
/// Ensure `config.md` exists at vault root (gives Config type a sidebar icon/color).
fn ensure_config_type_definition(vault_path: &str) {
let path = Path::new(vault_path).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);
}
let _ = write_if_missing(&path, CONFIG_TYPE_DEFINITION);
}
/// Migrate legacy `config/agents.md` → root `AGENTS.md` for existing vaults.
@@ -79,33 +249,20 @@ pub fn migrate_agents_md(vault_path: &str) {
let root_agents = vault.join("AGENTS.md");
let config_agents = vault.join("config").join("agents.md");
// If legacy config/agents.md exists with real content, migrate it to root
if config_agents.exists() {
let config_content = fs::read_to_string(&config_agents).unwrap_or_default();
if !config_content.is_empty() {
// Only migrate if root AGENTS.md is missing, empty, or is a stub
if root_agents_can_be_replaced(&root_agents) {
let _ = fs::write(&root_agents, &config_content);
log::info!("Migrated config/agents.md content to root AGENTS.md");
}
// Remove legacy file
let _ = fs::remove_file(&config_agents);
if let Ok(outcome) = migrate_legacy_agents_file(&root_agents, &config_agents) {
if outcome.copied_to_root {
log::info!("Migrated config/agents.md content to root AGENTS.md");
}
if outcome.removed_legacy {
log::info!("Removed legacy config/agents.md");
}
}
// Clean up empty config/ directory
let config_dir = vault.join("config");
if config_dir.is_dir() {
let is_empty = fs::read_dir(&config_dir).map_or(true, |mut d| d.next().is_none());
if is_empty {
let _ = fs::remove_dir(&config_dir);
log::info!("Removed empty config/ directory");
}
if cleanup_empty_config_dir(vault).unwrap_or(false) {
log::info!("Removed empty config/ directory");
}
// Ensure root AGENTS.md exists with content
let _ = sync_default_agents_file(vault_path);
let _ = sync_ai_guidance_files(vault_path);
}
/// Repair config files: ensure `AGENTS.md` at vault root and `config.md` type definition.
@@ -116,32 +273,10 @@ pub fn repair_config_files(vault_path: &str) -> Result<String, String> {
let root_agents = vault.join("AGENTS.md");
let config_agents = vault.join("config").join("agents.md");
// Step 1: Migrate legacy config/agents.md → root AGENTS.md
if config_agents.exists() {
let config_content = fs::read_to_string(&config_agents).unwrap_or_default();
if !config_content.is_empty() {
if root_agents_can_be_replaced(&root_agents) {
fs::write(&root_agents, &config_content)
.map_err(|e| format!("Failed to write AGENTS.md: {e}"))?;
}
fs::remove_file(&config_agents)
.map_err(|e| format!("Failed to remove config/agents.md: {e}"))?;
}
}
migrate_legacy_agents_file(&root_agents, &config_agents)?;
let _ = cleanup_empty_config_dir(vault)?;
sync_ai_guidance_files(vault_path)?;
// Step 2: Clean up empty config/ directory
let config_dir = vault.join("config");
if config_dir.is_dir() {
let is_empty = fs::read_dir(&config_dir).map_or(true, |mut d| d.next().is_none());
if is_empty {
let _ = fs::remove_dir(&config_dir);
}
}
// Step 3: Seed AGENTS.md with defaults if still missing or empty
sync_default_agents_file(vault_path)?;
// Step 4: Ensure config.md type definition at vault root
write_if_missing(&vault.join("config.md"), CONFIG_TYPE_DEFINITION)?;
Ok("Config files repaired".to_string())
@@ -153,26 +288,53 @@ mod tests {
use std::fs;
use tempfile::TempDir;
#[test]
fn test_seed_config_files_creates_agents_at_root() {
fn create_vault() -> (TempDir, PathBuf) {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
(dir, vault)
}
fn config_dir(vault: &Path) -> PathBuf {
let dir = vault.join("config");
fs::create_dir_all(&dir).unwrap();
dir
}
fn write_root_agents(vault: &Path, content: &str) {
fs::write(vault.join("AGENTS.md"), content).unwrap();
}
fn write_root_claude(vault: &Path, content: &str) {
fs::write(vault.join("CLAUDE.md"), content).unwrap();
}
fn write_legacy_agents(vault: &Path, content: &str) {
fs::write(config_dir(vault).join("agents.md"), content).unwrap();
}
fn read_root_agents(vault: &Path) -> String {
fs::read_to_string(vault.join("AGENTS.md")).unwrap()
}
fn read_root_claude(vault: &Path) -> String {
fs::read_to_string(vault.join("CLAUDE.md")).unwrap()
}
#[test]
fn test_seed_config_files_creates_guidance_files_at_root() {
let (_dir, vault) = create_vault();
seed_config_files(vault.to_str().unwrap());
assert!(vault.join("AGENTS.md").exists());
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
assert!(content.contains("Tolaria Vault"));
// Must NOT create config/ directory
assert!(!vault.join("config").exists());
assert!(read_root_agents(&vault).contains("Tolaria Vault"));
assert_eq!(read_root_claude(&vault), CLAUDE_MD_SHIM);
}
#[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();
let (_dir, vault) = create_vault();
seed_config_files(vault.to_str().unwrap());
@@ -180,69 +342,67 @@ mod tests {
let content = fs::read_to_string(vault.join("config.md")).unwrap();
assert!(content.contains("type: Type"));
assert!(content.contains("icon: gear-six"));
assert!(!vault.join("config").exists());
}
#[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();
fn test_seed_config_files_preserves_custom_agents() {
let (_dir, vault) = create_vault();
seed_config_files(vault.to_str().unwrap());
let custom = "# Custom Agent Config\nMy custom instructions\n";
fs::write(vault.join("AGENTS.md"), custom).unwrap();
write_root_agents(&vault, "# Custom Agent Config\nMy custom instructions\n");
seed_config_files(vault.to_str().unwrap());
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
assert!(
content.contains("Custom Agent Config"),
read_root_agents(&vault).contains("Custom Agent Config"),
"must preserve existing content"
);
}
#[test]
fn test_seed_config_files_reseeds_empty() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
fs::write(vault.join("AGENTS.md"), "").unwrap();
let (_dir, vault) = create_vault();
write_root_agents(&vault, "");
seed_config_files(vault.to_str().unwrap());
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
assert!(content.contains("Tolaria Vault"));
assert!(read_root_agents(&vault).contains("Tolaria Vault"));
}
#[test]
fn test_seed_config_files_refreshes_stale_default_agents() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
fs::write(
vault.join("AGENTS.md"),
let (_dir, vault) = create_vault();
write_root_agents(
&vault,
"# AGENTS.md — Tolaria Vault\n\n- The first H1 in the body is the note title. Do not add `title:` frontmatter.\n",
)
.unwrap();
);
seed_config_files(vault.to_str().unwrap());
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
let content = read_root_agents(&vault);
assert!(content.contains("Legacy `title:` frontmatter is still read as a fallback"));
assert!(content.contains("views/*.yml"));
assert!(content.contains("Belongs to:"));
}
#[test]
fn test_seed_config_files_preserves_custom_claude() {
let (_dir, vault) = create_vault();
write_root_claude(&vault, "# Custom Claude instructions\nDo not overwrite\n");
seed_config_files(vault.to_str().unwrap());
assert!(read_root_claude(&vault).contains("Custom Claude instructions"));
}
#[test]
fn test_migrate_agents_md_moves_config_to_root() {
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 vault agent instructions\nCustom content\n";
fs::write(config_dir.join("agents.md"), custom).unwrap();
let (_dir, vault) = create_vault();
write_legacy_agents(&vault, "# My vault agent instructions\nCustom content\n");
migrate_agents_md(vault.to_str().unwrap());
let root_content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
let config_dir = vault.join("config");
let root_content = read_root_agents(&vault);
assert!(root_content.contains("My vault agent instructions"));
assert!(!config_dir.join("agents.md").exists());
assert!(!config_dir.exists());
@@ -250,60 +410,49 @@ mod tests {
#[test]
fn test_migrate_agents_md_preserves_existing_root() {
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_root = "# My root agent config\nDo not overwrite\n";
fs::write(vault.join("AGENTS.md"), custom_root).unwrap();
fs::write(config_dir.join("agents.md"), "Legacy content").unwrap();
let (_dir, vault) = create_vault();
write_root_agents(&vault, "# My root agent config\nDo not overwrite\n");
write_legacy_agents(&vault, "Legacy content");
migrate_agents_md(vault.to_str().unwrap());
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
let config_dir = vault.join("config");
let content = read_root_agents(&vault);
assert!(content.contains("My root agent config"));
assert!(!config_dir.join("agents.md").exists());
}
#[test]
fn test_migrate_agents_md_replaces_stub_with_config_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();
fs::write(
vault.join("AGENTS.md"),
let (_dir, vault) = create_vault();
write_root_agents(
&vault,
"# Agent Instructions\nSee config/agents.md for vault instructions.\n",
)
.unwrap();
let real_content = "# Real Agent Config\nImportant instructions\n";
fs::write(config_dir.join("agents.md"), real_content).unwrap();
);
write_legacy_agents(&vault, "# Real Agent Config\nImportant instructions\n");
migrate_agents_md(vault.to_str().unwrap());
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
let content = read_root_agents(&vault);
assert!(content.contains("Real Agent Config"));
}
#[test]
fn test_migrate_agents_md_idempotent_when_no_legacy() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let (_dir, vault) = create_vault();
migrate_agents_md(vault.to_str().unwrap());
assert!(vault.join("AGENTS.md").exists());
let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
let root = read_root_agents(&vault);
assert!(root.contains("Tolaria Vault"));
assert_eq!(read_root_claude(&vault), CLAUDE_MD_SHIM);
}
#[test]
fn test_migrate_agents_md_keeps_nonempty_config_dir() {
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 (_dir, vault) = create_vault();
let config_dir = config_dir(&vault);
fs::write(config_dir.join("agents.md"), "Agent content").unwrap();
fs::write(config_dir.join("other.md"), "Other file").unwrap();
@@ -316,71 +465,98 @@ mod tests {
#[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 (_dir, vault) = create_vault();
let msg = repair_config_files(vault.to_str().unwrap()).unwrap();
assert_eq!(msg, "Config files repaired");
assert!(vault.join("AGENTS.md").exists());
assert!(vault.join("CLAUDE.md").exists());
assert!(vault.join("config.md").exists());
assert!(!vault.join("config").exists());
let agents = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
let agents = read_root_agents(&vault);
assert!(agents.contains("Tolaria Vault"));
}
#[test]
fn test_repair_config_files_preserves_custom_content() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let custom = "# My custom agent config\nDo not overwrite me\n";
fs::write(vault.join("AGENTS.md"), custom).unwrap();
let (_dir, vault) = create_vault();
write_root_agents(&vault, "# My custom agent config\nDo not overwrite me\n");
repair_config_files(vault.to_str().unwrap()).unwrap();
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
assert!(
content.contains("My custom agent config"),
read_root_agents(&vault).contains("My custom agent config"),
"must preserve existing content"
);
}
#[test]
fn test_repair_config_files_migrates_legacy_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 original = "# My vault agents instructions\nCustom content here\n";
fs::write(config_dir.join("agents.md"), original).unwrap();
let (_dir, vault) = create_vault();
write_legacy_agents(
&vault,
"# My vault agents instructions\nCustom content here\n",
);
repair_config_files(vault.to_str().unwrap()).unwrap();
let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
let config_dir = vault.join("config");
let root = read_root_agents(&vault);
assert!(root.contains("My vault agents instructions"));
assert!(!config_dir.join("agents.md").exists());
}
#[test]
fn test_repair_config_files_replaces_stub_with_legacy() {
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(
vault.join("AGENTS.md"),
let (_dir, vault) = create_vault();
write_root_agents(
&vault,
"# Agent Instructions\nSee config/agents.md for vault instructions.\n",
)
.unwrap();
let real = "# Real Instructions\nImportant stuff\n";
fs::write(config_dir.join("agents.md"), real).unwrap();
);
write_legacy_agents(&vault, "# Real Instructions\nImportant stuff\n");
repair_config_files(vault.to_str().unwrap()).unwrap();
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
let content = read_root_agents(&vault);
assert!(content.contains("Real Instructions"));
}
#[test]
fn test_get_ai_guidance_status_reports_custom_and_repairable_files() {
let (_dir, vault) = create_vault();
write_root_agents(&vault, "# Custom Agent Config\nHands off\n");
write_root_claude(&vault, "");
let status = get_ai_guidance_status(vault.to_str().unwrap()).unwrap();
assert_eq!(status.agents_state, AiGuidanceFileState::Custom);
assert_eq!(status.claude_state, AiGuidanceFileState::Broken);
assert!(status.can_restore);
}
#[test]
fn test_restore_ai_guidance_files_repairs_without_overwriting_custom_agents() {
let (_dir, vault) = create_vault();
write_root_agents(&vault, "# Custom Agent Config\nHands off\n");
write_root_claude(&vault, "");
let status = restore_ai_guidance_files(vault.to_str().unwrap()).unwrap();
assert_eq!(status.agents_state, AiGuidanceFileState::Custom);
assert_eq!(status.claude_state, AiGuidanceFileState::Managed);
assert!(!status.can_restore);
assert!(read_root_agents(&vault).contains("Custom Agent Config"));
assert_eq!(read_root_claude(&vault), CLAUDE_MD_SHIM);
}
#[test]
fn test_restore_ai_guidance_files_repairs_broken_agents_and_missing_claude() {
let (_dir, vault) = create_vault();
write_root_agents(&vault, "");
let status = restore_ai_guidance_files(vault.to_str().unwrap()).unwrap();
assert_eq!(status.agents_state, AiGuidanceFileState::Managed);
assert_eq!(status.claude_state, AiGuidanceFileState::Managed);
assert!(!status.can_restore);
}
}

View File

@@ -13,7 +13,10 @@ mod trash;
mod views;
pub use cache::{invalidate_cache, scan_vault_cached};
pub use config_seed::{migrate_agents_md, repair_config_files, seed_config_files};
pub use config_seed::{
get_ai_guidance_status, migrate_agents_md, repair_config_files, restore_ai_guidance_files,
seed_config_files, AiGuidanceFileState, VaultAiGuidanceStatus,
};
pub use entry::{FolderNode, VaultEntry};
pub use file::{get_note_content, save_note_content};
pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists};

View File

@@ -23,6 +23,7 @@ import { useTelemetry } from './hooks/useTelemetry'
import { useMcpStatus } from './hooks/useMcpStatus'
import { useAiAgentsOnboarding } from './hooks/useAiAgentsOnboarding'
import { useAiAgentsStatus } from './hooks/useAiAgentsStatus'
import { useVaultAiGuidanceStatus } from './hooks/useVaultAiGuidanceStatus'
import { useVaultLoader } from './hooks/useVaultLoader'
import { useAiAgentPreferences } from './hooks/useAiAgentPreferences'
import { useSettings } from './hooks/useSettings'
@@ -68,6 +69,9 @@ import { RenameDetectedBanner, type DetectedRename } from './components/RenameDe
import { openNoteListPropertiesPicker } from './components/note-list/noteListPropertiesEvents'
import { focusNoteIconPropertyEditor } from './components/noteIconPropertyEvents'
import { trackEvent } from './lib/telemetry'
import {
buildVaultAiGuidanceRefreshKey,
} from './lib/vaultAiGuidance'
import { extractDeletedContentFromDiff } from './components/note-list/noteListUtils'
import { hasNoteIconValue } from './utils/noteIcon'
import { OPEN_AI_CHAT_EVENT } from './utils/aiPromptBridge'
@@ -152,6 +156,13 @@ function App() {
}, [resolvedPath])
const vault = useVaultLoader(resolvedPath)
const {
status: vaultAiGuidanceStatus,
refresh: refreshVaultAiGuidance,
} = useVaultAiGuidanceStatus(
resolvedPath,
buildVaultAiGuidanceRefreshKey(vault.entries),
)
const { config: vaultConfig, updateConfig } = useVaultConfig(resolvedPath)
const explicitOrganizationEnabled = isExplicitOrganizationEnabled(vaultConfig.inbox?.explicitOrganization)
const effectiveSelection = sanitizeSelectionForOrganization(selection, vaultConfig.inbox?.explicitOrganization)
@@ -547,11 +558,25 @@ function App() {
const tauriInvoke = isTauri() ? invoke : mockInvoke
const msg = await tauriInvoke<string>('repair_vault', { vaultPath: resolvedPath })
await vault.reloadVault()
await refreshVaultAiGuidance()
setToastMessage(msg)
} catch (err) {
setToastMessage(`Failed to repair vault: ${err}`)
}
}, [resolvedPath, vault, setToastMessage])
}, [refreshVaultAiGuidance, resolvedPath, vault, setToastMessage])
const restoreVaultAiGuidance = useCallback(async (successToast: string | null = 'Tolaria AI guidance restored') => {
if (!resolvedPath) return
try {
const tauriInvoke = isTauri() ? invoke : mockInvoke
await tauriInvoke('restore_vault_ai_guidance', { vaultPath: resolvedPath })
await vault.reloadVault()
await refreshVaultAiGuidance()
if (successToast) setToastMessage(successToast)
} catch (err) {
setToastMessage(`Failed to restore Tolaria AI guidance: ${err}`)
}
}, [refreshVaultAiGuidance, resolvedPath, vault, setToastMessage])
const activeDeletedFile = useMemo(() => {
const activeTabPath = notes.activeTabPath
@@ -605,6 +630,8 @@ function App() {
onInstallMcp: installMcp,
onOpenAiAgents: dialogs.openSettings,
aiAgentsStatus,
vaultAiGuidanceStatus,
onRestoreVaultAiGuidance: () => { void restoreVaultAiGuidance() },
selectedAiAgent: aiAgentPreferences.defaultAiAgent,
onSetDefaultAiAgent: aiAgentPreferences.setDefaultAiAgent,
onCycleDefaultAiAgent: aiAgentPreferences.cycleDefaultAiAgent,
@@ -775,7 +802,7 @@ function App() {
</div>
<UpdateBanner status={updateStatus} actions={updateActions} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={vaultSwitcher.restoreGettingStarted} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isOffline={networkStatus.isOffline} isGitVault={!vault.modifiedFilesError} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} aiAgentsStatus={aiAgentsStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={vaultSwitcher.restoreGettingStarted} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isOffline={networkStatus.isOffline} isGitVault={!vault.modifiedFilesError} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />

View File

@@ -1,4 +1,5 @@
import type { AiAgentId, AiAgentsStatus } from '../lib/aiAgents'
import type { VaultAiGuidanceStatus } from '../lib/vaultAiGuidance'
import { useEffect, useState } from 'react'
import type { ClaudeCodeStatus } from '../hooks/useClaudeCodeStatus'
import type { McpStatus } from '../hooks/useMcpStatus'
@@ -43,8 +44,10 @@ interface StatusBarProps {
mcpStatus?: McpStatus
onInstallMcp?: () => void
aiAgentsStatus?: AiAgentsStatus
vaultAiGuidanceStatus?: VaultAiGuidanceStatus
defaultAiAgent?: AiAgentId
onSetDefaultAiAgent?: (agent: AiAgentId) => void
onRestoreVaultAiGuidance?: () => void
claudeCodeStatus?: ClaudeCodeStatus
claudeCodeVersion?: string | null
}
@@ -81,8 +84,10 @@ export function StatusBar({
mcpStatus,
onInstallMcp,
aiAgentsStatus,
vaultAiGuidanceStatus,
defaultAiAgent,
onSetDefaultAiAgent,
onRestoreVaultAiGuidance,
claudeCodeStatus,
claudeCodeVersion,
}: StatusBarProps) {
@@ -137,8 +142,10 @@ export function StatusBar({
mcpStatus={mcpStatus}
onInstallMcp={onInstallMcp}
aiAgentsStatus={aiAgentsStatus}
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
defaultAiAgent={defaultAiAgent}
onSetDefaultAiAgent={onSetDefaultAiAgent}
onRestoreVaultAiGuidance={onRestoreVaultAiGuidance}
claudeCodeStatus={claudeCodeStatus}
claudeCodeVersion={claudeCodeVersion}
/>

View File

@@ -0,0 +1,81 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AiAgentsBadge } from './AiAgentsBadge'
vi.mock('../../utils/url', async () => {
const actual = await vi.importActual('../../utils/url')
return { ...actual, openExternalUrl: vi.fn().mockResolvedValue(undefined) }
})
const installedStatuses = {
claude_code: { status: 'installed' as const, version: '1.0.20' },
codex: { status: 'installed' as const, version: '0.37.0' },
}
describe('AiAgentsBadge', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('shows the vault guidance summary and restore action', async () => {
const onRestoreGuidance = vi.fn()
render(
<AiAgentsBadge
statuses={installedStatuses}
guidanceStatus={{
agentsState: 'missing',
claudeState: 'managed',
canRestore: true,
}}
defaultAgent="claude_code"
onSetDefaultAgent={vi.fn()}
onRestoreGuidance={onRestoreGuidance}
/>,
)
act(() => {
const trigger = screen.getByTestId('status-ai-agents')
trigger.focus()
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
})
expect(screen.getByTestId('status-ai-guidance-summary')).toHaveTextContent('Tolaria guidance missing or broken')
act(() => {
fireEvent.click(screen.getByTestId('status-ai-guidance-restore'))
})
expect(onRestoreGuidance).toHaveBeenCalledOnce()
})
it('supports opening the menu and restoring guidance from the keyboard', () => {
const onRestoreGuidance = vi.fn()
render(
<AiAgentsBadge
statuses={installedStatuses}
guidanceStatus={{
agentsState: 'managed',
claudeState: 'broken',
canRestore: true,
}}
defaultAgent="claude_code"
onSetDefaultAgent={vi.fn()}
onRestoreGuidance={onRestoreGuidance}
/>,
)
act(() => {
const trigger = screen.getByTestId('status-ai-agents')
trigger.focus()
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
})
const restoreItem = screen.getByTestId('status-ai-guidance-restore')
act(() => {
restoreItem.focus()
fireEvent.keyDown(restoreItem, { key: 'Enter' })
})
expect(onRestoreGuidance).toHaveBeenCalledOnce()
})
})

View File

@@ -10,6 +10,13 @@ import {
type AiAgentDefinition,
type AiAgentsStatus,
} from '../../lib/aiAgents'
import {
getVaultAiGuidanceSummary,
isVaultAiGuidanceStatusChecking,
vaultAiGuidanceNeedsRestore,
vaultAiGuidanceUsesCustomFiles,
type VaultAiGuidanceStatus,
} from '../../lib/vaultAiGuidance'
import { openExternalUrl } from '../../utils/url'
import {
DropdownMenu,
@@ -25,18 +32,35 @@ import { ICON_STYLE, SEP_STYLE } from './styles'
interface AiAgentsBadgeProps {
statuses: AiAgentsStatus
guidanceStatus?: VaultAiGuidanceStatus
defaultAgent: AiAgentId
onSetDefaultAgent?: (agent: AiAgentId) => void
onRestoreGuidance?: () => void
}
function badgeTooltip(statuses: AiAgentsStatus, defaultAgent: AiAgentId): string {
function badgeTooltip(
statuses: AiAgentsStatus,
defaultAgent: AiAgentId,
guidanceStatus?: VaultAiGuidanceStatus,
): string {
const guidanceSummary = guidanceStatus && !isVaultAiGuidanceStatusChecking(guidanceStatus)
? getVaultAiGuidanceSummary(guidanceStatus)
: null
if (!hasAnyInstalledAiAgent(statuses)) return 'No AI agents detected — click for setup details'
const definition = getAiAgentDefinition(defaultAgent)
if (!isAiAgentInstalled(statuses, defaultAgent)) {
return `${definition.label} is selected but not installed — click for setup details`
}
const version = statuses[defaultAgent].version
return `Default AI agent: ${definition.label}${version ? ` ${version}` : ''}`
const base = `Default AI agent: ${definition.label}${version ? ` ${version}` : ''}`
if (!guidanceSummary) return base
if (vaultAiGuidanceNeedsRestore(guidanceStatus!)) {
return `${base}. ${guidanceSummary} — click for restore details`
}
if (vaultAiGuidanceUsesCustomFiles(guidanceStatus!)) {
return `${base}. ${guidanceSummary}`
}
return base
}
function installedAgentDefinitions(statuses: AiAgentsStatus): AiAgentDefinition[] {
@@ -72,11 +96,38 @@ function canSwitchAgents(
return installedAgents.some((definition) => definition.id !== defaultAgent)
}
function GuidanceMenuSection({
guidanceStatus,
onRestoreGuidance,
}: Pick<AiAgentsBadgeProps, 'guidanceStatus' | 'onRestoreGuidance'>) {
if (!guidanceStatus || isVaultAiGuidanceStatusChecking(guidanceStatus)) return null
return (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>Vault guidance</DropdownMenuLabel>
<DropdownMenuItem disabled data-testid="status-ai-guidance-summary">
{getVaultAiGuidanceSummary(guidanceStatus)}
</DropdownMenuItem>
{vaultAiGuidanceNeedsRestore(guidanceStatus) && guidanceStatus.canRestore && (
<DropdownMenuItem
onSelect={() => onRestoreGuidance?.()}
data-testid="status-ai-guidance-restore"
>
Restore Tolaria AI Guidance
</DropdownMenuItem>
)}
</>
)
}
function AgentMenuContent({
statuses,
guidanceStatus,
defaultAgent,
selectedAgentReady,
onSetDefaultAgent,
onRestoreGuidance,
}: AiAgentsBadgeProps & { selectedAgentReady: boolean }) {
const installedAgents = installedAgentDefinitions(statuses)
const missingAgents = missingAgentDefinitions(statuses)
@@ -120,14 +171,26 @@ function AgentMenuContent({
))}
</>
)}
<GuidanceMenuSection
guidanceStatus={guidanceStatus}
onRestoreGuidance={onRestoreGuidance}
/>
</DropdownMenuContent>
)
}
export function AiAgentsBadge({ statuses, defaultAgent, onSetDefaultAgent }: AiAgentsBadgeProps) {
export function AiAgentsBadge({
statuses,
guidanceStatus,
defaultAgent,
onSetDefaultAgent,
onRestoreGuidance,
}: AiAgentsBadgeProps) {
const hasInstalledAgent = hasAnyInstalledAiAgent(statuses)
const selectedAgentReady = isAiAgentInstalled(statuses, defaultAgent)
const showWarning = !hasInstalledAgent || !selectedAgentReady
const showWarning = !hasInstalledAgent
|| !selectedAgentReady
|| !!(guidanceStatus && vaultAiGuidanceNeedsRestore(guidanceStatus))
const showSwitcherCue = canSwitchAgents(installedAgentDefinitions(statuses), defaultAgent)
if (isAiAgentsStatusChecking(statuses)) return null
@@ -142,7 +205,7 @@ export function AiAgentsBadge({ statuses, defaultAgent, onSetDefaultAgent }: AiA
variant="ghost"
size="xs"
className="h-6 px-2 text-[11px] font-medium"
title={badgeTooltip(statuses, defaultAgent)}
title={badgeTooltip(statuses, defaultAgent, guidanceStatus)}
data-testid="status-ai-agents"
>
<span style={{ ...ICON_STYLE, color: showWarning ? 'var(--accent-orange)' : 'var(--muted-foreground)' }}>
@@ -155,8 +218,10 @@ export function AiAgentsBadge({ statuses, defaultAgent, onSetDefaultAgent }: AiA
</DropdownMenuTrigger>
<AgentMenuContent
statuses={statuses}
guidanceStatus={guidanceStatus}
defaultAgent={defaultAgent}
onSetDefaultAgent={onSetDefaultAgent}
onRestoreGuidance={onRestoreGuidance}
selectedAgentReady={selectedAgentReady}
/>
</DropdownMenu>

View File

@@ -1,6 +1,7 @@
import { Bell, FileText, Package, Settings } from 'lucide-react'
import { Megaphone } from '@phosphor-icons/react'
import type { AiAgentId, AiAgentsStatus } from '../../lib/aiAgents'
import type { VaultAiGuidanceStatus } from '../../lib/vaultAiGuidance'
import type { ClaudeCodeStatus } from '../../hooks/useClaudeCodeStatus'
import type { McpStatus } from '../../hooks/useMcpStatus'
import type { GitRemoteStatus, LastCommitInfo, SyncStatus } from '../../types'
@@ -49,8 +50,10 @@ interface StatusBarPrimarySectionProps {
mcpStatus?: McpStatus
onInstallMcp?: () => void
aiAgentsStatus?: AiAgentsStatus
vaultAiGuidanceStatus?: VaultAiGuidanceStatus
defaultAiAgent?: AiAgentId
onSetDefaultAiAgent?: (agent: AiAgentId) => void
onRestoreVaultAiGuidance?: () => void
claudeCodeStatus?: ClaudeCodeStatus
claudeCodeVersion?: string | null
}
@@ -90,8 +93,10 @@ export function StatusBarPrimarySection({
mcpStatus,
onInstallMcp,
aiAgentsStatus,
vaultAiGuidanceStatus,
defaultAiAgent,
onSetDefaultAiAgent,
onRestoreVaultAiGuidance,
claudeCodeStatus,
claudeCodeVersion,
}: StatusBarPrimarySectionProps) {
@@ -136,7 +141,15 @@ export function StatusBarPrimarySection({
<PulseBadge onClick={onClickPulse} disabled={isGitVault === false} />
{mcpStatus && <McpBadge status={mcpStatus} onInstall={onInstallMcp} />}
{aiAgentsStatus && defaultAiAgent
? <AiAgentsBadge statuses={aiAgentsStatus} defaultAgent={defaultAiAgent} onSetDefaultAgent={onSetDefaultAiAgent} />
? (
<AiAgentsBadge
statuses={aiAgentsStatus}
guidanceStatus={vaultAiGuidanceStatus}
defaultAgent={defaultAiAgent}
onSetDefaultAgent={onSetDefaultAiAgent}
onRestoreGuidance={onRestoreVaultAiGuidance}
/>
)
: claudeCodeStatus && <ClaudeCodeBadge status={claudeCodeStatus} version={claudeCodeVersion} />}
</div>
)

View File

@@ -0,0 +1,35 @@
import { describe, expect, it, vi } from 'vitest'
import { buildAiAgentCommands } from './aiAgentCommands'
describe('buildAiAgentCommands', () => {
it('adds a restore guidance command when the vault guidance needs repair', () => {
const onRestoreVaultAiGuidance = vi.fn()
const commands = buildAiAgentCommands({
vaultAiGuidanceStatus: {
agentsState: 'missing',
claudeState: 'managed',
canRestore: true,
},
onRestoreVaultAiGuidance,
})
const command = commands.find((item) => item.id === 'restore-vault-ai-guidance')
expect(command).toBeDefined()
command?.execute()
expect(onRestoreVaultAiGuidance).toHaveBeenCalledOnce()
})
it('omits the restore command when the vault guidance is already healthy', () => {
const commands = buildAiAgentCommands({
vaultAiGuidanceStatus: {
agentsState: 'managed',
claudeState: 'managed',
canRestore: false,
},
onRestoreVaultAiGuidance: vi.fn(),
})
expect(commands.find((item) => item.id === 'restore-vault-ai-guidance')).toBeUndefined()
})
})

View File

@@ -4,13 +4,20 @@ import {
type AiAgentId,
type AiAgentsStatus,
} from '../../lib/aiAgents'
import {
isVaultAiGuidanceStatusChecking,
vaultAiGuidanceNeedsRestore,
type VaultAiGuidanceStatus,
} from '../../lib/vaultAiGuidance'
import type { CommandAction } from './types'
interface AiAgentCommandsConfig {
aiAgentsStatus?: AiAgentsStatus
vaultAiGuidanceStatus?: VaultAiGuidanceStatus
selectedAiAgent?: AiAgentId
selectedAiAgentLabel?: string
onOpenAiAgents?: () => void
onRestoreVaultAiGuidance?: () => void
onCycleDefaultAiAgent?: () => void
onSetDefaultAiAgent?: (agent: AiAgentId) => void
}
@@ -35,11 +42,33 @@ function explicitSwitchCommands({
}))
}
function restoreGuidanceCommands({
vaultAiGuidanceStatus,
onRestoreVaultAiGuidance,
}: Pick<AiAgentCommandsConfig, 'vaultAiGuidanceStatus' | 'onRestoreVaultAiGuidance'>): CommandAction[] {
if (!vaultAiGuidanceStatus || !onRestoreVaultAiGuidance) return []
if (isVaultAiGuidanceStatusChecking(vaultAiGuidanceStatus)) return []
if (!vaultAiGuidanceNeedsRestore(vaultAiGuidanceStatus)) return []
return [
{
id: 'restore-vault-ai-guidance',
label: 'Restore Tolaria AI Guidance',
group: 'Settings',
keywords: ['ai', 'agent', 'guidance', 'restore', 'repair', 'claude', 'codex', 'agents'],
enabled: true,
execute: () => onRestoreVaultAiGuidance(),
},
]
}
export function buildAiAgentCommands({
aiAgentsStatus,
vaultAiGuidanceStatus,
selectedAiAgent,
selectedAiAgentLabel,
onOpenAiAgents,
onRestoreVaultAiGuidance,
onCycleDefaultAiAgent,
onSetDefaultAiAgent,
}: AiAgentCommandsConfig): CommandAction[] {
@@ -54,6 +83,11 @@ export function buildAiAgentCommands({
},
]
commands.push(...restoreGuidanceCommands({
vaultAiGuidanceStatus,
onRestoreVaultAiGuidance,
}))
const switchCommands = explicitSwitchCommands({
aiAgentsStatus,
selectedAiAgent,

View File

@@ -1,5 +1,6 @@
import { useCallback, useRef } from 'react'
import type { AiAgentId, AiAgentsStatus } from '../lib/aiAgents'
import type { VaultAiGuidanceStatus } from '../lib/vaultAiGuidance'
import { useAppKeyboard } from './useAppKeyboard'
import { useCommandRegistry } from './useCommandRegistry'
import type { CommandAction } from './useCommandRegistry'
@@ -58,7 +59,9 @@ interface AppCommandsConfig {
mcpStatus?: string
onInstallMcp?: () => void
aiAgentsStatus?: AiAgentsStatus
vaultAiGuidanceStatus?: VaultAiGuidanceStatus
onOpenAiAgents?: () => void
onRestoreVaultAiGuidance?: () => void
onSetDefaultAiAgent?: (agent: AiAgentId) => void
selectedAiAgent?: AiAgentId
onCycleDefaultAiAgent?: () => void
@@ -197,7 +200,9 @@ function createCommandRegistryConfig(config: AppCommandsConfig): Parameters<type
mcpStatus: config.mcpStatus,
onInstallMcp: config.onInstallMcp,
aiAgentsStatus: config.aiAgentsStatus,
vaultAiGuidanceStatus: config.vaultAiGuidanceStatus,
onOpenAiAgents: config.onOpenAiAgents,
onRestoreVaultAiGuidance: config.onRestoreVaultAiGuidance,
onSetDefaultAiAgent: config.onSetDefaultAiAgent,
selectedAiAgent: config.selectedAiAgent,
onCycleDefaultAiAgent: config.onCycleDefaultAiAgent,

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react'
import type { AiAgentId, AiAgentsStatus } from '../lib/aiAgents'
import type { VaultAiGuidanceStatus } from '../lib/vaultAiGuidance'
import type { SidebarSelection, VaultEntry } from '../types'
import type { NoteListFilter } from '../utils/noteListHelpers'
import type { ViewMode } from './useViewMode'
@@ -28,7 +29,9 @@ interface CommandRegistryConfig {
mcpStatus?: string
onInstallMcp?: () => void
aiAgentsStatus?: AiAgentsStatus
vaultAiGuidanceStatus?: VaultAiGuidanceStatus
onOpenAiAgents?: () => void
onRestoreVaultAiGuidance?: () => void
onSetDefaultAiAgent?: (agent: AiAgentId) => void
selectedAiAgent?: AiAgentId
onCycleDefaultAiAgent?: () => void
@@ -97,8 +100,8 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
onGoBack, onGoForward, canGoBack, canGoForward,
onCheckForUpdates, onCreateType,
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
mcpStatus, onInstallMcp, aiAgentsStatus,
onOpenAiAgents, onSetDefaultAiAgent, selectedAiAgent, onCycleDefaultAiAgent, selectedAiAgentLabel,
mcpStatus, onInstallMcp, aiAgentsStatus, vaultAiGuidanceStatus,
onOpenAiAgents, onRestoreVaultAiGuidance, onSetDefaultAiAgent, selectedAiAgent, onCycleDefaultAiAgent, selectedAiAgentLabel,
onReloadVault, onRepairVault,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
onOpenInNewWindow, onToggleFavorite, onToggleOrganized,
@@ -142,9 +145,11 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
}),
...buildAiAgentCommands({
aiAgentsStatus,
vaultAiGuidanceStatus,
selectedAiAgent,
selectedAiAgentLabel,
onOpenAiAgents,
onRestoreVaultAiGuidance,
onSetDefaultAiAgent,
onCycleDefaultAiAgent,
}),
@@ -162,8 +167,8 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
onGoBack, onGoForward, canGoBack, canGoForward,
vaultTypes,
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
mcpStatus, onInstallMcp, aiAgentsStatus,
onOpenAiAgents, onSetDefaultAiAgent, selectedAiAgent, onCycleDefaultAiAgent, selectedAiAgentLabel,
mcpStatus, onInstallMcp, aiAgentsStatus, vaultAiGuidanceStatus,
onOpenAiAgents, onRestoreVaultAiGuidance, onSetDefaultAiAgent, selectedAiAgent, onCycleDefaultAiAgent, selectedAiAgentLabel,
onReloadVault, onRepairVault,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
isSectionGroup, noteListFilter, onSetNoteListFilter,

View File

@@ -0,0 +1,71 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useVaultAiGuidanceStatus } from './useVaultAiGuidanceStatus'
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn(),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: vi.fn(),
}))
const { mockInvoke } = await import('../mock-tauri') as { mockInvoke: ReturnType<typeof vi.fn> }
describe('useVaultAiGuidanceStatus', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('starts in checking state and resolves guidance status', async () => {
mockInvoke.mockResolvedValue({
agents_state: 'managed',
claude_state: 'broken',
can_restore: true,
})
const { result } = renderHook(() => useVaultAiGuidanceStatus('/vault'))
expect(result.current.status.agentsState).toBe('checking')
expect(result.current.status.claudeState).toBe('checking')
await waitFor(() => {
expect(result.current.status).toEqual({
agentsState: 'managed',
claudeState: 'broken',
canRestore: true,
})
})
})
it('refreshes on demand', async () => {
mockInvoke
.mockResolvedValueOnce({
agents_state: 'managed',
claude_state: 'managed',
can_restore: false,
})
.mockResolvedValueOnce({
agents_state: 'managed',
claude_state: 'broken',
can_restore: true,
})
const { result } = renderHook(() => useVaultAiGuidanceStatus('/vault'))
await waitFor(() => {
expect(result.current.status.canRestore).toBe(false)
})
await act(async () => {
await result.current.refresh()
})
expect(result.current.status).toEqual({
agentsState: 'managed',
claudeState: 'broken',
canRestore: true,
})
})
})

View File

@@ -0,0 +1,70 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import {
createCheckingVaultAiGuidanceStatus,
normalizeVaultAiGuidanceStatus,
type VaultAiGuidanceStatus,
} from '../lib/vaultAiGuidance'
type RawVaultAiGuidanceStatus = Partial<{
agents_state: string | null
claude_state: string | null
can_restore: boolean | null
}>
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
export function useVaultAiGuidanceStatus(
vaultPath: string | null | undefined,
refreshKey = '',
): { status: VaultAiGuidanceStatus; refresh: () => Promise<VaultAiGuidanceStatus> } {
const checkingStatus = useMemo(() => createCheckingVaultAiGuidanceStatus(), [])
const [status, setStatus] = useState<VaultAiGuidanceStatus>(() => createCheckingVaultAiGuidanceStatus())
const refresh = useCallback(async () => {
if (!vaultPath) {
return checkingStatus
}
try {
const result = await tauriCall<RawVaultAiGuidanceStatus>('get_vault_ai_guidance_status', {
vaultPath,
})
const normalized = normalizeVaultAiGuidanceStatus(result)
setStatus(normalized)
return normalized
} catch {
const checking = createCheckingVaultAiGuidanceStatus()
setStatus(checking)
return checking
}
}, [checkingStatus, vaultPath])
useEffect(() => {
let cancelled = false
if (!vaultPath) return
tauriCall<RawVaultAiGuidanceStatus>('get_vault_ai_guidance_status', { vaultPath })
.then((result) => {
if (!cancelled) {
setStatus(normalizeVaultAiGuidanceStatus(result))
}
})
.catch(() => {
if (!cancelled) {
setStatus(createCheckingVaultAiGuidanceStatus())
}
})
return () => { cancelled = true }
}, [vaultPath, refreshKey])
return {
status: vaultPath ? status : checkingStatus,
refresh,
}
}

View File

@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest'
import {
buildVaultAiGuidanceRefreshKey,
createCheckingVaultAiGuidanceStatus,
getVaultAiGuidanceSummary,
normalizeVaultAiGuidanceStatus,
vaultAiGuidanceNeedsRestore,
vaultAiGuidanceUsesCustomFiles,
} from './vaultAiGuidance'
import type { VaultEntry } from '../types'
function makeEntry(filename: string, overrides: Partial<VaultEntry> = {}): VaultEntry {
return {
path: `/vault/${filename}`,
filename,
title: filename.replace(/\.md$/, ''),
isA: null,
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: 10,
createdAt: 5,
fileSize: 20,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
sort: null,
view: null,
visible: true,
organized: false,
favorite: false,
favoriteIndex: null,
listPropertiesDisplay: [],
outgoingLinks: [],
properties: {},
hasH1: true,
fileKind: 'markdown',
...overrides,
}
}
describe('vaultAiGuidance helpers', () => {
it('starts in checking state', () => {
expect(createCheckingVaultAiGuidanceStatus()).toEqual({
agentsState: 'checking',
claudeState: 'checking',
canRestore: false,
})
})
it('normalizes raw backend payloads', () => {
expect(normalizeVaultAiGuidanceStatus({
agents_state: 'managed',
claude_state: 'broken',
can_restore: true,
})).toEqual({
agentsState: 'managed',
claudeState: 'broken',
canRestore: true,
})
})
it('detects restoreable and custom states', () => {
const restoreable = normalizeVaultAiGuidanceStatus({
agents_state: 'missing',
claude_state: 'managed',
can_restore: true,
})
const custom = normalizeVaultAiGuidanceStatus({
agents_state: 'custom',
claude_state: 'managed',
can_restore: false,
})
expect(vaultAiGuidanceNeedsRestore(restoreable)).toBe(true)
expect(getVaultAiGuidanceSummary(restoreable)).toBe('Tolaria guidance missing or broken')
expect(vaultAiGuidanceUsesCustomFiles(custom)).toBe(true)
expect(getVaultAiGuidanceSummary(custom)).toBe('Using custom AGENTS.md')
})
it('builds a refresh key from AGENTS and CLAUDE entries only', () => {
const key = buildVaultAiGuidanceRefreshKey([
makeEntry('alpha.md'),
makeEntry('CLAUDE.md', { modifiedAt: 20, fileSize: 30 }),
makeEntry('AGENTS.md', { modifiedAt: 15, fileSize: 40 }),
])
expect(key).toBe('/vault/AGENTS.md:15:40|/vault/CLAUDE.md:20:30')
})
})

103
src/lib/vaultAiGuidance.ts Normal file
View File

@@ -0,0 +1,103 @@
import type { VaultEntry } from '../types'
export type VaultAiGuidanceFileState = 'checking' | 'managed' | 'missing' | 'broken' | 'custom'
export interface VaultAiGuidanceStatus {
agentsState: VaultAiGuidanceFileState
claudeState: VaultAiGuidanceFileState
canRestore: boolean
}
type RawVaultAiGuidanceStatus = Partial<{
agents_state: VaultAiGuidanceFileState | null
claude_state: VaultAiGuidanceFileState | null
can_restore: boolean | null
}>
const GUIDANCE_FILENAMES = new Set(['AGENTS.md', 'CLAUDE.md'])
export function createCheckingVaultAiGuidanceStatus(): VaultAiGuidanceStatus {
return {
agentsState: 'checking',
claudeState: 'checking',
canRestore: false,
}
}
function normalizeFileState(value: string | null | undefined): VaultAiGuidanceFileState {
switch (value) {
case 'managed':
case 'missing':
case 'broken':
case 'custom':
return value
default:
return 'checking'
}
}
export function normalizeVaultAiGuidanceStatus(
payload: RawVaultAiGuidanceStatus | null | undefined,
): VaultAiGuidanceStatus {
return {
agentsState: normalizeFileState(payload?.agents_state),
claudeState: normalizeFileState(payload?.claude_state),
canRestore: payload?.can_restore === true,
}
}
export function isVaultAiGuidanceStatusChecking(status: VaultAiGuidanceStatus): boolean {
return status.agentsState === 'checking' || status.claudeState === 'checking'
}
export function vaultAiGuidanceNeedsRestore(status: VaultAiGuidanceStatus): boolean {
if (!status.canRestore || isVaultAiGuidanceStatusChecking(status)) return false
return status.agentsState === 'missing'
|| status.agentsState === 'broken'
|| status.claudeState === 'missing'
|| status.claudeState === 'broken'
}
export function vaultAiGuidanceUsesCustomFiles(status: VaultAiGuidanceStatus): boolean {
return status.agentsState === 'custom' || status.claudeState === 'custom'
}
function isMissingOrBroken(state: VaultAiGuidanceFileState): boolean {
return state === 'missing' || state === 'broken'
}
function getBrokenGuidanceSummary(status: VaultAiGuidanceStatus): string | null {
if (isMissingOrBroken(status.agentsState)) {
return 'Tolaria guidance missing or broken'
}
if (isMissingOrBroken(status.claudeState)) {
return 'Claude compatibility shim missing or broken'
}
return null
}
function getCustomGuidanceSummary(status: VaultAiGuidanceStatus): string | null {
if (status.agentsState === 'custom' && status.claudeState === 'custom') {
return 'Custom AGENTS.md and CLAUDE.md active'
}
if (status.agentsState === 'custom') return 'Using custom AGENTS.md'
if (status.claudeState === 'custom') return 'Using custom CLAUDE.md'
return null
}
export function getVaultAiGuidanceSummary(status: VaultAiGuidanceStatus): string {
if (isVaultAiGuidanceStatusChecking(status)) return 'Checking vault guidance…'
const brokenSummary = getBrokenGuidanceSummary(status)
if (brokenSummary) return brokenSummary
const customSummary = getCustomGuidanceSummary(status)
if (customSummary) return customSummary
return 'Tolaria guidance ready'
}
export function buildVaultAiGuidanceRefreshKey(entries: VaultEntry[]): string {
return entries
.filter((entry) => GUIDANCE_FILENAMES.has(entry.filename))
.map((entry) => `${entry.path}:${entry.modifiedAt ?? 0}:${entry.fileSize}`)
.sort()
.join('|')
}

View File

@@ -107,6 +107,12 @@ let mockVaultList: { vaults: Array<{ label: string; path: string }>; active_vaul
active_vault: null,
}
let mockVaultAiGuidanceStatus = {
agents_state: 'managed',
claude_state: 'managed',
can_restore: false,
} as const
function escapeRegex({ text }: { text: string }) {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
@@ -276,6 +282,15 @@ export const mockHandlers: Record<string, (args: any) => any> = {
claude_code: { installed: false, version: null },
codex: { installed: false, version: null },
}),
get_vault_ai_guidance_status: () => ({ ...mockVaultAiGuidanceStatus }),
restore_vault_ai_guidance: () => {
mockVaultAiGuidanceStatus = {
agents_state: 'managed',
claude_state: 'managed',
can_restore: false,
}
return { ...mockVaultAiGuidanceStatus }
},
stream_claude_chat: () => 'mock-session',
stream_claude_agent: () => null,
stream_ai_agent: () => null,
@@ -349,7 +364,14 @@ export const mockHandlers: Record<string, (args: any) => any> = {
create_getting_started_vault: (args: { targetPath?: string | null }) => args.targetPath || '/Users/mock/Documents/Getting Started',
register_mcp_tools: () => 'registered',
check_mcp_status: () => 'installed',
repair_vault: (): string => 'Vault repaired',
repair_vault: (): string => {
mockVaultAiGuidanceStatus = {
agents_state: 'managed',
claude_state: 'managed',
can_restore: false,
}
return 'Vault repaired'
},
reinit_telemetry: (): null => null,
}

View File

@@ -0,0 +1,75 @@
import { test, expect } from '@playwright/test'
import { openCommandPalette, findCommand } from './helpers'
test('vault guidance restore command recovers missing managed guidance', async ({ page }) => {
await page.addInitScript(() => {
localStorage.clear()
Object.defineProperty(window, 'prompt', {
configurable: true,
value: () => '/Users/luca/Laputa',
})
let ref: Record<string, unknown> | null = null
let guidanceStatus = {
agents_state: 'missing',
claude_state: 'managed',
can_restore: true,
}
Object.defineProperty(window, '__mockHandlers', {
configurable: true,
set(value) {
ref = value as Record<string, unknown>
const originalGetSettings = ref.get_settings as () => Record<string, unknown>
ref.get_settings = () => ({
...originalGetSettings(),
default_ai_agent: 'codex',
})
ref.get_default_vault_path = () => '/Users/luca/Laputa'
ref.check_vault_exists = (args: { path: string }) => args.path === '/Users/luca/Laputa'
ref.get_ai_agents_status = () => ({
claude_code: { installed: false, version: null },
codex: { installed: true, version: '1.2.3' },
})
ref.get_vault_ai_guidance_status = () => ({ ...guidanceStatus })
ref.restore_vault_ai_guidance = () => {
guidanceStatus = {
agents_state: 'managed',
claude_state: 'managed',
can_restore: false,
}
return { ...guidanceStatus }
}
},
get() {
return ref
},
})
})
await page.goto('/', { waitUntil: 'domcontentloaded' })
await expect(page.getByTestId('welcome-screen')).toBeVisible()
await page.getByRole('button', { name: /Open existing vault/i }).click()
await expect(page.getByText('Claude Code not detected')).toBeVisible()
await page.getByRole('button', { name: 'Continue' }).click()
await expect(page.getByTestId('note-list-container')).toBeVisible({ timeout: 5_000 })
const aiBadge = page.getByTestId('status-ai-agents')
await expect(aiBadge).toHaveAttribute('title', /Tolaria guidance missing or broken/)
await openCommandPalette(page)
expect(await findCommand(page, 'Restore Tolaria AI Guidance')).toBe(true)
await page.keyboard.press('Enter')
await expect(page.getByText('Tolaria AI guidance restored')).toBeVisible()
await aiBadge.click()
await expect(page.getByTestId('status-ai-guidance-summary')).toHaveText('Tolaria guidance ready')
await expect(page.getByTestId('status-ai-guidance-restore')).toHaveCount(0)
await page.keyboard.press('Escape')
await openCommandPalette(page)
expect(await findCommand(page, 'Restore Tolaria AI Guidance')).toBe(false)
})