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 ├── weekly-review.md ← type: Procedure
├── john-doe.md ← type: Person ├── john-doe.md ← type: Person
├── some-topic.md ← type: Topic ├── some-topic.md ← type: Topic
├── AGENTS.md ← canonical Tolaria AI guidance
├── CLAUDE.md ← compatibility shim pointing at AGENTS.md
├── ... ├── ...
── type/ ← type definition documents ── type/ ← type definition documents
└── config/ ← meta-configuration files (agents.md, etc.)
``` ```
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. 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 1. Validates the path exists and is a directory
2. Scans root-level `.md` files (non-recursive) 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) 4. Files in non-protected subfolders are **not indexed** (flat vault enforcement)
5. For each `.md` file, calls `parse_md_file()`: 5. For each `.md` file, calls `parse_md_file()`:
- Reads content with `fs::read_to_string()` - 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 - 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`) - 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 ### Getting Started / Onboarding
`useOnboarding` hook detects first launch: `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. 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 ### 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 | | `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 | | `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames |
| `migration.rs` | `flatten_vault`, `vault_health_check`, `migrate_is_a_to_type` | | `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 | | `getting_started.rs` | Creates the Getting Started demo vault |
## Rust Backend Modules ## 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` | Invalidate cache and full rescan from filesystem → `Vec<VaultEntry>` |
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` | | `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
| `check_vault_exists` | Check if vault path exists | | `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_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 config defaults, and keep the cloned repo clean | | `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 ### Frontmatter

View File

@@ -186,7 +186,7 @@ tolaria/
├── playwright.config.ts # Full Playwright regression config ├── playwright.config.ts # Full Playwright regression config
├── playwright.smoke.config.ts # Curated pre-push Playwright config ├── playwright.smoke.config.ts # Curated pre-push Playwright config
├── ui-design.pen # Master design file ├── 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 ├── CLAUDE.md # Claude Code compatibility shim importing AGENTS.md
└── docs/ # This documentation └── docs/ # This documentation
``` ```

View File

@@ -1,6 +1,9 @@
#[cfg(desktop)] #[cfg(desktop)]
use crate::ai_agents::{AiAgentStreamRequest, AiAgentsStatus}; use crate::ai_agents::{AiAgentStreamRequest, AiAgentsStatus};
use crate::claude_cli::{AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus}; use crate::claude_cli::{AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus};
use crate::vault::VaultAiGuidanceStatus;
use super::expand_tilde;
#[cfg(desktop)] #[cfg(desktop)]
type StreamEmitter<Event> = Box<dyn Fn(Event) + Send>; 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() 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)] #[cfg(desktop)]
define_desktop_stream_command!( define_desktop_stream_command!(
stream_claude_chat, stream_claude_chat,

View File

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

View File

@@ -1,5 +1,6 @@
use serde::Serialize;
use std::fs; use std::fs;
use std::path::Path; use std::path::{Path, PathBuf};
use super::getting_started::{AGENTS_MD, LEGACY_AGENTS_MD}; 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. 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. /// 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> { 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); 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) 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 { fn root_agents_can_be_replaced(path: &Path) -> bool {
!path.exists() !path.exists() || agents_content_can_be_replaced(&read_file_or_empty(path))
|| fs::read_to_string(path).map_or(true, |content| { }
content.is_empty()
|| content.contains("See config/agents.md") fn matches_claude_shim(content: &str) -> bool {
|| content == LEGACY_AGENTS_MD let trimmed = content.trim();
|| content.contains("Do not add `title:` frontmatter.") 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> { pub(super) fn sync_default_agents_file(vault_path: &str) -> Result<bool, String> {
let agents_path = Path::new(vault_path).join("AGENTS.md"); let (agents_path, _) = guidance_paths(vault_path);
if root_agents_can_be_replaced(&agents_path) { sync_managed_file(&agents_path, AGENTS_MD, root_agents_can_be_replaced)
fs::write(&agents_path, AGENTS_MD) }
.map_err(|e| format!("Failed to write {}: {e}", agents_path.display()))?;
return Ok(true); pub fn get_ai_guidance_status(vault_path: &str) -> Result<VaultAiGuidanceStatus, String> {
} Ok(build_ai_guidance_status(vault_path))
Ok(false) }
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). /// Seed `AGENTS.md` at vault root if missing or empty (idempotent, per-file).
/// Also seeds `config.md` type definition for sidebar visibility. /// Also seeds `config.md` type definition for sidebar visibility.
pub fn seed_config_files(vault_path: &str) { pub fn seed_config_files(vault_path: &str) {
if sync_default_agents_file(vault_path).unwrap_or(false) { if sync_ai_guidance_files(vault_path).unwrap_or(false) {
log::info!("Seeded AGENTS.md at vault root"); log::info!("Seeded vault AI guidance files at vault root");
} }
ensure_config_type_definition(vault_path); 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). /// Ensure `config.md` exists at vault root (gives Config type a sidebar icon/color).
fn ensure_config_type_definition(vault_path: &str) { fn ensure_config_type_definition(vault_path: &str) {
let path = Path::new(vault_path).join("config.md"); let path = Path::new(vault_path).join("config.md");
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0); let _ = write_if_missing(&path, CONFIG_TYPE_DEFINITION);
if needs_write {
let _ = fs::write(&path, CONFIG_TYPE_DEFINITION);
}
} }
/// Migrate legacy `config/agents.md` → root `AGENTS.md` for existing vaults. /// 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 root_agents = vault.join("AGENTS.md");
let config_agents = vault.join("config").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 let Ok(outcome) = migrate_legacy_agents_file(&root_agents, &config_agents) {
if config_agents.exists() { if outcome.copied_to_root {
let config_content = fs::read_to_string(&config_agents).unwrap_or_default(); log::info!("Migrated config/agents.md content to root AGENTS.md");
if !config_content.is_empty() { }
// Only migrate if root AGENTS.md is missing, empty, or is a stub if outcome.removed_legacy {
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);
log::info!("Removed legacy config/agents.md"); log::info!("Removed legacy config/agents.md");
} }
} }
// Clean up empty config/ directory if cleanup_empty_config_dir(vault).unwrap_or(false) {
let config_dir = vault.join("config"); log::info!("Removed empty config/ directory");
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");
}
} }
// Ensure root AGENTS.md exists with content let _ = sync_ai_guidance_files(vault_path);
let _ = sync_default_agents_file(vault_path);
} }
/// Repair config files: ensure `AGENTS.md` at vault root and `config.md` type definition. /// 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 root_agents = vault.join("AGENTS.md");
let config_agents = vault.join("config").join("agents.md"); let config_agents = vault.join("config").join("agents.md");
// Step 1: Migrate legacy config/agents.md → root AGENTS.md migrate_legacy_agents_file(&root_agents, &config_agents)?;
if config_agents.exists() { let _ = cleanup_empty_config_dir(vault)?;
let config_content = fs::read_to_string(&config_agents).unwrap_or_default(); sync_ai_guidance_files(vault_path)?;
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}"))?;
}
}
// 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)?; write_if_missing(&vault.join("config.md"), CONFIG_TYPE_DEFINITION)?;
Ok("Config files repaired".to_string()) Ok("Config files repaired".to_string())
@@ -153,26 +288,53 @@ mod tests {
use std::fs; use std::fs;
use tempfile::TempDir; use tempfile::TempDir;
#[test] fn create_vault() -> (TempDir, PathBuf) {
fn test_seed_config_files_creates_agents_at_root() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault"); let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap(); 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()); seed_config_files(vault.to_str().unwrap());
assert!(vault.join("AGENTS.md").exists()); assert!(vault.join("AGENTS.md").exists());
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap(); assert!(read_root_agents(&vault).contains("Tolaria Vault"));
assert!(content.contains("Tolaria Vault")); assert_eq!(read_root_claude(&vault), CLAUDE_MD_SHIM);
// Must NOT create config/ directory
assert!(!vault.join("config").exists());
} }
#[test] #[test]
fn test_seed_config_files_creates_type_definition() { fn test_seed_config_files_creates_type_definition() {
let dir = TempDir::new().unwrap(); let (_dir, vault) = create_vault();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
seed_config_files(vault.to_str().unwrap()); seed_config_files(vault.to_str().unwrap());
@@ -180,69 +342,67 @@ mod tests {
let content = fs::read_to_string(vault.join("config.md")).unwrap(); let content = fs::read_to_string(vault.join("config.md")).unwrap();
assert!(content.contains("type: Type")); assert!(content.contains("type: Type"));
assert!(content.contains("icon: gear-six")); assert!(content.contains("icon: gear-six"));
assert!(!vault.join("config").exists());
} }
#[test] #[test]
fn test_seed_config_files_is_idempotent() { fn test_seed_config_files_preserves_custom_agents() {
let dir = TempDir::new().unwrap(); let (_dir, vault) = create_vault();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
seed_config_files(vault.to_str().unwrap()); seed_config_files(vault.to_str().unwrap());
let custom = "# Custom Agent Config\nMy custom instructions\n"; write_root_agents(&vault, "# Custom Agent Config\nMy custom instructions\n");
fs::write(vault.join("AGENTS.md"), custom).unwrap();
seed_config_files(vault.to_str().unwrap()); seed_config_files(vault.to_str().unwrap());
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
assert!( assert!(
content.contains("Custom Agent Config"), read_root_agents(&vault).contains("Custom Agent Config"),
"must preserve existing content" "must preserve existing content"
); );
} }
#[test] #[test]
fn test_seed_config_files_reseeds_empty() { fn test_seed_config_files_reseeds_empty() {
let dir = TempDir::new().unwrap(); let (_dir, vault) = create_vault();
let vault = dir.path().join("vault"); write_root_agents(&vault, "");
fs::create_dir_all(&vault).unwrap();
fs::write(vault.join("AGENTS.md"), "").unwrap();
seed_config_files(vault.to_str().unwrap()); seed_config_files(vault.to_str().unwrap());
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap(); assert!(read_root_agents(&vault).contains("Tolaria Vault"));
assert!(content.contains("Tolaria Vault"));
} }
#[test] #[test]
fn test_seed_config_files_refreshes_stale_default_agents() { fn test_seed_config_files_refreshes_stale_default_agents() {
let dir = TempDir::new().unwrap(); let (_dir, vault) = create_vault();
let vault = dir.path().join("vault"); write_root_agents(
fs::create_dir_all(&vault).unwrap(); &vault,
fs::write(
vault.join("AGENTS.md"),
"# AGENTS.md — Tolaria Vault\n\n- The first H1 in the body is the note title. Do not add `title:` frontmatter.\n", "# 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()); 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("Legacy `title:` frontmatter is still read as a fallback"));
assert!(content.contains("views/*.yml")); assert!(content.contains("views/*.yml"));
assert!(content.contains("Belongs to:")); 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] #[test]
fn test_migrate_agents_md_moves_config_to_root() { fn test_migrate_agents_md_moves_config_to_root() {
let dir = TempDir::new().unwrap(); let (_dir, vault) = create_vault();
let vault = dir.path().join("vault"); write_legacy_agents(&vault, "# My vault agent instructions\nCustom content\n");
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();
migrate_agents_md(vault.to_str().unwrap()); 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!(root_content.contains("My vault agent instructions"));
assert!(!config_dir.join("agents.md").exists()); assert!(!config_dir.join("agents.md").exists());
assert!(!config_dir.exists()); assert!(!config_dir.exists());
@@ -250,60 +410,49 @@ mod tests {
#[test] #[test]
fn test_migrate_agents_md_preserves_existing_root() { fn test_migrate_agents_md_preserves_existing_root() {
let dir = TempDir::new().unwrap(); let (_dir, vault) = create_vault();
let vault = dir.path().join("vault"); write_root_agents(&vault, "# My root agent config\nDo not overwrite\n");
let config_dir = vault.join("config"); write_legacy_agents(&vault, "Legacy content");
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();
migrate_agents_md(vault.to_str().unwrap()); 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!(content.contains("My root agent config"));
assert!(!config_dir.join("agents.md").exists()); assert!(!config_dir.join("agents.md").exists());
} }
#[test] #[test]
fn test_migrate_agents_md_replaces_stub_with_config_content() { fn test_migrate_agents_md_replaces_stub_with_config_content() {
let dir = TempDir::new().unwrap(); let (_dir, vault) = create_vault();
let vault = dir.path().join("vault"); write_root_agents(
let config_dir = vault.join("config"); &vault,
fs::create_dir_all(&config_dir).unwrap();
fs::write(
vault.join("AGENTS.md"),
"# Agent Instructions\nSee config/agents.md for vault instructions.\n", "# Agent Instructions\nSee config/agents.md for vault instructions.\n",
) );
.unwrap(); write_legacy_agents(&vault, "# Real Agent Config\nImportant instructions\n");
let real_content = "# Real Agent Config\nImportant instructions\n";
fs::write(config_dir.join("agents.md"), real_content).unwrap();
migrate_agents_md(vault.to_str().unwrap()); 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")); assert!(content.contains("Real Agent Config"));
} }
#[test] #[test]
fn test_migrate_agents_md_idempotent_when_no_legacy() { fn test_migrate_agents_md_idempotent_when_no_legacy() {
let dir = TempDir::new().unwrap(); let (_dir, vault) = create_vault();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
migrate_agents_md(vault.to_str().unwrap()); migrate_agents_md(vault.to_str().unwrap());
assert!(vault.join("AGENTS.md").exists()); 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!(root.contains("Tolaria Vault"));
assert_eq!(read_root_claude(&vault), CLAUDE_MD_SHIM);
} }
#[test] #[test]
fn test_migrate_agents_md_keeps_nonempty_config_dir() { fn test_migrate_agents_md_keeps_nonempty_config_dir() {
let dir = TempDir::new().unwrap(); let (_dir, vault) = create_vault();
let vault = dir.path().join("vault"); let config_dir = config_dir(&vault);
let config_dir = vault.join("config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(config_dir.join("agents.md"), "Agent content").unwrap(); fs::write(config_dir.join("agents.md"), "Agent content").unwrap();
fs::write(config_dir.join("other.md"), "Other file").unwrap(); fs::write(config_dir.join("other.md"), "Other file").unwrap();
@@ -316,71 +465,98 @@ mod tests {
#[test] #[test]
fn test_repair_config_files_creates_all() { fn test_repair_config_files_creates_all() {
let dir = TempDir::new().unwrap(); let (_dir, vault) = create_vault();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let msg = repair_config_files(vault.to_str().unwrap()).unwrap(); let msg = repair_config_files(vault.to_str().unwrap()).unwrap();
assert_eq!(msg, "Config files repaired"); assert_eq!(msg, "Config files repaired");
assert!(vault.join("AGENTS.md").exists()); assert!(vault.join("AGENTS.md").exists());
assert!(vault.join("CLAUDE.md").exists());
assert!(vault.join("config.md").exists()); assert!(vault.join("config.md").exists());
assert!(!vault.join("config").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")); assert!(agents.contains("Tolaria Vault"));
} }
#[test] #[test]
fn test_repair_config_files_preserves_custom_content() { fn test_repair_config_files_preserves_custom_content() {
let dir = TempDir::new().unwrap(); let (_dir, vault) = create_vault();
let vault = dir.path().join("vault"); write_root_agents(&vault, "# My custom agent config\nDo not overwrite me\n");
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();
repair_config_files(vault.to_str().unwrap()).unwrap(); repair_config_files(vault.to_str().unwrap()).unwrap();
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
assert!( assert!(
content.contains("My custom agent config"), read_root_agents(&vault).contains("My custom agent config"),
"must preserve existing content" "must preserve existing content"
); );
} }
#[test] #[test]
fn test_repair_config_files_migrates_legacy_config() { fn test_repair_config_files_migrates_legacy_config() {
let dir = TempDir::new().unwrap(); let (_dir, vault) = create_vault();
let vault = dir.path().join("vault"); write_legacy_agents(
let config_dir = vault.join("config"); &vault,
fs::create_dir_all(&config_dir).unwrap(); "# My vault agents instructions\nCustom content here\n",
let original = "# My vault agents instructions\nCustom content here\n"; );
fs::write(config_dir.join("agents.md"), original).unwrap();
repair_config_files(vault.to_str().unwrap()).unwrap(); 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!(root.contains("My vault agents instructions"));
assert!(!config_dir.join("agents.md").exists()); assert!(!config_dir.join("agents.md").exists());
} }
#[test] #[test]
fn test_repair_config_files_replaces_stub_with_legacy() { fn test_repair_config_files_replaces_stub_with_legacy() {
let dir = TempDir::new().unwrap(); let (_dir, vault) = create_vault();
let vault = dir.path().join("vault"); write_root_agents(
let config_dir = vault.join("config"); &vault,
fs::create_dir_all(&config_dir).unwrap();
fs::write(
vault.join("AGENTS.md"),
"# Agent Instructions\nSee config/agents.md for vault instructions.\n", "# Agent Instructions\nSee config/agents.md for vault instructions.\n",
) );
.unwrap(); write_legacy_agents(&vault, "# Real Instructions\nImportant stuff\n");
let real = "# Real Instructions\nImportant stuff\n";
fs::write(config_dir.join("agents.md"), real).unwrap();
repair_config_files(vault.to_str().unwrap()).unwrap(); 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")); 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; mod views;
pub use cache::{invalidate_cache, scan_vault_cached}; 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 entry::{FolderNode, VaultEntry};
pub use file::{get_note_content, save_note_content}; pub use file::{get_note_content, save_note_content};
pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists}; 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 { useMcpStatus } from './hooks/useMcpStatus'
import { useAiAgentsOnboarding } from './hooks/useAiAgentsOnboarding' import { useAiAgentsOnboarding } from './hooks/useAiAgentsOnboarding'
import { useAiAgentsStatus } from './hooks/useAiAgentsStatus' import { useAiAgentsStatus } from './hooks/useAiAgentsStatus'
import { useVaultAiGuidanceStatus } from './hooks/useVaultAiGuidanceStatus'
import { useVaultLoader } from './hooks/useVaultLoader' import { useVaultLoader } from './hooks/useVaultLoader'
import { useAiAgentPreferences } from './hooks/useAiAgentPreferences' import { useAiAgentPreferences } from './hooks/useAiAgentPreferences'
import { useSettings } from './hooks/useSettings' import { useSettings } from './hooks/useSettings'
@@ -68,6 +69,9 @@ import { RenameDetectedBanner, type DetectedRename } from './components/RenameDe
import { openNoteListPropertiesPicker } from './components/note-list/noteListPropertiesEvents' import { openNoteListPropertiesPicker } from './components/note-list/noteListPropertiesEvents'
import { focusNoteIconPropertyEditor } from './components/noteIconPropertyEvents' import { focusNoteIconPropertyEditor } from './components/noteIconPropertyEvents'
import { trackEvent } from './lib/telemetry' import { trackEvent } from './lib/telemetry'
import {
buildVaultAiGuidanceRefreshKey,
} from './lib/vaultAiGuidance'
import { extractDeletedContentFromDiff } from './components/note-list/noteListUtils' import { extractDeletedContentFromDiff } from './components/note-list/noteListUtils'
import { hasNoteIconValue } from './utils/noteIcon' import { hasNoteIconValue } from './utils/noteIcon'
import { OPEN_AI_CHAT_EVENT } from './utils/aiPromptBridge' import { OPEN_AI_CHAT_EVENT } from './utils/aiPromptBridge'
@@ -152,6 +156,13 @@ function App() {
}, [resolvedPath]) }, [resolvedPath])
const vault = useVaultLoader(resolvedPath) const vault = useVaultLoader(resolvedPath)
const {
status: vaultAiGuidanceStatus,
refresh: refreshVaultAiGuidance,
} = useVaultAiGuidanceStatus(
resolvedPath,
buildVaultAiGuidanceRefreshKey(vault.entries),
)
const { config: vaultConfig, updateConfig } = useVaultConfig(resolvedPath) const { config: vaultConfig, updateConfig } = useVaultConfig(resolvedPath)
const explicitOrganizationEnabled = isExplicitOrganizationEnabled(vaultConfig.inbox?.explicitOrganization) const explicitOrganizationEnabled = isExplicitOrganizationEnabled(vaultConfig.inbox?.explicitOrganization)
const effectiveSelection = sanitizeSelectionForOrganization(selection, vaultConfig.inbox?.explicitOrganization) const effectiveSelection = sanitizeSelectionForOrganization(selection, vaultConfig.inbox?.explicitOrganization)
@@ -547,11 +558,25 @@ function App() {
const tauriInvoke = isTauri() ? invoke : mockInvoke const tauriInvoke = isTauri() ? invoke : mockInvoke
const msg = await tauriInvoke<string>('repair_vault', { vaultPath: resolvedPath }) const msg = await tauriInvoke<string>('repair_vault', { vaultPath: resolvedPath })
await vault.reloadVault() await vault.reloadVault()
await refreshVaultAiGuidance()
setToastMessage(msg) setToastMessage(msg)
} catch (err) { } catch (err) {
setToastMessage(`Failed to repair vault: ${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 activeDeletedFile = useMemo(() => {
const activeTabPath = notes.activeTabPath const activeTabPath = notes.activeTabPath
@@ -605,6 +630,8 @@ function App() {
onInstallMcp: installMcp, onInstallMcp: installMcp,
onOpenAiAgents: dialogs.openSettings, onOpenAiAgents: dialogs.openSettings,
aiAgentsStatus, aiAgentsStatus,
vaultAiGuidanceStatus,
onRestoreVaultAiGuidance: () => { void restoreVaultAiGuidance() },
selectedAiAgent: aiAgentPreferences.defaultAiAgent, selectedAiAgent: aiAgentPreferences.defaultAiAgent,
onSetDefaultAiAgent: aiAgentPreferences.setDefaultAiAgent, onSetDefaultAiAgent: aiAgentPreferences.setDefaultAiAgent,
onCycleDefaultAiAgent: aiAgentPreferences.cycleDefaultAiAgent, onCycleDefaultAiAgent: aiAgentPreferences.cycleDefaultAiAgent,
@@ -775,7 +802,7 @@ function App() {
</div> </div>
<UpdateBanner status={updateStatus} actions={updateActions} /> <UpdateBanner status={updateStatus} actions={updateActions} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} /> <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} /> <DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} /> <Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} /> <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 { AiAgentId, AiAgentsStatus } from '../lib/aiAgents'
import type { VaultAiGuidanceStatus } from '../lib/vaultAiGuidance'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import type { ClaudeCodeStatus } from '../hooks/useClaudeCodeStatus' import type { ClaudeCodeStatus } from '../hooks/useClaudeCodeStatus'
import type { McpStatus } from '../hooks/useMcpStatus' import type { McpStatus } from '../hooks/useMcpStatus'
@@ -43,8 +44,10 @@ interface StatusBarProps {
mcpStatus?: McpStatus mcpStatus?: McpStatus
onInstallMcp?: () => void onInstallMcp?: () => void
aiAgentsStatus?: AiAgentsStatus aiAgentsStatus?: AiAgentsStatus
vaultAiGuidanceStatus?: VaultAiGuidanceStatus
defaultAiAgent?: AiAgentId defaultAiAgent?: AiAgentId
onSetDefaultAiAgent?: (agent: AiAgentId) => void onSetDefaultAiAgent?: (agent: AiAgentId) => void
onRestoreVaultAiGuidance?: () => void
claudeCodeStatus?: ClaudeCodeStatus claudeCodeStatus?: ClaudeCodeStatus
claudeCodeVersion?: string | null claudeCodeVersion?: string | null
} }
@@ -81,8 +84,10 @@ export function StatusBar({
mcpStatus, mcpStatus,
onInstallMcp, onInstallMcp,
aiAgentsStatus, aiAgentsStatus,
vaultAiGuidanceStatus,
defaultAiAgent, defaultAiAgent,
onSetDefaultAiAgent, onSetDefaultAiAgent,
onRestoreVaultAiGuidance,
claudeCodeStatus, claudeCodeStatus,
claudeCodeVersion, claudeCodeVersion,
}: StatusBarProps) { }: StatusBarProps) {
@@ -137,8 +142,10 @@ export function StatusBar({
mcpStatus={mcpStatus} mcpStatus={mcpStatus}
onInstallMcp={onInstallMcp} onInstallMcp={onInstallMcp}
aiAgentsStatus={aiAgentsStatus} aiAgentsStatus={aiAgentsStatus}
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
defaultAiAgent={defaultAiAgent} defaultAiAgent={defaultAiAgent}
onSetDefaultAiAgent={onSetDefaultAiAgent} onSetDefaultAiAgent={onSetDefaultAiAgent}
onRestoreVaultAiGuidance={onRestoreVaultAiGuidance}
claudeCodeStatus={claudeCodeStatus} claudeCodeStatus={claudeCodeStatus}
claudeCodeVersion={claudeCodeVersion} 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 AiAgentDefinition,
type AiAgentsStatus, type AiAgentsStatus,
} from '../../lib/aiAgents' } from '../../lib/aiAgents'
import {
getVaultAiGuidanceSummary,
isVaultAiGuidanceStatusChecking,
vaultAiGuidanceNeedsRestore,
vaultAiGuidanceUsesCustomFiles,
type VaultAiGuidanceStatus,
} from '../../lib/vaultAiGuidance'
import { openExternalUrl } from '../../utils/url' import { openExternalUrl } from '../../utils/url'
import { import {
DropdownMenu, DropdownMenu,
@@ -25,18 +32,35 @@ import { ICON_STYLE, SEP_STYLE } from './styles'
interface AiAgentsBadgeProps { interface AiAgentsBadgeProps {
statuses: AiAgentsStatus statuses: AiAgentsStatus
guidanceStatus?: VaultAiGuidanceStatus
defaultAgent: AiAgentId defaultAgent: AiAgentId
onSetDefaultAgent?: (agent: AiAgentId) => void 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' if (!hasAnyInstalledAiAgent(statuses)) return 'No AI agents detected — click for setup details'
const definition = getAiAgentDefinition(defaultAgent) const definition = getAiAgentDefinition(defaultAgent)
if (!isAiAgentInstalled(statuses, defaultAgent)) { if (!isAiAgentInstalled(statuses, defaultAgent)) {
return `${definition.label} is selected but not installed — click for setup details` return `${definition.label} is selected but not installed — click for setup details`
} }
const version = statuses[defaultAgent].version 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[] { function installedAgentDefinitions(statuses: AiAgentsStatus): AiAgentDefinition[] {
@@ -72,11 +96,38 @@ function canSwitchAgents(
return installedAgents.some((definition) => definition.id !== defaultAgent) 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({ function AgentMenuContent({
statuses, statuses,
guidanceStatus,
defaultAgent, defaultAgent,
selectedAgentReady, selectedAgentReady,
onSetDefaultAgent, onSetDefaultAgent,
onRestoreGuidance,
}: AiAgentsBadgeProps & { selectedAgentReady: boolean }) { }: AiAgentsBadgeProps & { selectedAgentReady: boolean }) {
const installedAgents = installedAgentDefinitions(statuses) const installedAgents = installedAgentDefinitions(statuses)
const missingAgents = missingAgentDefinitions(statuses) const missingAgents = missingAgentDefinitions(statuses)
@@ -120,14 +171,26 @@ function AgentMenuContent({
))} ))}
</> </>
)} )}
<GuidanceMenuSection
guidanceStatus={guidanceStatus}
onRestoreGuidance={onRestoreGuidance}
/>
</DropdownMenuContent> </DropdownMenuContent>
) )
} }
export function AiAgentsBadge({ statuses, defaultAgent, onSetDefaultAgent }: AiAgentsBadgeProps) { export function AiAgentsBadge({
statuses,
guidanceStatus,
defaultAgent,
onSetDefaultAgent,
onRestoreGuidance,
}: AiAgentsBadgeProps) {
const hasInstalledAgent = hasAnyInstalledAiAgent(statuses) const hasInstalledAgent = hasAnyInstalledAiAgent(statuses)
const selectedAgentReady = isAiAgentInstalled(statuses, defaultAgent) const selectedAgentReady = isAiAgentInstalled(statuses, defaultAgent)
const showWarning = !hasInstalledAgent || !selectedAgentReady const showWarning = !hasInstalledAgent
|| !selectedAgentReady
|| !!(guidanceStatus && vaultAiGuidanceNeedsRestore(guidanceStatus))
const showSwitcherCue = canSwitchAgents(installedAgentDefinitions(statuses), defaultAgent) const showSwitcherCue = canSwitchAgents(installedAgentDefinitions(statuses), defaultAgent)
if (isAiAgentsStatusChecking(statuses)) return null if (isAiAgentsStatusChecking(statuses)) return null
@@ -142,7 +205,7 @@ export function AiAgentsBadge({ statuses, defaultAgent, onSetDefaultAgent }: AiA
variant="ghost" variant="ghost"
size="xs" size="xs"
className="h-6 px-2 text-[11px] font-medium" className="h-6 px-2 text-[11px] font-medium"
title={badgeTooltip(statuses, defaultAgent)} title={badgeTooltip(statuses, defaultAgent, guidanceStatus)}
data-testid="status-ai-agents" data-testid="status-ai-agents"
> >
<span style={{ ...ICON_STYLE, color: showWarning ? 'var(--accent-orange)' : 'var(--muted-foreground)' }}> <span style={{ ...ICON_STYLE, color: showWarning ? 'var(--accent-orange)' : 'var(--muted-foreground)' }}>
@@ -155,8 +218,10 @@ export function AiAgentsBadge({ statuses, defaultAgent, onSetDefaultAgent }: AiA
</DropdownMenuTrigger> </DropdownMenuTrigger>
<AgentMenuContent <AgentMenuContent
statuses={statuses} statuses={statuses}
guidanceStatus={guidanceStatus}
defaultAgent={defaultAgent} defaultAgent={defaultAgent}
onSetDefaultAgent={onSetDefaultAgent} onSetDefaultAgent={onSetDefaultAgent}
onRestoreGuidance={onRestoreGuidance}
selectedAgentReady={selectedAgentReady} selectedAgentReady={selectedAgentReady}
/> />
</DropdownMenu> </DropdownMenu>

View File

@@ -1,6 +1,7 @@
import { Bell, FileText, Package, Settings } from 'lucide-react' import { Bell, FileText, Package, Settings } from 'lucide-react'
import { Megaphone } from '@phosphor-icons/react' import { Megaphone } from '@phosphor-icons/react'
import type { AiAgentId, AiAgentsStatus } from '../../lib/aiAgents' import type { AiAgentId, AiAgentsStatus } from '../../lib/aiAgents'
import type { VaultAiGuidanceStatus } from '../../lib/vaultAiGuidance'
import type { ClaudeCodeStatus } from '../../hooks/useClaudeCodeStatus' import type { ClaudeCodeStatus } from '../../hooks/useClaudeCodeStatus'
import type { McpStatus } from '../../hooks/useMcpStatus' import type { McpStatus } from '../../hooks/useMcpStatus'
import type { GitRemoteStatus, LastCommitInfo, SyncStatus } from '../../types' import type { GitRemoteStatus, LastCommitInfo, SyncStatus } from '../../types'
@@ -49,8 +50,10 @@ interface StatusBarPrimarySectionProps {
mcpStatus?: McpStatus mcpStatus?: McpStatus
onInstallMcp?: () => void onInstallMcp?: () => void
aiAgentsStatus?: AiAgentsStatus aiAgentsStatus?: AiAgentsStatus
vaultAiGuidanceStatus?: VaultAiGuidanceStatus
defaultAiAgent?: AiAgentId defaultAiAgent?: AiAgentId
onSetDefaultAiAgent?: (agent: AiAgentId) => void onSetDefaultAiAgent?: (agent: AiAgentId) => void
onRestoreVaultAiGuidance?: () => void
claudeCodeStatus?: ClaudeCodeStatus claudeCodeStatus?: ClaudeCodeStatus
claudeCodeVersion?: string | null claudeCodeVersion?: string | null
} }
@@ -90,8 +93,10 @@ export function StatusBarPrimarySection({
mcpStatus, mcpStatus,
onInstallMcp, onInstallMcp,
aiAgentsStatus, aiAgentsStatus,
vaultAiGuidanceStatus,
defaultAiAgent, defaultAiAgent,
onSetDefaultAiAgent, onSetDefaultAiAgent,
onRestoreVaultAiGuidance,
claudeCodeStatus, claudeCodeStatus,
claudeCodeVersion, claudeCodeVersion,
}: StatusBarPrimarySectionProps) { }: StatusBarPrimarySectionProps) {
@@ -136,7 +141,15 @@ export function StatusBarPrimarySection({
<PulseBadge onClick={onClickPulse} disabled={isGitVault === false} /> <PulseBadge onClick={onClickPulse} disabled={isGitVault === false} />
{mcpStatus && <McpBadge status={mcpStatus} onInstall={onInstallMcp} />} {mcpStatus && <McpBadge status={mcpStatus} onInstall={onInstallMcp} />}
{aiAgentsStatus && defaultAiAgent {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} />} : claudeCodeStatus && <ClaudeCodeBadge status={claudeCodeStatus} version={claudeCodeVersion} />}
</div> </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 AiAgentId,
type AiAgentsStatus, type AiAgentsStatus,
} from '../../lib/aiAgents' } from '../../lib/aiAgents'
import {
isVaultAiGuidanceStatusChecking,
vaultAiGuidanceNeedsRestore,
type VaultAiGuidanceStatus,
} from '../../lib/vaultAiGuidance'
import type { CommandAction } from './types' import type { CommandAction } from './types'
interface AiAgentCommandsConfig { interface AiAgentCommandsConfig {
aiAgentsStatus?: AiAgentsStatus aiAgentsStatus?: AiAgentsStatus
vaultAiGuidanceStatus?: VaultAiGuidanceStatus
selectedAiAgent?: AiAgentId selectedAiAgent?: AiAgentId
selectedAiAgentLabel?: string selectedAiAgentLabel?: string
onOpenAiAgents?: () => void onOpenAiAgents?: () => void
onRestoreVaultAiGuidance?: () => void
onCycleDefaultAiAgent?: () => void onCycleDefaultAiAgent?: () => void
onSetDefaultAiAgent?: (agent: AiAgentId) => 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({ export function buildAiAgentCommands({
aiAgentsStatus, aiAgentsStatus,
vaultAiGuidanceStatus,
selectedAiAgent, selectedAiAgent,
selectedAiAgentLabel, selectedAiAgentLabel,
onOpenAiAgents, onOpenAiAgents,
onRestoreVaultAiGuidance,
onCycleDefaultAiAgent, onCycleDefaultAiAgent,
onSetDefaultAiAgent, onSetDefaultAiAgent,
}: AiAgentCommandsConfig): CommandAction[] { }: AiAgentCommandsConfig): CommandAction[] {
@@ -54,6 +83,11 @@ export function buildAiAgentCommands({
}, },
] ]
commands.push(...restoreGuidanceCommands({
vaultAiGuidanceStatus,
onRestoreVaultAiGuidance,
}))
const switchCommands = explicitSwitchCommands({ const switchCommands = explicitSwitchCommands({
aiAgentsStatus, aiAgentsStatus,
selectedAiAgent, selectedAiAgent,

View File

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

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import type { AiAgentId, AiAgentsStatus } from '../lib/aiAgents' import type { AiAgentId, AiAgentsStatus } from '../lib/aiAgents'
import type { VaultAiGuidanceStatus } from '../lib/vaultAiGuidance'
import type { SidebarSelection, VaultEntry } from '../types' import type { SidebarSelection, VaultEntry } from '../types'
import type { NoteListFilter } from '../utils/noteListHelpers' import type { NoteListFilter } from '../utils/noteListHelpers'
import type { ViewMode } from './useViewMode' import type { ViewMode } from './useViewMode'
@@ -28,7 +29,9 @@ interface CommandRegistryConfig {
mcpStatus?: string mcpStatus?: string
onInstallMcp?: () => void onInstallMcp?: () => void
aiAgentsStatus?: AiAgentsStatus aiAgentsStatus?: AiAgentsStatus
vaultAiGuidanceStatus?: VaultAiGuidanceStatus
onOpenAiAgents?: () => void onOpenAiAgents?: () => void
onRestoreVaultAiGuidance?: () => void
onSetDefaultAiAgent?: (agent: AiAgentId) => void onSetDefaultAiAgent?: (agent: AiAgentId) => void
selectedAiAgent?: AiAgentId selectedAiAgent?: AiAgentId
onCycleDefaultAiAgent?: () => void onCycleDefaultAiAgent?: () => void
@@ -97,8 +100,8 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
onGoBack, onGoForward, canGoBack, canGoForward, onGoBack, onGoForward, canGoBack, canGoForward,
onCheckForUpdates, onCreateType, onCheckForUpdates, onCreateType,
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount, onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
mcpStatus, onInstallMcp, aiAgentsStatus, mcpStatus, onInstallMcp, aiAgentsStatus, vaultAiGuidanceStatus,
onOpenAiAgents, onSetDefaultAiAgent, selectedAiAgent, onCycleDefaultAiAgent, selectedAiAgentLabel, onOpenAiAgents, onRestoreVaultAiGuidance, onSetDefaultAiAgent, selectedAiAgent, onCycleDefaultAiAgent, selectedAiAgentLabel,
onReloadVault, onRepairVault, onReloadVault, onRepairVault,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
onOpenInNewWindow, onToggleFavorite, onToggleOrganized, onOpenInNewWindow, onToggleFavorite, onToggleOrganized,
@@ -142,9 +145,11 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
}), }),
...buildAiAgentCommands({ ...buildAiAgentCommands({
aiAgentsStatus, aiAgentsStatus,
vaultAiGuidanceStatus,
selectedAiAgent, selectedAiAgent,
selectedAiAgentLabel, selectedAiAgentLabel,
onOpenAiAgents, onOpenAiAgents,
onRestoreVaultAiGuidance,
onSetDefaultAiAgent, onSetDefaultAiAgent,
onCycleDefaultAiAgent, onCycleDefaultAiAgent,
}), }),
@@ -162,8 +167,8 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
onGoBack, onGoForward, canGoBack, canGoForward, onGoBack, onGoForward, canGoBack, canGoForward,
vaultTypes, vaultTypes,
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount, onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
mcpStatus, onInstallMcp, aiAgentsStatus, mcpStatus, onInstallMcp, aiAgentsStatus, vaultAiGuidanceStatus,
onOpenAiAgents, onSetDefaultAiAgent, selectedAiAgent, onCycleDefaultAiAgent, selectedAiAgentLabel, onOpenAiAgents, onRestoreVaultAiGuidance, onSetDefaultAiAgent, selectedAiAgent, onCycleDefaultAiAgent, selectedAiAgentLabel,
onReloadVault, onRepairVault, onReloadVault, onRepairVault,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
isSectionGroup, noteListFilter, onSetNoteListFilter, 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, active_vault: null,
} }
let mockVaultAiGuidanceStatus = {
agents_state: 'managed',
claude_state: 'managed',
can_restore: false,
} as const
function escapeRegex({ text }: { text: string }) { function escapeRegex({ text }: { text: string }) {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
} }
@@ -276,6 +282,15 @@ export const mockHandlers: Record<string, (args: any) => any> = {
claude_code: { installed: false, version: null }, claude_code: { installed: false, version: null },
codex: { 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_chat: () => 'mock-session',
stream_claude_agent: () => null, stream_claude_agent: () => null,
stream_ai_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', create_getting_started_vault: (args: { targetPath?: string | null }) => args.targetPath || '/Users/mock/Documents/Getting Started',
register_mcp_tools: () => 'registered', register_mcp_tools: () => 'registered',
check_mcp_status: () => 'installed', 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, 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)
})