Compare commits

...

9 Commits

Author SHA1 Message Date
lucaronin
a645edbc19 fix: repair malformed editor block ids 2026-04-28 18:56:36 +02:00
lucaronin
48f1fb0b50 feat: add gemini cli external ai setup 2026-04-28 18:30:14 +02:00
lucaronin
1aede09834 fix: preserve wikilinks inside markdown tables 2026-04-28 18:00:15 +02:00
lucaronin
d51b76f992 fix: restrict sentry releases to stable builds 2026-04-28 17:17:23 +02:00
lucaronin
8e66fb4c19 fix: preserve table math markdown 2026-04-28 16:47:52 +02:00
lucaronin
a2843a0cf3 fix: persist immediate note creation before open 2026-04-28 15:01:32 +02:00
lucaronin
38fb8a7370 feat: copy mcp config from app 2026-04-28 14:03:10 +02:00
lucaronin
be5a8bb300 fix: make editor left handle draggable 2026-04-28 12:31:13 +02:00
lucaronin
ce040c75fd test: cover table hover stability 2026-04-28 10:46:06 +02:00
59 changed files with 1957 additions and 327 deletions

View File

@@ -27,7 +27,7 @@ You can find some Loom walkthroughs below — they are short and to the point:
- 🔬 **Open source** — Tolaria is free and open source. I built this for [myself](https://x.com/lucaronin) and for sharing it with others.
- 📋 **Standards-based** — Notes are markdown files with YAML frontmatter. No proprietary formats, no locked-in data. Everything works with standard tools if you decide to move away from Tolaria.
- 🔍 **Types as lenses, not schemas** — Types in Tolaria are navigation aids, not enforcement mechanisms. There's no required fields, no validation, just helpful categories for finding notes.
- 🪄**AI-first but not AI-only** — A vault of files works very well with AI agents, but you are free to use whatever you want. We support Claude Code and Codex CLI (for now), but you can edit the vault with any AI you want. We provide an AGENTS file for your agents to figure out.
- 🪄**AI-first but not AI-only** — A vault of files works very well with AI agents, but you are free to use whatever you want. We support Claude Code, Codex CLI, and Gemini CLI setup paths, but you can edit the vault with any AI you want. We provide an AGENTS file for your agents to figure out.
- ⌨️ **Keyboard-first** — Tolaria is designed for power-users who want to use keyboard as much as possible. A lot of how we designed the Editor and the Command Palette is based on this.
- 💪 **Built from real use** — Tolaria was created for manage my personal vault of 10,000+ notes, and I use it every day. Every feature exists because it solved a real problem.
@@ -71,6 +71,12 @@ Tauri 2 on Linux requires WebKit2GTK 4.1 and GTK 3:
The bundled MCP server still spawns the system `node` binary at runtime on Linux, so install Node from your distro package manager if you want the external AI tooling flow.
### Gemini CLI setup
Tolaria can register the active vault's MCP server in `~/.gemini/settings.json` from the status bar or command palette action "Set Up External AI Tools". The generated entry is scoped to the selected vault with `VAULT_PATH` and requires Node.js 18+ plus a separately installed and signed-in Gemini CLI.
Use "Restore Tolaria AI Guidance" if you also want Tolaria to create a non-destructive `GEMINI.md` compatibility shim in the vault root. The shim points Gemini back to the shared `AGENTS.md` instructions and is not written over custom `GEMINI.md` content.
### Quick start
```bash

View File

@@ -175,6 +175,7 @@ Type is determined **purely** from the `type:` frontmatter field — it is never
├── some-topic.md ← type: Topic
├── AGENTS.md ← canonical Tolaria AI guidance
├── CLAUDE.md ← compatibility shim pointing at AGENTS.md
├── GEMINI.md ← optional Gemini CLI shim pointing at AGENTS.md
├── ...
└── type/ ← type definition documents
```
@@ -345,7 +346,7 @@ Command-layer path access is fenced to the active vault before file operations r
UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder and external-open calls route through the Tauri opener plugin, while copy-path uses the browser clipboard API; none of those actions mutate vault contents or bypass the backend write boundary.
The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with that vault's canonical path, or stops it when there is no selected vault. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`.
The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with that vault's canonical path, or stops it when there is no selected vault. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`. Manual MCP config export uses the same generated stdio entry as registration, so the copied snippet remains scoped to the active vault without writing third-party config files.
### Vault Caching
@@ -677,8 +678,9 @@ Per-vault settings stored locally and scoped by vault path:
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`
- `GEMINI.md` is an optional Gemini CLI compatibility shim that points Gemini 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
- `restore_vault_ai_guidance` repairs only Tolaria-managed files and creates the optional Gemini shim on explicit request; user-authored custom `AGENTS.md` / `CLAUDE.md` / `GEMINI.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
@@ -743,9 +745,9 @@ Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is ins
- **`useTelemetry(settings, loaded)`** — Reactively initializes/tears down Sentry and PostHog based on settings. Called once in `App`.
### Libraries
- **`src/lib/telemetry.ts`** — `initSentry()`, `teardownSentry()`, `initPostHog()`, `teardownPostHog()`, `trackEvent()`. Path scrubber via `beforeSend` hook. DSN/release/key from `VITE_SENTRY_DSN`, `VITE_SENTRY_RELEASE`, and `VITE_POSTHOG_KEY` env vars.
- **`src/lib/telemetry.ts`** — `initSentry()`, `teardownSentry()`, `initPostHog()`, `teardownPostHog()`, `trackEvent()`. Path scrubber via `beforeSend` hook. DSN/key from `VITE_SENTRY_DSN` and `VITE_POSTHOG_KEY`; `VITE_SENTRY_RELEASE` is treated as the build version and only becomes Sentry's `release` for stable calendar builds (`YYYY.M.D`). Alpha/prerelease/internal builds tag `tolaria.build_version` and `tolaria.release_kind` without creating normal Sentry Releases entries.
- **`src/main.tsx`** — React root error callbacks (`onCaughtError`, `onUncaughtError`, `onRecoverableError`) forward component-stack context to `Sentry.reactErrorHandler()` for debuggable production React errors.
- **`src-tauri/src/telemetry.rs`** — Rust-side Sentry init with `beforeSend` path scrubber. `init_sentry_from_settings()` reads settings and conditionally initializes. `reinit_sentry()` for runtime toggle.
- **`src-tauri/src/telemetry.rs`** — Rust-side Sentry init with `beforeSend` path scrubber. `init_sentry_from_settings()` reads settings and conditionally initializes; stable calendar `CARGO_PKG_VERSION` values become Sentry releases, while alpha/prerelease/internal versions are kept as diagnostic tags only. `reinit_sentry()` for runtime toggle.
### Tauri Commands
- **`reinit_telemetry`** — Re-reads settings and toggles Rust Sentry on/off. Called from frontend when user changes crash reporting setting.
@@ -771,6 +773,6 @@ Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is ins
- **`download_and_install_app_update`** — Channel-aware download/install with streamed progress events.
### CI/CD
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` with macOS Apple Silicon/Intel, Linux x64, and Windows x64 updater entries, then refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed. macOS release assets use `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`.
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon and Intel DMG/updater artifacts, Windows x64 installers/updater bundles, and Linux x86_64 `.deb` / AppImage artifacts. Stable macOS DMG/updater assets use the same `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`.
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` with macOS Apple Silicon/Intel, Linux x64, and Windows x64 updater entries, then refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed. macOS release assets use `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`, which is retained as a diagnostic build-version tag but not registered as a normal Sentry release for alpha builds.
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon and Intel DMG/updater artifacts, Windows x64 installers/updater bundles, and Linux x86_64 `.deb` / AppImage artifacts. Stable macOS DMG/updater assets use the same `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed stable version as `VITE_SENTRY_RELEASE`, which is registered as Sentry's release.
- **Beta cohorts** are handled in PostHog targeting only. There is no beta updater feed.

View File

@@ -294,7 +294,7 @@ Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existi
## MCP Server
The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistants (Claude Code, Cursor, or any MCP-compatible client).
The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistants (Claude Code, Gemini CLI, Cursor, or any MCP-compatible client).
### Tool Surface (14 tools)
@@ -326,10 +326,11 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
Tolaria can register itself as an MCP server in:
- `~/.claude.json` and `~/.claude/mcp.json` (Claude Code compatibility across current CLI and legacy MCP-file setups)
- `~/.gemini/settings.json` (Gemini CLI)
- `~/.cursor/mcp.json` (Cursor)
- `~/.config/mcp/mcp.json` (generic MCP-compatible clients)
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux bundle paths, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`). The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`.
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers and Gemini settings), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. The same generated entry is exposed as a manual JSON snippet in the MCP setup dialog and through the AI panel copy action, giving users a transparent fallback for MCP-compatible tools Tolaria does not auto-configure. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux bundle paths, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`) and owns connect, disconnect, exact-snippet load, and copy-to-clipboard actions. Gemini CLI still owns its own install and sign-in; Tolaria only writes the MCP entry and optional vault guidance shim. The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`.
### Architecture
@@ -376,8 +377,9 @@ flowchart LR
|----------|---------|
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with VAULT_PATH env |
| `sync_mcp_bridge_vault(vault_path?)` | Starts, restarts, or stops the desktop WebSocket bridge as the selected vault changes |
| `register_mcp(vault_path)` | Verifies Node.js, resolves the packaged `mcp-server/`, and writes Tolaria's explicit stdio entry to Claude Code, Cursor, and generic MCP configs on user request |
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code, Cursor, and generic MCP configs |
| `register_mcp(vault_path)` | Verifies Node.js, resolves the packaged `mcp-server/`, and writes Tolaria's explicit stdio entry to Claude Code, Gemini CLI, Cursor, and generic MCP configs on user request |
| `mcp_config_snippet(vault_path)` | Builds the exact `mcpServers.tolaria` JSON users can copy into any compatible client without writing third-party config files |
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code, Gemini CLI, Cursor, and generic MCP configs |
| `upsert_mcp_config(path, entry)` | Atomic config file update (create/merge, preserves others) |
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is replaced on vault switches, stopped when no active vault is selected, and killed on app exit via the `RunEvent::Exit` handler. The same desktop layer now keeps the Tauri asset protocol scoped to the active vault instead of every filesystem path.
@@ -476,7 +478,7 @@ Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnbo
`useGettingStartedClone` reuses the same parent-folder semantics for the status-bar / command-palette clone action, and `Toast` is rendered through the AI-agents onboarding gate so the resolved destination path stays visible right after a successful clone.
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL (`refactoringhq/tolaria-getting-started`), delegates the clone to the git backend, then normalizes Tolaria-managed root guidance and type scaffolding (`AGENTS.md`, `CLAUDE.md`, `type.md`, `note.md`) so fresh starter vaults pick up the current defaults even when the remote starter repo still carries a legacy copy or an older pre-`type:` `is_a`-era template. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions, and Tolaria seeds it as an organized `Note` so it stays out of the way in a fresh vault. The clone helper still accepts the legacy `LAPUTA_GETTING_STARTED_REPO_URL` environment override so older automation can continue to redirect the starter source during the transition.
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL (`refactoringhq/tolaria-getting-started`), delegates the clone to the git backend, then normalizes Tolaria-managed root guidance and type scaffolding (`AGENTS.md`, `CLAUDE.md`, `type.md`, `note.md`) so fresh starter vaults pick up the current defaults even when the remote starter repo still carries a legacy copy or an older pre-`type:` `is_a`-era template. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions, and Tolaria seeds it as an organized `Note` so it stays out of the way in a fresh vault. Optional `GEMINI.md` guidance is created only by the explicit AI guidance restore action. The clone helper still accepts the legacy `LAPUTA_GETTING_STARTED_REPO_URL` environment override so older automation can continue to redirect the starter source during the transition.
After the clone completes, Tolaria removes every configured git remote from the new starter vault. Getting Started vaults therefore open as local-only by default, and users opt into a remote later with the explicit Add Remote flow.
@@ -625,7 +627,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `rename.rs` | `rename_note` / `rename_note_filename` / `move_note_to_folder` — stage crash-safe file moves, update `title` frontmatter when needed, recover unfinished rename transactions, and report backlink rewrite failures |
| `image.rs` | `save_image` / `copy_image_to_vault` — save editor image attachments with sanitized filenames |
| `migration.rs` | `flatten_vault`, `vault_health_check`, `migrate_is_a_to_type` |
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, and repairs missing root type scaffolding such as `type.md` and `note.md` |
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md`, `CLAUDE.md`, and optional `GEMINI.md` shims), migrates legacy `config/agents.md`, and repairs missing root type scaffolding such as `type.md` and `note.md` |
| `getting_started.rs` | Clones and normalizes the public Getting Started starter vault |
## Rust Backend Modules
@@ -670,7 +672,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `check_vault_exists` | Check if vault path exists |
| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, `type.md`, and `note.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 |
| `get_vault_ai_guidance_status` | Report whether `AGENTS.md`, `CLAUDE.md`, and optional `GEMINI.md` guidance are managed, missing, broken, or custom |
| `restore_vault_ai_guidance` | Restore any missing/broken Tolaria-managed guidance files without overwriting custom ones |
### Frontmatter
@@ -724,9 +726,10 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `check_claude_cli` | Check if Claude CLI is available |
| `get_ai_agents_status` | Check Claude Code + Codex + OpenCode + Pi availability |
| `stream_ai_agent` | Stream Claude Code, Codex, OpenCode, or Pi through the normalized agent event layer |
| `register_mcp_tools` | Register MCP in Claude/Cursor/generic config for the active vault |
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Cursor/generic config |
| `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Cursor/generic config |
| `register_mcp_tools` | Register MCP in Claude/Gemini/Cursor/generic config for the active vault |
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Gemini/Cursor/generic config |
| `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Gemini/Cursor/generic config |
| `get_mcp_config_snippet` | Return the exact manual MCP JSON snippet for the active vault |
| `sync_mcp_bridge_vault` | Sync the desktop WebSocket bridge process to the selected vault, or stop it when no vault is selected |
The desktop MCP WebSocket bridge is intentionally local-only. `mcp-server/ws-bridge.js` binds both bridge ports to loopback, rejects non-loopback clients, accepts browser/Tauri origins only on the UI bridge, and rejects browser-origin requests on the tool bridge so remote pages cannot drive vault tools directly.
@@ -942,7 +945,7 @@ sequenceDiagram
**Architecture:**
- **Rust:** `sentry` crate initialized in `lib.rs::setup()` via `telemetry::init_sentry_from_settings()`
- **JS:** `@sentry/react` + `posthog-js` initialized lazily by `useTelemetry` hook; the React root also wires `onCaughtError`, `onUncaughtError`, and `onRecoverableError` through `Sentry.reactErrorHandler()` so production React invariants include component stack context when crash reporting is enabled.
- **Release grouping:** packaged release workflows pass `VITE_SENTRY_RELEASE` from the computed build version so frontend Sentry events group by the shipped alpha or stable version.
- **Release grouping:** packaged release workflows pass `VITE_SENTRY_RELEASE` from the computed build version, but the app only assigns Sentry's `release` field for stable calendar builds (`YYYY.M.D`). Alpha/prerelease/internal builds omit `release` so they do not create normal Sentry Releases entries, while both frontend and Rust Sentry scopes tag `tolaria.build_version` and `tolaria.release_kind` for diagnostics.
- **Settings:** `telemetry_consent`, `crash_reporting_enabled`, `analytics_enabled`, `anonymous_id` in `Settings` struct
- **Consent:** `TelemetryConsentDialog` shown when `telemetry_consent === null`

View File

@@ -425,6 +425,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
### Work with external MCP setup
1. **Backend registration/status**: Edit `src-tauri/src/mcp.rs`; registration must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows, Linux, and AppImage installs, and write an explicit stdio entry with `VAULT_PATH` plus `WS_UI_PORT=9711`
2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx`; users should see the Node.js prerequisite and the manual config shape before Tolaria writes third-party config files
1. **Backend registration/status/snippets**: Edit `src-tauri/src/mcp.rs`; registration and manual config generation must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows, Linux, and AppImage installs, and use an explicit stdio entry with `VAULT_PATH` plus `WS_UI_PORT=9711`
2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx` and `src/hooks/useMcpStatus.ts`; users should see the Node.js prerequisite, the exact generated manual config, and a copy action before Tolaria writes third-party config files
3. **Status hook/toasts**: Edit `src/hooks/useMcpStatus.ts` when setup, reconnect, disconnect, or failure messaging changes
4. **Gemini CLI compatibility**: Keep `~/.gemini/settings.json` in the registration path list and keep optional `GEMINI.md` generation behind `restore_vault_ai_guidance`; Gemini itself still needs its own install and sign-in outside Tolaria

View File

@@ -0,0 +1,35 @@
---
type: ADR
id: "0091"
title: "Gemini CLI external AI setup"
status: active
date: 2026-04-28
---
## Context
Tolaria already supports explicit MCP setup for external desktop AI tools. Users asked for Gemini CLI support so the same active-vault MCP server can be registered where Gemini reads tool configuration, with optional Gemini-specific vault guidance.
Gemini CLI reads MCP server definitions from `~/.gemini/settings.json` and can load project guidance from `GEMINI.md`. Tolaria must support those conventions without silently rewriting global user settings or overwriting user-authored vault instructions.
## Decision
Tolaria adds `~/.gemini/settings.json` to the explicit external AI setup path list. The existing MCP entry shape is reused: `mcpServers.tolaria` runs the packaged stdio server through Node.js, pins `VAULT_PATH` to the selected vault, and sets `WS_UI_PORT=9711`.
Vault guidance keeps `AGENTS.md` as the canonical shared source. `restore_vault_ai_guidance` can create or repair a managed `GEMINI.md` shim that imports `AGENTS.md`, while bootstrap and repair flows continue to seed only required Tolaria guidance (`AGENTS.md` and `CLAUDE.md`) plus type scaffolding. Custom `GEMINI.md` files are classified as custom and are not overwritten.
The setup dialog documents that Gemini CLI still needs its own install and sign-in. Tolaria does not store Gemini credentials or model-provider API keys.
## Options Considered
- **Add Gemini to explicit MCP setup and optional guidance restore** (chosen): matches the existing consent boundary, preserves user settings, and gives Gemini the shared vault context.
- **Generate `GEMINI.md` automatically for every vault**: simpler discovery, but turns an optional third-party compatibility file into startup side effect and dirties vaults for users who do not use Gemini.
- **Document manual Gemini setup only**: avoids code changes, but leaves users to transpose paths and loses the active-vault safety already available for other MCP clients.
- **Create a Gemini-specific MCP entry shape**: unnecessary because Gemini accepts the same `mcpServers` entry structure used by the existing Tolaria MCP server.
## Consequences
- External AI setup now writes/removes Tolaria's MCP entry in Claude, Gemini, Cursor, and generic MCP config files.
- Gemini users can create a managed `GEMINI.md` shim without duplicating the canonical `AGENTS.md` guidance.
- Existing vault bootstrap and repair remain non-invasive for users who do not use Gemini.
- Native QA requires a local Gemini CLI install and authentication for an end-to-end Gemini prompt; otherwise the app can still verify the generated config and documentation paths.

View File

@@ -142,3 +142,5 @@ proposed → active → superseded
| [0085](0085-non-git-vault-support.md) | Non-git vaults open with explicit later Git initialization | active |
| [0088](0088-markdown-durable-mermaid-diagrams.md) | Markdown-durable Mermaid diagrams in notes | active |
| [0089](0089-active-vault-filesystem-watcher.md) | Active vault filesystem watcher | active |
| [0090](0090-pi-cli-agent-adapter.md) | Pi CLI agent adapter | active |
| [0091](0091-gemini-cli-external-ai-setup.md) | Gemini CLI external AI setup | active |

View File

@@ -17,7 +17,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:regression": "playwright test tests/smoke/",
"playwright:integration": "playwright test --config playwright.integration.config.ts",
"test:coverage": "node scripts/run-vitest-coverage.mjs",

View File

@@ -154,14 +154,17 @@ mod tests {
let initial = get_vault_ai_guidance_status(vault_path.clone()).unwrap();
assert_eq!(initial.agents_state, AiGuidanceFileState::Missing);
assert_eq!(initial.claude_state, AiGuidanceFileState::Missing);
assert_eq!(initial.gemini_state, AiGuidanceFileState::Missing);
assert!(initial.can_restore);
let restored = restore_vault_ai_guidance(vault_path.clone()).unwrap();
assert_eq!(restored.agents_state, AiGuidanceFileState::Managed);
assert_eq!(restored.claude_state, AiGuidanceFileState::Managed);
assert_eq!(restored.gemini_state, AiGuidanceFileState::Managed);
assert!(!restored.can_restore);
assert!(dir.path().join("AGENTS.md").exists());
assert!(dir.path().join("CLAUDE.md").exists());
assert!(dir.path().join("GEMINI.md").exists());
}
}

View File

@@ -131,6 +131,15 @@ pub async fn check_mcp_status(vault_path: String) -> Result<crate::mcp::McpStatu
.map_err(|e| format!("MCP status check failed: {e}"))
}
#[cfg(desktop)]
#[tauri::command]
pub async fn get_mcp_config_snippet(vault_path: String) -> Result<String, String> {
let vault_path = super::expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || crate::mcp::mcp_config_snippet(&vault_path))
.await
.map_err(|e| format!("MCP config task failed: {e}"))?
}
#[cfg(desktop)]
#[tauri::command]
pub async fn sync_mcp_bridge_vault(
@@ -167,6 +176,12 @@ pub async fn check_mcp_status(_vault_path: String) -> Result<crate::mcp::McpStat
Ok(crate::mcp::McpStatus::NotInstalled)
}
#[cfg(mobile)]
#[tauri::command]
pub async fn get_mcp_config_snippet(_vault_path: String) -> Result<String, String> {
Err("MCP is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn sync_mcp_bridge_vault(_vault_path: Option<String>) -> Result<String, String> {

View File

@@ -435,6 +435,7 @@ macro_rules! app_invoke_handler {
commands::register_mcp_tools,
commands::remove_mcp_tools,
commands::check_mcp_status,
commands::get_mcp_config_snippet,
commands::sync_mcp_bridge_vault,
commands::repair_vault,
commands::reinit_telemetry,

View File

@@ -250,6 +250,7 @@ fn mcp_config_paths_for_home(home: &Path) -> Vec<PathBuf> {
vec![
home.join(".claude.json"),
home.join(".claude").join("mcp.json"),
home.join(".gemini").join("settings.json"),
home.join(".cursor").join("mcp.json"),
home.join(".config").join("mcp").join("mcp.json"),
]
@@ -313,6 +314,28 @@ fn build_mcp_entry(node_command: &str, index_js: &str, vault_path: &str) -> serd
})
}
fn build_mcp_config_snippet(entry: &serde_json::Value) -> Result<String, String> {
let mut servers = serde_json::Map::new();
servers.insert(MCP_SERVER_NAME.to_string(), entry.clone());
let config = serde_json::json!({ "mcpServers": servers });
serde_json::to_string_pretty(&config)
.map_err(|e| format!("Failed to serialize MCP config snippet: {e}"))
}
/// Build the exact MCP config JSON users can copy into compatible tools.
pub fn mcp_config_snippet(vault_path: &str) -> Result<String, String> {
let node = find_node().map_err(|e| {
format!("Node.js 18+ is required on PATH before Tolaria can build MCP config: {e}")
})?;
let server_dir = mcp_server_dir()?;
let index_js = server_dir.join("index.js").to_string_lossy().into_owned();
let node_command = node.to_string_lossy().into_owned();
let entry = build_mcp_entry(&node_command, &index_js, vault_path);
build_mcp_config_snippet(&entry)
}
/// Write MCP registration to a list of config file paths.
/// Returns "registered" on first registration, "updated" if already present.
fn register_mcp_to_configs(entry: &serde_json::Value, config_paths: &[PathBuf]) -> String {
@@ -502,6 +525,20 @@ mod tests {
write_config_json(config_path, serde_json::json!({ "mcpServers": servers }));
}
struct ExpectedMcpServer<'a> {
index_js: &'a str,
vault_path: &'a str,
}
fn assert_registered_tolaria_server(
config: &serde_json::Value,
expected: ExpectedMcpServer<'_>,
) {
let server = &config["mcpServers"][MCP_SERVER_NAME];
assert_eq!(server["args"][0], expected.index_js);
assert_eq!(server["env"]["VAULT_PATH"], expected.vault_path);
}
fn write_index_js(dir: &Path) -> PathBuf {
let index_js = dir.join("index.js");
std::fs::write(&index_js, "console.log('ok');").unwrap();
@@ -518,6 +555,22 @@ mod tests {
assert_eq!(entry["env"]["WS_UI_PORT"], "9711");
}
#[test]
fn build_mcp_config_snippet_wraps_tolaria_server_entry() {
let entry = test_mcp_entry("/path/to/index.js", "/my/vault");
let snippet = build_mcp_config_snippet(&entry).unwrap();
let config: serde_json::Value = serde_json::from_str(&snippet).unwrap();
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
"/path/to/index.js"
);
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"],
"/my/vault"
);
}
#[test]
fn first_node_lookup_path_uses_first_non_empty_line() {
let stdout = b"\nC:\\Program Files\\nodejs\\node.exe\r\nC:\\Other\\node.exe\r\n";
@@ -565,13 +618,12 @@ mod tests {
assert!(!was_update);
let config = read_config(&config_path);
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
"/test/index.js"
);
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"],
"/test/vault"
assert_registered_tolaria_server(
&config,
ExpectedMcpServer {
index_js: "/test/index.js",
vault_path: "/test/vault",
},
);
}
@@ -666,6 +718,35 @@ mod tests {
assert!(config["mcpServers"][MCP_SERVER_NAME].is_object());
}
#[test]
fn upsert_preserves_gemini_settings_json_fields() {
let (_tmp, config_path) = temp_config_path("settings.json");
write_config_json(
&config_path,
serde_json::json!({
"theme": "GitHub",
"mcpServers": {
"other": { "command": "example" }
}
}),
);
let entry = test_mcp_entry("/gemini/index.js", "/gemini-vault");
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
let config = read_config(&config_path);
assert!(!was_update);
assert_eq!(config["theme"], "GitHub");
assert_eq!(config["mcpServers"]["other"]["command"], "example");
assert_registered_tolaria_server(
&config,
ExpectedMcpServer {
index_js: "/gemini/index.js",
vault_path: "/gemini-vault",
},
);
}
#[test]
fn upsert_creates_parent_dirs() {
let tmp = tempfile::tempdir().unwrap();
@@ -736,6 +817,7 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let claude_user_cfg = tmp.path().join(".claude.json");
let claude_cfg = tmp.path().join("claude").join("mcp.json");
let gemini_cfg = tmp.path().join(".gemini").join("settings.json");
let cursor_cfg = tmp.path().join("cursor").join("mcp.json");
let generic_cfg = tmp.path().join(".config").join("mcp").join("mcp.json");
let entry = test_mcp_entry("/test/index.js", "/vault");
@@ -745,6 +827,7 @@ mod tests {
&[
claude_user_cfg.clone(),
claude_cfg.clone(),
gemini_cfg.clone(),
cursor_cfg.clone(),
generic_cfg.clone(),
],
@@ -752,14 +835,18 @@ mod tests {
assert!(claude_user_cfg.exists());
assert!(claude_cfg.exists());
assert!(gemini_cfg.exists());
assert!(cursor_cfg.exists());
assert!(generic_cfg.exists());
let raw = std::fs::read_to_string(&claude_user_cfg).unwrap();
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
"/test/index.js"
assert_registered_tolaria_server(
&config,
ExpectedMcpServer {
index_js: "/test/index.js",
vault_path: "/vault",
},
);
}
@@ -773,6 +860,7 @@ mod tests {
vec![
home.join(".claude.json"),
home.join(".claude").join("mcp.json"),
home.join(".gemini").join("settings.json"),
home.join(".cursor").join("mcp.json"),
home.join(".config").join("mcp").join("mcp.json"),
]

View File

@@ -1,5 +1,7 @@
use crate::settings;
use chrono::NaiveDate;
use regex::Regex;
use std::borrow::Cow;
use std::sync::Mutex;
/// Global Sentry guard — must live for the duration of the app.
@@ -44,6 +46,41 @@ fn parse_embedded_sentry_dsn(raw: Option<&str>) -> Option<sentry::types::Dsn> {
normalize_http_like_value(&normalized).parse().ok()
}
fn is_stable_calendar_release(version: &str) -> bool {
let mut parts = version.split('.');
let (Some(year), Some(month), Some(day)) = (parts.next(), parts.next(), parts.next()) else {
return false;
};
if parts.next().is_some() {
return false;
}
let (Ok(year), Ok(month), Ok(day)) = (
year.parse::<i32>(),
month.parse::<u32>(),
day.parse::<u32>(),
) else {
return false;
};
NaiveDate::from_ymd_opt(year, month, day).is_some()
}
fn sentry_release_for_version(version: &'static str) -> Option<Cow<'static, str>> {
is_stable_calendar_release(version).then(|| version.into())
}
fn sentry_release_kind(version: &str) -> &'static str {
if is_stable_calendar_release(version) {
"stable"
} else if version.contains('-') {
"prerelease"
} else {
"internal"
}
}
/// Initialize Sentry if the user has opted in to crash reporting.
/// Returns `true` if Sentry was initialized, `false` if skipped.
pub fn init_sentry_from_settings() -> bool {
@@ -61,9 +98,10 @@ pub fn init_sentry_from_settings() -> bool {
};
let anonymous_id = settings.anonymous_id.unwrap_or_default();
let build_version = env!("CARGO_PKG_VERSION");
let guard = sentry::init(sentry::ClientOptions {
dsn: Some(dsn),
release: Some(env!("CARGO_PKG_VERSION").into()),
release: sentry_release_for_version(build_version),
send_default_pii: false,
before_send: Some(std::sync::Arc::new(|mut event| {
if let Some(ref mut msg) = event.message {
@@ -79,6 +117,8 @@ pub fn init_sentry_from_settings() -> bool {
id: Some(anonymous_id),
..Default::default()
}));
scope.set_tag("tolaria.build_version", build_version);
scope.set_tag("tolaria.release_kind", sentry_release_kind(build_version));
});
*SENTRY_GUARD.lock().unwrap() = Some(guard);
@@ -160,4 +200,26 @@ mod tests {
// Without SENTRY_DSN env var set at compile time, init should return false
assert!(!init_sentry_from_settings());
}
#[test]
fn test_sentry_release_for_stable_calendar_version() {
assert!(sentry_release_for_version("2026.4.28").is_some());
}
#[test]
fn test_sentry_release_for_alpha_version_is_none() {
assert!(sentry_release_for_version("2026.4.28-alpha.7").is_none());
}
#[test]
fn test_sentry_release_for_local_dev_version_is_none() {
assert!(sentry_release_for_version("0.1.0").is_none());
}
#[test]
fn test_sentry_release_kind_classifies_versions() {
assert_eq!(sentry_release_kind("2026.4.28"), "stable");
assert_eq!(sentry_release_kind("2026.4.28-alpha.7"), "prerelease");
assert_eq!(sentry_release_kind("0.1.0"), "internal");
}
}

View File

@@ -58,6 +58,16 @@ _organized: true
This file is only a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`.
";
const GEMINI_MD_SHIM: &str = "---
type: Note
_organized: true
---
@AGENTS.md
This file is only a Gemini CLI compatibility shim. Keep shared agent instructions in `AGENTS.md`.
";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AiGuidanceFileState {
@@ -71,9 +81,16 @@ pub enum AiGuidanceFileState {
pub struct VaultAiGuidanceStatus {
pub agents_state: AiGuidanceFileState,
pub claude_state: AiGuidanceFileState,
pub gemini_state: AiGuidanceFileState,
pub can_restore: bool,
}
struct GuidancePaths {
agents: PathBuf,
claude: PathBuf,
gemini: PathBuf,
}
#[derive(Debug, Default)]
struct LegacyAgentsMigrationOutcome {
copied_to_root: bool,
@@ -109,6 +126,10 @@ fn matches_claude_shim(content: &str) -> bool {
|| trimmed == CLAUDE_MD_SHIM.trim()
}
fn matches_gemini_shim(content: &str) -> bool {
content.trim() == GEMINI_MD_SHIM.trim()
}
fn claude_shim_can_be_replaced(path: &Path) -> bool {
!path.exists() || {
let content = read_file_or_empty(path);
@@ -116,6 +137,13 @@ fn claude_shim_can_be_replaced(path: &Path) -> bool {
}
}
fn gemini_shim_can_be_replaced(path: &Path) -> bool {
!path.exists() || {
let content = read_file_or_empty(path);
content.trim().is_empty() || matches_gemini_shim(&content)
}
}
fn sync_managed_file(
path: &Path,
content: &str,
@@ -150,9 +178,12 @@ fn classify_guidance_file(
AiGuidanceFileState::Custom
}
fn guidance_paths(vault_path: &Path) -> (PathBuf, PathBuf) {
let vault = vault_path;
(vault.join("AGENTS.md"), vault.join("CLAUDE.md"))
fn guidance_paths(vault_path: &Path) -> GuidancePaths {
GuidancePaths {
agents: vault_path.join("AGENTS.md"),
claude: vault_path.join("CLAUDE.md"),
gemini: vault_path.join("GEMINI.md"),
}
}
fn classify_agents_file(path: &Path) -> AiGuidanceFileState {
@@ -167,6 +198,10 @@ fn classify_claude_file(path: &Path) -> AiGuidanceFileState {
classify_guidance_file(path, matches_claude_shim, claude_shim_can_be_replaced)
}
fn classify_gemini_file(path: &Path) -> AiGuidanceFileState {
classify_guidance_file(path, matches_gemini_shim, gemini_shim_can_be_replaced)
}
fn guidance_file_needs_restore(state: AiGuidanceFileState) -> bool {
matches!(
state,
@@ -175,29 +210,43 @@ fn guidance_file_needs_restore(state: AiGuidanceFileState) -> bool {
}
fn build_ai_guidance_status(vault_path: &Path) -> 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);
let paths = guidance_paths(vault_path);
let agents_state = classify_agents_file(&paths.agents);
let claude_state = classify_claude_file(&paths.claude);
let gemini_state = classify_gemini_file(&paths.gemini);
VaultAiGuidanceStatus {
agents_state,
claude_state,
gemini_state,
can_restore: guidance_file_needs_restore(agents_state)
|| guidance_file_needs_restore(claude_state),
|| guidance_file_needs_restore(claude_state)
|| guidance_file_needs_restore(gemini_state),
}
}
fn sync_claude_shim_file(vault_path: &Path) -> Result<bool, String> {
let (_, claude_path) = guidance_paths(vault_path);
sync_managed_file(&claude_path, CLAUDE_MD_SHIM, claude_shim_can_be_replaced)
let paths = guidance_paths(vault_path);
sync_managed_file(&paths.claude, CLAUDE_MD_SHIM, claude_shim_can_be_replaced)
}
fn sync_ai_guidance_files(vault_path: &Path) -> Result<bool, String> {
fn sync_gemini_shim_file(vault_path: &Path) -> Result<bool, String> {
let paths = guidance_paths(vault_path);
sync_managed_file(&paths.gemini, GEMINI_MD_SHIM, gemini_shim_can_be_replaced)
}
fn sync_required_ai_guidance_files(vault_path: &Path) -> 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 sync_all_ai_guidance_files(vault_path: &Path) -> Result<bool, String> {
let wrote_required = sync_required_ai_guidance_files(vault_path)?;
let wrote_gemini = sync_gemini_shim_file(vault_path)?;
Ok(wrote_required || wrote_gemini)
}
fn migrate_legacy_agents_file(
root_agents: &Path,
config_agents: &Path,
@@ -241,8 +290,8 @@ fn cleanup_empty_config_dir(vault: &Path) -> Result<bool, String> {
}
pub(super) fn sync_default_agents_file(vault_path: &Path) -> Result<bool, String> {
let (agents_path, _) = guidance_paths(vault_path);
sync_managed_file(&agents_path, AGENTS_MD, root_agents_can_be_replaced)
let paths = guidance_paths(vault_path);
sync_managed_file(&paths.agents, AGENTS_MD, root_agents_can_be_replaced)
}
pub fn get_ai_guidance_status(
@@ -255,7 +304,7 @@ pub fn restore_ai_guidance_files(
vault_path: impl AsRef<str>,
) -> Result<VaultAiGuidanceStatus, String> {
let vault_path = Path::new(vault_path.as_ref());
sync_ai_guidance_files(vault_path)?;
sync_all_ai_guidance_files(vault_path)?;
Ok(build_ai_guidance_status(vault_path))
}
@@ -263,7 +312,7 @@ pub fn restore_ai_guidance_files(
/// Also seeds Tolaria-managed root type definitions used by repair/bootstrap flows.
pub fn seed_config_files(vault_path: impl AsRef<str>) {
let vault_path = Path::new(vault_path.as_ref());
if sync_ai_guidance_files(vault_path).unwrap_or(false) {
if sync_required_ai_guidance_files(vault_path).unwrap_or(false) {
log::info!("Seeded vault AI guidance files at vault root");
}
@@ -307,7 +356,7 @@ pub fn migrate_agents_md(vault_path: impl AsRef<str>) {
log::info!("Removed empty config/ directory");
}
let _ = sync_ai_guidance_files(vault);
let _ = sync_required_ai_guidance_files(vault);
}
/// Repair config files: ensure `AGENTS.md` at vault root and root type definitions.
@@ -320,7 +369,7 @@ pub fn repair_config_files(vault_path: impl AsRef<str>) -> Result<String, String
migrate_legacy_agents_file(&root_agents, &config_agents)?;
let _ = cleanup_empty_config_dir(vault)?;
sync_ai_guidance_files(vault)?;
sync_required_ai_guidance_files(vault)?;
write_if_missing(&vault.join("type.md"), TYPE_TYPE_DEFINITION)?;
write_if_missing(&vault.join("note.md"), NOTE_TYPE_DEFINITION)?;
@@ -355,6 +404,10 @@ mod tests {
fs::write(vault.join("CLAUDE.md"), content).unwrap();
}
fn write_root_gemini(vault: &Path, content: &str) {
fs::write(vault.join("GEMINI.md"), content).unwrap();
}
fn write_legacy_agents(vault: &Path, content: &str) {
fs::write(config_dir(vault).join("agents.md"), content).unwrap();
}
@@ -367,6 +420,10 @@ mod tests {
fs::read_to_string(vault.join("CLAUDE.md")).unwrap()
}
fn read_root_gemini(vault: &Path) -> String {
fs::read_to_string(vault.join("GEMINI.md")).unwrap()
}
type VaultOperation = fn(&Path);
fn run_seed(vault: &Path) {
@@ -454,15 +511,52 @@ mod tests {
assert!(content.contains(expected_root_text));
}
fn assert_required_agents_file_seeded(vault: &Path) {
assert!(vault.join("AGENTS.md").exists());
assert!(read_root_agents(vault).contains("Tolaria Vault"));
}
fn assert_required_guidance_shims_seeded(vault: &Path) {
assert_eq!(read_root_claude(vault), CLAUDE_MD_SHIM);
assert!(!vault.join("GEMINI.md").exists());
}
fn assert_required_guidance_files_seeded(vault: &Path) {
assert_required_agents_file_seeded(vault);
assert_required_guidance_shims_seeded(vault);
}
fn assert_root_type_definitions_seeded(vault: &Path) {
assert!(vault.join("type.md").exists());
assert!(vault.join("note.md").exists());
assert!(!vault.join("config").exists());
}
fn assert_type_definition_content(vault: &Path) {
let type_content = fs::read_to_string(vault.join("type.md")).unwrap();
let note_content = fs::read_to_string(vault.join("note.md")).unwrap();
assert_type_definition_body(&type_content);
assert_note_definition_body(&note_content);
}
fn assert_type_definition_body(type_content: &str) {
assert!(type_content.contains("type: Type"));
assert!(type_content.contains("# Type"));
assert!(type_content.contains("visible: false"));
}
fn assert_note_definition_body(note_content: &str) {
assert!(note_content.contains("type: Type"));
assert!(note_content.contains("# Note"));
}
#[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());
assert!(read_root_agents(&vault).contains("Tolaria Vault"));
assert_eq!(read_root_claude(&vault), CLAUDE_MD_SHIM);
assert_required_guidance_files_seeded(&vault);
}
#[test]
@@ -471,16 +565,8 @@ mod tests {
seed_config_files(vault.to_str().unwrap());
assert!(vault.join("type.md").exists());
assert!(vault.join("note.md").exists());
let type_content = fs::read_to_string(vault.join("type.md")).unwrap();
let note_content = fs::read_to_string(vault.join("note.md")).unwrap();
assert!(type_content.contains("type: Type"));
assert!(type_content.contains("# Type"));
assert!(type_content.contains("visible: false"));
assert!(note_content.contains("type: Type"));
assert!(note_content.contains("# Note"));
assert!(!vault.join("config").exists());
assert_root_type_definitions_seeded(&vault);
assert_type_definition_content(&vault);
}
#[test]
@@ -604,20 +690,12 @@ mod tests {
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("type.md").exists());
assert!(vault.join("note.md").exists());
assert!(!vault.join("config").exists());
let agents = read_root_agents(&vault);
assert!(agents.contains("Tolaria Vault"));
let type_content = fs::read_to_string(vault.join("type.md")).unwrap();
assert!(type_content.contains("# Type"));
assert!(type_content.contains("visible: false"));
let note_content = fs::read_to_string(vault.join("note.md")).unwrap();
assert!(note_content.contains("type: Type"));
assert!(note_content.contains("general-purpose document"));
assert_required_guidance_files_seeded(&vault);
assert_root_type_definitions_seeded(&vault);
assert_type_definition_content(&vault);
assert!(fs::read_to_string(vault.join("note.md"))
.unwrap()
.contains("general-purpose document"));
}
#[test]
@@ -656,9 +734,15 @@ mod tests {
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);
assert_eq!(
status,
VaultAiGuidanceStatus {
agents_state: AiGuidanceFileState::Custom,
claude_state: AiGuidanceFileState::Broken,
gemini_state: AiGuidanceFileState::Missing,
can_restore: true,
}
);
}
#[test]
@@ -668,11 +752,18 @@ mod tests {
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_eq!(
status,
VaultAiGuidanceStatus {
agents_state: AiGuidanceFileState::Custom,
claude_state: AiGuidanceFileState::Managed,
gemini_state: AiGuidanceFileState::Managed,
can_restore: false,
}
);
assert!(read_root_agents(&vault).contains("Custom Agent Config"));
assert_eq!(read_root_claude(&vault), CLAUDE_MD_SHIM);
assert_eq!(read_root_gemini(&vault), GEMINI_MD_SHIM);
}
#[test]
@@ -681,8 +772,28 @@ mod tests {
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_eq!(
status,
VaultAiGuidanceStatus {
agents_state: AiGuidanceFileState::Managed,
claude_state: AiGuidanceFileState::Managed,
gemini_state: AiGuidanceFileState::Managed,
can_restore: false,
}
);
}
#[test]
fn test_restore_ai_guidance_files_preserves_custom_gemini() {
let (_dir, vault) = create_vault();
write_root_agents(&vault, AGENTS_MD);
write_root_claude(&vault, CLAUDE_MD_SHIM);
write_root_gemini(&vault, "# Custom Gemini instructions\nDo not overwrite\n");
let status = restore_ai_guidance_files(vault.to_str().unwrap()).unwrap();
assert_eq!(status.gemini_state, AiGuidanceFileState::Custom);
assert!(!status.can_restore);
assert!(read_root_gemini(&vault).contains("Custom Gemini instructions"));
}
}

View File

@@ -453,7 +453,16 @@ function App() {
trackEvent('vault_opened', { has_git: gitRepoState === 'ready' ? 1 : 0, note_count: vault.entries.length })
}
}, [vault.entries.length, gitRepoState, resolvedPath])
const { mcpStatus, connectMcp, disconnectMcp } = useMcpStatus(resolvedPath, setToastMessage)
const {
mcpStatus,
connectMcp,
disconnectMcp,
mcpConfigSnippet,
mcpConfigLoading,
mcpConfigError,
loadMcpConfigSnippet,
copyMcpConfig,
} = useMcpStatus(resolvedPath, setToastMessage)
const gitRemoteStatus = useGitRemoteStatus(resolvedPath)
const loadVaultModifiedFiles = vault.loadModifiedFiles
const refreshGitRemoteStatus = gitRemoteStatus.refreshRemoteStatus
@@ -493,6 +502,14 @@ function App() {
}
}, [disconnectMcp])
const handleCopyMcpConfig = useCallback(() => {
void copyMcpConfig()
}, [copyMcpConfig])
const handleLoadMcpConfigSnippet = useCallback(() => {
void loadMcpConfigSnippet().catch(() => undefined)
}, [loadMcpConfigSnippet])
// Detect external file renames on window focus
const [detectedRenames, setDetectedRenames] = useState<DetectedRename[]>([])
useEffect(() => {
@@ -1601,6 +1618,7 @@ function App() {
onInitializeProperties={handleInitializeProperties}
showAIChat={dialogs.showAIChat}
onToggleAIChat={dialogs.toggleAIChat}
onCopyMcpConfig={handleCopyMcpConfig}
vaultPath={resolvedPath}
noteList={aiNoteList}
noteListFilter={aiNoteListFilter}
@@ -1685,7 +1703,7 @@ function App() {
/>
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} locale={appLocale} systemLocale={systemLocale} isGitVault={isGitVault} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
<McpSetupDialog open={showMcpSetupDialog} status={mcpStatus} busyAction={mcpDialogAction} onClose={closeMcpSetupDialog} onConnect={handleConnectMcp} onDisconnect={handleDisconnectMcp} />
<McpSetupDialog open={showMcpSetupDialog} status={mcpStatus} busyAction={mcpDialogAction} manualConfigSnippet={mcpConfigSnippet} manualConfigLoading={mcpConfigLoading} manualConfigError={mcpConfigError} onClose={closeMcpSetupDialog} onConnect={handleConnectMcp} onCopyManualConfig={handleCopyMcpConfig} onDisconnect={handleDisconnectMcp} onLoadManualConfig={handleLoadMcpConfigSnippet} />
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />
{deleteActions.confirmDelete && (
<ConfirmDeleteDialog

View File

@@ -85,6 +85,15 @@ describe('AiPanel', () => {
expect(mockClearConversation).toHaveBeenCalledOnce()
})
it('copies the MCP config from the AI panel header action', () => {
const onCopyMcpConfig = vi.fn()
render(<AiPanel onClose={vi.fn()} onCopyMcpConfig={onCopyMcpConfig} vaultPath="/tmp/vault" />)
fireEvent.click(screen.getByRole('button', { name: 'Copy MCP config' }))
expect(onCopyMcpConfig).toHaveBeenCalledOnce()
})
it('renders empty state without context', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.getByText('Open a note, then ask Claude Code about it')).toBeTruthy()

View File

@@ -21,6 +21,7 @@ export type { AiAgentMessage } from '../hooks/useCliAiAgent'
interface AiPanelProps {
onClose: () => void
onCopyMcpConfig?: () => void
onOpenNote?: (path: string) => void
onUnsupportedAiPaste?: (message: string) => void
defaultAiAgent?: AiAgentId
@@ -42,6 +43,7 @@ interface AiPanelProps {
interface AiPanelViewProps {
controller: AiPanelController
onClose: () => void
onCopyMcpConfig?: () => void
onOpenNote?: (path: string) => void
onUnsupportedAiPaste?: (message: string) => void
defaultAiAgent?: AiAgentId
@@ -58,6 +60,7 @@ function readinessFromReadyFlag(ready: boolean | undefined): AiAgentReadiness {
export function AiPanelView({
controller,
onClose,
onCopyMcpConfig,
onOpenNote,
onUnsupportedAiPaste,
defaultAiAgent: providedDefaultAiAgent,
@@ -113,6 +116,7 @@ export function AiPanelView({
agentLabel={agentLabel}
agentReadiness={defaultAiAgentReadiness}
onClose={onClose}
onCopyMcpConfig={onCopyMcpConfig}
onNewChat={handleNewChat}
/>
{activeEntry && (
@@ -144,6 +148,7 @@ export function AiPanelView({
export function AiPanel({
onClose,
onCopyMcpConfig,
onOpenNote,
onUnsupportedAiPaste,
defaultAiAgent: providedDefaultAiAgent,
@@ -183,6 +188,7 @@ export function AiPanel({
<AiPanelView
controller={controller}
onClose={onClose}
onCopyMcpConfig={onCopyMcpConfig}
onOpenNote={onOpenNote}
onUnsupportedAiPaste={onUnsupportedAiPaste}
defaultAiAgent={providedDefaultAiAgent}

View File

@@ -1,6 +1,8 @@
import { useEffect, useRef } from 'react'
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
import { Copy } from 'lucide-react'
import { AiMessage } from './AiMessage'
import { Button } from '@/components/ui/button'
import { WikilinkChatInput } from './WikilinkChatInput'
import { extractInlineWikilinkReferences } from './inlineWikilinkText'
import type { AiAgentMessage } from '../hooks/useCliAiAgent'
@@ -12,6 +14,7 @@ interface AiPanelHeaderProps {
agentLabel: string
agentReadiness: AiAgentReadiness
onClose: () => void
onCopyMcpConfig?: () => void
onNewChat: () => void
}
@@ -122,6 +125,7 @@ export function AiPanelHeader({
agentLabel,
agentReadiness,
onClose,
onCopyMcpConfig,
onNewChat,
}: AiPanelHeaderProps) {
return (
@@ -140,21 +144,39 @@ export function AiPanelHeader({
: `${agentLabel}${agentReadiness === 'missing' ? ' · not installed' : ''}`}
</span>
</div>
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
{onCopyMcpConfig ? (
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onCopyMcpConfig}
aria-label="Copy MCP config"
title="Copy MCP config"
data-testid="ai-copy-mcp-config"
>
<Copy size={15} />
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onNewChat}
aria-label="New AI chat"
title="New AI chat"
>
<Plus size={16} />
</button>
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onClose}
aria-label="Close AI panel"
title="Close AI panel"
>
<X size={16} />
</button>
</Button>
</div>
)
}

View File

@@ -67,6 +67,7 @@ interface EditorProps {
onInitializeProperties?: (path: string) => void
showAIChat?: boolean
onToggleAIChat?: () => void
onCopyMcpConfig?: () => void
vaultPath?: string
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
@@ -338,6 +339,7 @@ function EditorLayout({
showDiffToggle,
showAIChat,
onToggleAIChat,
onCopyMcpConfig,
inspectorCollapsed,
onToggleInspector,
onNavigateWikilink,
@@ -400,6 +402,7 @@ function EditorLayout({
showDiffToggle: boolean
showAIChat?: boolean
onToggleAIChat?: () => void
onCopyMcpConfig?: () => void
inspectorCollapsed: boolean
onToggleInspector: () => void
onNavigateWikilink: (target: string) => void
@@ -520,6 +523,7 @@ function EditorLayout({
noteListFilter={noteListFilter}
onToggleInspector={onToggleInspector}
onToggleAIChat={onToggleAIChat}
onCopyMcpConfig={onCopyMcpConfig}
onNavigateWikilink={onNavigateWikilink}
onViewCommitDiff={handleViewCommitDiff}
onUpdateFrontmatter={onUpdateFrontmatter}
@@ -551,6 +555,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
inspectorEntry, inspectorContent, gitHistory,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties,
showAIChat, onToggleAIChat,
onCopyMcpConfig,
vaultPath, noteList, noteListFilter,
onToggleFavorite, onToggleOrganized, onRevealFile, onCopyFilePath, onOpenExternalFile,
onDeleteNote, onArchiveNote, onUnarchiveNote,
@@ -611,6 +616,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
showDiffToggle={showDiffToggle}
showAIChat={showAIChat}
onToggleAIChat={onToggleAIChat}
onCopyMcpConfig={onCopyMcpConfig}
inspectorCollapsed={inspectorCollapsed}
onToggleInspector={onToggleInspector}
onNavigateWikilink={onNavigateWikilink}

View File

@@ -25,6 +25,7 @@ interface EditorRightPanelProps {
noteListFilter?: { type: string | null; query: string }
onToggleInspector: () => void
onToggleAIChat?: () => void
onCopyMcpConfig?: () => void
onNavigateWikilink: (target: string) => void
onViewCommitDiff: (commitHash: string) => Promise<void>
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
@@ -48,6 +49,7 @@ export function EditorRightPanel({
inspectorEntry, inspectorContent, entries, gitHistory, vaultPath,
noteList, noteListFilter,
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
onCopyMcpConfig,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties, onToggleRawEditor, onOpenNote,
onFileCreated, onFileModified, onVaultChanged,
locale,
@@ -87,6 +89,7 @@ export function EditorRightPanel({
<AiPanelView
controller={aiPanelController}
onClose={() => onToggleAIChat?.()}
onCopyMcpConfig={onCopyMcpConfig}
onOpenNote={onOpenNote}
onUnsupportedAiPaste={onUnsupportedAiPaste}
defaultAiAgent={defaultAiAgent}

View File

@@ -2,6 +2,20 @@ import { describe, it, expect, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { McpSetupDialog } from './McpSetupDialog'
const MANUAL_CONFIG = JSON.stringify({
mcpServers: {
tolaria: {
type: 'stdio',
command: 'node',
args: ['/Applications/Tolaria.app/Contents/Resources/mcp-server/index.js'],
env: {
VAULT_PATH: '/Users/luca/Laputa',
WS_UI_PORT: '9711',
},
},
},
}, null, 2)
describe('McpSetupDialog', () => {
it('renders the explicit setup flow without mutating config by default', () => {
render(
@@ -9,6 +23,7 @@ describe('McpSetupDialog', () => {
open={true}
status="not_installed"
busyAction={null}
manualConfigSnippet={MANUAL_CONFIG}
onClose={vi.fn()}
onConnect={vi.fn()}
onDisconnect={vi.fn()}
@@ -18,13 +33,16 @@ describe('McpSetupDialog', () => {
expect(screen.getByText('Set Up External AI Tools')).toBeInTheDocument()
expect(screen.getByText(/will not touch third-party config files until you confirm here/i)).toBeInTheDocument()
expect(screen.getByText(/requires Node.js 18\+ on PATH/i)).toBeInTheDocument()
expect(screen.getByText(/type: stdio/i)).toBeInTheDocument()
expect(screen.getByText(/VAULT_PATH/i)).toBeInTheDocument()
expect(screen.getByText(/WS_UI_PORT/i)).toBeInTheDocument()
expect(screen.getByTestId('mcp-config-snippet')).toHaveTextContent('"type": "stdio"')
expect(screen.getByTestId('mcp-config-snippet')).toHaveTextContent('"VAULT_PATH": "/Users/luca/Laputa"')
expect(screen.getByTestId('mcp-config-snippet')).toHaveTextContent('"WS_UI_PORT": "9711"')
expect(screen.getAllByText('~/.claude.json')).toHaveLength(2)
expect(screen.getByText('~/.claude/mcp.json')).toBeInTheDocument()
expect(screen.getAllByText('~/.gemini/settings.json')).toHaveLength(2)
expect(screen.getAllByText('~/.config/mcp/mcp.json')).toHaveLength(2)
expect(screen.getByText(/picked up by other MCP-compatible tools/i)).toBeInTheDocument()
expect(screen.getByText(/Gemini CLI needs its own install and sign-in/i)).toBeInTheDocument()
expect(screen.getByText('GEMINI.md')).toBeInTheDocument()
expect(screen.getByTestId('mcp-setup-connect')).toHaveTextContent('Connect External AI Tools')
expect(screen.queryByTestId('mcp-setup-disconnect')).not.toBeInTheDocument()
})
@@ -49,6 +67,7 @@ describe('McpSetupDialog', () => {
it('routes actions through the dialog buttons', () => {
const onClose = vi.fn()
const onConnect = vi.fn()
const onCopyManualConfig = vi.fn()
const onDisconnect = vi.fn()
render(
@@ -58,16 +77,37 @@ describe('McpSetupDialog', () => {
busyAction={null}
onClose={onClose}
onConnect={onConnect}
onCopyManualConfig={onCopyManualConfig}
onDisconnect={onDisconnect}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
fireEvent.click(screen.getByTestId('mcp-copy-config'))
fireEvent.click(screen.getByTestId('mcp-setup-connect'))
fireEvent.click(screen.getByTestId('mcp-setup-disconnect'))
expect(onClose).toHaveBeenCalledOnce()
expect(onCopyManualConfig).toHaveBeenCalledOnce()
expect(onConnect).toHaveBeenCalledOnce()
expect(onDisconnect).toHaveBeenCalledOnce()
})
it('loads exact manual config when opened', () => {
const onLoadManualConfig = vi.fn()
render(
<McpSetupDialog
open={true}
status="not_installed"
busyAction={null}
onClose={vi.fn()}
onConnect={vi.fn()}
onDisconnect={vi.fn()}
onLoadManualConfig={onLoadManualConfig}
/>,
)
expect(onLoadManualConfig).toHaveBeenCalledOnce()
})
})

View File

@@ -1,4 +1,5 @@
import { ShieldCheck } from 'lucide-react'
import { useEffect } from 'react'
import { Copy, ShieldCheck } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
@@ -14,6 +15,29 @@ interface McpSetupDialogProps {
open: boolean
status: McpStatus
busyAction: 'connect' | 'disconnect' | null
manualConfigError?: string | null
manualConfigLoading?: boolean
manualConfigSnippet?: string | null
onClose: () => void
onConnect: () => void
onCopyManualConfig?: () => void
onDisconnect: () => void
onLoadManualConfig?: () => void
}
interface ManualMcpConfigSectionProps {
error?: string | null
loading: boolean
onCopy?: () => void
snippet?: string | null
}
interface McpSetupActionsProps {
buttonsDisabled: boolean
connectBusy: boolean
disconnectBusy: boolean
primaryLabel: string
secondaryLabel: string | null
onClose: () => void
onConnect: () => void
onDisconnect: () => void
@@ -41,19 +65,100 @@ function actionCopy(status: McpStatus) {
}
}
function manualConfigText({ error, loading, snippet }: ManualMcpConfigSectionProps): string {
if (loading) return 'Loading exact MCP config...'
return error ?? snippet ?? 'Exact config is available after a vault is open.'
}
function ManualMcpConfigSection(props: ManualMcpConfigSectionProps) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<p className="m-0 text-sm font-medium text-foreground">Manual MCP config</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={props.onCopy}
disabled={!props.onCopy || props.loading}
data-testid="mcp-copy-config"
>
<Copy size={14} />
Copy MCP config
</Button>
</div>
<pre
tabIndex={0}
className="max-h-48 overflow-auto rounded-md border border-border bg-background px-3 py-3 font-mono text-xs leading-5 text-foreground"
data-testid="mcp-config-snippet"
>
{manualConfigText(props)}
</pre>
</div>
)
}
function McpSetupActions({
buttonsDisabled,
connectBusy,
disconnectBusy,
primaryLabel,
secondaryLabel,
onClose,
onConnect,
onDisconnect,
}: McpSetupActionsProps) {
return (
<DialogFooter className="flex-row items-center justify-end gap-2 sm:justify-end">
<Button type="button" variant="outline" onClick={onClose} disabled={buttonsDisabled}>
Cancel
</Button>
{secondaryLabel ? (
<Button
type="button"
variant="destructive"
onClick={onDisconnect}
disabled={buttonsDisabled}
data-testid="mcp-setup-disconnect"
>
{disconnectBusy ? 'Disconnecting…' : secondaryLabel}
</Button>
) : null}
<Button
type="button"
autoFocus
onClick={onConnect}
disabled={buttonsDisabled}
data-testid="mcp-setup-connect"
>
{connectBusy ? 'Connecting…' : primaryLabel}
</Button>
</DialogFooter>
)
}
export function McpSetupDialog({
open,
status,
busyAction,
manualConfigError,
manualConfigLoading = false,
manualConfigSnippet,
onClose,
onConnect,
onCopyManualConfig,
onDisconnect,
onLoadManualConfig,
}: McpSetupDialogProps) {
const copy = actionCopy(status)
const connectBusy = busyAction === 'connect'
const disconnectBusy = busyAction === 'disconnect'
const buttonsDisabled = busyAction !== null || status === 'checking'
useEffect(() => {
if (open) onLoadManualConfig?.()
}, [open, onLoadManualConfig])
return (
<Dialog open={open} onOpenChange={(next) => { if (!next) onClose() }}>
<DialogContent showCloseButton={false} className="sm:max-w-[520px]" data-testid="mcp-setup-dialog">
@@ -75,46 +180,34 @@ export function McpSetupDialog({
<div className="rounded-md border border-border bg-muted/30 px-3 py-3 font-mono text-xs text-foreground">
<div>~/.claude.json</div>
<div>~/.claude/mcp.json</div>
<div>~/.gemini/settings.json</div>
<div>~/.cursor/mcp.json</div>
<div>~/.config/mcp/mcp.json</div>
</div>
<div className="rounded-md border border-border bg-background px-3 py-3 font-mono text-xs leading-5 text-foreground">
<div>type: stdio</div>
<div>command: node</div>
<div>args: &lt;Tolaria resources&gt;/mcp-server/index.js</div>
<div>VAULT_PATH: active vault</div>
<div>WS_UI_PORT: 9711</div>
</div>
<ManualMcpConfigSection
error={manualConfigError}
loading={manualConfigLoading}
onCopy={onCopyManualConfig}
snippet={manualConfigSnippet}
/>
<p>
Claude Code CLI reads <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.claude.json</code>, Cursor reads <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.cursor/mcp.json</code>, and the generic <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.config/mcp/mcp.json</code> path is picked up by other MCP-compatible tools. Cancel leaves all files untouched, reconnect is idempotent, and disconnect removes Tolaria&apos;s entry again.
Claude Code CLI reads <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.claude.json</code>, Gemini CLI reads <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.gemini/settings.json</code>, Cursor reads <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.cursor/mcp.json</code>, and the generic <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.config/mcp/mcp.json</code> path is picked up by other MCP-compatible tools. Cancel leaves all files untouched, reconnect is idempotent, and disconnect removes Tolaria&apos;s entry again.
</p>
<p>
Gemini CLI needs its own install and sign-in. Use Restore Tolaria AI Guidance when you want a vault-root <code className="rounded bg-muted px-1 py-0.5 text-xs">GEMINI.md</code> compatibility shim that points Gemini back to the shared <code className="rounded bg-muted px-1 py-0.5 text-xs">AGENTS.md</code> instructions without overwriting custom guidance.
</p>
</div>
<DialogFooter className="flex-row items-center justify-end gap-2 sm:justify-end">
<Button type="button" variant="outline" onClick={onClose} disabled={buttonsDisabled}>
Cancel
</Button>
{copy.secondaryLabel ? (
<Button
type="button"
variant="destructive"
onClick={onDisconnect}
disabled={buttonsDisabled}
data-testid="mcp-setup-disconnect"
>
{disconnectBusy ? 'Disconnecting…' : copy.secondaryLabel}
</Button>
) : null}
<Button
type="button"
autoFocus
onClick={onConnect}
disabled={buttonsDisabled}
data-testid="mcp-setup-connect"
>
{connectBusy ? 'Connecting…' : copy.primaryLabel}
</Button>
</DialogFooter>
<McpSetupActions
buttonsDisabled={buttonsDisabled}
connectBusy={connectBusy}
disconnectBusy={disconnectBusy}
primaryLabel={copy.primaryLabel}
secondaryLabel={copy.secondaryLabel}
onClose={onClose}
onConnect={onConnect}
onDisconnect={onDisconnect}
/>
</DialogContent>
</Dialog>
)

View File

@@ -56,7 +56,7 @@ describe('NoteList virtualized datasets', () => {
expect(screen.getByText('Note 499')).toBeInTheDocument()
})
it('filters large datasets by search query', async () => {
it('filters large datasets by search query', { timeout: 15000 }, async () => {
vi.useFakeTimers()
try {
const entries = [

View File

@@ -58,6 +58,7 @@ describe('AiAgentsBadge', () => {
guidanceStatus={{
agentsState: 'missing',
claudeState: 'managed',
geminiState: 'managed',
canRestore: true,
}}
defaultAgent="claude_code"
@@ -88,6 +89,7 @@ describe('AiAgentsBadge', () => {
guidanceStatus={{
agentsState: 'managed',
claudeState: 'broken',
geminiState: 'managed',
canRestore: true,
}}
defaultAgent="claude_code"

View File

@@ -6,14 +6,20 @@ import { TolariaSideMenu } from './tolariaBlockNoteSideMenu'
let capturedDragHandleMenu: ComponentType | null = null
vi.mock('@blocknote/react', () => ({
AddBlockButton: () => <button type="button">Add block</button>,
DragHandleMenu: ({ children }: PropsWithChildren) => (
<div data-testid="drag-handle-menu">{children}</div>
),
RemoveBlockItem: ({ children }: PropsWithChildren) => <div>{children}</div>,
SideMenu: ({ dragHandleMenu }: { dragHandleMenu?: ComponentType }) => {
DragHandleButton: ({ dragHandleMenu }: { dragHandleMenu?: ComponentType }) => {
capturedDragHandleMenu = dragHandleMenu ?? null
return <div data-testid="side-menu" />
return (
<button type="button" draggable>
Open block menu
</button>
)
},
RemoveBlockItem: ({ children }: PropsWithChildren) => <div>{children}</div>,
SideMenu: ({ children }: PropsWithChildren) => <div data-testid="side-menu">{children}</div>,
TableColumnHeaderItem: ({ children }: PropsWithChildren) => <div>{children}</div>,
TableRowHeaderItem: ({ children }: PropsWithChildren) => <div>{children}</div>,
useDictionary: () => ({
@@ -32,6 +38,10 @@ describe('TolariaSideMenu', () => {
expect(screen.getByTestId('side-menu')).toBeInTheDocument()
expect(capturedDragHandleMenu).not.toBeNull()
expect(screen.getAllByRole('button').map((button) => button.textContent)).toEqual([
'Open block menu',
'Add block',
])
const DragHandleMenuComponent = capturedDragHandleMenu!
render(<DragHandleMenuComponent />)

View File

@@ -1,5 +1,7 @@
import {
AddBlockButton,
DragHandleMenu,
DragHandleButton,
RemoveBlockItem,
SideMenu,
TableColumnHeaderItem,
@@ -21,5 +23,10 @@ function TolariaDragHandleMenu() {
}
export function TolariaSideMenu(props: SideMenuProps) {
return <SideMenu {...props} dragHandleMenu={TolariaDragHandleMenu} />
return (
<SideMenu {...props}>
<DragHandleButton dragHandleMenu={TolariaDragHandleMenu} />
<AddBlockButton />
</SideMenu>
)
}

View File

@@ -9,6 +9,7 @@ describe('buildAiAgentCommands', () => {
vaultAiGuidanceStatus: {
agentsState: 'missing',
claudeState: 'managed',
geminiState: 'managed',
canRestore: true,
},
onRestoreVaultAiGuidance,
@@ -16,6 +17,7 @@ describe('buildAiAgentCommands', () => {
const command = commands.find((item) => item.id === 'restore-vault-ai-guidance')
expect(command).toBeDefined()
expect(command?.keywords).toContain('gemini')
command?.execute()
expect(onRestoreVaultAiGuidance).toHaveBeenCalledOnce()
})
@@ -25,6 +27,7 @@ describe('buildAiAgentCommands', () => {
vaultAiGuidanceStatus: {
agentsState: 'managed',
claudeState: 'managed',
geminiState: 'managed',
canRestore: false,
},
onRestoreVaultAiGuidance: vi.fn(),

View File

@@ -67,7 +67,7 @@ function restoreGuidanceCommands({
id: 'restore-vault-ai-guidance',
label: 'Restore Tolaria AI Guidance',
group: 'Settings',
keywords: aiAgentKeywords('ai', 'agent', 'guidance', 'restore', 'repair', 'agents'),
keywords: aiAgentKeywords('ai', 'agent', 'guidance', 'restore', 'repair', 'agents', 'gemini'),
enabled: true,
execute: () => onRestoreVaultAiGuidance(),
},

View File

@@ -126,4 +126,16 @@ describe('buildSettingsCommands', () => {
expect(listener).toHaveBeenCalledTimes(1)
window.removeEventListener(TOGGLE_GITIGNORED_VISIBILITY_EVENT, listener)
})
it('makes external AI setup discoverable for Gemini CLI', () => {
const onInstallMcp = vi.fn()
const command = findCommand('install-mcp', buildSettingsCommands({
onOpenSettings: vi.fn(),
onInstallMcp,
}))
expect(command?.keywords).toContain('gemini')
command?.execute()
expect(onInstallMcp).toHaveBeenCalledOnce()
})
})

View File

@@ -149,7 +149,7 @@ function buildMaintenanceCommands({
id: 'install-mcp',
label: mcpStatus === 'installed' ? 'Manage External AI Tools…' : 'Set Up External AI Tools…',
group: 'Settings',
keywords: ['mcp', 'ai', 'tools', 'external', 'setup', 'connect', 'disconnect', 'claude', 'codex', 'cursor', 'consent'],
keywords: ['mcp', 'ai', 'tools', 'external', 'setup', 'details', 'copy', 'export', 'manual', 'config', 'connect', 'disconnect', 'claude', 'gemini', 'codex', 'cursor', 'consent'],
enabled: true,
execute: () => onInstallMcp?.(),
},

View File

@@ -0,0 +1,38 @@
let fallbackBlockIdSequence = 0
function createEditorBlockId(): string {
const randomUUID = globalThis.crypto?.randomUUID
if (typeof randomUUID === 'function') return randomUUID.call(globalThis.crypto)
fallbackBlockIdSequence += 1
return `tolaria-block-${fallbackBlockIdSequence}`
}
function isEditorBlockRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function hasUsableBlockId(block: Record<string, unknown>): boolean {
return typeof block.id === 'string' && block.id.trim().length > 0
}
function repairEditorBlock(block: unknown): unknown {
if (!isEditorBlockRecord(block)) return block
const children = Array.isArray(block.children)
? repairMalformedEditorBlocks(block.children)
: block.children
const missingId = !hasUsableBlockId(block)
if (!missingId && children === block.children) return block
return {
...block,
...(missingId ? { id: createEditorBlockId() } : {}),
...(children === block.children ? {} : { children }),
}
}
export function repairMalformedEditorBlocks(blocks: unknown[]): unknown[] {
return blocks.map(repairEditorBlock)
}

View File

@@ -476,6 +476,48 @@ describe('useEditorTabSwap raw mode sync', () => {
expect(mockEditor.replaceBlocks).toHaveBeenCalled()
})
it('repairs parsed blocks with missing ids before applying them to the editor', async () => {
const tabA = makeTab('a.md', 'Note A')
const malformedTab = {
...makeTab('malformed.md', 'Malformed'),
content: '---\ntitle: Malformed\n---\n\n# Malformed\n\n- Parent\n - Child',
}
const { mockEditor, rerenderWith } = await createSwapHarness({
initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
setupEditor: (editor) => {
editor.tryParseMarkdownToBlocks.mockReturnValue([
{
type: 'bulletListItem',
content: [{ type: 'text', text: 'Parent', styles: {} }],
children: [
{
type: 'bulletListItem',
content: [{ type: 'text', text: 'Child', styles: {} }],
children: [],
},
],
},
])
},
})
mockEditor.replaceBlocks.mockClear()
await rerenderWith({ tabs: [malformedTab], activeTabPath: 'malformed.md' })
const appliedBlocks = mockEditor.replaceBlocks.mock.calls[0][1]
expect(appliedBlocks).toEqual([
expect.objectContaining({
id: expect.any(String),
children: [
expect.objectContaining({
id: expect.any(String),
}),
],
}),
])
})
it('ignores editor change events before the pending tab swap applies a new untitled note', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })

View File

@@ -7,6 +7,7 @@ import { injectMathInBlocks, preProcessMathMarkdown } from '../utils/mathMarkdow
import { injectMermaidInBlocks, preProcessMermaidMarkdown, serializeMermaidAwareBlocks } from '../utils/mermaidMarkdown'
import { failNoteOpenTrace, finishNoteOpenTrace } from '../utils/noteOpenPerformance'
import { resolveImageUrls, portableImageUrls } from '../utils/vaultImages'
import { repairMalformedEditorBlocks } from './editorBlockRepair'
import {
extractEditorBody,
getH1TextFromBlocks,
@@ -165,13 +166,21 @@ async function resolveBlocksForTarget(
const preprocessed = preProcessEditorMarkdown(body, vaultPath)
const fastPathBlocks = buildFastPathBlocks({ preprocessed })
if (fastPathBlocks) {
const nextState = { blocks: fastPathBlocks, scrollTop: 0, sourceContent: content }
const nextState = {
blocks: repairMalformedEditorBlocks(fastPathBlocks) as EditorBlocks,
scrollTop: 0,
sourceContent: content,
}
cacheEditorState(cache, targetPath, nextState)
return nextState
}
const parsed = normalizeParsedImageBlocks(await parseMarkdownBlocks(editor, preprocessed)) as EditorBlocks
const nextState = { blocks: injectEditorMarkdownBlocks(parsed), scrollTop: 0, sourceContent: content }
const nextState = {
blocks: repairMalformedEditorBlocks(injectEditorMarkdownBlocks(parsed)) as EditorBlocks,
scrollTop: 0,
sourceContent: content,
}
cacheEditorState(cache, targetPath, nextState)
return nextState
}
@@ -182,18 +191,19 @@ function applyBlocksToEditor(
scrollTop: number,
suppressChangeRef: MutableRefObject<boolean>,
) {
const safeBlocks = repairMalformedEditorBlocks(blocks) as EditorBlocks
suppressChangeRef.current = true
try {
const current = editor.document
if (current.length > 0 && blocks.length > 0) {
editor.replaceBlocks(current, blocks)
} else if (blocks.length > 0) {
editor.insertBlocks(blocks, current[0], 'before')
if (current.length > 0 && safeBlocks.length > 0) {
editor.replaceBlocks(current, safeBlocks)
} else if (safeBlocks.length > 0) {
editor.insertBlocks(safeBlocks, current[0], 'before')
}
} catch (err) {
console.error('applyBlocks failed, trying fallback:', err)
try {
const html = editor.blocksToHTMLLossy(blocks)
const html = editor.blocksToHTMLLossy(safeBlocks)
editor._tiptapEditor.commands.setContent(html)
} catch (err2) {
console.error('Fallback also failed:', err2)

View File

@@ -24,6 +24,14 @@ function renderSubject(onToast = vi.fn()) {
return renderHook(() => useMcpStatus('/vault', onToast))
}
function mockClipboard(writeText = vi.fn(() => Promise.resolve())) {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
return writeText
}
function mockStatusFlow(
initialStatus: 'installed' | 'not_installed',
overrides: Partial<Record<'register_mcp_tools' | 'remove_mcp_tools', unknown>> = {},
@@ -76,6 +84,7 @@ async function runMutationScenario({
describe('useMcpStatus', () => {
beforeEach(() => {
vi.clearAllMocks()
mockClipboard()
})
it('checks the active vault status without auto-registering on mount', async () => {
@@ -160,4 +169,47 @@ describe('useMcpStatus', () => {
expect(mockInvoke).toHaveBeenCalledWith(overrideKey, commandArgs)
expect(onToast).toHaveBeenCalledWith(expect.stringContaining(toastFragment))
})
it('loads the exact manual MCP config snippet for the active vault', async () => {
const snippet = JSON.stringify({ mcpServers: { tolaria: { type: 'stdio' } } })
mockCommands({
check_mcp_status: 'installed',
get_mcp_config_snippet: snippet,
})
const { result } = renderSubject()
await waitFor(() => {
expect(result.current.mcpStatus).toBe('installed')
})
await act(async () => {
await result.current.loadMcpConfigSnippet()
})
expect(result.current.mcpConfigSnippet).toBe(snippet)
expect(result.current.mcpConfigError).toBeNull()
expect(mockInvoke).toHaveBeenCalledWith('get_mcp_config_snippet', { vaultPath: '/vault' })
})
it('copies the manual MCP config snippet to the clipboard', async () => {
const writeText = mockClipboard()
const onToast = vi.fn()
const snippet = JSON.stringify({ mcpServers: { tolaria: { type: 'stdio' } } })
mockCommands({
check_mcp_status: 'not_installed',
get_mcp_config_snippet: snippet,
})
const { result } = renderSubject(onToast)
await waitFor(() => {
expect(result.current.mcpStatus).toBe('not_installed')
})
await act(async () => {
await expect(result.current.copyMcpConfig()).resolves.toBe(true)
})
expect(writeText).toHaveBeenCalledWith(snippet)
expect(onToast).toHaveBeenCalledWith('Tolaria MCP config copied to clipboard')
})
})

View File

@@ -1,49 +1,136 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
export type McpStatus = 'checking' | 'installed' | 'not_installed'
type ManualConfigSnippet = string
type McpCommand =
| 'check_mcp_status'
| 'get_mcp_config_snippet'
| 'register_mcp_tools'
| 'remove_mcp_tools'
type McpCommandResult = string
type McpStatusResponse = string
type ToastHandler = (msg: ToastMessage) => void
type ToastMessage = string
type VaultPath = string
function tauriCall<T>(command: string, args?: Record<string, unknown>): Promise<T> {
interface ManualMcpConfigState {
error: ToastMessage | null
loading: boolean
snippet: ManualConfigSnippet | null
vaultPath: VaultPath
}
const EMPTY_MANUAL_CONFIG: ManualMcpConfigState = {
error: null,
loading: false,
snippet: null,
vaultPath: '',
}
function tauriCall<T>(command: McpCommand, args?: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
function normalizeMcpStatus(value: string | null | undefined): McpStatus {
function normalizeMcpStatus(value: McpStatusResponse | null | undefined): McpStatus {
return value === 'installed' ? 'installed' : 'not_installed'
}
async function fetchMcpStatus(vaultPath: string): Promise<McpStatus> {
async function fetchMcpStatus(vaultPath: VaultPath): Promise<McpStatus> {
try {
const result = await tauriCall<string>('check_mcp_status', { vaultPath })
const result = await tauriCall<McpStatusResponse>('check_mcp_status', { vaultPath })
return normalizeMcpStatus(result)
} catch {
return 'not_installed'
}
}
function connectSuccessToast(result: string): string {
function connectSuccessToast(result: McpCommandResult): ToastMessage {
return result === 'registered'
? 'Tolaria external AI tools connected successfully'
: 'Tolaria external AI tools setup refreshed successfully'
}
function disconnectSuccessToast(result: string): string {
function disconnectSuccessToast(result: McpCommandResult): ToastMessage {
return result === 'removed'
? 'Tolaria external AI tools disconnected successfully'
: 'Tolaria external AI tools were already disconnected'
}
function errorMessage(error: unknown): ToastMessage {
return error instanceof Error ? error.message : String(error)
}
function visibleManualConfig(
state: ManualMcpConfigState,
vaultPath: VaultPath,
): ManualMcpConfigState {
return state.vaultPath === vaultPath ? state : EMPTY_MANUAL_CONFIG
}
async function writeClipboardText(value: ManualConfigSnippet): Promise<void> {
if (!navigator.clipboard?.writeText) {
throw new Error('Clipboard API is unavailable')
}
await navigator.clipboard.writeText(value)
}
function useManualMcpConfig(
vaultPath: VaultPath,
onToastRef: MutableRefObject<ToastHandler>,
) {
const [manualConfig, setManualConfig] = useState<ManualMcpConfigState>(EMPTY_MANUAL_CONFIG)
const loadMcpConfigSnippet = useCallback(async () => {
setManualConfig({ error: null, loading: true, snippet: null, vaultPath })
try {
const snippet = await tauriCall<ManualConfigSnippet>('get_mcp_config_snippet', { vaultPath })
setManualConfig({ error: null, loading: false, snippet, vaultPath })
return snippet
} catch (error) {
const message = errorMessage(error)
setManualConfig({ error: message, loading: false, snippet: null, vaultPath })
throw error
}
}, [vaultPath])
const copyMcpConfig = useCallback(async () => {
try {
const snippet = await loadMcpConfigSnippet()
await writeClipboardText(snippet)
onToastRef.current('Tolaria MCP config copied to clipboard')
return true
} catch (error) {
onToastRef.current(`Copy MCP config failed: ${errorMessage(error)}`)
return false
}
}, [loadMcpConfigSnippet, onToastRef])
const currentManualConfig = visibleManualConfig(manualConfig, vaultPath)
return {
mcpConfigSnippet: currentManualConfig.snippet,
mcpConfigLoading: currentManualConfig.loading,
mcpConfigError: currentManualConfig.error,
loadMcpConfigSnippet,
copyMcpConfig,
}
}
/**
* Detects whether the active vault is explicitly connected to external MCP
* clients and exposes connect / disconnect actions.
*/
export function useMcpStatus(
vaultPath: string,
onToast: (msg: string) => void,
vaultPath: VaultPath,
onToast: ToastHandler,
) {
const [status, setStatus] = useState<McpStatus>('checking')
const onToastRef = useRef(onToast)
useEffect(() => { onToastRef.current = onToast })
const manualConfigActions = useManualMcpConfig(vaultPath, onToastRef)
const refreshMcpStatus = useCallback(async () => {
const nextStatus = await fetchMcpStatus(vaultPath)
@@ -91,5 +178,11 @@ export function useMcpStatus(
}
}, [refreshMcpStatus])
return { mcpStatus: status, refreshMcpStatus, connectMcp, disconnectMcp }
return {
mcpStatus: status,
refreshMcpStatus,
connectMcp,
disconnectMcp,
...manualConfigActions,
}
}

View File

@@ -74,11 +74,17 @@ describe('useNoteActions hook', () => {
return renderHook(() => useNoteActions(makeConfig(entries)))
}
function createImmediateEntry(type?: string) {
async function flushAsyncWork() {
await Promise.resolve()
await Promise.resolve()
}
async function createImmediateEntry(type?: string) {
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
const { result } = renderActions()
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate(type)
await flushAsyncWork()
})
const [createdEntry] = addEntry.mock.calls[0]
vi.restoreAllMocks()
@@ -194,25 +200,29 @@ describe('useNoteActions hook', () => {
expect(setToastMessage).toHaveBeenCalledWith('Property deleted')
})
it('handleCreateNoteImmediate creates note with timestamp-based title', () => {
const createdEntry = createImmediateEntry()
it('handleCreateNoteImmediate creates note with timestamp-based title', async () => {
const createdEntry = await createImmediateEntry()
expect(createdEntry.title).toBe('Untitled Note 1700000000')
expect(createdEntry.filename).toBe('untitled-note-1700000000.md')
expect(createdEntry.isA).toBe('Note')
})
it('handleCreateNoteImmediate generates unique names on rapid calls via timestamp', () => {
it('handleCreateNoteImmediate generates unique names on rapid calls via timestamp', async () => {
vi.useFakeTimers()
let ts = 1700000000000
vi.spyOn(Date, 'now').mockImplementation(() => { ts += 1000; return ts })
const { result } = renderHook(() => useNoteActions(makeConfig()))
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
await flushAsyncWork()
})
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 2)
await flushAsyncWork()
})
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 2) })
expect(addEntry).toHaveBeenCalledTimes(3)
const filenames = addEntry.mock.calls.map(([e]: [VaultEntry]) => e.filename)
@@ -224,8 +234,8 @@ describe('useNoteActions hook', () => {
vi.restoreAllMocks()
})
it('handleCreateNoteImmediate accepts custom type', () => {
const createdEntry = createImmediateEntry('Project')
it('handleCreateNoteImmediate accepts custom type', async () => {
const createdEntry = await createImmediateEntry('Project')
expect(createdEntry.filename).toMatch(/^untitled-project-\d+\.md$/)
expect(createdEntry.isA).toBe('Project')
})
@@ -255,40 +265,29 @@ describe('useNoteActions hook', () => {
expect(tabContent).toContain('## Steps')
})
it('handleCreateNoteImmediate does not throw for custom types with special characters', () => {
it.each([
['Q&A', (entry: VaultEntry) => { expect(entry.isA).toBe('Q&A') }],
['+++', (entry: VaultEntry) => { expect(entry.filename).not.toBe('.md') }],
])('handleCreateNoteImmediate handles custom type "%s"', async (typeName, assertEntry) => {
const { result } = renderHook(() => useNoteActions(makeConfig()))
expect(() => {
act(() => {
result.current.handleCreateNoteImmediate('Q&A')
})
}).not.toThrow()
const [entry] = addEntry.mock.calls[0]
expect(entry.isA).toBe('Q&A')
expect(entry.path).not.toContain('//')
})
it('handleCreateNoteImmediate does not throw for types that slugify to empty string', () => {
const { result } = renderHook(() => useNoteActions(makeConfig()))
expect(() => {
act(() => {
result.current.handleCreateNoteImmediate('+++')
})
}).not.toThrow()
await act(async () => {
expect(() => { result.current.handleCreateNoteImmediate(typeName) }).not.toThrow()
await flushAsyncWork()
})
const [entry] = addEntry.mock.calls[0]
expect(entry.path).not.toContain('//')
expect(entry.filename).not.toBe('.md')
assertEntry(entry)
})
it('handleCreateNoteImmediate uses template for typed notes', () => {
it('handleCreateNoteImmediate uses template for typed notes', async () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', template: '## Custom Template\n\n' })
const { result } = renderHook(() => useNoteActions(makeConfig([typeEntry])))
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate('Project')
await flushAsyncWork()
})
const tabContent = result.current.tabs[0].content
@@ -307,7 +306,15 @@ describe('useNoteActions hook', () => {
})
describe('pending save lifecycle', () => {
it('createAndPersist calls addPendingSave on start (non-Tauri)', async () => {
it.each([
['start', 'Pending Test', 'pending-test.md', 'addPendingSave'],
['completion', 'Persist OK', 'persist-ok.md', 'removePendingSave'],
])('createAndPersist calls pending-save callback on %s (non-Tauri)', async (
_phase,
title,
pathFragment,
callbackName,
) => {
const addPendingSave = vi.fn()
const removePendingSave = vi.fn()
const config = makeConfig()
@@ -317,28 +324,12 @@ describe('useNoteActions hook', () => {
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
result.current.handleCreateNote('Pending Test', 'Note')
await new Promise((r) => setTimeout(r, 0))
result.current.handleCreateNote(title, 'Note')
await flushAsyncWork()
})
expect(addPendingSave).toHaveBeenCalledWith(expect.stringContaining('pending-test.md'))
})
it('createAndPersist calls removePendingSave when persist completes (non-Tauri)', async () => {
const addPendingSave = vi.fn()
const removePendingSave = vi.fn()
const config = makeConfig()
config.addPendingSave = addPendingSave
config.removePendingSave = removePendingSave
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
result.current.handleCreateNote('Persist OK', 'Note')
await new Promise((r) => setTimeout(r, 0))
})
expect(removePendingSave).toHaveBeenCalledWith(expect.stringContaining('persist-ok.md'))
const callback = callbackName === 'addPendingSave' ? addPendingSave : removePendingSave
expect(callback).toHaveBeenCalledWith(expect.stringContaining(pathFragment))
})
it('createAndPersist calls removePendingSave AND reverts when persist fails (Tauri)', async () => {
@@ -363,22 +354,34 @@ describe('useNoteActions hook', () => {
expect(setToastMessage).toHaveBeenCalledWith('Failed to create note — disk write error')
})
it('handleCreateNoteImmediate calls trackUnsaved and markContentPending (no disk write)', async () => {
const trackUnsaved = vi.fn()
const markContentPending = vi.fn()
it('handleCreateNoteImmediate creates the backing file before opening the note', async () => {
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(invoke).mockResolvedValueOnce(undefined)
const addPendingSave = vi.fn()
const removePendingSave = vi.fn()
const onNewNotePersisted = vi.fn()
const config = makeConfig()
config.trackUnsaved = trackUnsaved
config.markContentPending = markContentPending
config.addPendingSave = addPendingSave
config.removePendingSave = removePendingSave
config.onNewNotePersisted = onNewNotePersisted
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
result.current.handleCreateNoteImmediate()
await new Promise((r) => setTimeout(r, 0))
await flushAsyncWork()
})
expect(trackUnsaved).toHaveBeenCalledWith(expect.stringMatching(/untitled-note-\d+\.md$/))
expect(markContentPending).toHaveBeenCalledWith(expect.stringMatching(/untitled-note-\d+\.md$/), expect.stringContaining('type: Note'))
const createdPath = expect.stringMatching(/untitled-note-\d+\.md$/)
expect(vi.mocked(invoke)).toHaveBeenCalledWith('create_note_content', {
path: createdPath,
content: expect.stringContaining('type: Note'),
})
expect(addPendingSave).toHaveBeenCalledWith(createdPath)
expect(removePendingSave).toHaveBeenCalledWith(createdPath)
expect(onNewNotePersisted).toHaveBeenCalledOnce()
expect(addEntry).toHaveBeenCalledTimes(1)
expect(result.current.tabs[0].entry.path).toMatch(/untitled-note-\d+\.md$/)
})
it('calls onNewNotePersisted after successful disk write (non-Tauri)', async () => {
@@ -454,20 +457,24 @@ describe('useNoteActions hook', () => {
expect(setToastMessage).not.toHaveBeenCalled()
})
it('handleCreateNoteImmediate does not call invoke (no disk write)', async () => {
it('handleCreateNoteImmediate writes each rapid note before opening it', async () => {
vi.useFakeTimers()
vi.mocked(invoke).mockResolvedValue(undefined)
const { result } = renderHook(() => useNoteActions(makeConfig()))
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
await flushAsyncWork()
})
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 2)
await flushAsyncWork()
})
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 2) })
expect(addEntry).toHaveBeenCalledTimes(3)
// No disk writes for immediate creation — notes are unsaved/in-memory
expect(invoke).not.toHaveBeenCalled()
expect(vi.mocked(invoke).mock.calls.filter(([command]) => command === 'create_note_content')).toHaveLength(3)
expect(removeEntry).not.toHaveBeenCalled()
})

View File

@@ -211,6 +211,10 @@ describe('useNoteCreation hook', () => {
})
const tabDeps = { openTabWithContent }
const flushImmediateCreate = async () => {
await Promise.resolve()
await Promise.resolve()
}
beforeEach(() => {
vi.clearAllMocks()
@@ -230,10 +234,13 @@ describe('useNoteCreation hook', () => {
expect(openTabWithContent.mock.calls[0][1]).toBe('---\ntitle: Test Note\ntype: Note\n---\n')
})
it('handleCreateNoteImmediate generates timestamp-based title', () => {
it('handleCreateNoteImmediate generates timestamp-based title', async () => {
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => { result.current.handleCreateNoteImmediate() })
await act(async () => {
result.current.handleCreateNoteImmediate()
await flushImmediateCreate()
})
expect(addEntry).toHaveBeenCalledTimes(1)
expect(addEntry.mock.calls[0][0].title).toBe('Untitled Note 1700000000')
expect(addEntry.mock.calls[0][0].filename).toBe('untitled-note-1700000000.md')
@@ -242,17 +249,21 @@ describe('useNoteCreation hook', () => {
vi.restoreAllMocks()
})
it('handleCreateNoteImmediate generates unique names on rapid calls via timestamp', () => {
it('handleCreateNoteImmediate generates unique names on rapid calls via timestamp', async () => {
vi.useFakeTimers()
let ts = 1700000000000
vi.spyOn(Date, 'now').mockImplementation(() => { ts += 1000; return ts })
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
await flushImmediateCreate()
})
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 2)
await flushImmediateCreate()
})
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 2) })
const filenames = addEntry.mock.calls.map(([e]: [VaultEntry]) => e.filename)
// Each call consumes Date.now() multiple times (filename + buildNewEntry), so just verify uniqueness
expect(new Set(filenames).size).toBe(3)
@@ -262,16 +273,20 @@ describe('useNoteCreation hook', () => {
vi.restoreAllMocks()
})
it('handleCreateNoteImmediate avoids filename collisions when called twice in the same second', () => {
it('handleCreateNoteImmediate avoids filename collisions when called twice in the same second', async () => {
vi.useFakeTimers()
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
await flushImmediateCreate()
})
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS)
await flushImmediateCreate()
})
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS) })
const filenames = addEntry.mock.calls.map(([entry]: [VaultEntry]) => entry.filename)
expect(filenames).toEqual([
@@ -282,66 +297,117 @@ describe('useNoteCreation hook', () => {
vi.restoreAllMocks()
})
it('serializes rapid immediate-create bursts after the first note', () => {
it('serializes rapid immediate-create bursts after the first note', async () => {
vi.useFakeTimers()
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
await flushImmediateCreate()
})
expect(addEntry).toHaveBeenCalledTimes(1)
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS) })
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS)
await flushImmediateCreate()
})
expect(addEntry).toHaveBeenCalledTimes(2)
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS) })
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS)
await flushImmediateCreate()
})
expect(addEntry).toHaveBeenCalledTimes(3)
vi.restoreAllMocks()
})
it('handleCreateNoteImmediate accepts custom type', () => {
it('handleCreateNoteImmediate accepts custom type', async () => {
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => { result.current.handleCreateNoteImmediate('Project') })
await act(async () => {
result.current.handleCreateNoteImmediate('Project')
await flushImmediateCreate()
})
expect(addEntry.mock.calls[0][0].isA).toBe('Project')
expect(addEntry.mock.calls[0][0].status).toBeNull()
expect(openTabWithContent.mock.calls[0][1]).toBe('---\ntype: Project\n---\n\n# \n\n## Objective\n\n\n\n## Key Results\n\n\n\n## Notes\n\n')
})
it('handleCreateNoteImmediate slugifies custom type names for filenames', () => {
it('handleCreateNoteImmediate slugifies custom type names for filenames', async () => {
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate('Q&A / Ops')
await flushImmediateCreate()
})
expect(addEntry.mock.calls[0][0].filename).toBe('untitled-q-a-ops-1700000000.md')
vi.restoreAllMocks()
})
it('handleCreateNoteImmediate tracks unsaved state', async () => {
const trackUnsaved = vi.fn()
const markContentPending = vi.fn()
const config = makeConfig()
config.trackUnsaved = trackUnsaved
config.markContentPending = markContentPending
it('handleCreateNoteImmediate creates the backing file before opening the note', async () => {
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(invoke).mockResolvedValueOnce(undefined)
const addPendingSave = vi.fn()
const removePendingSave = vi.fn()
const onNewNotePersisted = vi.fn()
const config = {
...makeConfig(),
addPendingSave,
removePendingSave,
onNewNotePersisted,
}
const { result } = renderHook(() => useNoteCreation(config, tabDeps))
await act(async () => { result.current.handleCreateNoteImmediate() })
expect(trackUnsaved).toHaveBeenCalledWith(expect.stringMatching(/untitled-note-\d+\.md$/))
expect(markContentPending).toHaveBeenCalled()
await act(async () => {
result.current.handleCreateNoteImmediate()
await flushImmediateCreate()
})
const createdPath = expect.stringMatching(/untitled-note-\d+\.md$/)
expect(vi.mocked(invoke)).toHaveBeenCalledWith('create_note_content', {
path: createdPath,
content: expect.stringContaining('type: Note'),
})
expect(addPendingSave).toHaveBeenCalledWith(createdPath)
expect(removePendingSave).toHaveBeenCalledWith(createdPath)
expect(onNewNotePersisted).toHaveBeenCalledOnce()
expect(addEntry).toHaveBeenCalledTimes(1)
expect(openTabWithContent).toHaveBeenCalledTimes(1)
expect(vi.mocked(invoke).mock.invocationCallOrder[0]).toBeLessThan(
openTabWithContent.mock.invocationCallOrder[0],
)
})
it('handleCreateNoteImmediate requests editor focus for the new path', () => {
it('handleCreateNoteImmediate does not open an optimistic note when disk creation fails', async () => {
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(invoke).mockRejectedValueOnce(new Error('disk full'))
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
await act(async () => {
result.current.handleCreateNoteImmediate()
await flushImmediateCreate()
})
expect(addEntry).not.toHaveBeenCalled()
expect(openTabWithContent).not.toHaveBeenCalled()
expect(setToastMessage).toHaveBeenCalledWith('Failed to create note — disk write error')
})
it('handleCreateNoteImmediate requests editor focus for the new path', async () => {
const focusListener = vi.fn()
window.addEventListener('laputa:focus-editor', focusListener)
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => { result.current.handleCreateNoteImmediate() })
await act(async () => {
result.current.handleCreateNoteImmediate()
await flushImmediateCreate()
})
expect(focusListener).toHaveBeenCalledTimes(1)
const event = focusListener.mock.calls[0][0] as CustomEvent

View File

@@ -1,10 +1,11 @@
import { useCallback, useEffect, useRef } from 'react'
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, addMockEntry, mockInvoke } from '../mock-tauri'
import type { VaultEntry } from '../types'
import { slugifyNoteStem as slugify } from '../utils/noteSlug'
import { resolveEntry } from '../utils/wikilink'
import { trackEvent } from '../lib/telemetry'
import { cacheNoteContent } from './useTabManagement'
export interface NewEntryParams {
path: string
@@ -370,13 +371,15 @@ async function createTypeSilently({
}
interface ImmediateCreateDeps {
addPendingSave?: (path: string) => void
entries: VaultEntry[]
vaultPath: string
pendingSlugs: Set<string>
openTabWithContent: (entry: VaultEntry, content: string) => void
addEntry: (entry: VaultEntry) => void
trackUnsaved?: (path: string) => void
markContentPending?: (path: string, content: string) => void
onNewNotePersisted?: () => void
removePendingSave?: (path: string) => void
setToastMessage: (msg: string | null) => void
}
interface ImmediateCreateRequest {
@@ -384,12 +387,14 @@ interface ImmediateCreateRequest {
}
interface ImmediateCreateQueueConfig {
addPendingSave?: (path: string) => void
entries: VaultEntry[]
vaultPath: string
addEntry: (entry: VaultEntry) => void
openTabWithContent: (entry: VaultEntry, content: string) => void
trackUnsaved?: (path: string) => void
markContentPending?: (path: string, content: string) => void
onNewNotePersisted?: () => void
removePendingSave?: (path: string) => void
setToastMessage: (msg: string | null) => void
}
/** Generate a unique untitled filename using a timestamp. */
@@ -410,8 +415,26 @@ function generateUntitledFilename(entries: VaultEntry[], type: string, pendingSl
return candidate
}
/** Create an untitled note without persisting to disk (deferred save). */
function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): void {
async function persistImmediateEntry(
deps: ImmediateCreateDeps,
entry: VaultEntry,
content: string,
): Promise<boolean> {
try {
await persistOptimistic(entry.path, content, {
onStart: deps.addPendingSave,
onEnd: deps.removePendingSave,
onPersisted: deps.onNewNotePersisted,
})
return true
} catch (error) {
deps.setToastMessage(createPersistFailureMessage(entry, error))
return false
}
}
/** Create an untitled note and write its backing file before opening it. */
async function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): Promise<boolean> {
const noteType = type || 'Note'
const slug = generateUntitledFilename(deps.entries, noteType, deps.pendingSlugs)
const title = slug_to_title(slug)
@@ -419,11 +442,68 @@ function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): void {
const status = null
const entry = buildNewEntry({ path: `${deps.vaultPath}/${slug}.md`, slug, title, type: noteType, status })
const content = buildNoteContent({ title: null, type: noteType, status, template, initialEmptyHeading: true })
const didPersist = await persistImmediateEntry(deps, entry, content)
if (!didPersist) return false
cacheNoteContent(entry.path, content)
deps.openTabWithContent(entry, content)
addEntryWithMock(entry, content, deps.addEntry)
deps.trackUnsaved?.(entry.path)
deps.markContentPending?.(entry.path, content)
signalFocusEditor({ path: entry.path, selectTitle: true })
return true
}
function trackImmediateCreate(request: ImmediateCreateRequest, didCreate: boolean): void {
if (!didCreate) return
trackEvent('note_created', {
has_type: request.type ? 1 : 0,
creation_path: request.type ? 'type_section' : 'cmd_n',
})
}
function useLatestImmediateCreateDeps(
config: ImmediateCreateQueueConfig,
pendingSlugsRef: MutableRefObject<Set<string>>,
) {
const {
entries,
vaultPath,
openTabWithContent,
addEntry,
addPendingSave,
onNewNotePersisted,
removePendingSave,
setToastMessage,
} = config
const latestDepsRef = useRef<ImmediateCreateDeps | null>(null)
const syncDeps = useCallback(() => {
latestDepsRef.current = {
entries,
vaultPath,
pendingSlugs: pendingSlugsRef.current,
openTabWithContent,
addEntry,
addPendingSave,
onNewNotePersisted,
removePendingSave,
setToastMessage,
}
}, [
entries,
vaultPath,
openTabWithContent,
addEntry,
addPendingSave,
onNewNotePersisted,
removePendingSave,
setToastMessage,
pendingSlugsRef,
])
useEffect(() => {
syncDeps()
}, [syncDeps])
return { latestDepsRef, syncDeps }
}
function useImmediateCreateQueue(config: ImmediateCreateQueueConfig): (type?: string) => void {
@@ -431,40 +511,13 @@ function useImmediateCreateQueue(config: ImmediateCreateQueueConfig): (type?: st
const queuedImmediateCreatesRef = useRef<ImmediateCreateRequest[]>([])
const immediateCreateLockedRef = useRef(false)
const immediateCreateTimerRef = useRef<number | null>(null)
const latestDepsRef = useRef<ImmediateCreateDeps | null>(null)
const syncDeps = useCallback(() => {
latestDepsRef.current = {
entries: config.entries,
vaultPath: config.vaultPath,
pendingSlugs: pendingSlugsRef.current,
openTabWithContent: config.openTabWithContent,
addEntry: config.addEntry,
trackUnsaved: config.trackUnsaved,
markContentPending: config.markContentPending,
}
}, [
config.entries,
config.vaultPath,
config.openTabWithContent,
config.addEntry,
config.trackUnsaved,
config.markContentPending,
])
useEffect(() => {
syncDeps()
}, [syncDeps])
const { latestDepsRef, syncDeps } = useLatestImmediateCreateDeps(config, pendingSlugsRef)
const executeRequest = useCallback((request: ImmediateCreateRequest) => {
const deps = latestDepsRef.current
if (!deps) return
createNoteImmediate(deps, request.type)
trackEvent('note_created', {
has_type: request.type ? 1 : 0,
creation_path: request.type ? 'type_section' : 'cmd_n',
})
}, [])
void createNoteImmediate(deps, request.type).then((didCreate) => trackImmediateCreate(request, didCreate))
}, [latestDepsRef])
const scheduleQueuedBurst = useCallback(function scheduleQueuedBurst() {
if (immediateCreateTimerRef.current !== null) return
@@ -577,9 +630,11 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
entries,
vaultPath,
addEntry,
addPendingSave,
openTabWithContent,
trackUnsaved: config.trackUnsaved,
markContentPending: config.markContentPending,
onNewNotePersisted,
removePendingSave,
setToastMessage,
})
return {

View File

@@ -22,6 +22,7 @@ describe('useVaultAiGuidanceStatus', () => {
mockInvoke.mockResolvedValue({
agents_state: 'managed',
claude_state: 'broken',
gemini_state: 'missing',
can_restore: true,
})
@@ -29,11 +30,13 @@ describe('useVaultAiGuidanceStatus', () => {
expect(result.current.status.agentsState).toBe('checking')
expect(result.current.status.claudeState).toBe('checking')
expect(result.current.status.geminiState).toBe('checking')
await waitFor(() => {
expect(result.current.status).toEqual({
agentsState: 'managed',
claudeState: 'broken',
geminiState: 'missing',
canRestore: true,
})
})
@@ -44,11 +47,13 @@ describe('useVaultAiGuidanceStatus', () => {
.mockResolvedValueOnce({
agents_state: 'managed',
claude_state: 'managed',
gemini_state: 'managed',
can_restore: false,
})
.mockResolvedValueOnce({
agents_state: 'managed',
claude_state: 'broken',
gemini_state: 'managed',
can_restore: true,
})
@@ -65,6 +70,7 @@ describe('useVaultAiGuidanceStatus', () => {
expect(result.current.status).toEqual({
agentsState: 'managed',
claudeState: 'broken',
geminiState: 'managed',
canRestore: true,
})
})

View File

@@ -11,6 +11,7 @@ import {
type RawVaultAiGuidanceStatus = Partial<{
agents_state: VaultAiGuidanceFileState | null
claude_state: VaultAiGuidanceFileState | null
gemini_state: VaultAiGuidanceFileState | null
can_restore: boolean | null
}>

View File

@@ -3,6 +3,7 @@ import { afterEach, describe, it, expect, vi } from 'vitest'
const sentryMocks = vi.hoisted(() => ({
close: vi.fn(),
init: vi.fn(),
setTag: vi.fn(),
setUser: vi.fn(),
}))
@@ -22,6 +23,7 @@ afterEach(() => {
vi.unstubAllEnvs()
sentryMocks.close.mockClear()
sentryMocks.init.mockClear()
sentryMocks.setTag.mockClear()
sentryMocks.setUser.mockClear()
})
@@ -69,17 +71,23 @@ describe('trackEvent', () => {
})
describe('initSentry', () => {
it('passes the configured build release to Sentry', () => {
it.each([
['stable builds', '2026.4.23', '2026.4.23', 'stable'],
['alpha builds', '2026.4.28-alpha.7', undefined, 'prerelease'],
['local builds', '0.1.0', undefined, 'internal'],
])('sets release metadata for %s', (_name, buildVersion, sentryRelease, releaseKind) => {
vi.stubEnv('VITE_SENTRY_DSN', 'https://public@example.ingest.sentry.io/123456')
vi.stubEnv('VITE_SENTRY_RELEASE', '2026.4.23')
vi.stubEnv('VITE_SENTRY_RELEASE', buildVersion)
initSentry('anonymous-user')
expect(sentryMocks.init).toHaveBeenCalledWith(expect.objectContaining({
dsn: 'https://public@example.ingest.sentry.io/123456',
release: '2026.4.23',
release: sentryRelease,
}))
expect(sentryMocks.setUser).toHaveBeenCalledWith({ id: 'anonymous-user' })
expect(sentryMocks.setTag).toHaveBeenCalledWith('tolaria.build_version', buildVersion)
expect(sentryMocks.setTag).toHaveBeenCalledWith('tolaria.release_kind', releaseKind)
})
})

View File

@@ -25,7 +25,7 @@ let posthogInstance: typeof import('posthog-js').default | null = null
export function initSentry(anonymousId: string): void {
if (sentryInitialized) return
const { sentryDsn, sentryRelease } = resolveFrontendTelemetryConfig()
const { sentryDsn, sentryBuildVersion, sentryRelease } = resolveFrontendTelemetryConfig()
if (!sentryDsn) return
Sentry.init({
@@ -35,6 +35,14 @@ export function initSentry(anonymousId: string): void {
beforeSend: scrubSentryEvent,
})
Sentry.setUser({ id: anonymousId })
if (sentryBuildVersion) {
const releaseKind = sentryRelease
? 'stable'
: sentryBuildVersion.includes('-') ? 'prerelease' : 'internal'
Sentry.setTag('tolaria.build_version', sentryBuildVersion)
Sentry.setTag('tolaria.release_kind', releaseKind)
}
sentryInitialized = true
}

View File

@@ -48,6 +48,7 @@ describe('resolveFrontendTelemetryConfig', () => {
},
expected: {
sentryDsn: 'https://public@example.ingest.sentry.io/123456',
sentryBuildVersion: '2026.4.23',
sentryRelease: '2026.4.23',
posthogKey: 'phc_test_key',
posthogHost: 'https://eu.i.posthog.com',
@@ -62,6 +63,7 @@ describe('resolveFrontendTelemetryConfig', () => {
},
expected: {
sentryDsn: 'https://public@example.ingest.sentry.io/123456',
sentryBuildVersion: '2026.4.23',
sentryRelease: '2026.4.23',
posthogKey: 'phc_test_key',
posthogHost: 'https://eu.i.posthog.com',
@@ -83,6 +85,24 @@ describe('resolveFrontendTelemetryConfig', () => {
expect(resolveConfig({ VITE_SENTRY_RELEASE: 'false' }).sentryRelease).toBe('')
})
it('keeps stable calendar versions as Sentry releases', () => {
expect(resolveConfig({ VITE_SENTRY_RELEASE: '2026.4.28' }).sentryRelease).toBe('2026.4.28')
})
it('drops prerelease versions from the Sentry release field', () => {
expect(resolveConfig({ VITE_SENTRY_RELEASE: '2026.4.28-alpha.7' })).toMatchObject({
sentryBuildVersion: '2026.4.28-alpha.7',
sentryRelease: '',
})
})
it('drops local development versions from the Sentry release field', () => {
expect(resolveConfig({ VITE_SENTRY_RELEASE: '0.1.0' })).toMatchObject({
sentryBuildVersion: '0.1.0',
sentryRelease: '',
})
})
it('drops invalid PostHog hosts instead of loading scripts from them', () => {
expect(resolveConfig({ VITE_POSTHOG_HOST: 'not a url' }).posthogHost).toBeNull()
})
@@ -93,6 +113,7 @@ describe('resolveFrontendTelemetryConfig', () => {
VITE_POSTHOG_HOST: 'false',
})).toEqual({
sentryDsn: '',
sentryBuildVersion: '2026.4.23',
sentryRelease: '2026.4.23',
posthogKey: 'phc_test_key',
posthogHost: null,
@@ -105,6 +126,7 @@ describe('resolveFrontendTelemetryConfig', () => {
VITE_POSTHOG_HOST: 'https://le',
})).toEqual({
sentryDsn: '',
sentryBuildVersion: '2026.4.23',
sentryRelease: '2026.4.23',
posthogKey: 'phc_test_key',
posthogHost: null,
@@ -115,6 +137,7 @@ describe('resolveFrontendTelemetryConfig', () => {
VITE_POSTHOG_HOST: 'http://localhost:8010',
})).toEqual({
sentryDsn: 'http://public@localhost:9000/123456',
sentryBuildVersion: '2026.4.23',
sentryRelease: '2026.4.23',
posthogKey: 'phc_test_key',
posthogHost: 'http://localhost:8010',

View File

@@ -17,6 +17,7 @@ type TelemetryEnv = {
export type FrontendTelemetryConfig = {
sentryDsn: string
sentryBuildVersion: string
sentryRelease: string
posthogKey: string
posthogHost: string | null
@@ -87,8 +88,18 @@ function normalizeSentryDsn(value: string): string {
}
function normalizeSentryRelease(value: string): string {
if (!value) return ''
return DISALLOWED_TELEMETRY_VALUES.has(value.toLowerCase()) ? '' : value
const match = /^(\d{4})\.(\d{1,2})\.(\d{1,2})$/.exec(value)
if (!match) return ''
const year = Number(match[1])
const month = Number(match[2])
const day = Number(match[3])
const date = new Date(Date.UTC(year, month - 1, day))
const validDate = date.getUTCFullYear() === year
&& date.getUTCMonth() === month - 1
&& date.getUTCDate() === day
return validDate ? value : ''
}
function normalizePostHogHost(value: string): string | null {
@@ -103,15 +114,17 @@ export function resolveFrontendTelemetryConfig(
const sentryDsn = normalizeSentryDsn(
sanitizeTelemetryEnvValue(env.VITE_SENTRY_DSN),
)
const sentryRelease = normalizeSentryRelease(
sanitizeTelemetryEnvValue(env.VITE_SENTRY_RELEASE),
)
const sanitizedSentryVersion = sanitizeTelemetryEnvValue(env.VITE_SENTRY_RELEASE)
const sentryBuildVersion = DISALLOWED_TELEMETRY_VALUES.has(sanitizedSentryVersion.toLowerCase())
? ''
: sanitizedSentryVersion
const sentryRelease = normalizeSentryRelease(sentryBuildVersion)
const posthogKey = sanitizeTelemetryEnvValue(env.VITE_POSTHOG_KEY)
const posthogHost = normalizePostHogHost(
sanitizeTelemetryEnvValue(env.VITE_POSTHOG_HOST),
)
return { sentryDsn, sentryRelease, posthogKey, posthogHost }
return { sentryDsn, sentryBuildVersion, sentryRelease, posthogKey, posthogHost }
}
export { DEFAULT_POSTHOG_HOST as _defaultPostHogHostForTest }

View File

@@ -51,6 +51,7 @@ describe('vaultAiGuidance helpers', () => {
expect(createCheckingVaultAiGuidanceStatus()).toEqual({
agentsState: 'checking',
claudeState: 'checking',
geminiState: 'checking',
canRestore: false,
})
})
@@ -59,10 +60,12 @@ describe('vaultAiGuidance helpers', () => {
expect(normalizeVaultAiGuidanceStatus({
agents_state: 'managed',
claude_state: 'broken',
gemini_state: 'missing',
can_restore: true,
})).toEqual({
agentsState: 'managed',
claudeState: 'broken',
geminiState: 'missing',
canRestore: true,
})
})
@@ -71,11 +74,13 @@ describe('vaultAiGuidance helpers', () => {
const restoreable = normalizeVaultAiGuidanceStatus({
agents_state: 'missing',
claude_state: 'managed',
gemini_state: 'managed',
can_restore: true,
})
const custom = normalizeVaultAiGuidanceStatus({
agents_state: 'custom',
claude_state: 'managed',
gemini_state: 'managed',
can_restore: false,
})
@@ -85,13 +90,34 @@ describe('vaultAiGuidance helpers', () => {
expect(getVaultAiGuidanceSummary(custom)).toBe('Using custom AGENTS.md')
})
it('summarizes optional Gemini guidance states', () => {
const missing = normalizeVaultAiGuidanceStatus({
agents_state: 'managed',
claude_state: 'managed',
gemini_state: 'missing',
can_restore: true,
})
const custom = normalizeVaultAiGuidanceStatus({
agents_state: 'managed',
claude_state: 'managed',
gemini_state: 'custom',
can_restore: false,
})
expect(vaultAiGuidanceNeedsRestore(missing)).toBe(true)
expect(getVaultAiGuidanceSummary(missing)).toBe('Gemini guidance can be created')
expect(vaultAiGuidanceUsesCustomFiles(custom)).toBe(true)
expect(getVaultAiGuidanceSummary(custom)).toBe('Using custom GEMINI.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('GEMINI.md', { modifiedAt: 25, fileSize: 35 }),
makeEntry('AGENTS.md', { modifiedAt: 15, fileSize: 40 }),
])
expect(key).toBe('/vault/AGENTS.md:15:40|/vault/CLAUDE.md:20:30')
expect(key).toBe('/vault/AGENTS.md:15:40|/vault/CLAUDE.md:20:30|/vault/GEMINI.md:25:35')
})
})

View File

@@ -5,21 +5,24 @@ export type VaultAiGuidanceFileState = 'checking' | 'managed' | 'missing' | 'bro
export interface VaultAiGuidanceStatus {
agentsState: VaultAiGuidanceFileState
claudeState: VaultAiGuidanceFileState
geminiState: VaultAiGuidanceFileState
canRestore: boolean
}
type RawVaultAiGuidanceStatus = Partial<{
agents_state: VaultAiGuidanceFileState | null
claude_state: VaultAiGuidanceFileState | null
gemini_state: VaultAiGuidanceFileState | null
can_restore: boolean | null
}>
const GUIDANCE_FILENAMES = new Set(['AGENTS.md', 'CLAUDE.md'])
const GUIDANCE_FILENAMES = new Set(['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'])
export function createCheckingVaultAiGuidanceStatus(): VaultAiGuidanceStatus {
return {
agentsState: 'checking',
claudeState: 'checking',
geminiState: 'checking',
canRestore: false,
}
}
@@ -42,12 +45,15 @@ export function normalizeVaultAiGuidanceStatus(
return {
agentsState: normalizeFileState(payload?.agents_state),
claudeState: normalizeFileState(payload?.claude_state),
geminiState: normalizeFileState(payload?.gemini_state),
canRestore: payload?.can_restore === true,
}
}
export function isVaultAiGuidanceStatusChecking(status: VaultAiGuidanceStatus): boolean {
return status.agentsState === 'checking' || status.claudeState === 'checking'
return status.agentsState === 'checking'
|| status.claudeState === 'checking'
|| status.geminiState === 'checking'
}
export function vaultAiGuidanceNeedsRestore(status: VaultAiGuidanceStatus): boolean {
@@ -56,16 +62,26 @@ export function vaultAiGuidanceNeedsRestore(status: VaultAiGuidanceStatus): bool
|| status.agentsState === 'broken'
|| status.claudeState === 'missing'
|| status.claudeState === 'broken'
|| status.geminiState === 'missing'
|| status.geminiState === 'broken'
}
export function vaultAiGuidanceUsesCustomFiles(status: VaultAiGuidanceStatus): boolean {
return status.agentsState === 'custom' || status.claudeState === 'custom'
return status.agentsState === 'custom'
|| status.claudeState === 'custom'
|| status.geminiState === 'custom'
}
function isMissingOrBroken(state: VaultAiGuidanceFileState): boolean {
return state === 'missing' || state === 'broken'
}
function formatGuidanceFileList(names: string[]): string {
if (names.length < 2) return names.join('')
if (names.length === 2) return `${names[0]} and ${names[1]}`
return `${names.slice(0, -1).join(', ')}, and ${names[names.length - 1]}`
}
function getBrokenGuidanceSummary(status: VaultAiGuidanceStatus): string | null {
if (isMissingOrBroken(status.agentsState)) {
return 'Tolaria guidance missing or broken'
@@ -73,15 +89,27 @@ function getBrokenGuidanceSummary(status: VaultAiGuidanceStatus): string | null
if (isMissingOrBroken(status.claudeState)) {
return 'Claude compatibility shim missing or broken'
}
if (status.geminiState === 'missing') {
return 'Gemini guidance can be created'
}
if (status.geminiState === 'broken') {
return 'Gemini guidance 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'
const customNames = [
status.agentsState === 'custom' ? 'AGENTS.md' : null,
status.claudeState === 'custom' ? 'CLAUDE.md' : null,
status.geminiState === 'custom' ? 'GEMINI.md' : null,
].filter((name): name is string => name !== null)
if (customNames.length > 1) {
return `Custom ${formatGuidanceFileList(customNames)} active`
}
if (status.agentsState === 'custom') return 'Using custom AGENTS.md'
if (status.claudeState === 'custom') return 'Using custom CLAUDE.md'
if (status.geminiState === 'custom') return 'Using custom GEMINI.md'
return null
}

View File

@@ -142,11 +142,13 @@ describe('mockHandlers additional coverage', () => {
expect(mockHandlers.get_vault_ai_guidance_status()).toEqual({
agents_state: 'managed',
claude_state: 'managed',
gemini_state: 'managed',
can_restore: false,
})
expect(mockHandlers.restore_vault_ai_guidance()).toEqual({
agents_state: 'managed',
claude_state: 'managed',
gemini_state: 'managed',
can_restore: false,
})
expect(mockHandlers.repair_vault()).toBe('Vault repaired')

View File

@@ -135,6 +135,7 @@ let mockVaultList: { vaults: Array<{ label: string; path: string }>; active_vaul
let mockVaultAiGuidanceStatus = {
agents_state: 'managed',
claude_state: 'managed',
gemini_state: 'managed',
can_restore: false,
} as const
@@ -386,6 +387,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
mockVaultAiGuidanceStatus = {
agents_state: 'managed',
claude_state: 'managed',
gemini_state: 'managed',
can_restore: false,
}
return { ...mockVaultAiGuidanceStatus }
@@ -486,11 +488,25 @@ export const mockHandlers: Record<string, (args: any) => any> = {
},
register_mcp_tools: () => 'registered',
check_mcp_status: () => 'installed',
get_mcp_config_snippet: (args: { vaultPath?: string }) => JSON.stringify({
mcpServers: {
tolaria: {
type: 'stdio',
command: 'node',
args: ['/mock/Tolaria/mcp-server/index.js'],
env: {
VAULT_PATH: args.vaultPath ?? '/Users/mock/Documents/Getting Started',
WS_UI_PORT: '9711',
},
},
},
}, null, 2),
sync_mcp_bridge_vault: (args: { vaultPath?: string | null }) => args.vaultPath ? 'started' : 'stopped',
repair_vault: (): string => {
mockVaultAiGuidanceStatus = {
agents_state: 'managed',
claude_state: 'managed',
gemini_state: 'managed',
can_restore: false,
}
return 'Vault repaired'

View File

@@ -95,6 +95,58 @@ describe('math markdown round-trip', () => {
)
})
it('round-trips inline math inside table cells', () => {
const tableCellMath = preProcessMathMarkdown({ markdown: '$a+b$' })
const blocks = [{
type: 'table',
content: {
type: 'tableContent',
rows: [{
cells: [{
type: 'tableCell',
content: [{ type: 'text', text: tableCellMath, styles: {} }],
}],
}],
},
children: [],
}]
const [tableBlock] = injectMathInBlocks(blocks) as Array<{
content: { rows: Array<{ cells: Array<{ content: unknown[] }> }> }
}>
expect(tableBlock.content.rows[0].cells[0].content).toEqual([{
type: MATH_INLINE_TYPE,
props: { latex: 'a+b' },
content: undefined,
}])
const editor = {
blocksToMarkdownLossy: vi.fn(() => '| Formula |\n| --- |\n| $a+b$ |'),
}
expect(serializeMathAwareBlocks(editor, [tableBlock])).toBe('| Formula |\n| --- |\n| $a+b$ |')
expect(editor.blocksToMarkdownLossy).toHaveBeenCalledWith([{
type: 'table',
content: {
type: 'tableContent',
rows: [{
cells: [{
type: 'tableCell',
content: [{ type: 'text', text: '$a+b$' }],
}],
}],
},
children: [],
}])
})
it('leaves display-style math inside table cells as Markdown source', () => {
expect(preProcessMathMarkdown({ markdown: '| Formula |\n| --- |\n| $$c$$ |' })).toBe(
'| Formula |\n| --- |\n| $$c$$ |',
)
})
it('leaves inline code and fenced code math-looking text untouched', () => {
const markdown = [
'Keep `$not_math$` literal.',

View File

@@ -19,12 +19,33 @@ interface InlineItem {
interface BlockLike {
type?: string
content?: InlineItem[]
content?: BlockContent
props?: Record<string, string>
children?: BlockLike[]
[key: string]: unknown
}
type BlockContent = InlineItem[] | TableContentLike | unknown
interface TableContentLike {
type?: string
rows?: TableRowLike[]
[key: string]: unknown
}
interface TableRowLike {
cells?: TableCellValue[]
[key: string]: unknown
}
interface TableCellLike {
content?: InlineItem[]
[key: string]: unknown
}
type TableCellValue = TableCellLike | string
type InlineContentTransform = (content: InlineItem[]) => InlineItem[]
interface MarkdownSerializer {
blocksToMarkdownLossy: (blocks: unknown[]) => string
}
@@ -111,7 +132,7 @@ function isCodeFence({ text: line }: { text: string }): boolean {
}
function isSingleDollar({ text, index }: TextPosition): boolean {
return text[index] === '$' && text[index + 1] !== '$'
return text[index] === '$' && text[index - 1] !== '$' && text[index + 1] !== '$'
}
function isInlineMathEnd(position: TextPosition): boolean {
@@ -274,10 +295,47 @@ function restoreInlineMath(content: InlineItem[]): InlineItem[] {
})
}
function isTableContent(content: BlockContent): content is TableContentLike {
return Boolean(
content
&& typeof content === 'object'
&& !Array.isArray(content)
&& (content as TableContentLike).type === 'tableContent'
&& Array.isArray((content as TableContentLike).rows),
)
}
function transformTableCell(cell: TableCellValue, transform: InlineContentTransform): TableCellValue {
if (typeof cell === 'string' || !Array.isArray(cell.content)) return cell
return { ...cell, content: transform(cell.content) }
}
function transformTableContent(
content: TableContentLike,
transform: InlineContentTransform,
): TableContentLike {
return {
...content,
rows: content.rows?.map((row) => ({
...row,
cells: row.cells?.map((cell) => transformTableCell(cell, transform)),
})),
}
}
function transformBlockContent(
content: BlockContent,
transform: InlineContentTransform,
): BlockContent {
if (Array.isArray(content)) return transform(content)
if (isTableContent(content)) return transformTableContent(content, transform)
return content
}
function injectMathInBlock(block: BlockLike): BlockLike {
const content = Array.isArray(block.content) ? expandInlineMath(block.content) : block.content
const content = transformBlockContent(block.content, expandInlineMath)
const children = Array.isArray(block.children) ? block.children.map(injectMathInBlock) : block.children
const latex = readDisplayMathToken(content)
const latex = Array.isArray(content) ? readDisplayMathToken(content) : null
if (latex !== null) {
return buildMathBlock({ block, latex })
@@ -303,7 +361,7 @@ function buildMathBlock({ block, latex }: { block: BlockLike } & LatexPayload):
}
function restoreInlineMathInBlock(block: BlockLike): BlockLike {
const content = Array.isArray(block.content) ? restoreInlineMath(block.content) : block.content
const content = transformBlockContent(block.content, restoreInlineMath)
const children = Array.isArray(block.children) ? block.children.map(restoreInlineMathInBlock) : block.children
return { ...block, content, children }
}

View File

@@ -34,6 +34,23 @@ describe('preProcessWikilinks', () => {
expect(preProcessWikilinks(input)).toBe(input)
})
it('preserves wikilinks and inline formatting inside markdown tables', () => {
const input = [
'| Topic | Reference |',
'| --- | --- |',
'| [[Project Alpha]] | **bold** and [[Project Beta]] |',
'',
'Outside [[Project Gamma]]',
].join('\n')
const result = preProcessWikilinks(input)
expect(result).toContain('| [[Project Alpha]] | **bold** and [[Project Beta]] |')
expect(result).toContain('WIKILINK:Project Gamma')
expect(result).not.toContain('WIKILINK:Project Alpha')
expect(result).not.toContain('WIKILINK:Project Beta')
})
it('handles empty string', () => {
expect(preProcessWikilinks('')).toBe('')
})

View File

@@ -2,10 +2,16 @@
const WL_START = '\u2039WIKILINK:'
const WL_END = '\u203A'
const WL_RE = new RegExp(`${WL_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^${WL_END}]+)${WL_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'g')
const WIKILINK_RE = /\[\[([^\]]+)\]\]/g
const FORMAT_MARKERS = new Set(['*', '_', '`', '~'])
/** Pre-process markdown: replace [[target]] with placeholder tokens */
export function preProcessWikilinks(md: string): string {
return md.replace(/\[\[([^\]]+)\]\]/g, (_m, target) => `${WL_START}${target}${WL_END}`)
const lines = md.split('\n')
const tableLines = findMarkdownTableLines(lines)
return lines.map((line, index) => (
tableLines[index] ? line : replaceWikilinksWithPlaceholders(line)
)).join('\n')
}
// Minimal shape of a BlockNote block for wikilink processing
@@ -25,6 +31,54 @@ interface InlineItem {
type ContentTransform = (content: InlineItem[]) => InlineItem[]
function replaceWikilinksWithPlaceholders(line: string): string {
return line.replace(WIKILINK_RE, (_match, target) => `${WL_START}${target}${WL_END}`)
}
function findMarkdownTableLines(lines: string[]): boolean[] {
const tableLines = lines.map(() => false)
for (let index = 0; index < lines.length - 1; index++) {
if (!isPotentialTableRow(lines[index]) || !isMarkdownTableSeparator(lines[index + 1])) {
continue
}
tableLines[index] = true
tableLines[index + 1] = true
index = markTableBodyLines(lines, tableLines, index + 2) - 1
}
return tableLines
}
function markTableBodyLines(lines: string[], tableLines: boolean[], start: number): number {
let index = start
while (index < lines.length && isPotentialTableRow(lines[index])) {
tableLines[index] = true
index++
}
return index
}
function isPotentialTableRow(line: string): boolean {
const trimmed = line.trim()
return trimmed.includes('|') && trimmed !== '|'
}
function isMarkdownTableSeparator(line: string): boolean {
const cells = splitTableCells(line)
return cells.length > 1 && cells.every(isMarkdownTableSeparatorCell)
}
function splitTableCells(line: string): string[] {
let trimmed = line.trim()
if (trimmed.startsWith('|')) trimmed = trimmed.slice(1)
if (trimmed.endsWith('|')) trimmed = trimmed.slice(0, -1)
return trimmed.split('|').map((cell) => cell.trim()).filter(Boolean)
}
function isMarkdownTableSeparatorCell(cell: string): boolean {
return /^:?-{3,}:?$/.test(cell)
}
/** Walk blocks recursively, applying a transform to each block's inline content */
function walkBlocks(blocks: unknown[], transform: ContentTransform, clone = false): unknown[] {
return (blocks as BlockLike[]).map(block => {
@@ -188,21 +242,15 @@ function stripMarkdownChars(s: string): string {
let result = ''
let i = 0
while (i < s.length) {
if (s[i] === '[' && s[i + 1] === '[') {
i += 2
let inner = ''
while (i < s.length - 1 && !(s[i] === ']' && s[i + 1] === ']')) { inner += s[i]; i++ }
if (i < s.length - 1) i += 2
const pipe = inner.indexOf('|')
result += pipe !== -1 ? inner.slice(pipe + 1) : inner
if (s.startsWith('[[', i)) {
const parsed = readUntilSequence(s, i + 2, ']]')
result += wikilinkDisplayText(parsed.text)
i = parsed.nextIndex
} else if (s[i] === '[') {
i++
let text = ''
while (i < s.length && s[i] !== ']') { text += s[i]; i++ }
if (i < s.length) i++
if (i < s.length && s[i] === '(') { i++; while (i < s.length && s[i] !== ')') i++; if (i < s.length) i++ }
result += text
} else if (s[i] === '*' || s[i] === '_' || s[i] === '`' || s[i] === '~') {
const parsed = readUntilChar(s, i + 1, ']')
result += parsed.text
i = skipMarkdownLinkDestination(s, parsed.nextIndex)
} else if (FORMAT_MARKERS.has(s[i])) {
i++
} else {
result += s[i]
@@ -212,6 +260,30 @@ function stripMarkdownChars(s: string): string {
return result
}
function readUntilSequence(value: string, start: number, sequence: string): { text: string, nextIndex: number } {
const end = value.indexOf(sequence, start)
if (end === -1) return { text: value.slice(start), nextIndex: value.length }
return { text: value.slice(start, end), nextIndex: end + sequence.length }
}
function readUntilChar(value: string, start: number, char: string): { text: string, nextIndex: number } {
const end = value.indexOf(char, start)
if (end === -1) return { text: value.slice(start), nextIndex: value.length }
return { text: value.slice(start, end), nextIndex: end + 1 }
}
function skipMarkdownLinkDestination(value: string, start: number): number {
if (value[start] !== '(') return start
const end = value.indexOf(')', start + 1)
return end === -1 ? value.length : end + 1
}
function wikilinkDisplayText(inner: string): string {
const pipe = inner.indexOf('|')
return pipe === -1 ? inner : inner.slice(pipe + 1)
}
/** Extract sub-heading text (## , ### , etc.) stripped of the # prefix. */
function extractSubheadingText(line: string): string | null {
const t = line.trim()

View File

@@ -252,6 +252,19 @@ async function installFixtureVaultInitScript({ page, vaultPath, isGitRepo }: Fix
save_vault_list: () => null,
save_settings: () => null,
register_mcp_tools: () => null,
get_mcp_config_snippet: () => JSON.stringify({
mcpServers: {
tolaria: {
type: 'stdio',
command: 'node',
args: ['/fixture/Tolaria/mcp-server/index.js'],
env: {
VAULT_PATH: resolvedVaultPath,
WS_UI_PORT: '9711',
},
},
},
}, null, 2),
reinit_telemetry: () => null,
update_menu_state: () => null,
get_settings: () => ({

View File

@@ -100,7 +100,7 @@ test.describe('AI chat conversation history', () => {
await expect(restoredMessage).toContainText('Keep this thread alive')
await expect(restoredMessage).toContainText('[mock-claude code]')
await page.keyboard.press('Tab')
await page.getByTitle('New AI chat').focus()
await expect(page.getByTitle('New AI chat')).toBeFocused()
await page.keyboard.press('Enter')
await expect(page.getByTestId('ai-message')).toHaveCount(0)

View File

@@ -0,0 +1,114 @@
import fs from 'fs'
import { test, expect, type Page } from '@playwright/test'
import { createFixtureVaultCopy, openFixtureVaultTauri, removeFixtureVaultCopy } from '../helpers/fixtureVault'
import { triggerMenuCommand } from './testBridge'
interface CreateNoteProbe {
createCalls: string[]
getBeforeCreate: string[]
}
interface ProbeWindow {
__mockHandlers?: Record<string, (args?: unknown) => unknown>
__createNoteBackingFileProbe?: CreateNoteProbe
}
let tempVaultDir: string
async function pinFixtureHandlers(page: Page): Promise<void> {
await page.evaluate(() => {
const probeWindow = window as typeof window & ProbeWindow
const handlers = probeWindow.__mockHandlers
if (!handlers?.create_note_content || !handlers.get_note_content) {
throw new Error('Fixture vault handlers are missing create/read commands')
}
Object.defineProperty(window, '__mockHandlers', {
configurable: true,
get: () => handlers,
set: (nextHandlers) => Object.assign(handlers, nextHandlers),
})
})
}
async function recordCreateNoteCalls(page: Page): Promise<void> {
await page.evaluate(() => {
const probeWindow = window as typeof window & ProbeWindow
const handlers = probeWindow.__mockHandlers as Record<string, (args?: unknown) => unknown>
const originalCreate = handlers.create_note_content.bind(handlers)
const probe: CreateNoteProbe = { createCalls: [], getBeforeCreate: [] }
probeWindow.__createNoteBackingFileProbe = probe
handlers.create_note_content = async (args?: unknown) => {
const notePath = String((args as { path?: unknown } | undefined)?.path ?? '')
await new Promise((resolve) => setTimeout(resolve, 100))
const result = await originalCreate(args)
probe.createCalls.push(notePath)
return result
}
})
}
async function rejectReadsBeforeCreate(page: Page): Promise<void> {
await page.evaluate(() => {
const probeWindow = window as typeof window & ProbeWindow
const handlers = probeWindow.__mockHandlers as Record<string, (args?: unknown) => unknown>
const originalGet = handlers.get_note_content.bind(handlers)
const probe = probeWindow.__createNoteBackingFileProbe as CreateNoteProbe
handlers.get_note_content = (args?: unknown) => {
const notePath = String((args as { path?: unknown } | undefined)?.path ?? '')
if (notePath.includes('untitled-note-') && !probe.createCalls.includes(notePath)) {
probe.getBeforeCreate.push(notePath)
throw new Error(`File does not exist: ${notePath}`)
}
return originalGet(args)
}
})
}
async function installCreateNoteBackingFileProbe(page: Page): Promise<void> {
await pinFixtureHandlers(page)
await recordCreateNoteCalls(page)
await rejectReadsBeforeCreate(page)
}
async function readProbe(page: Page): Promise<CreateNoteProbe> {
return page.evaluate(() => {
const probeWindow = window as typeof window & ProbeWindow
return probeWindow.__createNoteBackingFileProbe ?? { createCalls: [], getBeforeCreate: [] }
})
}
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(60_000)
tempVaultDir = createFixtureVaultCopy()
await openFixtureVaultTauri(page, tempVaultDir)
await installCreateNoteBackingFileProbe(page)
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('@smoke creating a note writes its backing file before reload can read it', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (error) => errors.push(error.message))
await triggerMenuCommand(page, 'file-new-note')
await triggerMenuCommand(page, 'vault-reload')
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(/untitled-note-\d+/i, {
timeout: 5_000,
})
await expect.poll(() => readProbe(page), { timeout: 5_000 }).toMatchObject({
createCalls: [expect.stringMatching(/untitled-note-\d+\.md$/)],
getBeforeCreate: [],
})
const { createCalls, getBeforeCreate } = await readProbe(page)
expect(getBeforeCreate).toEqual([])
expect(errors.filter((message) => message.includes('File does not exist'))).toEqual([])
expect(fs.readFileSync(createCalls[0], 'utf8')).toContain('type: Note')
})

View File

@@ -19,10 +19,11 @@ async function blockOuterForText(page: Page, text: string): Promise<Locator> {
return textNode.locator('xpath=ancestor::*[contains(concat(" ", normalize-space(@class), " "), " bn-block-outer ")][1]')
}
async function visibleDragHandle(page: Page, block: Locator): Promise<Locator> {
async function visibleLeftBlockHandle(page: Page, block: Locator): Promise<Locator> {
await block.hover()
const handle = page.locator('.bn-side-menu [draggable="true"]').first()
const handle = page.locator('.bn-side-menu button').first()
await expect(handle).toBeVisible({ timeout: 5_000 })
await expect(handle).toHaveAttribute('draggable', 'true')
return handle
}
@@ -59,7 +60,7 @@ test('dragging the left block handle reorders editor blocks', async ({ page }) =
await expect.poll(async () => editor.textContent()).toMatch(/Alpha Project[\s\S]*This is a test project[\s\S]*Notes/)
const handle = await visibleDragHandle(page, notesHeading)
const handle = await visibleLeftBlockHandle(page, notesHeading)
await dragHandleToBlock(page, handle, paragraph)
await expect.poll(async () => editor.textContent()).toMatch(/Alpha Project[\s\S]*Notes[\s\S]*This is a test project/)

View File

@@ -9,6 +9,8 @@ let tempVaultDir: string
const INLINE_LATEX = 'E=mc^2'
const DISPLAY_LATEX = '\\int_0^1 x^2 \\, dx = \\frac{1}{3}'
const MALFORMED_LATEX = '\\frac{'
const TABLE_INLINE_LATEX = '\\frac{a}{b}+c'
const TABLE_DISPLAY_STYLE_LATEX = '\\sum_{i=1}^{n} i'
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(90_000)
@@ -107,6 +109,11 @@ ${DISPLAY_LATEX}
$$
Malformed math $${MALFORMED_LATEX}$ stays visible.
| Kind | Formula |
| --- | --- |
| inline | $${TABLE_INLINE_LATEX}$ |
| display-style | $$${TABLE_DISPLAY_STYLE_LATEX}$$ |
`
await setRawEditorContent(page, nextContent)
@@ -117,15 +124,20 @@ Malformed math $${MALFORMED_LATEX}$ stays visible.
await toggleRawMode(page, '.bn-editor')
await expectMathNode(page, '.math--inline', INLINE_LATEX)
await expectMathNode(page, '.math--inline', TABLE_INLINE_LATEX)
await expectMathNode(page, '.math--block', DISPLAY_LATEX)
await expectMathNode(page, '.math--inline', MALFORMED_LATEX)
await expect(page.locator('.math .katex, .math .katex-error')).toHaveCount(3)
await expect(page.locator('table')).toHaveCount(1)
await expect(page.locator('.math .katex, .math .katex-error')).toHaveCount(4)
await toggleRawMode(page, '.cm-content')
const rawAfterRichMode = await getRawEditorContent(page)
expect(rawAfterRichMode).toContain(`$${INLINE_LATEX}$`)
expect(rawAfterRichMode).toContain(`$$\n${DISPLAY_LATEX}\n$$`)
expect(rawAfterRichMode).toContain(`$${MALFORMED_LATEX}$`)
expect(rawAfterRichMode).toContain(`$${TABLE_INLINE_LATEX}$`)
expect(rawAfterRichMode).toContain(`$$${TABLE_DISPLAY_STYLE_LATEX}$$`)
expect(rawAfterRichMode).not.toContain('@@TOLARIA_MATH')
await toggleRawMode(page, '.bn-editor')
await openNote(page, 'Note C')
@@ -136,4 +148,7 @@ Malformed math $${MALFORMED_LATEX}$ stays visible.
expect(reopenedRaw).toContain(`$${INLINE_LATEX}$`)
expect(reopenedRaw).toContain(`$$\n${DISPLAY_LATEX}\n$$`)
expect(reopenedRaw).toContain(`$${MALFORMED_LATEX}$`)
expect(reopenedRaw).toContain(`$${TABLE_INLINE_LATEX}$`)
expect(reopenedRaw).toContain(`$$${TABLE_DISPLAY_STYLE_LATEX}$$`)
expect(reopenedRaw).not.toContain('@@TOLARIA_MATH')
})

View File

@@ -0,0 +1,47 @@
import { test, expect } from '@playwright/test'
import { sendShortcut } from './helpers'
test.describe('MCP config copy', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/vault/ping', route => route.fulfill({ status: 503 }))
await page.goto('/')
await expect(page.getByTestId('note-list-container')).toBeVisible({ timeout: 5_000 })
})
test('copies the active-vault MCP config from the AI panel using only the keyboard', async ({ context, page }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await page.locator('.app__note-list .cursor-pointer').first().click()
await page.locator('.bn-editor').click()
await sendShortcut(page, 'L', ['Meta', 'Shift'])
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3_000 })
await page.getByTestId('agent-input').focus()
await page.keyboard.press('Shift+Tab')
await page.keyboard.press('Shift+Tab')
await page.keyboard.press('Shift+Tab')
const copyButton = page.getByRole('button', { name: 'Copy MCP config' })
await expect(copyButton).toBeFocused()
await copyButton.press('Enter')
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toContain('"mcpServers"')
const copiedConfig = await page.evaluate(() => navigator.clipboard.readText())
const parsedConfig = JSON.parse(copiedConfig) as {
mcpServers: {
tolaria: {
args: string[]
command: string
env: Record<string, string>
type: string
}
}
}
const tolariaServer = parsedConfig.mcpServers.tolaria
expect(tolariaServer.type).toBe('stdio')
expect(tolariaServer.command).toBe('node')
expect(tolariaServer.args[0]).toContain('mcp-server/index.js')
expect(tolariaServer.env.VAULT_PATH).toBeTruthy()
expect(tolariaServer.env.WS_UI_PORT).toBe('9711')
})
})

View File

@@ -0,0 +1,80 @@
import { test, expect, type Page } from '@playwright/test'
import { createFixtureVaultCopy, openFixtureVaultTauri, removeFixtureVaultCopy } from '../helpers/fixtureVault'
import { seedBlockNoteTable, triggerMenuCommand } from './testBridge'
let tempVaultDir: string
function trackUnexpectedErrors(page: Page): string[] {
const errors: string[] = []
page.on('pageerror', (error) => {
errors.push(error.message)
})
page.on('console', (message) => {
if (message.type() !== 'error') return
const text = message.text()
if (text.includes('ws://localhost:9711')) return
errors.push(text)
})
return errors
}
async function createUntitledNote(page: Page): Promise<void> {
await triggerMenuCommand(page, 'file-new-note')
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
}
async function moveAcrossElement(page: Page, selector: string): Promise<void> {
const target = page.locator(selector).first()
await expect(target).toBeVisible({ timeout: 5_000 })
const box = await target.boundingBox()
expect(box).not.toBeNull()
if (!box) return
const points = [
{ x: box.x + 2, y: box.y + 2 },
{ x: box.x + box.width / 2, y: box.y + box.height / 2 },
{ x: box.x + Math.max(2, box.width - 2), y: box.y + Math.max(2, box.height - 2) },
]
for (const point of points) {
await page.mouse.move(point.x, point.y, { steps: 4 })
}
}
test.describe('table hover crash regression', () => {
test.beforeEach(({ page }, testInfo) => {
void page
testInfo.setTimeout(60_000)
tempVaultDir = createFixtureVaultCopy()
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('moving through table wrappers, cells, and nearby text keeps the editor stable', async ({ page }) => {
const errors = trackUnexpectedErrors(page)
await openFixtureVaultTauri(page, tempVaultDir)
await createUntitledNote(page)
await seedBlockNoteTable(page, [180, 120, 120])
await expect(page.locator('div.tableWrapper')).toBeVisible({ timeout: 5_000 })
await moveAcrossElement(page, 'div.tableWrapper')
await page.locator('table th').first().hover()
await page.locator('table td').first().hover()
const trailingParagraph = page.locator('.bn-editor [data-content-type="paragraph"]').last()
await trailingParagraph.hover()
await trailingParagraph.click()
await page.keyboard.type('stable after table hover')
const editor = page.getByRole('textbox').last()
await expect(editor).toContainText('stable after table hover')
await expect(page.locator('table')).toHaveCount(1)
expect(errors).toEqual([])
})
})

View File

@@ -14098,6 +14098,113 @@
]
}
]
},
{
"type": "frame",
"id": "mcp_config_copy_design",
"x": 0,
"y": 12580,
"name": "MCP Config Copy — Light",
"theme": {
"Mode": "Light"
},
"width": 1440,
"fill": "$--background",
"layout": "vertical",
"gap": 20,
"padding": 40,
"children": [
{
"type": "text",
"id": "mcp_config_copy_title",
"fill": "$--foreground",
"content": "MCP Config Copy",
"fontFamily": "Inter",
"fontSize": 24,
"fontWeight": "700",
"letterSpacing": 0
},
{
"type": "text",
"id": "mcp_config_copy_description",
"fill": "$--muted-foreground",
"content": "AI panel exposes a compact Copy MCP config action; setup dialog shows the exact generated JSON with a keyboard-focusable code block and copy button.",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "normal",
"letterSpacing": 0
},
{
"type": "frame",
"id": "mcp_config_copy_ai_header",
"name": "AI panel header action",
"width": 520,
"height": 52,
"fill": "$--background",
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "$--border"
},
"children": [
{
"type": "text",
"id": "mcp_config_copy_ai_label",
"fill": "$--muted-foreground",
"content": "AI Agent · Claude Code",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "600",
"letterSpacing": 0
},
{
"type": "rectangle",
"id": "mcp_config_copy_icon_button",
"name": "Copy MCP config icon button",
"x": 440,
"y": 14,
"width": 24,
"height": 24,
"cornerRadius": 6,
"fill": "$--muted"
}
]
},
{
"type": "frame",
"id": "mcp_config_copy_manual_block",
"name": "Manual MCP config block",
"width": 520,
"fill": "$--background",
"layout": "vertical",
"gap": 8,
"children": [
{
"type": "text",
"id": "mcp_config_copy_manual_title",
"fill": "$--foreground",
"content": "Manual MCP config",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "600",
"letterSpacing": 0
},
{
"type": "rectangle",
"id": "mcp_config_copy_code_block",
"width": 520,
"height": 148,
"cornerRadius": 8,
"fill": "$--background",
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "$--border"
}
}
]
}
]
}
],
"themes": {