feat: add vault ai guidance restore flow
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
31
src/App.tsx
31
src/App.tsx
@@ -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} />
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
81
src/components/status-bar/AiAgentsBadge.test.tsx
Normal file
81
src/components/status-bar/AiAgentsBadge.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
35
src/hooks/commands/aiAgentCommands.test.ts
Normal file
35
src/hooks/commands/aiAgentCommands.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
71
src/hooks/useVaultAiGuidanceStatus.test.ts
Normal file
71
src/hooks/useVaultAiGuidanceStatus.test.ts
Normal 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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
70
src/hooks/useVaultAiGuidanceStatus.ts
Normal file
70
src/hooks/useVaultAiGuidanceStatus.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
97
src/lib/vaultAiGuidance.test.ts
Normal file
97
src/lib/vaultAiGuidance.test.ts
Normal 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
103
src/lib/vaultAiGuidance.ts
Normal 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('|')
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
75
tests/smoke/vault-ai-guidance-restore.spec.ts
Normal file
75
tests/smoke/vault-ai-guidance-restore.spec.ts
Normal 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)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user