diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index de4f92e4..de3d4c2e 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -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: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8bde24ef..ab5d6437 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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` | | `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 diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 1362ea40..ddb2e614 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -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 ``` diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs index d1b78a54..7d8e1e6c 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -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 = Box; @@ -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 { + 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 { + 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, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d11396bf..205fe08c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/vault/config_seed.rs b/src-tauri/src/vault/config_seed.rs index 480089ba..0c1e662f 100644 --- a/src-tauri/src/vault/config_seed.rs +++ b/src-tauri/src/vault/config_seed.rs @@ -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 { 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 { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { - 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 { + Ok(build_ai_guidance_status(vault_path)) +} + +pub fn restore_ai_guidance_files(vault_path: &str) -> Result { + 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 { 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); + } } diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index 8cf669b1..dba080fc 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -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}; diff --git a/src/App.tsx b/src/App.tsx index cd18d2c0..c044bacd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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('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() { - 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} /> + 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() }} /> setToastMessage(null)} /> diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index 6ac3d505..ce783ea0 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -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} /> diff --git a/src/components/status-bar/AiAgentsBadge.test.tsx b/src/components/status-bar/AiAgentsBadge.test.tsx new file mode 100644 index 00000000..0a0ee7ea --- /dev/null +++ b/src/components/status-bar/AiAgentsBadge.test.tsx @@ -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( + , + ) + + 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( + , + ) + + 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() + }) +}) diff --git a/src/components/status-bar/AiAgentsBadge.tsx b/src/components/status-bar/AiAgentsBadge.tsx index e64b55ce..33609c54 100644 --- a/src/components/status-bar/AiAgentsBadge.tsx +++ b/src/components/status-bar/AiAgentsBadge.tsx @@ -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) { + if (!guidanceStatus || isVaultAiGuidanceStatusChecking(guidanceStatus)) return null + + return ( + <> + + Vault guidance + + {getVaultAiGuidanceSummary(guidanceStatus)} + + {vaultAiGuidanceNeedsRestore(guidanceStatus) && guidanceStatus.canRestore && ( + onRestoreGuidance?.()} + data-testid="status-ai-guidance-restore" + > + Restore Tolaria AI Guidance + + )} + + ) +} + 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({ ))} )} + ) } -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" > @@ -155,8 +218,10 @@ export function AiAgentsBadge({ statuses, defaultAgent, onSetDefaultAgent }: AiA diff --git a/src/components/status-bar/StatusBarSections.tsx b/src/components/status-bar/StatusBarSections.tsx index 6fcd727b..e8556ffc 100644 --- a/src/components/status-bar/StatusBarSections.tsx +++ b/src/components/status-bar/StatusBarSections.tsx @@ -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({ {mcpStatus && } {aiAgentsStatus && defaultAiAgent - ? + ? ( + + ) : claudeCodeStatus && } ) diff --git a/src/hooks/commands/aiAgentCommands.test.ts b/src/hooks/commands/aiAgentCommands.test.ts new file mode 100644 index 00000000..8571d1ae --- /dev/null +++ b/src/hooks/commands/aiAgentCommands.test.ts @@ -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() + }) +}) diff --git a/src/hooks/commands/aiAgentCommands.ts b/src/hooks/commands/aiAgentCommands.ts index 5f3d531e..bcd2fe41 100644 --- a/src/hooks/commands/aiAgentCommands.ts +++ b/src/hooks/commands/aiAgentCommands.ts @@ -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): 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, diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index dd01c368..3dbaae14 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -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 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, diff --git a/src/hooks/useVaultAiGuidanceStatus.test.ts b/src/hooks/useVaultAiGuidanceStatus.test.ts new file mode 100644 index 00000000..4517618d --- /dev/null +++ b/src/hooks/useVaultAiGuidanceStatus.test.ts @@ -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 } + +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, + }) + }) +}) diff --git a/src/hooks/useVaultAiGuidanceStatus.ts b/src/hooks/useVaultAiGuidanceStatus.ts new file mode 100644 index 00000000..fb78378c --- /dev/null +++ b/src/hooks/useVaultAiGuidanceStatus.ts @@ -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(command: string, args: Record): Promise { + return isTauri() ? invoke(command, args) : mockInvoke(command, args) +} + +export function useVaultAiGuidanceStatus( + vaultPath: string | null | undefined, + refreshKey = '', +): { status: VaultAiGuidanceStatus; refresh: () => Promise } { + const checkingStatus = useMemo(() => createCheckingVaultAiGuidanceStatus(), []) + const [status, setStatus] = useState(() => createCheckingVaultAiGuidanceStatus()) + + const refresh = useCallback(async () => { + if (!vaultPath) { + return checkingStatus + } + + try { + const result = await tauriCall('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('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, + } +} diff --git a/src/lib/vaultAiGuidance.test.ts b/src/lib/vaultAiGuidance.test.ts new file mode 100644 index 00000000..5354580d --- /dev/null +++ b/src/lib/vaultAiGuidance.test.ts @@ -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 { + 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') + }) +}) diff --git a/src/lib/vaultAiGuidance.ts b/src/lib/vaultAiGuidance.ts new file mode 100644 index 00000000..6db083d2 --- /dev/null +++ b/src/lib/vaultAiGuidance.ts @@ -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('|') +} diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index eff45df3..fdf2e6ef 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -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 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 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, } diff --git a/tests/smoke/vault-ai-guidance-restore.spec.ts b/tests/smoke/vault-ai-guidance-restore.spec.ts new file mode 100644 index 00000000..46c23707 --- /dev/null +++ b/tests/smoke/vault-ai-guidance-restore.spec.ts @@ -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 | 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 + + const originalGetSettings = ref.get_settings as () => Record + 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) +})