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
|
||||
├── john-doe.md ← type: Person
|
||||
├── some-topic.md ← type: Topic
|
||||
├── AGENTS.md ← canonical Tolaria AI guidance
|
||||
├── CLAUDE.md ← compatibility shim pointing at AGENTS.md
|
||||
├── ...
|
||||
├── type/ ← type definition documents
|
||||
└── config/ ← meta-configuration files (agents.md, etc.)
|
||||
└── type/ ← type definition documents
|
||||
```
|
||||
|
||||
New notes are created at the vault root: `{vault}/{slug}.md`. Changing a note's type only requires updating the `type:` field in frontmatter — the file does not move. The `type/` folder exists solely for type definition documents, and `config/` for configuration files.
|
||||
New notes are created at the vault root: `{vault}/{slug}.md`. Changing a note's type only requires updating the `type:` field in frontmatter — the file does not move. The `type/` folder exists solely for type definition documents. Legacy `config/` content is still recognized during migration and repair, but Tolaria's managed AI guidance now lives at the vault root.
|
||||
|
||||
A `flatten_vault` migration command is available to move existing notes from type-based subfolders to the vault root.
|
||||
|
||||
@@ -271,7 +272,7 @@ type SidebarSelection =
|
||||
|
||||
1. Validates the path exists and is a directory
|
||||
2. Scans root-level `.md` files (non-recursive)
|
||||
3. Recursively scans protected folders: `type/`, `config/`, `attachments/`
|
||||
3. Recursively scans protected folders: `type/`, legacy `config/`, `attachments/`
|
||||
4. Files in non-protected subfolders are **not indexed** (flat vault enforcement)
|
||||
5. For each `.md` file, calls `parse_md_file()`:
|
||||
- Reads content with `fs::read_to_string()`
|
||||
@@ -529,6 +530,15 @@ Per-vault settings stored locally and scoped by vault path:
|
||||
- Settings: zoom, view mode, tag colors, status colors, property display modes, Inbox note-list column overrides, explicit organization workflow toggle
|
||||
- One-time migration from localStorage (`configMigration.ts`)
|
||||
|
||||
### AI Guidance Files
|
||||
|
||||
Tolaria tracks managed vault-level AI guidance separately from normal note content:
|
||||
- `AGENTS.md` is the canonical managed guidance file for Tolaria-aware coding agents
|
||||
- `CLAUDE.md` is a compatibility shim that points Claude Code back to `AGENTS.md`
|
||||
- `useVaultAiGuidanceStatus` reads `get_vault_ai_guidance_status` and normalizes the backend state into four UI cases: `managed`, `missing`, `broken`, and `custom`
|
||||
- `restore_vault_ai_guidance` repairs only Tolaria-managed files; user-authored custom `AGENTS.md` / `CLAUDE.md` files are surfaced as custom and left untouched
|
||||
- The status bar AI badge and command palette consume that abstraction to expose restore actions only when the managed guidance is missing or broken
|
||||
|
||||
### Getting Started / Onboarding
|
||||
|
||||
`useOnboarding` hook detects first launch:
|
||||
|
||||
@@ -437,7 +437,7 @@ On first launch, `useOnboarding` checks if the default vault exists. If not, it
|
||||
|
||||
Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code and Codex are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch.
|
||||
|
||||
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL, delegates the clone to the git backend, then normalizes Tolaria-managed config files (`AGENTS.md`, `config.md`) so fresh starter vaults pick up the current default guidance even when the remote starter repo still carries a legacy copy.
|
||||
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL, delegates the clone to the git backend, then normalizes Tolaria-managed config files (`AGENTS.md`, `CLAUDE.md`, `config.md`) so fresh starter vaults pick up the current default guidance even when the remote starter repo still carries a legacy copy. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions.
|
||||
|
||||
### Remote Clone & Auth Model
|
||||
|
||||
@@ -571,7 +571,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `rename.rs` | `rename_note` — renames files, updates `title` frontmatter, and updates wikilinks across the vault |
|
||||
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames |
|
||||
| `migration.rs` | `flatten_vault`, `vault_health_check`, `migrate_is_a_to_type` |
|
||||
| `config_seed.rs` | Seeds `config/` folder, migrates `AGENTS.md`, repairs missing config files |
|
||||
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, repairs missing config files |
|
||||
| `getting_started.rs` | Creates the Getting Started demo vault |
|
||||
|
||||
## Rust Backend Modules
|
||||
@@ -608,8 +608,10 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `reload_vault` | Invalidate cache and full rescan from filesystem → `Vec<VaultEntry>` |
|
||||
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
|
||||
| `check_vault_exists` | Check if vault path exists |
|
||||
| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md` and `config.md` defaults |
|
||||
| `create_getting_started_vault` | Clone the public Getting Started vault, refresh Tolaria-managed config defaults, and keep the cloned repo clean |
|
||||
| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, and `config.md` defaults |
|
||||
| `create_getting_started_vault` | Clone the public Getting Started vault, refresh Tolaria-managed guidance/config defaults, and keep the cloned repo clean |
|
||||
| `get_vault_ai_guidance_status` | Report whether `AGENTS.md` and the `CLAUDE.md` shim are managed, missing, broken, or custom |
|
||||
| `restore_vault_ai_guidance` | Restore any missing/broken Tolaria-managed guidance files without overwriting custom ones |
|
||||
|
||||
### Frontmatter
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ tolaria/
|
||||
├── playwright.config.ts # Full Playwright regression config
|
||||
├── playwright.smoke.config.ts # Curated pre-push Playwright config
|
||||
├── ui-design.pen # Master design file
|
||||
├── AGENTS.md # Shared project instructions for coding agents
|
||||
├── AGENTS.md # Canonical shared instructions for coding agents
|
||||
├── CLAUDE.md # Claude Code compatibility shim importing AGENTS.md
|
||||
└── docs/ # This documentation
|
||||
```
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#[cfg(desktop)]
|
||||
use crate::ai_agents::{AiAgentStreamRequest, AiAgentsStatus};
|
||||
use crate::claude_cli::{AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus};
|
||||
use crate::vault::VaultAiGuidanceStatus;
|
||||
|
||||
use super::expand_tilde;
|
||||
|
||||
#[cfg(desktop)]
|
||||
type StreamEmitter<Event> = Box<dyn Fn(Event) + Send>;
|
||||
@@ -58,6 +61,18 @@ pub fn get_ai_agents_status() -> AiAgentsStatus {
|
||||
crate::ai_agents::get_ai_agents_status()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_vault_ai_guidance_status(vault_path: String) -> Result<VaultAiGuidanceStatus, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::vault::get_ai_guidance_status(vault_path.as_ref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn restore_vault_ai_guidance(vault_path: String) -> Result<VaultAiGuidanceStatus, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::vault::restore_ai_guidance_files(vault_path.as_ref())
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
define_desktop_stream_command!(
|
||||
stream_claude_chat,
|
||||
|
||||
@@ -178,6 +178,8 @@ macro_rules! app_invoke_handler {
|
||||
commands::init_git_repo,
|
||||
commands::check_claude_cli,
|
||||
commands::get_ai_agents_status,
|
||||
commands::get_vault_ai_guidance_status,
|
||||
commands::restore_vault_ai_guidance,
|
||||
commands::stream_claude_chat,
|
||||
commands::stream_claude_agent,
|
||||
commands::stream_ai_agent,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use serde::Serialize;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::getting_started::{AGENTS_MD, LEGACY_AGENTS_MD};
|
||||
|
||||
@@ -18,6 +19,33 @@ sidebar label: Config
|
||||
Vault configuration files. These control how AI agents, tools, and other integrations interact with this vault.
|
||||
";
|
||||
|
||||
const CLAUDE_MD_SHIM: &str = "@AGENTS.md
|
||||
|
||||
This file is a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`.
|
||||
";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AiGuidanceFileState {
|
||||
Managed,
|
||||
Missing,
|
||||
Broken,
|
||||
Custom,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct VaultAiGuidanceStatus {
|
||||
pub agents_state: AiGuidanceFileState,
|
||||
pub claude_state: AiGuidanceFileState,
|
||||
pub can_restore: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct LegacyAgentsMigrationOutcome {
|
||||
copied_to_root: bool,
|
||||
removed_legacy: bool,
|
||||
}
|
||||
|
||||
/// Write a file if it doesn't exist or is empty (corrupt). Returns true if written.
|
||||
fn write_if_missing(path: &Path, content: &str) -> Result<bool, String> {
|
||||
let needs_write = !path.exists() || fs::metadata(path).map_or(true, |m| m.len() == 0);
|
||||
@@ -27,31 +55,176 @@ fn write_if_missing(path: &Path, content: &str) -> Result<bool, String> {
|
||||
Ok(needs_write)
|
||||
}
|
||||
|
||||
fn read_file_or_empty(path: &Path) -> String {
|
||||
fs::read_to_string(path).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn agents_content_can_be_replaced(content: &str) -> bool {
|
||||
content.is_empty()
|
||||
|| content.contains("See config/agents.md")
|
||||
|| content == LEGACY_AGENTS_MD
|
||||
|| content.contains("Do not add `title:` frontmatter.")
|
||||
}
|
||||
|
||||
fn root_agents_can_be_replaced(path: &Path) -> bool {
|
||||
!path.exists()
|
||||
|| fs::read_to_string(path).map_or(true, |content| {
|
||||
content.is_empty()
|
||||
|| content.contains("See config/agents.md")
|
||||
|| content == LEGACY_AGENTS_MD
|
||||
|| content.contains("Do not add `title:` frontmatter.")
|
||||
})
|
||||
!path.exists() || agents_content_can_be_replaced(&read_file_or_empty(path))
|
||||
}
|
||||
|
||||
fn matches_claude_shim(content: &str) -> bool {
|
||||
let trimmed = content.trim();
|
||||
trimmed == "@AGENTS.md" || trimmed == CLAUDE_MD_SHIM.trim()
|
||||
}
|
||||
|
||||
fn claude_shim_can_be_replaced(path: &Path) -> bool {
|
||||
!path.exists() || {
|
||||
let content = read_file_or_empty(path);
|
||||
content.trim().is_empty() || matches_claude_shim(&content)
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_managed_file(
|
||||
path: &Path,
|
||||
content: &str,
|
||||
can_replace: fn(&Path) -> bool,
|
||||
) -> Result<bool, String> {
|
||||
if !can_replace(path) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
fs::write(path, content).map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn classify_guidance_file(
|
||||
path: &Path,
|
||||
matches_managed: fn(&str) -> bool,
|
||||
can_replace: fn(&Path) -> bool,
|
||||
) -> AiGuidanceFileState {
|
||||
if !path.exists() {
|
||||
return AiGuidanceFileState::Missing;
|
||||
}
|
||||
|
||||
let content = read_file_or_empty(path);
|
||||
if matches_managed(&content) {
|
||||
return AiGuidanceFileState::Managed;
|
||||
}
|
||||
|
||||
if can_replace(path) {
|
||||
return AiGuidanceFileState::Broken;
|
||||
}
|
||||
|
||||
AiGuidanceFileState::Custom
|
||||
}
|
||||
|
||||
fn guidance_paths(vault_path: &str) -> (PathBuf, PathBuf) {
|
||||
let vault = Path::new(vault_path);
|
||||
(vault.join("AGENTS.md"), vault.join("CLAUDE.md"))
|
||||
}
|
||||
|
||||
fn classify_agents_file(path: &Path) -> AiGuidanceFileState {
|
||||
classify_guidance_file(
|
||||
path,
|
||||
|content| content == AGENTS_MD,
|
||||
root_agents_can_be_replaced,
|
||||
)
|
||||
}
|
||||
|
||||
fn classify_claude_file(path: &Path) -> AiGuidanceFileState {
|
||||
classify_guidance_file(path, matches_claude_shim, claude_shim_can_be_replaced)
|
||||
}
|
||||
|
||||
fn guidance_file_needs_restore(state: AiGuidanceFileState) -> bool {
|
||||
matches!(
|
||||
state,
|
||||
AiGuidanceFileState::Missing | AiGuidanceFileState::Broken
|
||||
)
|
||||
}
|
||||
|
||||
fn build_ai_guidance_status(vault_path: &str) -> VaultAiGuidanceStatus {
|
||||
let (agents_path, claude_path) = guidance_paths(vault_path);
|
||||
let agents_state = classify_agents_file(&agents_path);
|
||||
let claude_state = classify_claude_file(&claude_path);
|
||||
|
||||
VaultAiGuidanceStatus {
|
||||
agents_state,
|
||||
claude_state,
|
||||
can_restore: guidance_file_needs_restore(agents_state)
|
||||
|| guidance_file_needs_restore(claude_state),
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_claude_shim_file(vault_path: &str) -> Result<bool, String> {
|
||||
let (_, claude_path) = guidance_paths(vault_path);
|
||||
sync_managed_file(&claude_path, CLAUDE_MD_SHIM, claude_shim_can_be_replaced)
|
||||
}
|
||||
|
||||
fn sync_ai_guidance_files(vault_path: &str) -> Result<bool, String> {
|
||||
let wrote_agents = sync_default_agents_file(vault_path)?;
|
||||
let wrote_claude = sync_claude_shim_file(vault_path)?;
|
||||
Ok(wrote_agents || wrote_claude)
|
||||
}
|
||||
|
||||
fn migrate_legacy_agents_file(
|
||||
root_agents: &Path,
|
||||
config_agents: &Path,
|
||||
) -> Result<LegacyAgentsMigrationOutcome, String> {
|
||||
let mut outcome = LegacyAgentsMigrationOutcome::default();
|
||||
if !config_agents.exists() {
|
||||
return Ok(outcome);
|
||||
}
|
||||
|
||||
let config_content = read_file_or_empty(config_agents);
|
||||
if !config_content.is_empty() && root_agents_can_be_replaced(root_agents) {
|
||||
fs::write(root_agents, &config_content)
|
||||
.map_err(|e| format!("Failed to write AGENTS.md: {e}"))?;
|
||||
outcome.copied_to_root = true;
|
||||
}
|
||||
|
||||
fs::remove_file(config_agents)
|
||||
.map_err(|e| format!("Failed to remove config/agents.md: {e}"))?;
|
||||
outcome.removed_legacy = true;
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
fn cleanup_empty_config_dir(vault: &Path) -> Result<bool, String> {
|
||||
let config_dir = vault.join("config");
|
||||
if !config_dir.is_dir() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let is_empty = fs::read_dir(&config_dir)
|
||||
.map_err(|e| format!("Failed to inspect {}: {e}", config_dir.display()))?
|
||||
.next()
|
||||
.is_none();
|
||||
if !is_empty {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
fs::remove_dir(&config_dir)
|
||||
.map_err(|e| format!("Failed to remove {}: {e}", config_dir.display()))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(super) fn sync_default_agents_file(vault_path: &str) -> Result<bool, String> {
|
||||
let agents_path = Path::new(vault_path).join("AGENTS.md");
|
||||
if root_agents_can_be_replaced(&agents_path) {
|
||||
fs::write(&agents_path, AGENTS_MD)
|
||||
.map_err(|e| format!("Failed to write {}: {e}", agents_path.display()))?;
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(false)
|
||||
let (agents_path, _) = guidance_paths(vault_path);
|
||||
sync_managed_file(&agents_path, AGENTS_MD, root_agents_can_be_replaced)
|
||||
}
|
||||
|
||||
pub fn get_ai_guidance_status(vault_path: &str) -> Result<VaultAiGuidanceStatus, String> {
|
||||
Ok(build_ai_guidance_status(vault_path))
|
||||
}
|
||||
|
||||
pub fn restore_ai_guidance_files(vault_path: &str) -> Result<VaultAiGuidanceStatus, String> {
|
||||
sync_ai_guidance_files(vault_path)?;
|
||||
get_ai_guidance_status(vault_path)
|
||||
}
|
||||
|
||||
/// Seed `AGENTS.md` at vault root if missing or empty (idempotent, per-file).
|
||||
/// Also seeds `config.md` type definition for sidebar visibility.
|
||||
pub fn seed_config_files(vault_path: &str) {
|
||||
if sync_default_agents_file(vault_path).unwrap_or(false) {
|
||||
log::info!("Seeded AGENTS.md at vault root");
|
||||
if sync_ai_guidance_files(vault_path).unwrap_or(false) {
|
||||
log::info!("Seeded vault AI guidance files at vault root");
|
||||
}
|
||||
|
||||
ensure_config_type_definition(vault_path);
|
||||
@@ -60,10 +233,7 @@ pub fn seed_config_files(vault_path: &str) {
|
||||
/// Ensure `config.md` exists at vault root (gives Config type a sidebar icon/color).
|
||||
fn ensure_config_type_definition(vault_path: &str) {
|
||||
let path = Path::new(vault_path).join("config.md");
|
||||
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
let _ = fs::write(&path, CONFIG_TYPE_DEFINITION);
|
||||
}
|
||||
let _ = write_if_missing(&path, CONFIG_TYPE_DEFINITION);
|
||||
}
|
||||
|
||||
/// Migrate legacy `config/agents.md` → root `AGENTS.md` for existing vaults.
|
||||
@@ -79,33 +249,20 @@ pub fn migrate_agents_md(vault_path: &str) {
|
||||
let root_agents = vault.join("AGENTS.md");
|
||||
let config_agents = vault.join("config").join("agents.md");
|
||||
|
||||
// If legacy config/agents.md exists with real content, migrate it to root
|
||||
if config_agents.exists() {
|
||||
let config_content = fs::read_to_string(&config_agents).unwrap_or_default();
|
||||
if !config_content.is_empty() {
|
||||
// Only migrate if root AGENTS.md is missing, empty, or is a stub
|
||||
if root_agents_can_be_replaced(&root_agents) {
|
||||
let _ = fs::write(&root_agents, &config_content);
|
||||
log::info!("Migrated config/agents.md content to root AGENTS.md");
|
||||
}
|
||||
// Remove legacy file
|
||||
let _ = fs::remove_file(&config_agents);
|
||||
if let Ok(outcome) = migrate_legacy_agents_file(&root_agents, &config_agents) {
|
||||
if outcome.copied_to_root {
|
||||
log::info!("Migrated config/agents.md content to root AGENTS.md");
|
||||
}
|
||||
if outcome.removed_legacy {
|
||||
log::info!("Removed legacy config/agents.md");
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up empty config/ directory
|
||||
let config_dir = vault.join("config");
|
||||
if config_dir.is_dir() {
|
||||
let is_empty = fs::read_dir(&config_dir).map_or(true, |mut d| d.next().is_none());
|
||||
if is_empty {
|
||||
let _ = fs::remove_dir(&config_dir);
|
||||
log::info!("Removed empty config/ directory");
|
||||
}
|
||||
if cleanup_empty_config_dir(vault).unwrap_or(false) {
|
||||
log::info!("Removed empty config/ directory");
|
||||
}
|
||||
|
||||
// Ensure root AGENTS.md exists with content
|
||||
let _ = sync_default_agents_file(vault_path);
|
||||
let _ = sync_ai_guidance_files(vault_path);
|
||||
}
|
||||
|
||||
/// Repair config files: ensure `AGENTS.md` at vault root and `config.md` type definition.
|
||||
@@ -116,32 +273,10 @@ pub fn repair_config_files(vault_path: &str) -> Result<String, String> {
|
||||
let root_agents = vault.join("AGENTS.md");
|
||||
let config_agents = vault.join("config").join("agents.md");
|
||||
|
||||
// Step 1: Migrate legacy config/agents.md → root AGENTS.md
|
||||
if config_agents.exists() {
|
||||
let config_content = fs::read_to_string(&config_agents).unwrap_or_default();
|
||||
if !config_content.is_empty() {
|
||||
if root_agents_can_be_replaced(&root_agents) {
|
||||
fs::write(&root_agents, &config_content)
|
||||
.map_err(|e| format!("Failed to write AGENTS.md: {e}"))?;
|
||||
}
|
||||
fs::remove_file(&config_agents)
|
||||
.map_err(|e| format!("Failed to remove config/agents.md: {e}"))?;
|
||||
}
|
||||
}
|
||||
migrate_legacy_agents_file(&root_agents, &config_agents)?;
|
||||
let _ = cleanup_empty_config_dir(vault)?;
|
||||
sync_ai_guidance_files(vault_path)?;
|
||||
|
||||
// Step 2: Clean up empty config/ directory
|
||||
let config_dir = vault.join("config");
|
||||
if config_dir.is_dir() {
|
||||
let is_empty = fs::read_dir(&config_dir).map_or(true, |mut d| d.next().is_none());
|
||||
if is_empty {
|
||||
let _ = fs::remove_dir(&config_dir);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Seed AGENTS.md with defaults if still missing or empty
|
||||
sync_default_agents_file(vault_path)?;
|
||||
|
||||
// Step 4: Ensure config.md type definition at vault root
|
||||
write_if_missing(&vault.join("config.md"), CONFIG_TYPE_DEFINITION)?;
|
||||
|
||||
Ok("Config files repaired".to_string())
|
||||
@@ -153,26 +288,53 @@ mod tests {
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_creates_agents_at_root() {
|
||||
fn create_vault() -> (TempDir, PathBuf) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
(dir, vault)
|
||||
}
|
||||
|
||||
fn config_dir(vault: &Path) -> PathBuf {
|
||||
let dir = vault.join("config");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn write_root_agents(vault: &Path, content: &str) {
|
||||
fs::write(vault.join("AGENTS.md"), content).unwrap();
|
||||
}
|
||||
|
||||
fn write_root_claude(vault: &Path, content: &str) {
|
||||
fs::write(vault.join("CLAUDE.md"), content).unwrap();
|
||||
}
|
||||
|
||||
fn write_legacy_agents(vault: &Path, content: &str) {
|
||||
fs::write(config_dir(vault).join("agents.md"), content).unwrap();
|
||||
}
|
||||
|
||||
fn read_root_agents(vault: &Path) -> String {
|
||||
fs::read_to_string(vault.join("AGENTS.md")).unwrap()
|
||||
}
|
||||
|
||||
fn read_root_claude(vault: &Path) -> String {
|
||||
fs::read_to_string(vault.join("CLAUDE.md")).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_creates_guidance_files_at_root() {
|
||||
let (_dir, vault) = create_vault();
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
|
||||
assert!(vault.join("AGENTS.md").exists());
|
||||
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(content.contains("Tolaria Vault"));
|
||||
// Must NOT create config/ directory
|
||||
assert!(!vault.join("config").exists());
|
||||
assert!(read_root_agents(&vault).contains("Tolaria Vault"));
|
||||
assert_eq!(read_root_claude(&vault), CLAUDE_MD_SHIM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_creates_type_definition() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let (_dir, vault) = create_vault();
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
|
||||
@@ -180,69 +342,67 @@ mod tests {
|
||||
let content = fs::read_to_string(vault.join("config.md")).unwrap();
|
||||
assert!(content.contains("type: Type"));
|
||||
assert!(content.contains("icon: gear-six"));
|
||||
assert!(!vault.join("config").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_is_idempotent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
fn test_seed_config_files_preserves_custom_agents() {
|
||||
let (_dir, vault) = create_vault();
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
let custom = "# Custom Agent Config\nMy custom instructions\n";
|
||||
fs::write(vault.join("AGENTS.md"), custom).unwrap();
|
||||
write_root_agents(&vault, "# Custom Agent Config\nMy custom instructions\n");
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(
|
||||
content.contains("Custom Agent Config"),
|
||||
read_root_agents(&vault).contains("Custom Agent Config"),
|
||||
"must preserve existing content"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_reseeds_empty() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
fs::write(vault.join("AGENTS.md"), "").unwrap();
|
||||
let (_dir, vault) = create_vault();
|
||||
write_root_agents(&vault, "");
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(content.contains("Tolaria Vault"));
|
||||
assert!(read_root_agents(&vault).contains("Tolaria Vault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_refreshes_stale_default_agents() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
fs::write(
|
||||
vault.join("AGENTS.md"),
|
||||
let (_dir, vault) = create_vault();
|
||||
write_root_agents(
|
||||
&vault,
|
||||
"# AGENTS.md — Tolaria Vault\n\n- The first H1 in the body is the note title. Do not add `title:` frontmatter.\n",
|
||||
)
|
||||
.unwrap();
|
||||
);
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
|
||||
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
let content = read_root_agents(&vault);
|
||||
assert!(content.contains("Legacy `title:` frontmatter is still read as a fallback"));
|
||||
assert!(content.contains("views/*.yml"));
|
||||
assert!(content.contains("Belongs to:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_preserves_custom_claude() {
|
||||
let (_dir, vault) = create_vault();
|
||||
write_root_claude(&vault, "# Custom Claude instructions\nDo not overwrite\n");
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
|
||||
assert!(read_root_claude(&vault).contains("Custom Claude instructions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_agents_md_moves_config_to_root() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let config_dir = vault.join("config");
|
||||
fs::create_dir_all(&config_dir).unwrap();
|
||||
let custom = "# My vault agent instructions\nCustom content\n";
|
||||
fs::write(config_dir.join("agents.md"), custom).unwrap();
|
||||
let (_dir, vault) = create_vault();
|
||||
write_legacy_agents(&vault, "# My vault agent instructions\nCustom content\n");
|
||||
|
||||
migrate_agents_md(vault.to_str().unwrap());
|
||||
|
||||
let root_content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
let config_dir = vault.join("config");
|
||||
let root_content = read_root_agents(&vault);
|
||||
assert!(root_content.contains("My vault agent instructions"));
|
||||
assert!(!config_dir.join("agents.md").exists());
|
||||
assert!(!config_dir.exists());
|
||||
@@ -250,60 +410,49 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_migrate_agents_md_preserves_existing_root() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let config_dir = vault.join("config");
|
||||
fs::create_dir_all(&config_dir).unwrap();
|
||||
let custom_root = "# My root agent config\nDo not overwrite\n";
|
||||
fs::write(vault.join("AGENTS.md"), custom_root).unwrap();
|
||||
fs::write(config_dir.join("agents.md"), "Legacy content").unwrap();
|
||||
let (_dir, vault) = create_vault();
|
||||
write_root_agents(&vault, "# My root agent config\nDo not overwrite\n");
|
||||
write_legacy_agents(&vault, "Legacy content");
|
||||
|
||||
migrate_agents_md(vault.to_str().unwrap());
|
||||
|
||||
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
let config_dir = vault.join("config");
|
||||
let content = read_root_agents(&vault);
|
||||
assert!(content.contains("My root agent config"));
|
||||
assert!(!config_dir.join("agents.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_agents_md_replaces_stub_with_config_content() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let config_dir = vault.join("config");
|
||||
fs::create_dir_all(&config_dir).unwrap();
|
||||
fs::write(
|
||||
vault.join("AGENTS.md"),
|
||||
let (_dir, vault) = create_vault();
|
||||
write_root_agents(
|
||||
&vault,
|
||||
"# Agent Instructions\nSee config/agents.md for vault instructions.\n",
|
||||
)
|
||||
.unwrap();
|
||||
let real_content = "# Real Agent Config\nImportant instructions\n";
|
||||
fs::write(config_dir.join("agents.md"), real_content).unwrap();
|
||||
);
|
||||
write_legacy_agents(&vault, "# Real Agent Config\nImportant instructions\n");
|
||||
|
||||
migrate_agents_md(vault.to_str().unwrap());
|
||||
|
||||
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
let content = read_root_agents(&vault);
|
||||
assert!(content.contains("Real Agent Config"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_agents_md_idempotent_when_no_legacy() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let (_dir, vault) = create_vault();
|
||||
|
||||
migrate_agents_md(vault.to_str().unwrap());
|
||||
|
||||
assert!(vault.join("AGENTS.md").exists());
|
||||
let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
let root = read_root_agents(&vault);
|
||||
assert!(root.contains("Tolaria Vault"));
|
||||
assert_eq!(read_root_claude(&vault), CLAUDE_MD_SHIM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_agents_md_keeps_nonempty_config_dir() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let config_dir = vault.join("config");
|
||||
fs::create_dir_all(&config_dir).unwrap();
|
||||
let (_dir, vault) = create_vault();
|
||||
let config_dir = config_dir(&vault);
|
||||
fs::write(config_dir.join("agents.md"), "Agent content").unwrap();
|
||||
fs::write(config_dir.join("other.md"), "Other file").unwrap();
|
||||
|
||||
@@ -316,71 +465,98 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_repair_config_files_creates_all() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let (_dir, vault) = create_vault();
|
||||
|
||||
let msg = repair_config_files(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(msg, "Config files repaired");
|
||||
|
||||
assert!(vault.join("AGENTS.md").exists());
|
||||
assert!(vault.join("CLAUDE.md").exists());
|
||||
assert!(vault.join("config.md").exists());
|
||||
assert!(!vault.join("config").exists());
|
||||
|
||||
let agents = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
let agents = read_root_agents(&vault);
|
||||
assert!(agents.contains("Tolaria Vault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repair_config_files_preserves_custom_content() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let custom = "# My custom agent config\nDo not overwrite me\n";
|
||||
fs::write(vault.join("AGENTS.md"), custom).unwrap();
|
||||
let (_dir, vault) = create_vault();
|
||||
write_root_agents(&vault, "# My custom agent config\nDo not overwrite me\n");
|
||||
|
||||
repair_config_files(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(
|
||||
content.contains("My custom agent config"),
|
||||
read_root_agents(&vault).contains("My custom agent config"),
|
||||
"must preserve existing content"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repair_config_files_migrates_legacy_config() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let config_dir = vault.join("config");
|
||||
fs::create_dir_all(&config_dir).unwrap();
|
||||
let original = "# My vault agents instructions\nCustom content here\n";
|
||||
fs::write(config_dir.join("agents.md"), original).unwrap();
|
||||
let (_dir, vault) = create_vault();
|
||||
write_legacy_agents(
|
||||
&vault,
|
||||
"# My vault agents instructions\nCustom content here\n",
|
||||
);
|
||||
|
||||
repair_config_files(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
let config_dir = vault.join("config");
|
||||
let root = read_root_agents(&vault);
|
||||
assert!(root.contains("My vault agents instructions"));
|
||||
assert!(!config_dir.join("agents.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repair_config_files_replaces_stub_with_legacy() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let config_dir = vault.join("config");
|
||||
fs::create_dir_all(&config_dir).unwrap();
|
||||
fs::write(
|
||||
vault.join("AGENTS.md"),
|
||||
let (_dir, vault) = create_vault();
|
||||
write_root_agents(
|
||||
&vault,
|
||||
"# Agent Instructions\nSee config/agents.md for vault instructions.\n",
|
||||
)
|
||||
.unwrap();
|
||||
let real = "# Real Instructions\nImportant stuff\n";
|
||||
fs::write(config_dir.join("agents.md"), real).unwrap();
|
||||
);
|
||||
write_legacy_agents(&vault, "# Real Instructions\nImportant stuff\n");
|
||||
|
||||
repair_config_files(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
let content = read_root_agents(&vault);
|
||||
assert!(content.contains("Real Instructions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_ai_guidance_status_reports_custom_and_repairable_files() {
|
||||
let (_dir, vault) = create_vault();
|
||||
write_root_agents(&vault, "# Custom Agent Config\nHands off\n");
|
||||
write_root_claude(&vault, "");
|
||||
|
||||
let status = get_ai_guidance_status(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(status.agents_state, AiGuidanceFileState::Custom);
|
||||
assert_eq!(status.claude_state, AiGuidanceFileState::Broken);
|
||||
assert!(status.can_restore);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restore_ai_guidance_files_repairs_without_overwriting_custom_agents() {
|
||||
let (_dir, vault) = create_vault();
|
||||
write_root_agents(&vault, "# Custom Agent Config\nHands off\n");
|
||||
write_root_claude(&vault, "");
|
||||
|
||||
let status = restore_ai_guidance_files(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(status.agents_state, AiGuidanceFileState::Custom);
|
||||
assert_eq!(status.claude_state, AiGuidanceFileState::Managed);
|
||||
assert!(!status.can_restore);
|
||||
assert!(read_root_agents(&vault).contains("Custom Agent Config"));
|
||||
assert_eq!(read_root_claude(&vault), CLAUDE_MD_SHIM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restore_ai_guidance_files_repairs_broken_agents_and_missing_claude() {
|
||||
let (_dir, vault) = create_vault();
|
||||
write_root_agents(&vault, "");
|
||||
|
||||
let status = restore_ai_guidance_files(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(status.agents_state, AiGuidanceFileState::Managed);
|
||||
assert_eq!(status.claude_state, AiGuidanceFileState::Managed);
|
||||
assert!(!status.can_restore);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,10 @@ mod trash;
|
||||
mod views;
|
||||
|
||||
pub use cache::{invalidate_cache, scan_vault_cached};
|
||||
pub use config_seed::{migrate_agents_md, repair_config_files, seed_config_files};
|
||||
pub use config_seed::{
|
||||
get_ai_guidance_status, migrate_agents_md, repair_config_files, restore_ai_guidance_files,
|
||||
seed_config_files, AiGuidanceFileState, VaultAiGuidanceStatus,
|
||||
};
|
||||
pub use entry::{FolderNode, VaultEntry};
|
||||
pub use file::{get_note_content, save_note_content};
|
||||
pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists};
|
||||
|
||||
31
src/App.tsx
31
src/App.tsx
@@ -23,6 +23,7 @@ import { useTelemetry } from './hooks/useTelemetry'
|
||||
import { useMcpStatus } from './hooks/useMcpStatus'
|
||||
import { useAiAgentsOnboarding } from './hooks/useAiAgentsOnboarding'
|
||||
import { useAiAgentsStatus } from './hooks/useAiAgentsStatus'
|
||||
import { useVaultAiGuidanceStatus } from './hooks/useVaultAiGuidanceStatus'
|
||||
import { useVaultLoader } from './hooks/useVaultLoader'
|
||||
import { useAiAgentPreferences } from './hooks/useAiAgentPreferences'
|
||||
import { useSettings } from './hooks/useSettings'
|
||||
@@ -68,6 +69,9 @@ import { RenameDetectedBanner, type DetectedRename } from './components/RenameDe
|
||||
import { openNoteListPropertiesPicker } from './components/note-list/noteListPropertiesEvents'
|
||||
import { focusNoteIconPropertyEditor } from './components/noteIconPropertyEvents'
|
||||
import { trackEvent } from './lib/telemetry'
|
||||
import {
|
||||
buildVaultAiGuidanceRefreshKey,
|
||||
} from './lib/vaultAiGuidance'
|
||||
import { extractDeletedContentFromDiff } from './components/note-list/noteListUtils'
|
||||
import { hasNoteIconValue } from './utils/noteIcon'
|
||||
import { OPEN_AI_CHAT_EVENT } from './utils/aiPromptBridge'
|
||||
@@ -152,6 +156,13 @@ function App() {
|
||||
}, [resolvedPath])
|
||||
|
||||
const vault = useVaultLoader(resolvedPath)
|
||||
const {
|
||||
status: vaultAiGuidanceStatus,
|
||||
refresh: refreshVaultAiGuidance,
|
||||
} = useVaultAiGuidanceStatus(
|
||||
resolvedPath,
|
||||
buildVaultAiGuidanceRefreshKey(vault.entries),
|
||||
)
|
||||
const { config: vaultConfig, updateConfig } = useVaultConfig(resolvedPath)
|
||||
const explicitOrganizationEnabled = isExplicitOrganizationEnabled(vaultConfig.inbox?.explicitOrganization)
|
||||
const effectiveSelection = sanitizeSelectionForOrganization(selection, vaultConfig.inbox?.explicitOrganization)
|
||||
@@ -547,11 +558,25 @@ function App() {
|
||||
const tauriInvoke = isTauri() ? invoke : mockInvoke
|
||||
const msg = await tauriInvoke<string>('repair_vault', { vaultPath: resolvedPath })
|
||||
await vault.reloadVault()
|
||||
await refreshVaultAiGuidance()
|
||||
setToastMessage(msg)
|
||||
} catch (err) {
|
||||
setToastMessage(`Failed to repair vault: ${err}`)
|
||||
}
|
||||
}, [resolvedPath, vault, setToastMessage])
|
||||
}, [refreshVaultAiGuidance, resolvedPath, vault, setToastMessage])
|
||||
|
||||
const restoreVaultAiGuidance = useCallback(async (successToast: string | null = 'Tolaria AI guidance restored') => {
|
||||
if (!resolvedPath) return
|
||||
try {
|
||||
const tauriInvoke = isTauri() ? invoke : mockInvoke
|
||||
await tauriInvoke('restore_vault_ai_guidance', { vaultPath: resolvedPath })
|
||||
await vault.reloadVault()
|
||||
await refreshVaultAiGuidance()
|
||||
if (successToast) setToastMessage(successToast)
|
||||
} catch (err) {
|
||||
setToastMessage(`Failed to restore Tolaria AI guidance: ${err}`)
|
||||
}
|
||||
}, [refreshVaultAiGuidance, resolvedPath, vault, setToastMessage])
|
||||
|
||||
const activeDeletedFile = useMemo(() => {
|
||||
const activeTabPath = notes.activeTabPath
|
||||
@@ -605,6 +630,8 @@ function App() {
|
||||
onInstallMcp: installMcp,
|
||||
onOpenAiAgents: dialogs.openSettings,
|
||||
aiAgentsStatus,
|
||||
vaultAiGuidanceStatus,
|
||||
onRestoreVaultAiGuidance: () => { void restoreVaultAiGuidance() },
|
||||
selectedAiAgent: aiAgentPreferences.defaultAiAgent,
|
||||
onSetDefaultAiAgent: aiAgentPreferences.setDefaultAiAgent,
|
||||
onCycleDefaultAiAgent: aiAgentPreferences.cycleDefaultAiAgent,
|
||||
@@ -775,7 +802,7 @@ function App() {
|
||||
</div>
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} />
|
||||
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={vaultSwitcher.restoreGettingStarted} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isOffline={networkStatus.isOffline} isGitVault={!vault.modifiedFilesError} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} aiAgentsStatus={aiAgentsStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={vaultSwitcher.restoreGettingStarted} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isOffline={networkStatus.isOffline} isGitVault={!vault.modifiedFilesError} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
|
||||
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AiAgentId, AiAgentsStatus } from '../lib/aiAgents'
|
||||
import type { VaultAiGuidanceStatus } from '../lib/vaultAiGuidance'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ClaudeCodeStatus } from '../hooks/useClaudeCodeStatus'
|
||||
import type { McpStatus } from '../hooks/useMcpStatus'
|
||||
@@ -43,8 +44,10 @@ interface StatusBarProps {
|
||||
mcpStatus?: McpStatus
|
||||
onInstallMcp?: () => void
|
||||
aiAgentsStatus?: AiAgentsStatus
|
||||
vaultAiGuidanceStatus?: VaultAiGuidanceStatus
|
||||
defaultAiAgent?: AiAgentId
|
||||
onSetDefaultAiAgent?: (agent: AiAgentId) => void
|
||||
onRestoreVaultAiGuidance?: () => void
|
||||
claudeCodeStatus?: ClaudeCodeStatus
|
||||
claudeCodeVersion?: string | null
|
||||
}
|
||||
@@ -81,8 +84,10 @@ export function StatusBar({
|
||||
mcpStatus,
|
||||
onInstallMcp,
|
||||
aiAgentsStatus,
|
||||
vaultAiGuidanceStatus,
|
||||
defaultAiAgent,
|
||||
onSetDefaultAiAgent,
|
||||
onRestoreVaultAiGuidance,
|
||||
claudeCodeStatus,
|
||||
claudeCodeVersion,
|
||||
}: StatusBarProps) {
|
||||
@@ -137,8 +142,10 @@ export function StatusBar({
|
||||
mcpStatus={mcpStatus}
|
||||
onInstallMcp={onInstallMcp}
|
||||
aiAgentsStatus={aiAgentsStatus}
|
||||
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
onSetDefaultAiAgent={onSetDefaultAiAgent}
|
||||
onRestoreVaultAiGuidance={onRestoreVaultAiGuidance}
|
||||
claudeCodeStatus={claudeCodeStatus}
|
||||
claudeCodeVersion={claudeCodeVersion}
|
||||
/>
|
||||
|
||||
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 AiAgentsStatus,
|
||||
} from '../../lib/aiAgents'
|
||||
import {
|
||||
getVaultAiGuidanceSummary,
|
||||
isVaultAiGuidanceStatusChecking,
|
||||
vaultAiGuidanceNeedsRestore,
|
||||
vaultAiGuidanceUsesCustomFiles,
|
||||
type VaultAiGuidanceStatus,
|
||||
} from '../../lib/vaultAiGuidance'
|
||||
import { openExternalUrl } from '../../utils/url'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -25,18 +32,35 @@ import { ICON_STYLE, SEP_STYLE } from './styles'
|
||||
|
||||
interface AiAgentsBadgeProps {
|
||||
statuses: AiAgentsStatus
|
||||
guidanceStatus?: VaultAiGuidanceStatus
|
||||
defaultAgent: AiAgentId
|
||||
onSetDefaultAgent?: (agent: AiAgentId) => void
|
||||
onRestoreGuidance?: () => void
|
||||
}
|
||||
|
||||
function badgeTooltip(statuses: AiAgentsStatus, defaultAgent: AiAgentId): string {
|
||||
function badgeTooltip(
|
||||
statuses: AiAgentsStatus,
|
||||
defaultAgent: AiAgentId,
|
||||
guidanceStatus?: VaultAiGuidanceStatus,
|
||||
): string {
|
||||
const guidanceSummary = guidanceStatus && !isVaultAiGuidanceStatusChecking(guidanceStatus)
|
||||
? getVaultAiGuidanceSummary(guidanceStatus)
|
||||
: null
|
||||
if (!hasAnyInstalledAiAgent(statuses)) return 'No AI agents detected — click for setup details'
|
||||
const definition = getAiAgentDefinition(defaultAgent)
|
||||
if (!isAiAgentInstalled(statuses, defaultAgent)) {
|
||||
return `${definition.label} is selected but not installed — click for setup details`
|
||||
}
|
||||
const version = statuses[defaultAgent].version
|
||||
return `Default AI agent: ${definition.label}${version ? ` ${version}` : ''}`
|
||||
const base = `Default AI agent: ${definition.label}${version ? ` ${version}` : ''}`
|
||||
if (!guidanceSummary) return base
|
||||
if (vaultAiGuidanceNeedsRestore(guidanceStatus!)) {
|
||||
return `${base}. ${guidanceSummary} — click for restore details`
|
||||
}
|
||||
if (vaultAiGuidanceUsesCustomFiles(guidanceStatus!)) {
|
||||
return `${base}. ${guidanceSummary}`
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
function installedAgentDefinitions(statuses: AiAgentsStatus): AiAgentDefinition[] {
|
||||
@@ -72,11 +96,38 @@ function canSwitchAgents(
|
||||
return installedAgents.some((definition) => definition.id !== defaultAgent)
|
||||
}
|
||||
|
||||
function GuidanceMenuSection({
|
||||
guidanceStatus,
|
||||
onRestoreGuidance,
|
||||
}: Pick<AiAgentsBadgeProps, 'guidanceStatus' | 'onRestoreGuidance'>) {
|
||||
if (!guidanceStatus || isVaultAiGuidanceStatusChecking(guidanceStatus)) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>Vault guidance</DropdownMenuLabel>
|
||||
<DropdownMenuItem disabled data-testid="status-ai-guidance-summary">
|
||||
{getVaultAiGuidanceSummary(guidanceStatus)}
|
||||
</DropdownMenuItem>
|
||||
{vaultAiGuidanceNeedsRestore(guidanceStatus) && guidanceStatus.canRestore && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onRestoreGuidance?.()}
|
||||
data-testid="status-ai-guidance-restore"
|
||||
>
|
||||
Restore Tolaria AI Guidance
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentMenuContent({
|
||||
statuses,
|
||||
guidanceStatus,
|
||||
defaultAgent,
|
||||
selectedAgentReady,
|
||||
onSetDefaultAgent,
|
||||
onRestoreGuidance,
|
||||
}: AiAgentsBadgeProps & { selectedAgentReady: boolean }) {
|
||||
const installedAgents = installedAgentDefinitions(statuses)
|
||||
const missingAgents = missingAgentDefinitions(statuses)
|
||||
@@ -120,14 +171,26 @@ function AgentMenuContent({
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<GuidanceMenuSection
|
||||
guidanceStatus={guidanceStatus}
|
||||
onRestoreGuidance={onRestoreGuidance}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
)
|
||||
}
|
||||
|
||||
export function AiAgentsBadge({ statuses, defaultAgent, onSetDefaultAgent }: AiAgentsBadgeProps) {
|
||||
export function AiAgentsBadge({
|
||||
statuses,
|
||||
guidanceStatus,
|
||||
defaultAgent,
|
||||
onSetDefaultAgent,
|
||||
onRestoreGuidance,
|
||||
}: AiAgentsBadgeProps) {
|
||||
const hasInstalledAgent = hasAnyInstalledAiAgent(statuses)
|
||||
const selectedAgentReady = isAiAgentInstalled(statuses, defaultAgent)
|
||||
const showWarning = !hasInstalledAgent || !selectedAgentReady
|
||||
const showWarning = !hasInstalledAgent
|
||||
|| !selectedAgentReady
|
||||
|| !!(guidanceStatus && vaultAiGuidanceNeedsRestore(guidanceStatus))
|
||||
const showSwitcherCue = canSwitchAgents(installedAgentDefinitions(statuses), defaultAgent)
|
||||
|
||||
if (isAiAgentsStatusChecking(statuses)) return null
|
||||
@@ -142,7 +205,7 @@ export function AiAgentsBadge({ statuses, defaultAgent, onSetDefaultAgent }: AiA
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-6 px-2 text-[11px] font-medium"
|
||||
title={badgeTooltip(statuses, defaultAgent)}
|
||||
title={badgeTooltip(statuses, defaultAgent, guidanceStatus)}
|
||||
data-testid="status-ai-agents"
|
||||
>
|
||||
<span style={{ ...ICON_STYLE, color: showWarning ? 'var(--accent-orange)' : 'var(--muted-foreground)' }}>
|
||||
@@ -155,8 +218,10 @@ export function AiAgentsBadge({ statuses, defaultAgent, onSetDefaultAgent }: AiA
|
||||
</DropdownMenuTrigger>
|
||||
<AgentMenuContent
|
||||
statuses={statuses}
|
||||
guidanceStatus={guidanceStatus}
|
||||
defaultAgent={defaultAgent}
|
||||
onSetDefaultAgent={onSetDefaultAgent}
|
||||
onRestoreGuidance={onRestoreGuidance}
|
||||
selectedAgentReady={selectedAgentReady}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Bell, FileText, Package, Settings } from 'lucide-react'
|
||||
import { Megaphone } from '@phosphor-icons/react'
|
||||
import type { AiAgentId, AiAgentsStatus } from '../../lib/aiAgents'
|
||||
import type { VaultAiGuidanceStatus } from '../../lib/vaultAiGuidance'
|
||||
import type { ClaudeCodeStatus } from '../../hooks/useClaudeCodeStatus'
|
||||
import type { McpStatus } from '../../hooks/useMcpStatus'
|
||||
import type { GitRemoteStatus, LastCommitInfo, SyncStatus } from '../../types'
|
||||
@@ -49,8 +50,10 @@ interface StatusBarPrimarySectionProps {
|
||||
mcpStatus?: McpStatus
|
||||
onInstallMcp?: () => void
|
||||
aiAgentsStatus?: AiAgentsStatus
|
||||
vaultAiGuidanceStatus?: VaultAiGuidanceStatus
|
||||
defaultAiAgent?: AiAgentId
|
||||
onSetDefaultAiAgent?: (agent: AiAgentId) => void
|
||||
onRestoreVaultAiGuidance?: () => void
|
||||
claudeCodeStatus?: ClaudeCodeStatus
|
||||
claudeCodeVersion?: string | null
|
||||
}
|
||||
@@ -90,8 +93,10 @@ export function StatusBarPrimarySection({
|
||||
mcpStatus,
|
||||
onInstallMcp,
|
||||
aiAgentsStatus,
|
||||
vaultAiGuidanceStatus,
|
||||
defaultAiAgent,
|
||||
onSetDefaultAiAgent,
|
||||
onRestoreVaultAiGuidance,
|
||||
claudeCodeStatus,
|
||||
claudeCodeVersion,
|
||||
}: StatusBarPrimarySectionProps) {
|
||||
@@ -136,7 +141,15 @@ export function StatusBarPrimarySection({
|
||||
<PulseBadge onClick={onClickPulse} disabled={isGitVault === false} />
|
||||
{mcpStatus && <McpBadge status={mcpStatus} onInstall={onInstallMcp} />}
|
||||
{aiAgentsStatus && defaultAiAgent
|
||||
? <AiAgentsBadge statuses={aiAgentsStatus} defaultAgent={defaultAiAgent} onSetDefaultAgent={onSetDefaultAiAgent} />
|
||||
? (
|
||||
<AiAgentsBadge
|
||||
statuses={aiAgentsStatus}
|
||||
guidanceStatus={vaultAiGuidanceStatus}
|
||||
defaultAgent={defaultAiAgent}
|
||||
onSetDefaultAgent={onSetDefaultAiAgent}
|
||||
onRestoreGuidance={onRestoreVaultAiGuidance}
|
||||
/>
|
||||
)
|
||||
: claudeCodeStatus && <ClaudeCodeBadge status={claudeCodeStatus} version={claudeCodeVersion} />}
|
||||
</div>
|
||||
)
|
||||
|
||||
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 AiAgentsStatus,
|
||||
} from '../../lib/aiAgents'
|
||||
import {
|
||||
isVaultAiGuidanceStatusChecking,
|
||||
vaultAiGuidanceNeedsRestore,
|
||||
type VaultAiGuidanceStatus,
|
||||
} from '../../lib/vaultAiGuidance'
|
||||
import type { CommandAction } from './types'
|
||||
|
||||
interface AiAgentCommandsConfig {
|
||||
aiAgentsStatus?: AiAgentsStatus
|
||||
vaultAiGuidanceStatus?: VaultAiGuidanceStatus
|
||||
selectedAiAgent?: AiAgentId
|
||||
selectedAiAgentLabel?: string
|
||||
onOpenAiAgents?: () => void
|
||||
onRestoreVaultAiGuidance?: () => void
|
||||
onCycleDefaultAiAgent?: () => void
|
||||
onSetDefaultAiAgent?: (agent: AiAgentId) => void
|
||||
}
|
||||
@@ -35,11 +42,33 @@ function explicitSwitchCommands({
|
||||
}))
|
||||
}
|
||||
|
||||
function restoreGuidanceCommands({
|
||||
vaultAiGuidanceStatus,
|
||||
onRestoreVaultAiGuidance,
|
||||
}: Pick<AiAgentCommandsConfig, 'vaultAiGuidanceStatus' | 'onRestoreVaultAiGuidance'>): CommandAction[] {
|
||||
if (!vaultAiGuidanceStatus || !onRestoreVaultAiGuidance) return []
|
||||
if (isVaultAiGuidanceStatusChecking(vaultAiGuidanceStatus)) return []
|
||||
if (!vaultAiGuidanceNeedsRestore(vaultAiGuidanceStatus)) return []
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'restore-vault-ai-guidance',
|
||||
label: 'Restore Tolaria AI Guidance',
|
||||
group: 'Settings',
|
||||
keywords: ['ai', 'agent', 'guidance', 'restore', 'repair', 'claude', 'codex', 'agents'],
|
||||
enabled: true,
|
||||
execute: () => onRestoreVaultAiGuidance(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function buildAiAgentCommands({
|
||||
aiAgentsStatus,
|
||||
vaultAiGuidanceStatus,
|
||||
selectedAiAgent,
|
||||
selectedAiAgentLabel,
|
||||
onOpenAiAgents,
|
||||
onRestoreVaultAiGuidance,
|
||||
onCycleDefaultAiAgent,
|
||||
onSetDefaultAiAgent,
|
||||
}: AiAgentCommandsConfig): CommandAction[] {
|
||||
@@ -54,6 +83,11 @@ export function buildAiAgentCommands({
|
||||
},
|
||||
]
|
||||
|
||||
commands.push(...restoreGuidanceCommands({
|
||||
vaultAiGuidanceStatus,
|
||||
onRestoreVaultAiGuidance,
|
||||
}))
|
||||
|
||||
const switchCommands = explicitSwitchCommands({
|
||||
aiAgentsStatus,
|
||||
selectedAiAgent,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import type { AiAgentId, AiAgentsStatus } from '../lib/aiAgents'
|
||||
import type { VaultAiGuidanceStatus } from '../lib/vaultAiGuidance'
|
||||
import { useAppKeyboard } from './useAppKeyboard'
|
||||
import { useCommandRegistry } from './useCommandRegistry'
|
||||
import type { CommandAction } from './useCommandRegistry'
|
||||
@@ -58,7 +59,9 @@ interface AppCommandsConfig {
|
||||
mcpStatus?: string
|
||||
onInstallMcp?: () => void
|
||||
aiAgentsStatus?: AiAgentsStatus
|
||||
vaultAiGuidanceStatus?: VaultAiGuidanceStatus
|
||||
onOpenAiAgents?: () => void
|
||||
onRestoreVaultAiGuidance?: () => void
|
||||
onSetDefaultAiAgent?: (agent: AiAgentId) => void
|
||||
selectedAiAgent?: AiAgentId
|
||||
onCycleDefaultAiAgent?: () => void
|
||||
@@ -197,7 +200,9 @@ function createCommandRegistryConfig(config: AppCommandsConfig): Parameters<type
|
||||
mcpStatus: config.mcpStatus,
|
||||
onInstallMcp: config.onInstallMcp,
|
||||
aiAgentsStatus: config.aiAgentsStatus,
|
||||
vaultAiGuidanceStatus: config.vaultAiGuidanceStatus,
|
||||
onOpenAiAgents: config.onOpenAiAgents,
|
||||
onRestoreVaultAiGuidance: config.onRestoreVaultAiGuidance,
|
||||
onSetDefaultAiAgent: config.onSetDefaultAiAgent,
|
||||
selectedAiAgent: config.selectedAiAgent,
|
||||
onCycleDefaultAiAgent: config.onCycleDefaultAiAgent,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { AiAgentId, AiAgentsStatus } from '../lib/aiAgents'
|
||||
import type { VaultAiGuidanceStatus } from '../lib/vaultAiGuidance'
|
||||
import type { SidebarSelection, VaultEntry } from '../types'
|
||||
import type { NoteListFilter } from '../utils/noteListHelpers'
|
||||
import type { ViewMode } from './useViewMode'
|
||||
@@ -28,7 +29,9 @@ interface CommandRegistryConfig {
|
||||
mcpStatus?: string
|
||||
onInstallMcp?: () => void
|
||||
aiAgentsStatus?: AiAgentsStatus
|
||||
vaultAiGuidanceStatus?: VaultAiGuidanceStatus
|
||||
onOpenAiAgents?: () => void
|
||||
onRestoreVaultAiGuidance?: () => void
|
||||
onSetDefaultAiAgent?: (agent: AiAgentId) => void
|
||||
selectedAiAgent?: AiAgentId
|
||||
onCycleDefaultAiAgent?: () => void
|
||||
@@ -97,8 +100,8 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
onGoBack, onGoForward, canGoBack, canGoForward,
|
||||
onCheckForUpdates, onCreateType,
|
||||
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
|
||||
mcpStatus, onInstallMcp, aiAgentsStatus,
|
||||
onOpenAiAgents, onSetDefaultAiAgent, selectedAiAgent, onCycleDefaultAiAgent, selectedAiAgentLabel,
|
||||
mcpStatus, onInstallMcp, aiAgentsStatus, vaultAiGuidanceStatus,
|
||||
onOpenAiAgents, onRestoreVaultAiGuidance, onSetDefaultAiAgent, selectedAiAgent, onCycleDefaultAiAgent, selectedAiAgentLabel,
|
||||
onReloadVault, onRepairVault,
|
||||
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
|
||||
onOpenInNewWindow, onToggleFavorite, onToggleOrganized,
|
||||
@@ -142,9 +145,11 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
}),
|
||||
...buildAiAgentCommands({
|
||||
aiAgentsStatus,
|
||||
vaultAiGuidanceStatus,
|
||||
selectedAiAgent,
|
||||
selectedAiAgentLabel,
|
||||
onOpenAiAgents,
|
||||
onRestoreVaultAiGuidance,
|
||||
onSetDefaultAiAgent,
|
||||
onCycleDefaultAiAgent,
|
||||
}),
|
||||
@@ -162,8 +167,8 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
onGoBack, onGoForward, canGoBack, canGoForward,
|
||||
vaultTypes,
|
||||
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
|
||||
mcpStatus, onInstallMcp, aiAgentsStatus,
|
||||
onOpenAiAgents, onSetDefaultAiAgent, selectedAiAgent, onCycleDefaultAiAgent, selectedAiAgentLabel,
|
||||
mcpStatus, onInstallMcp, aiAgentsStatus, vaultAiGuidanceStatus,
|
||||
onOpenAiAgents, onRestoreVaultAiGuidance, onSetDefaultAiAgent, selectedAiAgent, onCycleDefaultAiAgent, selectedAiAgentLabel,
|
||||
onReloadVault, onRepairVault,
|
||||
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
|
||||
isSectionGroup, noteListFilter, onSetNoteListFilter,
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
let mockVaultAiGuidanceStatus = {
|
||||
agents_state: 'managed',
|
||||
claude_state: 'managed',
|
||||
can_restore: false,
|
||||
} as const
|
||||
|
||||
function escapeRegex({ text }: { text: string }) {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
@@ -276,6 +282,15 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
claude_code: { installed: false, version: null },
|
||||
codex: { installed: false, version: null },
|
||||
}),
|
||||
get_vault_ai_guidance_status: () => ({ ...mockVaultAiGuidanceStatus }),
|
||||
restore_vault_ai_guidance: () => {
|
||||
mockVaultAiGuidanceStatus = {
|
||||
agents_state: 'managed',
|
||||
claude_state: 'managed',
|
||||
can_restore: false,
|
||||
}
|
||||
return { ...mockVaultAiGuidanceStatus }
|
||||
},
|
||||
stream_claude_chat: () => 'mock-session',
|
||||
stream_claude_agent: () => null,
|
||||
stream_ai_agent: () => null,
|
||||
@@ -349,7 +364,14 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
create_getting_started_vault: (args: { targetPath?: string | null }) => args.targetPath || '/Users/mock/Documents/Getting Started',
|
||||
register_mcp_tools: () => 'registered',
|
||||
check_mcp_status: () => 'installed',
|
||||
repair_vault: (): string => 'Vault repaired',
|
||||
repair_vault: (): string => {
|
||||
mockVaultAiGuidanceStatus = {
|
||||
agents_state: 'managed',
|
||||
claude_state: 'managed',
|
||||
can_restore: false,
|
||||
}
|
||||
return 'Vault repaired'
|
||||
},
|
||||
reinit_telemetry: (): null => null,
|
||||
}
|
||||
|
||||
|
||||
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