Compare commits

...

9 Commits

Author SHA1 Message Date
Test
f4961d0bc3 fix: add missing ws dependency for smoke tests
The ai-notes-visibility-fix smoke test imports 'ws' (WebSocketServer)
but it wasn't listed as a dev dependency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 04:10:50 +01:00
Test
058de96cbc docs: update ARCHITECTURE, ABSTRACTIONS, GETTING-STARTED to reflect current codebase
Updated all three docs to reflect significant features added since they were last written:
- AI agent panel (Claude CLI subprocess with tool execution + NDJSON streaming)
- Vault cache system (git-based incremental caching in cache.rs)
- Theme system (vault-based themes, useThemeManager, ThemePropertyEditor)
- Search & indexing (qmd integration, keyword/semantic/hybrid modes)
- Pulse view (git activity feed with pagination)
- GitHub OAuth (device flow, vault clone/create)
- Vault management (multi-vault, vault config, onboarding, WelcomeScreen)
- Raw editor mode (CodeMirror 6 alternative)
- Command palette (Cmd+K registry)
- Auto-sync & conflict resolution

Also added mandatory docs-update rule to CLAUDE.md: docs/ files must be
updated in the same commit as significant feature changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 04:09:23 +01:00
Test
d29f919182 test: add Playwright smoke test for AI note visibility and tab opening
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 03:56:37 +01:00
Test
42e37e035c test: add unit tests for detectFileOperation and parseBashFileCreation
24 tests covering Write/Edit/Bash file detection, edge cases
(malformed JSON, files outside vault, non-md files, undefined input),
and the parseBashFileCreation helper for redirect/tee patterns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 03:41:06 +01:00
Test
0ad0fa9b6b fix: AI-created notes now trigger vault refresh and auto-open in tab
Root causes:
- toolInputMapRef in useAiAgent was overwritten by tool_progress events
  (which arrive with input=undefined AFTER the assistant message set
  the full input), causing detectFileOperation to receive undefined
  and skip file creation detection entirely.
- MCP open_note only broadcast open_tab without vault_changed, so
  the note list didn't refresh when Claude Code called open_note.
- detectFileOperation only handled Write/Edit but not Bash commands
  that create .md files via redirects.

Fixes:
- Preserve accumulated input in toolInputMapRef (input ?? prev?.input)
- MCP open_note now broadcasts vault_changed before open_tab
- detectFileOperation now detects Bash redirect patterns (>, >>, tee)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 03:39:34 +01:00
Test
c37c03d6a9 fix: remove --resume from AI chat to fix conversation history
The AI chat was using both --resume (CLI session resumption) AND formatted
conversation history in the prompt simultaneously. This dual-context approach
confused the model — it saw the conversation twice (from session + from prompt
markup), leading to "I don't have context" responses on follow-ups.

Fix: remove --resume entirely from chat mode. Each CLI call is now independent,
with full conversation history formatted into the prompt via
<conversation_history> markup. trimHistory handles graceful truncation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 03:08:24 +01:00
Test
fcc264d7dc feat: restore MCP UI-steering tools (highlight_editor, refresh_vault)
Add highlight_editor and refresh_vault tools to the MCP stdio server
so Claude Code can visually highlight UI elements and trigger vault
rescans. Also fix outdated test.js imports after the ai-agent-full-shell
simplification removed write operations from vault.js.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:03:21 +01:00
Test
382ba0a6d4 test: add Playwright smoke test for wikilink rendering in AI chat
Update mock agent response to include [[wikilinks]] for testing.
Add smoke test verifying wikilinks render as clickable elements
with correct text, attributes, and styling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:58:08 +01:00
Test
4719810b10 feat: render [[wikilinks]] as clickable links in AI chat
- System prompts instruct AI to use [[Note Title]] wikilink syntax
- preprocessWikilinks converts [[Target]] to markdown links
- Custom urlTransform allows wikilink:// scheme through sanitizer
- Click handler resolves target via findEntryByTarget and opens note
- Styled as colored chips matching primary accent
- Works in both AiPanel (agent) and AIChatPanel (legacy chat)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:40:44 +01:00
27 changed files with 1631 additions and 534 deletions

View File

@@ -1 +1 @@
66070
91305

View File

@@ -115,7 +115,29 @@ Tauri v2 + React + TypeScript desktop app. Reads a vault of markdown files with
- **Never develop on `main`** — always on `task/<slug>` branch
- **Commit every 2030 min** — atomic commits, one concern per commit (`feat:`, `fix:`, `refactor:`, `test:`, `docs:`)
- **Update docs/** when changing architecture, abstractions, or significant design
- **Update docs/** when changing architecture, abstractions, or significant design (mandatory — see rule below)
## ⛔ DOCS — Keep docs/ in sync with code (mandatory)
After any significant feature change, update the relevant `docs/` files **in the same commit**:
- **`docs/ARCHITECTURE.md`** — stack, system overview, component structure, Tauri commands, data flow, backend modules
- **`docs/ABSTRACTIONS.md`** — domain models, VaultEntry fields, entity types, key abstractions, integration patterns
- **`docs/GETTING-STARTED.md`** — directory structure, key files, common tasks, test commands, onboarding
**What counts as "significant":**
- Adding a new Tauri command or backend module
- Adding a new major component, hook, or feature (not a bugfix)
- Changing the data model (VaultEntry fields, new types, new config files)
- Adding a new integration (API, service, transport)
- Changing the architecture (new panels, new state management, new build steps)
**How to update:**
1. Read the relevant doc section before making changes
2. After your code changes, update the doc to reflect the new state
3. Commit doc changes together with the code — not in a separate follow-up commit
If unsure whether a change is "significant", err on the side of updating. Stale docs are worse than slightly verbose docs.
## TDD — Red/Green/Refactor (mandatory)

View File

@@ -8,24 +8,32 @@ All data lives in markdown files with YAML frontmatter. There is no database —
### VaultEntry
The core data type representing a single note, defined identically in Rust (`src-tauri/src/vault.rs`) and TypeScript (`src/types.ts`):
The core data type representing a single note, defined in Rust (`src-tauri/src/vault/mod.rs`) and TypeScript (`src/types.ts`):
```typescript
// src/types.ts
interface VaultEntry {
path: string // Absolute file path: /Users/luca/Laputa/project/my-project.md
filename: string // Just the filename: my-project.md
title: string // Extracted from first # heading, or filename as fallback
isA: string | null // Entity type: Project, Procedure, Person, etc.
aliases: string[] // Alternative names for wikilink resolution
belongsTo: string[] // Parent relationships (wikilinks)
relatedTo: string[] // Related entity links (wikilinks)
status: string | null // Active, Done, Paused, Archived, Dropped
owner: string | null // Person responsible
cadence: string | null // Update frequency: Weekly, Monthly, etc.
path: string // Absolute file path
filename: string // Just the filename
title: string // From first # heading, or filename fallback
isA: string | null // Entity type: Project, Procedure, Person, etc.
aliases: string[] // Alternative names for wikilink resolution
belongsTo: string[] // Parent relationships (wikilinks)
relatedTo: string[] // Related entity links (wikilinks)
relationships: Record<string, string[]> // All frontmatter fields containing wikilinks
outgoingLinks: string[] // All [[wikilinks]] found in note body
status: string | null // Active, Done, Paused, Archived, Dropped
owner: string | null // Person responsible
cadence: string | null // Update frequency: Weekly, Monthly, etc.
modifiedAt: number | null // Unix timestamp (seconds)
createdAt: number | null // Unix timestamp (seconds)
fileSize: number
wordCount: number | null // Body word count (excludes frontmatter)
snippet: string | null // First 200 chars of body
archived: boolean // Archived flag
trashed: boolean // Trashed flag
trashedAt: number | null // When trashed (for auto-purge)
properties: Record<string, string> // Scalar frontmatter fields (custom properties)
}
```
@@ -47,26 +55,40 @@ Entity type is inferred from the folder structure. The vault is organized by typ
├── quarter/ → "Quarter"
├── journal/ → "Journal"
├── essay/ → "Essay"
── evergreen/ → "Evergreen"
── evergreen/ → "Evergreen"
└── theme/ → "Theme" ← vault-based themes
```
Mapping logic lives in `vault.rs:parse_md_file()`. If a folder doesn't match any known type, the folder name is capitalized and used as-is.
Mapping logic lives in `vault/mod.rs:parse_md_file()`. If a folder doesn't match any known type, the folder name is capitalized and used as-is.
### Types as Files
Each entity type can have a corresponding **type document** in the `type/` folder (e.g., `type/project.md`, `type/person.md`). Type documents:
- Have `Is A: Type` in their frontmatter
- Describe what the type means, its expected properties, and how it relates to other types
- Are navigable entities — they appear in the sidebar under "Types" and can be opened/edited like any other note
- Define type metadata: icon, color, order, sidebar label, template, sort, view, visibility
- Are navigable entities — they appear in the sidebar under "Types" and can be opened/edited like any note
- Serve as the "definition" for their type category
**Type document properties** (read by Rust and used in the UI):
| Property | Type | Description |
|----------|------|-------------|
| `icon` | string | Phosphor icon name (kebab-case, e.g., "cooking-pot") |
| `color` | string | Accent color: red, purple, blue, green, yellow, orange |
| `order` | number | Sidebar display order (lower = higher priority) |
| `sidebar_label` | string | Custom label overriding auto-pluralization |
| `template` | string | Markdown template for new notes of this type |
| `sort` | string | Default sort: "modified:desc", "title:asc", "property:Priority:asc" |
| `view` | string | Default view mode: "all", "editor-list", "editor-only" |
| `visible` | bool | Whether type appears in sidebar (default: true) |
**Type relationship**: When any entry has an `isA` value (e.g., "Project"), the Rust backend automatically adds a `"Type"` entry to its `relationships` map pointing to `[[type/project]]`. This makes the type navigable from the Inspector panel.
**UI behavior**:
- Clicking a section group header (e.g., "Projects") pins the type document at the top of the NoteList if it exists, with instances listed below
- Clicking a section group header pins the type document at the top of the NoteList if it exists
- Viewing a type document in entity view shows an "Instances" group listing all entries of that type
- The Type field in the Inspector properties panel is rendered as a clickable chip that navigates to the type document
- The Type field in the Inspector is rendered as a clickable chip that navigates to the type document
### Frontmatter Format
@@ -88,24 +110,48 @@ aliases:
---
```
Supported value types (defined in `src-tauri/src/frontmatter.rs` as `FrontmatterValue`):
Supported value types (defined in `src-tauri/src/frontmatter/yaml.rs` as `FrontmatterValue`):
- **String**: `status: Active`
- **Number**: `priority: 5`
- **Bool**: `archived: true`
- **List**: Multi-line ` - item` or inline `[item1, item2]`
- **Null**: `owner:` (empty value)
### Custom Relationships
The Rust parser scans all frontmatter keys for fields containing `[[wikilinks]]`. Any non-standard field with wikilink values is captured in the `relationships` HashMap:
```yaml
---
Topics:
- "[[topic/writing]]"
- "[[topic/productivity]]"
Key People:
- "[[person/matteo-cellini]]"
---
```
Becomes: `relationships["Topics"] = ["[[topic/writing]]", "[[topic/productivity]]"]`
This enables arbitrary, extensible relationship types without code changes.
### Outgoing Links
All `[[wikilinks]]` in the note body (not frontmatter) are extracted by regex and stored in `outgoingLinks`. Used for backlink detection and relationship graphs.
### Title Extraction
Title comes from the first `# Heading` in the markdown body. If none is found, the filename (without `.md`) is used as fallback. This logic lives in `vault.rs:extract_title()`.
Title comes from the first `# Heading` in the markdown body. If none is found, the filename (without `.md`) is used as fallback. Logic in `vault/parsing.rs:extract_title()`.
### Sidebar Selection
Navigation state is modeled as a discriminated union:
```typescript
type SidebarFilter = 'all' | 'archived' | 'trash' | 'changes' | 'pulse'
type SidebarSelection =
| { kind: 'filter'; filter: 'all' | 'favorites' }
| { kind: 'filter'; filter: SidebarFilter }
| { kind: 'sectionGroup'; type: string } // e.g. type: 'Project'
| { kind: 'entity'; entry: VaultEntry } // specific entity selected
| { kind: 'topic'; entry: VaultEntry } // topic selected
@@ -115,49 +161,60 @@ type SidebarSelection =
### Vault Scanning (Rust)
`vault::scan_vault(path)` in `src-tauri/src/vault.rs`:
`vault::scan_vault(path)` in `src-tauri/src/vault/mod.rs`:
1. Validates the path exists and is a directory
2. Uses `walkdir` to recursively traverse the directory (follows symlinks)
2. Uses `walkdir` to recursively traverse (follows symlinks)
3. Filters to `.md` files only
4. For each file, calls `parse_md_file()`:
- Reads file content with `fs::read_to_string()`
- Reads content with `fs::read_to_string()`
- Parses frontmatter with `gray_matter::Matter::<YAML>`
- Extracts title from first `#` heading
- Infers entity type from parent folder name
- Parses dates (`created_at`, `created_time`) as ISO 8601 to Unix timestamps
- Collects file metadata (size, modification time)
5. Sorts results by `modified_at` descending (newest first)
- Infers entity type from parent folder name (or explicit `Is A`/`type` frontmatter)
- Parses dates as ISO 8601 to Unix timestamps
- Extracts relationships, outgoing links, custom properties, word count, snippet
5. Sorts by `modified_at` descending
6. Skips unparseable files with a warning log
### Vault Caching
`vault::scan_vault_cached(path)` wraps scanning with git-based caching:
1. Reads `.laputa-cache.json` if it exists
2. Compares cache version, vault path, and git HEAD commit hash
3. If cache is valid and same commit → only re-parse uncommitted changed files
4. If different commit → use `git diff` to find changed files → selective re-parse
5. If no cache → full scan
6. Writes updated cache after every scan
### Frontmatter Manipulation (Rust)
`frontmatter::update_frontmatter_content()` in `src-tauri/src/frontmatter.rs` performs line-by-line YAML editing:
`frontmatter/ops.rs:update_frontmatter_content()` performs line-by-line YAML editing:
1. Finds the frontmatter block between `---` delimiters
2. Iterates through lines looking for the target key (handles quoted keys like `"Is A"`)
2. Iterates through lines looking for the target key
3. If found: replaces the value (consuming multi-line list items if present)
4. If not found: appends the new key-value at the end of the frontmatter
5. If no frontmatter exists: creates a new `---` block with the key-value
4. If not found: appends the new key-value at the end
5. If no frontmatter exists: creates a new `---` block
The `with_frontmatter()` helper wraps this in a read-transform-write cycle on the actual file.
### Content Loading
- **Tauri mode**: Content is loaded on-demand when a tab is opened via `invoke('get_note_content', { path })`
- **Browser mode**: All content is loaded at startup from `MOCK_CONTENT` in `mock-tauri.ts`
- **Tauri mode**: Content loaded on-demand when a tab is opened via `invoke('get_note_content', { path })`
- **Browser mode**: All content loaded at startup from mock data
- Content for backlink detection (`allContent`) is stored in memory as `Record<string, string>`
## Git Integration
Git operations live in `src-tauri/src/git.rs`. All operations shell out to the `git` CLI (not libgit2).
Git operations live in `src-tauri/src/git/`. All operations shell out to the `git` CLI (not libgit2).
### Data Types
```typescript
interface GitCommit {
hash: string // Full SHA-1
shortHash: string // First 7 chars
hash: string
shortHash: string
message: string
author: string
date: number // Unix timestamp
@@ -168,32 +225,54 @@ interface ModifiedFile {
relativePath: string // Relative to vault root
status: 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed'
}
interface PulseCommit {
hash: string
shortHash: string
message: string
date: number
githubUrl: string | null
files: PulseFile[]
added: number
modified: number
deleted: number
}
```
### Operations
| Operation | Git command | Notes |
|-----------|------------|-------|
| File history | `git log --format=%H\|%h\|%an\|%aI\|%s -n 20 -- <file>` | Last 20 commits for a file |
| Modified files | `git status --porcelain` | Filtered to `.md` files only |
| File diff | `git diff -- <file>`, fallback to `--cached`, then synthetic diff for untracked | Unified diff format |
| Commit | `git add -A && git commit -m "<message>"` | Stages all changes |
| Push | `git push` | Pushes to upstream of current branch |
| Module | Operation | Notes |
|--------|-----------|-------|
| `history.rs` | File history | `git log` — last 20 commits per file |
| `status.rs` | Modified files | `git status --porcelain` — filtered to `.md` |
| `status.rs` | File diff | `git diff`, fallback to `--cached`, then synthetic for untracked |
| `commit.rs` | Commit | `git add -A && git commit -m "..."` |
| `remote.rs` | Pull / Push | `git pull --rebase` / `git push` |
| `conflict.rs` | Conflict resolution | Detect conflicts, resolve with ours/theirs/manual |
| `pulse.rs` | Activity feed | `git log` with `--name-status` for file changes |
### Auto-Sync
`useAutoSync` hook handles automatic git sync:
- Configurable interval (from app settings: `auto_pull_interval_minutes`)
- Pulls on interval, pushes after commits
- Detects merge conflicts → opens `ConflictResolverModal`
### Frontend Integration
- **Modified file badges**: Loaded at startup, shown in sidebar and breadcrumb bar
- **Diff view**: Loaded on-demand when user clicks the diff toggle in the breadcrumb bar
- **Git history**: Loaded when active tab changes, shown in Inspector panel
- **Commit dialog**: Triggered from sidebar, runs commit + push
- **Modified file badges**: Orange dots in sidebar and tab bar
- **Diff view**: Toggle in breadcrumb bar → shows unified diff
- **Git history**: Shown in Inspector panel for active note
- **Commit dialog**: Triggered from sidebar or Cmd+K
- **Pulse view**: Activity feed when Pulse filter is selected
## BlockNote Customization
The editor uses [BlockNote](https://www.blocknotejs.org/) (not CodeMirror 6) for rich text editing.
The editor uses [BlockNote](https://www.blocknotejs.org/) for rich text editing, with CodeMirror 6 available as a raw editing alternative.
### Custom Wikilink Inline Content
Defined in `src/components/Editor.tsx`:
Defined in `src/components/editorSchema.tsx`:
```typescript
const WikiLink = createReactInlineContentSpec(
@@ -202,69 +281,196 @@ const WikiLink = createReactInlineContentSpec(
propSchema: { target: { default: "" } },
content: "none",
},
{
render: (props) => (
<span className="wikilink" data-target={props.inlineContent.props.target}>
{props.inlineContent.props.target}
</span>
),
}
{ render: (props) => <span className="wikilink">...</span> }
)
const schema = BlockNoteSchema.create({
inlineContentSpecs: {
...defaultInlineContentSpecs,
wikilink: WikiLink,
},
})
```
### Markdown-to-BlockNote Pipeline
Since BlockNote doesn't natively understand `[[wikilinks]]`, content goes through a preprocessing pipeline in `src/utils/wikilinks.ts`:
```
Raw markdown
→ splitFrontmatter() → [yaml, body]
→ preProcessWikilinks(body) → replaces [[target]] with Unicode placeholder tokens
→ editor.tryParseMarkdownToBlocks() → BlockNote block tree
→ injectWikilinks(blocks) → walks tree, replaces placeholder text with wikilink inline content nodes
→ injectWikilinks(blocks) → walks tree, replaces placeholders with wikilink inline content nodes
→ editor.replaceBlocks()
```
Placeholder tokens use `\u2039` (single left-pointing angle quotation mark) and `\u203A` (single right-pointing) to avoid colliding with markdown syntax.
Placeholder tokens use `\u2039` and `\u203A` to avoid colliding with markdown syntax.
### BlockNote-to-Markdown Pipeline (Save)
```
BlockNote blocks
→ editor.blocksToMarkdownLossy()
→ postProcessWikilinks() → restore [[target]] syntax from wikilink nodes
→ prepend frontmatter yaml
→ invoke('save_note_content', { path, content })
```
### Wikilink Navigation
Two navigation mechanisms:
1. **Click handler**: A DOM event listener on `.editor__blocknote-container` catches clicks on `.wikilink` elements and calls `onNavigateWikilink(target)`.
1. **Click handler**: DOM event listener on `.editor__blocknote-container` catches clicks on `.wikilink` elements `onNavigateWikilink(target)`.
2. **Suggestion menu**: Typing `[[` triggers `SuggestionMenuController` with filtered vault entries.
2. **Suggestion menu**: Typing `[[` triggers BlockNote's `SuggestionMenuController`, which shows a filtered list of all vault entries. Selecting one inserts a wikilink inline content node.
Wikilink resolution (`useNoteActions`) uses fuzzy matching: exact title → alias → path stem → filename stem → slug-to-words.
Wikilink resolution in `useNoteActions.handleNavigateWikilink()` uses fuzzy matching:
- Exact title match
- Alias match
- Path stem match (e.g., `person/matteo-cellini`)
- Filename stem match
- Slug-to-words match (e.g., `matteo-cellini``matteo cellini`)
### Raw Editor Mode
Toggle via Cmd+K → "Raw Editor" or breadcrumb bar button. Uses CodeMirror 6 (`useCodeMirror` hook) to edit the raw markdown + frontmatter directly. Changes saved via the same `save_note_content` command.
## Theme System
See [THEMING.md](./THEMING.md) for the full theme system documentation.
In brief: `src/theme.json` defines editor typography and styling as nested JSON. The `useEditorTheme` hook flattens it into CSS custom properties that are applied as inline styles on the BlockNote container.
### Overview
Two-layer theming:
1. **Global CSS variables** (`src/index.css`): App-wide colors via `:root`, bridged to Tailwind v4
2. **Editor theme** (`src/theme.json`): BlockNote typography, flattened to CSS vars by `useEditorTheme`
### Vault-Based Themes
Themes are markdown notes in `theme/` with `Is A: Theme` frontmatter. Each property becomes a CSS variable with `--` prefix.
```yaml
---
Is A: Theme
Description: Light theme with warm, paper-like tones
background: "#FFFFFF"
foreground: "#37352F"
accent-blue: "#155DFF"
editor-font-size: 16
editor-line-height: 1.5
---
```
### ThemeManager
`useThemeManager` hook manages the theme lifecycle:
```typescript
interface ThemeManager {
themes: ThemeFile[]
activeThemeId: string | null
activeTheme: ThemeFile | null
isDark: boolean
switchTheme(themeId: string): Promise<void>
createTheme(name?: string): Promise<string>
reloadThemes(): Promise<void>
updateThemeProperty(key: string, value: string): Promise<void>
}
```
- Detects dark backgrounds via luminance calculation → sets `color-scheme` and `data-theme-mode`
- Live preview: re-applies when active theme note is saved
- Three built-in themes: Default (light), Dark (deep navy), Minimal (high contrast)
- Legacy JSON themes (`_themes/*.json`) supported for backward compatibility
### Theme Property Editor
`ThemePropertyEditor` component provides an interactive UI for editing theme properties. Uses `themeSchema.ts` to determine input types (color picker, number slider, text field) based on property names and values.
## Inspector Abstraction
The Inspector panel (`src/components/Inspector.tsx`) is composed of four sub-panels:
The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
1. **DynamicPropertiesPanel** (`src/components/DynamicPropertiesPanel.tsx`): Renders frontmatter as editable key-value pairs with two distinct sections:
- **Editable properties** (top): frontmatter fields the user can modify — shown with interactive hover styling (`hover:bg-muted`), cursor pointer, and click-to-edit. Includes Type badge, Status pill, boolean toggles, array tag pills, and text fields.
- **Info section** (bottom, separated by border): read-only derived metadata — Modified, Created, Words, File Size. Uses muted text color (`--text-muted`) with no hover states or click interaction. These fields are computed from file metadata and content, not from frontmatter.
- Keys in `SKIP_KEYS` (`aliases`, `notion_id`, `workspace`, `is_a`, `Is A`) are hidden from the editable section since they are either internal or already displayed elsewhere (e.g., `is_a` is shown via the TypeRow badge).
2. **Relationships**: Shows `belongs_to` and `related_to` wikilinks as clickable chips.
3. **Backlinks**: Scans `allContent` for notes that reference the current note via `[[title]]` or `[[path]]`.
4. **Git History**: Shows the last few commits from `gitHistory` state.
1. **DynamicPropertiesPanel** (`src/components/DynamicPropertiesPanel.tsx`): Renders frontmatter as editable key-value pairs:
- **Editable properties** (top): Type badge, Status pill with dropdown, boolean toggles, array tag pills, text fields. Click-to-edit interaction.
- **Info section** (bottom, separated by border): Read-only derived metadata — Modified, Created, Words, File Size. Uses muted styling with no interaction.
- Keys in `SKIP_KEYS` (`aliases`, `notion_id`, `workspace`, `is_a`, `Is A`) are hidden from the editable section.
Frontmatter parsing on the TypeScript side is handled by `src/utils/frontmatter.ts:parseFrontmatter()`, a lightweight YAML parser that handles strings, booleans, inline arrays, and multi-line lists.
2. **RelationshipsPanel**: Shows `belongs_to`, `related_to`, and all custom relationship fields as clickable wikilink chips.
3. **BacklinksPanel**: Scans `allContent` for notes that reference the current note via `[[title]]` or `[[path]]`.
4. **GitHistoryPanel**: Shows recent commits from file history with relative timestamps.
## Search & Indexing
### Search Modes
```typescript
type SearchMode = 'keyword' | 'semantic' | 'hybrid'
interface SearchResult {
title: string
path: string
snippet: string
score: number
}
interface SearchResponse {
results: SearchResult[]
elapsedMs: number
}
```
### Search Integration
`SearchPanel` component provides the search UI:
- Mode selector (keyword/semantic/hybrid)
- Real-time results as user types
- Click result to open note in editor
- Shows relevance score and snippet
### Indexing
Managed by `useIndexing` hook:
- Checks index status on vault load
- Two-phase indexing: scanning (parse files) → embedding (generate vectors)
- Progress streamed via Tauri events
- Incremental updates after git sync
- Metadata persisted in `.laputa-index.json`
## Vault Management
### Vault Switching
`useVaultSwitcher` hook manages multiple vaults:
- Persists vault list to `~/.config/com.laputa.app/vaults.json`
- Switching closes all tabs and resets sidebar
- Supports adding, removing, hiding/restoring vaults
- Default vault: Getting Started demo vault
### Vault Config
Per-vault settings stored in `config/ui.config.md`:
- Editable as a normal note (YAML frontmatter)
- Managed by `useVaultConfig` hook and `vaultConfigStore`
- Settings: zoom, view mode, tag colors, status colors, property display modes
- One-time migration from localStorage (`configMigration.ts`)
### Getting Started / Onboarding
`useOnboarding` hook detects first launch:
- If vault path doesn't exist → show `WelcomeScreen`
- User can create Getting Started vault or open existing folder
- Welcome state tracked in localStorage (`laputa_welcome_dismissed`)
### GitHub Integration
Device Authorization Flow for GitHub-backed vaults:
- `GitHubDeviceFlow` component handles OAuth
- `GitHubVaultModal` for cloning existing repos or creating new ones
- Token persisted in app settings for future git operations
- `SettingsPanel` shows connection status with disconnect option
## Settings
App-level settings persisted at `~/.config/com.laputa.app/settings.json`:
```typescript
interface Settings {
anthropic_key: string | null
openai_key: string | null
google_key: string | null
github_token: string | null
github_username: string | null
auto_pull_interval_minutes: number | null
}
```
Managed by `useSettings` hook and `SettingsPanel` component.

View File

@@ -9,46 +9,58 @@ Laputa is a personal knowledge and life management desktop app. It reads a vault
| Desktop shell | Tauri v2 | 2.10.0 |
| Frontend | React + TypeScript | React 19, TS 5.9 |
| Editor | BlockNote | 0.46.2 |
| Raw editor | CodeMirror 6 | - |
| Styling | Tailwind CSS v4 + CSS variables | 4.1.18 |
| UI primitives | Radix UI + shadcn/ui | - |
| Icons | Phosphor Icons + Lucide | - |
| Build | Vite | 7.3.1 |
| Backend language | Rust (edition 2021) | 1.77.2 |
| Frontmatter parsing | gray_matter | 0.2 |
| AI | Anthropic Claude API (Haiku 3.5 default) | - |
| AI (in-app chat) | Anthropic Claude API (Haiku 3.5 default) | - |
| AI (agent panel) | Claude CLI subprocess (streaming NDJSON) | - |
| Search | qmd (keyword + semantic + hybrid) | - |
| MCP | @modelcontextprotocol/sdk | 1.0 |
| Tests | Vitest (unit), Playwright (E2E), cargo test (Rust) | - |
| Tests | Vitest (unit), Playwright (E2E/smoke), cargo test (Rust) | - |
| Package manager | pnpm | - |
## System Overview
```
┌─────────────────────────────────────────────────────────────┐
│ Tauri v2 Window │
│ │
│ ┌─────────────────── React Frontend ───────────────────┐
│ │ │
│ │ App.tsx (orchestrator) │
│ │ ├── Sidebar (navigation + filters)
│ │ ├── NoteList (filtered note list)
│ │ ├── Editor (BlockNote + tabs + diff)
│ │ │ ├── Inspector (metadata + relationships) │
│ │ │ ── AIChatPanel (AI assistant + context)
│ │ ├── StatusBar (footer info) │
│ │ └── Modals (QuickOpen, CreateNote, CommitDialog) │
│ │
└──────────────┬──────────┬──────────────────────────┘
│ │
Tauri IPC│ Vite Proxy / WS
┌──────────────▼────┐ ┌──▼───────────────────────────┐
│ │ Rust Backend │ │ External Services
│ lib.rs → 10 cmds │ │ Anthropic API (Claude) │
│ vault/ MCP Server (ws://9710) │
frontmatter.rs │ │
│ git.rs │ └──────────────────────────────
│ │ ai_chat.rs │
└───────────────────┘
└─────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────
Tauri v2 Window
│ ┌─────────────────── React Frontend ────────────────────────┐ │
│ │ │ │
│ │ App.tsx (orchestrator) │ │
│ │ ├── WelcomeScreen (onboarding / vault-missing) │ │
│ │ ├── Sidebar (navigation + filters + types) │ │
│ │ ├── NoteList / PulseView (filtered list / activity) │ │
│ │ ├── Editor (BlockNote + tabs + diff + raw) │
│ │ │ ── Inspector (metadata + relationships) │ │
│ │ │ ├── AIChatPanel (API-based chat) │ │
│ │ │ └── AiPanel (Claude CLI agent + tools) │
│ │ ├── SearchPanel (keyword/semantic/hybrid search) │ │
│ ├── SettingsPanel (API keys, GitHub, zoom, theme) │
├── StatusBar (vault picker + sync + version) │
├── CommandPalette (Cmd+K fuzzy command launcher) │
│ └── Modals (CreateNote, CreateType, Commit, GitHub) │
│ │ │ │
└──────────────┬──────────┬──────────────────────────────────┘
Tauri IPC│ Vite Proxy / WS
┌──────────────▼────┐ ┌──▼────────────────────────────────
│ │ Rust Backend │ │ External Services
│ lib.rs → 61 cmds │ │ Anthropic API (Claude chat)
│ │ vault/ │ │ Claude CLI (agent subprocess) │ │
│ │ frontmatter/ │ │ MCP Server (ws://9710, 9711) │ │
│ │ git/ │ │ qmd (search/indexing engine) │ │
│ │ github/ │ │ GitHub API (OAuth, repos, clone) │ │
│ │ theme/ │ │ │ │
│ │ search.rs │ └───────────────────────────────────┘ │
│ │ indexing.rs │ │
│ │ claude_cli.rs │ │
│ └───────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
```
## Four-Panel Layout
@@ -57,71 +69,90 @@ Laputa is a personal knowledge and life management desktop app. It reads a vault
┌────────┬─────────────┬─────────────────────────┬────────────┐
│Sidebar │ Note List │ Editor │ Inspector │
│(250px) │ (300px) │ (flex-1) │ (280px) │
│ │ │ │ OR │
│ All │ [Search] │ [Tab Bar] │ AI Chat │
│ Favs │ [Type Pill] │ [Breadcrumb Bar] │
│ │ OR │ │ OR │
│ All │ Pulse View │ [Tab Bar] │ AI Chat │
│ Favs │ │ [Breadcrumb Bar] │ OR
│ Changes│ [Search] │ │ AI Agent │
│ Pulse │ [Sort/Filt] │ # My Note │ │
│ │ │ │ Context │
│Projects│ Note 1 │ # My Note │ Messages │
│Experim.│ Note 2 │ │ Actions │
│Respons.│ Note 3 │ Content here... │ Input │
│Procedu.│ ... │ │ │
│People │ │ │ │
│Projects│ Note 1 │ Content here... │ Messages │
│Experim.│ Note 2 │ (BlockNote or Raw) │ Actions │
│Respons.│ Note 3 │ │ Input │
│People │ ... │ │ │
│Events │ │ │ │
│Topics │ │ │ │
├────────┴─────────────┴─────────────────────────┴────────────┤
│ StatusBar: v0.4.2 │ main │ Synced 2m ago │ 3 pending notes
└─────────────────────────────────────────────────────────────┘
│ StatusBar: v0.4.2 │ main │ Synced 2m ago │ Vault: ~/Laputa
└─────────────────────────────────────────────────────────────
```
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Favorites) and collapsible section groups (Projects, Experiments, Responsibilities, etc.)
- **Note List** (200-500px, resizable): Filtered list of notes matching the sidebar selection. Shows snippets, modified dates, relationship groups, and orange dot indicators for uncommitted modified notes.
- **Editor** (flex, fills remaining space): Tab bar (with orange modified dots on dirty tabs), breadcrumb bar with word count and modified indicator, BlockNote editor with wikilink support. Can toggle to diff view for modified files. Decomposed into focused subcomponents: `Editor` (orchestrator), `EditorContent` (breadcrumb + editor/diff views), `EditorRightPanel` (inspector/AI toggle), `SingleEditorView` (BlockNote + suggestions), with hooks `useDiffMode` and `useEditorFocus`.
- **Inspector / AI Chat** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, backlinks, git history) and AI Chat panel. The Sparkle icon in the breadcrumb bar toggles between them.
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Favorites, Changes, Pulse) and collapsible type-based section groups. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
- **Note List / Pulse View** (200-500px, resizable): When a section group or filter is selected, shows filtered notes with snippets, modified dates, and status indicators. When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
- **Editor** (flex, fills remaining space): Tab bar with modified dots, breadcrumb bar with word count, BlockNote rich text editor with wikilink support. Can toggle to diff view (modified files) or raw CodeMirror view. Decomposed into `Editor` (orchestrator), `EditorContent`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, `useEditorSave`, `useRawMode`.
- **Inspector / AI Chat / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, backlinks, git history), AI Chat panel (API-based), and AI Agent panel (Claude CLI subprocess with tool execution). The Sparkle icon in the breadcrumb bar toggles between them.
Panels are separated by `ResizeHandle` components that support drag-to-resize.
## AI Chat System
## AI System
### Architecture
Laputa has two AI interfaces with distinct architectures:
The AI chat feature has three layers:
### AI Chat (AIChatPanel)
Simple chat mode — no tool execution, streaming text responses.
1. **Frontend** (`AIChatPanel` + `useAIChat` hook) — UI and state management
2. **API Proxy** (Vite middleware in dev, Rust `ai_chat` command in Tauri) — routes to Anthropic
3. **MCP Server** (`mcp-server/`) — vault operation tools for AI assistants
3. **Context picker** — selected notes sent as system context with token estimation
### Data Flow
### AI Agent (AiPanel)
Full agent mode — spawns Claude CLI as a subprocess with tool access and MCP vault integration.
1. **Frontend** (`AiPanel` + `useAiAgent` hook) — streaming UI with reasoning blocks, tool action cards, and response display
2. **Backend** (`claude_cli.rs`) — spawns `claude` binary with `--output-format stream-json`, parses NDJSON events
3. **MCP Integration** — passes vault MCP config via `--mcp-config` flag so the agent can search, read, and modify vault notes
#### Agent Event Flow
```
User types message in AIChatPanel
→ useAIChat.sendMessage(text)
→ buildSystemPrompt(contextNotes, allContent, model)
→ Assembles selected notes as system context
Estimates tokens, truncates if needed
→ streamChat(messages, systemPrompt, model, callbacks)
→ POST /api/ai/chat (Vite proxy → Anthropic API)
SSE stream parsed, chunks dispatched to onChunk callback
→ UI updates in real-time as tokens arrive
→ On completion: message added to conversation history
User sends message in AiPanel
→ useAiAgent.sendMessage(text, references)
→ buildContextSnapshot(activeNote, linkedNotes, openTabs)
→ invoke('stream_claude_agent', { message, systemPrompt, vaultPath })
Rust spawns: claude -p <msg> --output-format stream-json --mcp-config <json>
→ NDJSON lines parsed into ClaudeStreamEvent variants:
Init, TextDelta, ThinkingDelta, ToolStart, ToolDone, Result, Error, Done
Events emitted via Tauri: app_handle.emit("claude-agent-stream", &event)
→ Frontend listener routes events:
onText → accumulate response (revealed on Done)
onThinking → show reasoning block (collapsed on first text)
onToolStart → add AiActionCard with spinner
onToolDone → update card with output
onDone → reveal full response, detect file operations
```
### Context Picker
#### File Operation Detection
The context picker controls which notes are sent to the AI as context:
When the agent writes or edits vault files, `useAiAgent` detects this from tool inputs (Write/Edit tool JSON) and calls `onFileCreated` or `onFileModified` callbacks to trigger vault reload.
- **Current note** is auto-added when the panel opens
- **Add button** opens a search dropdown to select additional notes
- **Token estimation** shows approximate context size (~4 chars/token)
- **Truncation** kicks in when context exceeds 60% of model limit (108k tokens)
- Context pills show selected notes with remove buttons
### Context Building
### API Key Management
Both AI modes use context from the active note and linked entries. The agent panel (`ai-context.ts`) builds a structured JSON snapshot:
- Stored in `localStorage` under key `laputa:anthropic-api-key`
- Configurable via the key icon in the AI Chat header
- When no key is set, falls back to mock responses for testing
```json
{
"activeNote": { "path", "title", "type", "frontmatter", "content" },
"linkedNotes": [{ "path", "title", "content" }],
"openTabs": [{ "title", "snippet" }],
"vaultMetadata": { "noteTypes", "stats", "filter" },
"references": [{ "title", "path", "type" }]
}
```
### Models
Token budget: 60% of 180k context limit (~108k tokens max). Active note gets priority, then linked notes, then truncation.
### Models (Chat mode)
| Model | ID | Use case |
|-------|----|----------|
@@ -129,11 +160,17 @@ The context picker controls which notes are sent to the AI as context:
| Sonnet 4 | `claude-sonnet-4-20250514` | Balanced |
| Opus 4 | `claude-opus-4-20250514` | Most capable |
### MCP Server
### API Key Management
- Stored in app settings (`~/.config/com.laputa.app/settings.json`) under `anthropic_key`
- Configurable via Settings panel (also supports `openai_key`, `google_key`)
- Claude CLI (agent mode) uses its own authentication — no API key needed
## MCP Server
The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistants (Claude Code, Cursor, or any MCP-compatible client).
#### Tool Surface (14 tools)
### Tool Surface (14 tools)
| Tool | Params | Description |
|------|--------|-------------|
@@ -152,24 +189,22 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
| `ui_highlight` | `element, [path]` | Highlight a UI element (editor, tab, properties, notelist) |
| `ui_set_filter` | `type` | Set the sidebar filter to a specific type |
#### Transports
### Transports
- **stdio** — standard MCP transport for Claude Code / Cursor (`node mcp-server/index.js`)
- **WebSocket** — live bridge for Laputa app integration:
- Port **9710**: Tool bridge — AI/Claude clients call vault tools here
- Port **9711**: UI bridge — Frontend listens for UI action broadcasts from MCP tools
#### Auto-Registration
### Auto-Registration
On app startup, Laputa automatically registers itself as an MCP server in:
- `~/.claude/mcp.json` (Claude Code)
- `~/.cursor/mcp.json` (Cursor)
The registration is non-destructive (additive preserves other MCP servers) and uses `upsert` semantics. The entry points to `mcp-server/index.js` with the active vault path as `VAULT_PATH` env var.
Registration is non-destructive (additive, preserves other servers) and uses `upsert` semantics. The `useMcpStatus` hook tracks registration state (`checking | installed | not_installed | no_claude_cli`).
Registration also runs from the frontend via the `useMcpRegistration` hook and `register_mcp_tools` Tauri command, ensuring the config stays up-to-date when the vault path changes.
#### Architecture
### Architecture
```
┌─────────────────────────────────────────────────────┐
@@ -200,16 +235,16 @@ The WebSocket bridge enables real-time vault operations from both the frontend a
```
Frontend (useMcpBridge) ←→ ws://localhost:9710 ←→ ws-bridge.js ←→ vault.js
MCP stdio tools ←→ ws://localhost:9711 ←→ Frontend UI actions
MCP stdio tools ←→ ws://localhost:9711 ←→ Frontend UI actions (useAiActivity)
```
**Tool bridge protocol** (port 9710):
- Request: `{ "id": "req-1", "tool": "search_notes", "args": { "query": "test" } }`
- Response: `{ "id": "req-1", "result": { ... } }`
- Error: `{ "id": "req-1", "error": "message" }`
**UI bridge protocol** (port 9711):
- Broadcast: `{ "type": "ui_action", "action": "open_note", "path": "..." }`
- `useAiActivity` hook receives these and applies them (highlight with 800ms feedback, open note, set filter, etc.)
### Rust MCP Module
@@ -223,29 +258,132 @@ MCP stdio tools ←→ ws://localhost:9711 ←→ Frontend UI actions
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is killed on app exit via `RunEvent::Exit` handler.
### Rust Backend (Tauri)
## Search & Indexing
The `ai_chat` Tauri command (`src-tauri/src/ai_chat.rs`) provides a non-streaming alternative:
- Uses `reqwest` to call the Anthropic Messages API directly
- API key from `ANTHROPIC_API_KEY` environment variable
- Returns full response (not streamed)
- Used in production Tauri builds where Vite proxy is unavailable
### Search Engine
### Files
Search uses the external `qmd` binary (semantic search engine) with three modes:
| File | Purpose |
|------|---------|
| `src/components/AIChatPanel.tsx` | Main UI: context bar, messages, input, quick actions |
| `src/hooks/useAIChat.ts` | Chat state: messages, streaming, send/retry/clear |
| `src/hooks/useMcpBridge.ts` | WebSocket client for MCP vault tool calls |
| `src/hooks/useMcpRegistration.ts` | Auto-registers Laputa MCP on vault load |
| `src/utils/ai-chat.ts` | API client, token estimation, context builder |
| `src-tauri/src/ai_chat.rs` | Rust Anthropic API client (non-streaming) |
| `src-tauri/src/mcp.rs` | MCP server spawning + config registration |
| `mcp-server/index.js` | MCP server entry (stdio transport, 14 tools) |
| `mcp-server/vault.js` | Vault file operations (9 functions) |
| `mcp-server/ws-bridge.js` | WebSocket bridge server (tool + UI bridges) |
| `mcp-server/test.js` | 26 unit tests for all vault.js functions |
| Mode | Command | Description |
|------|---------|-------------|
| `keyword` | `qmd search` | Term matching (default) |
| `semantic` | `qmd vsearch` | Vector similarity search |
| `hybrid` | `qmd query` | Combined keyword + semantic |
### Indexing Flow
```
Vault opened
→ check_index_status() → parse qmd status output
→ if stale or missing:
→ start_indexing() (two phases):
Phase 1 (Scanning): qmd update — scan all .md files
Phase 2 (Embedding): qmd embed — generate vector embeddings
→ Progress streamed via Tauri "indexing-progress" event
→ Metadata saved to .laputa-index.json (last_indexed_commit, timestamp)
→ run_incremental_update() for subsequent changes
```
Embedding failure is non-fatal — keyword search still works.
### qmd Binary Resolution
1. Bundled macOS app resource: `<app>/Contents/Resources/qmd/qmd`
2. Dev mode: `CARGO_MANIFEST_DIR/resources/qmd/qmd`
3. System locations: `~/.bun/bin/qmd`, `/usr/local/bin/qmd`, `/opt/homebrew/bin/qmd`
4. PATH lookup via `which qmd`
5. Auto-install via `bun install -g qmd` if missing
## Vault Cache System
The vault cache (`src-tauri/src/vault/cache.rs`) accelerates vault scanning using git-based incremental updates.
### Cache File
`.laputa-cache.json` at vault root. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v5 (bumped on VaultEntry field changes to force full rescan).
### Three Cache Strategies
1. **Same Commit (Cache Hit)**: Git HEAD matches cached hash → only re-parse uncommitted changed files via `git status --porcelain`
2. **Different Commit (Incremental Update)**: Uses `git diff <old>..<new> --name-only` to find changed files + uncommitted changes → selective re-parse
3. **No Cache / Corrupt Cache (Full Scan)**: Recursive `walkdir` of all `.md` files → full parse
Cache auto-excludes itself from git via `.git/info/exclude`.
## Theme System
See [THEMING.md](./THEMING.md) for the full theme system documentation.
### Two-Layer Architecture
1. **Global CSS variables** (`src/index.css`): App-wide colors, borders, backgrounds. Bridged to Tailwind v4 via `@theme inline`.
2. **Editor theme** (`src/theme.json`): BlockNote-specific typography. Flattened to CSS vars by `useEditorTheme`.
### Vault-Based Themes
Themes are markdown notes in the `theme/` folder with `Is A: Theme` frontmatter. Each frontmatter property becomes a CSS variable. Managed by `useThemeManager` hook and the `src-tauri/src/theme/` Rust module (create, seed, defaults).
- **Vault settings**: `.laputa/settings.json` stores the active theme reference
- **Legacy support**: `_themes/*.json` files still supported for backward compatibility
- **Built-in themes**: Default (light), Dark, Minimal — auto-seeded on vault open
- **Live preview**: Re-applies when the active theme note is saved
## Vault Management
### Vault List
Persisted at `~/.config/com.laputa.app/vaults.json`:
```json
{
"vaults": [{ "label": "My Vault", "path": "/path/to/vault" }],
"active_vault": "/path/to/vault",
"hidden_defaults": []
}
```
Managed by `useVaultSwitcher` hook. Switching vaults closes all tabs and resets sidebar.
### Vault Config
Per-vault UI settings stored in `config/ui.config.md` (YAML frontmatter in a markdown note):
- `zoom`: Float zoom level (0.81.5)
- `view_mode`: "all" | "editor-list" | "editor-only"
- `tag_colors`, `status_colors`: Custom color overrides
- `property_display_modes`: Property display preferences
### Getting Started Vault
On first launch, `useOnboarding` checks if the default vault exists. If not, shows `WelcomeScreen` with two options:
- **Create Getting Started vault** → calls `create_getting_started_vault()` Tauri command
- **Open an existing folder** → system file picker
### GitHub OAuth Integration
Implements GitHub Device Authorization Flow for cloning/creating GitHub-backed vaults.
**Flow:**
1. User clicks "Login with GitHub" in Settings panel
2. `github_device_flow_start()` returns a user code + verification URL
3. User authorizes at `github.com/login/device`
4. App polls `github_device_flow_poll()` until authorized
5. Token stored in `~/.config/com.laputa.app/settings.json`
**Vault operations:**
- `GitHubVaultModal`: Clone existing repo or create new private/public repo
- `clone_repo()`: Clones with token-injected HTTPS URL
- Token persists for future git push/pull operations
## Pulse View
`PulseView` is a git activity feed that replaces the NoteList when the Pulse filter is selected.
- Groups commits by day ("Today", "Yesterday", or full date)
- Shows commit message, short hash, timestamp, and changed files
- Files have status icons (added/modified/deleted) and are clickable to open in editor
- Links to GitHub commits when `githubUrl` is available
- Infinite scroll pagination (20 commits per page) via Intersection Observer
Backend: `get_vault_pulse` Tauri command parses `git log` with `--name-status`.
## Data Flow
@@ -253,93 +391,199 @@ The `ai_chat` Tauri command (`src-tauri/src/ai_chat.rs`) provides a non-streamin
```
1. Tauri setup:
a. run_startup_tasks() → purge trash, migrate frontmatter, register MCP config
a. run_startup_tasks() → purge trash, migrate frontmatter, seed themes, register MCP
b. spawn_ws_bridge() → start MCP WebSocket bridge (ports 9710, 9711)
2. App mounts
3. useVaultLoader fires:
a. isTauri() ? invoke('list_vault') : mockInvoke('list_vault')
→ VaultEntry[] stored in state
b. Load all content (mock mode) or on-demand (Tauri mode)
c. invoke('get_modified_files') → ModifiedFile[] stored in state
d. useMcpRegistration → invoke('register_mcp_tools') → ensures MCP config current
4. User clicks note in NoteList
4. useNoteActions.handleSelectNote:
a. invoke('get_note_content') → raw markdown string
3. useOnboarding checks vault exists → WelcomeScreen if not
4. useVaultLoader fires:
a. invoke('list_vault', { path }) → scan_vault_cached() → VaultEntry[]
b. Load modified files via invoke('get_modified_files')
c. useMcpStatus → register MCP if needed
d. useThemeManager → load and apply active theme
e. useIndexing → check index status, trigger incremental update if needed
5. User clicks note in NoteList
6. useNoteActions.handleSelectNote:
a. invoke('get_note_content') → raw markdown
b. Add tab { entry, content } to tabs state
c. Set activeTabPath
5. Editor renders BlockNoteTab:
7. Editor renders BlockNoteTab:
a. splitFrontmatter(content) → [yaml, body]
b. preProcessWikilinks(body) → replaces [[target]] with tokens
c. editor.tryParseMarkdownToBlocks(preprocessed)
d. injectWikilinks(blocks) → replaces tokens with wikilink nodes
e. editor.replaceBlocks()
6. Inspector renders frontmatter parsed from content
8. Inspector renders frontmatter parsed from content
```
### Frontmatter Edit Flow
### Auto-Save Flow
```
User edits property in Inspector
handleUpdateFrontmatter(path, key, value)
Tauri: invoke('update_frontmatter') → Rust reads file, modifies YAML, writes back
Mock: updateMockFrontmatter() → client-side YAML manipulation
Update tab content in state
→ Update allContent for backlink recalculation
→ Toast: "Property updated"
Editor content changes
useEditorSave detects change (debounced)
serialize BlockNote blocks → markdown
postProcessWikilinks → restore [[target]] syntax
invoke('save_note_content', { path, content })
→ Update tab status indicator
```
### Git Flow
### Git Sync Flow
```
User clicks Commit button → CommitDialog opens
handleCommitPush(message)
→ invoke('git_commit') → git add -A && git commit -m "..."
→ invoke('git_push') → git push
useAutoSync (configurable interval, default from settings):
invoke('git_pull') → GitPullResult
→ if conflicts → ConflictResolverModal
→ if fast-forward → reload vault
→ invoke('git_push') → GitPushResult
Manual commit:
→ CommitDialog → invoke('git_commit', { message })
→ invoke('git_push')
→ Reload modified files
→ Toast: "Committed and pushed"
```
## Vault Module Structure
The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| File | Purpose | CodeScene Health |
|------|---------|-----------------|
| `mod.rs` | Core types (`VaultEntry`, `Frontmatter`), `parse_md_file`, `scan_vault`, relationship extraction | 10.0 |
| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title` | 9.68 |
| `cache.rs` | Git-based incremental vault caching (`scan_vault_cached`), git helpers | 9.68 |
| `trash.rs` | `purge_trash` — deletes trashed notes older than 30 days | 9.38 |
| `rename.rs` | `rename_note` — renames files and updates wikilinks across the vault | 9.68 |
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames | 10.0 |
| File | Purpose |
|------|---------|
| `mod.rs` | Core types (`VaultEntry`, `Frontmatter`), `parse_md_file`, `scan_vault`, relationship/link extraction |
| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title` |
| `cache.rs` | Git-based incremental vault caching (`scan_vault_cached`), git helpers |
| `trash.rs` | `purge_trash` — deletes trashed notes older than 30 days |
| `rename.rs` | `rename_note` — renames files and updates wikilinks across the vault |
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames |
| `migration.rs` | Frontmatter migration utilities |
| `getting_started.rs` | Creates the Getting Started demo vault |
Public API (re-exported from `mod.rs`): `scan_vault_cached`, `save_image`, `rename_note`, `RenameResult`, `purge_trash`, `get_note_content`, `parse_md_file`, `VaultEntry`.
## Rust Backend Modules
## Tauri IPC Commands
| Module | Purpose |
|--------|---------|
| `vault/` | Vault scanning, caching, parsing, trash, rename, image, migration |
| `frontmatter/` | YAML frontmatter read/write (`mod.rs`, `yaml.rs`, `ops.rs`) |
| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`) |
| `github/` | GitHub OAuth + API (`auth.rs`, `api.rs`, `clone.rs`) |
| `theme/` | Theme management (`mod.rs`, `create.rs`, `defaults.rs`, `seed.rs`) |
| `search.rs` | qmd search integration (keyword/semantic/hybrid) |
| `indexing.rs` | qmd indexing with progress streaming |
| `claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing |
| `ai_chat.rs` | Direct Anthropic API client (non-streaming, for Tauri builds) |
| `mcp.rs` | MCP server spawning + config registration |
| `commands.rs` | All 61 Tauri command handlers |
| `settings.rs` | App settings persistence |
| `vault_config.rs` | Per-vault UI config |
| `vault_list.rs` | Vault list persistence |
| `menu.rs` | Native macOS menu bar |
All commands are defined in `src-tauri/src/lib.rs` and registered via `tauri::generate_handler![]`.
## Tauri IPC Commands (61 total)
| Command | Params | Returns | Backend function |
|---------|--------|---------|-----------------|
| `list_vault` | `path` | `Vec<VaultEntry>` | `vault::scan_vault()` |
| `get_note_content` | `path` | `String` | `vault::get_note_content()` |
| `update_frontmatter` | `path, key, value` | `String` (updated content) | `frontmatter::with_frontmatter()` |
| `delete_frontmatter_property` | `path, key` | `String` (updated content) | `frontmatter::with_frontmatter()` |
| `get_file_history` | `vault_path, path` | `Vec<GitCommit>` | `git::get_file_history()` |
| `get_modified_files` | `vault_path` | `Vec<ModifiedFile>` | `git::get_modified_files()` |
| `get_file_diff` | `vault_path, path` | `String` (unified diff) | `git::get_file_diff()` |
| `git_commit` | `vault_path, message` | `String` | `git::git_commit()` |
| `git_push` | `vault_path` | `String` | `git::git_push()` |
| `ai_chat` | `request: AiChatRequest` | `AiChatResponse` | `ai_chat::send_chat()` |
| `register_mcp_tools` | `vault_path` | `String` ("registered" or "updated") | `mcp::register_mcp()` |
### Vault Operations
All commands return `Result<T, String>`. Errors are serialized as JSON error objects to the frontend.
| Command | Description |
|---------|-------------|
| `list_vault` | Scan vault (cached) → `Vec<VaultEntry>` |
| `get_note_content` | Read note file content |
| `save_note_content` | Write note content to disk |
| `delete_note` | Move note to trash |
| `rename_note` | Rename note + update cross-vault wikilinks |
| `batch_archive_notes` | Archive multiple notes |
| `batch_trash_notes` | Trash multiple notes |
| `purge_trash` | Delete notes trashed >30 days ago |
| `check_vault_exists` | Check if vault path exists |
| `create_getting_started_vault` | Bootstrap demo vault |
### Frontmatter
| Command | Description |
|---------|-------------|
| `update_frontmatter` | Update a frontmatter property |
| `delete_frontmatter_property` | Remove a frontmatter property |
### Git
| Command | Description |
|---------|-------------|
| `git_commit` | Stage all + commit |
| `git_pull` | Pull from remote |
| `git_push` | Push to remote |
| `git_resolve_conflict` | Resolve a merge conflict |
| `git_commit_conflict_resolution` | Commit conflict resolution |
| `get_file_history` | Last N commits for a file |
| `get_modified_files` | `git status` filtered to .md |
| `get_file_diff` | Unified diff for a file |
| `get_file_diff_at_commit` | Diff at a specific commit |
| `get_conflict_files` | List conflicted files |
| `get_conflict_mode` | Get conflict resolution mode |
| `get_vault_pulse` | Git activity feed (paginated) |
| `get_last_commit_info` | Latest commit metadata |
### GitHub
| Command | Description |
|---------|-------------|
| `github_device_flow_start` | Begin OAuth device flow |
| `github_device_flow_poll` | Poll for authorization |
| `github_get_user` | Get authenticated user info |
| `github_list_repos` | List user's repos |
| `github_create_repo` | Create new repo |
| `clone_repo` | Clone repo with token auth |
### Search & Indexing
| Command | Description |
|---------|-------------|
| `search_vault` | Search via qmd (keyword/semantic/hybrid) |
| `get_index_status` | Check qmd index state |
| `start_indexing` | Full index with progress streaming |
| `trigger_incremental_index` | Incremental index update |
### Theme
| Command | Description |
|---------|-------------|
| `list_themes` | List all themes (legacy JSON) |
| `get_theme` | Read a theme file |
| `get_vault_settings` | Read `.laputa/settings.json` |
| `save_vault_settings` | Write vault settings |
| `set_active_theme` | Set active theme ID |
| `create_theme` | Create JSON theme from template |
| `create_vault_theme` | Create markdown theme note |
| `ensure_vault_themes` | Seed default themes if missing |
| `restore_default_themes` | Restore all default themes |
### AI & MCP
| Command | Description |
|---------|-------------|
| `ai_chat` | Direct Anthropic API call (non-streaming) |
| `stream_claude_chat` | Claude CLI chat mode (streaming) |
| `stream_claude_agent` | Claude CLI agent mode (streaming + tools) |
| `check_claude_cli` | Check if Claude CLI is available |
| `register_mcp_tools` | Register MCP in Claude/Cursor config |
| `check_mcp_status` | Check MCP registration state |
### Settings & Config
| Command | Description |
|---------|-------------|
| `get_settings` | Load app settings |
| `save_settings` | Save app settings |
| `load_vault_list` | Load vault list |
| `save_vault_list` | Save vault list |
| `get_vault_config` | Load per-vault UI config |
| `save_vault_config` | Save per-vault UI config |
| `get_default_vault_path` | Get default vault path |
| `get_build_number` | Get app build number |
| `save_image` | Save base64 image to vault |
| `copy_image_to_vault` | Copy image file to vault |
| `update_menu_state` | Update native menu checkmarks |
## Mock Layer
When running outside Tauri (browser at `localhost:5201`), `src/mock-tauri.ts` provides a transparent mock layer:
When running outside Tauri (browser at `localhost:5173`), `src/mock-tauri.ts` provides a transparent mock layer:
```typescript
// In hooks, the pattern is always:
if (isTauri()) {
result = await invoke<T>('command_name', { args })
} else {
@@ -347,14 +591,7 @@ if (isTauri()) {
}
```
The mock layer includes:
- **15 sample entries** across all entity types (Project, Responsibility, Procedure, Experiment, Note, Person, Event, Topic, Essay)
- **Full markdown content** with realistic frontmatter for each entry
- **Mock git history, modified files, and diff output**
- **Mock AI chat responses** with context-aware answers (summarize, expand, grammar)
- `addMockEntry()` and `updateMockContent()` for runtime updates
This means the entire UI can be developed and tested in Chrome without the Rust backend.
The mock layer includes sample entries across all entity types, full markdown content with realistic frontmatter, mock git history, mock AI responses, and mock pulse commits.
## State Management
@@ -362,11 +599,18 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
| State owner | State | Purpose |
|-------------|-------|---------|
| `App.tsx` | `selection`, panel widths, dialog visibility, toast, `showAIChat` | UI state |
| `App.tsx` | `selection`, panel widths, dialog visibility, toast, view mode | UI state |
| `useVaultLoader` | `entries`, `allContent`, `modifiedFiles` | Vault data |
| `useNoteActions` | `tabs`, `activeTabPath` | Open tabs and note operations |
| `useAIChat` | `messages`, `isStreaming`, `streamingContent` | AI conversation state |
| `useMcpBridge` | `connected`, tool methods | MCP WebSocket connection |
| `useTabManagement` | Tab ordering, pinning, swapping | Tab lifecycle |
| `useVaultSwitcher` | `vaultPath`, `extraVaults` | Vault switching |
| `useThemeManager` | `themes`, `activeThemeId`, `isDark` | Theme state |
| `useAIChat` | `messages`, `isStreaming` | AI chat conversation |
| `useAiAgent` | `messages`, `status`, tool actions | AI agent conversation |
| `useAutoSync` | Sync interval, pull/push state | Git auto-sync |
| `useIndexing` | Index status, progress | Search indexing |
| `useSettings` | App settings (API keys, GitHub token) | Persistent settings |
| `useVaultConfig` | Per-vault UI preferences | Vault-specific config |
Data flows unidirectionally: `App` passes data and callbacks as props to child components. No child-to-child communication — everything goes through `App`.
@@ -374,10 +618,14 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
| Shortcut | Action |
|----------|--------|
| Cmd+P | Open Quick Open palette |
| Cmd+N | Open Create Note dialog |
| Cmd+S | Show "Saved" toast |
| Cmd+K | Open command palette |
| Cmd+P | Open quick open palette |
| Cmd+N | Create new note |
| Cmd+S | Save current note |
| Cmd+W | Close active tab |
| Cmd+Z / Cmd+Shift+Z | Undo / Redo |
| Cmd+19 | Switch to tab N |
| Cmd+[ / Cmd+] | Navigate back / forward |
| `[[` in editor | Open wikilink suggestion menu |
## Auto-Release & In-App Updates
@@ -399,47 +647,22 @@ push to main
→ generate latest.json (per-arch + universal platform entries)
→ publish GitHub Release with all assets + auto-generated notes
→ pages job:
→ fetch all releases via gh api
→ build static HTML release history page
→ deploy to gh-pages via peaceiris/actions-gh-pages
→ deploy to gh-pages
```
### Versioning
Format: `0.YYYYMMDD.GITHUB_RUN_NUMBER` (e.g. `0.20260223.42`). The `0.` prefix keeps it SemVer-compatible while making it clear these are date-based auto-releases. The version is stamped into both `tauri.conf.json` and `Cargo.toml` dynamically in the workflow.
Format: `0.YYYYMMDD.GITHUB_RUN_NUMBER` (e.g. `0.20260223.42`). Stamped into `tauri.conf.json` and `Cargo.toml` dynamically.
### Universal Binary
macOS builds produce both `aarch64-apple-darwin` and `x86_64-apple-darwin` in parallel. The release job merges them with `lipo` — copying the arm64 `.app` as the base and replacing only the main executable with a universal fat binary. The per-arch updater tarballs are also uploaded so the Tauri updater downloads only the relevant architecture (smaller download).
### Updater Endpoint
The Tauri updater plugin is configured to fetch:
```
https://github.com/refactoringhq/laputa-app/releases/latest/download/latest.json
```
This JSON manifest contains `version`, `pub_date`, `notes`, and per-platform entries (`darwin-aarch64`, `darwin-x86_64`) with `url` and `signature` fields. The updater compares the manifest version against the running app version, downloads the matching platform artifact, verifies the signature, and installs it.
### In-App Update UI
### In-App Updates
```
App startup (3s delay)
→ useUpdater.check()
→ idle (no update) → no UI shown
→ available → UpdateBanner: "Laputa X.Y.Z is available" + Release Notes + Update Now + X
user clicks Update Now → downloading → progress bar
download complete → ready → "Restart to apply" + Restart Now button
→ user clicks Restart → relaunch()
→ network error / 404 → fail silently, no UI
→ idle (no update) → no UI
→ available → UpdateBanner with release notes + "Update Now"
→ downloading → progress bar
→ ready → "Restart to apply" + Restart Now
→ network error → fail silently
```
| Component | File | Purpose |
|-----------|------|---------|
| `useUpdater` | `src/hooks/useUpdater.ts` | State machine: idle → available → downloading → ready → error |
| `UpdateBanner` | `src/components/UpdateBanner.tsx` | Top-of-app notification bar |
| `restartApp` | `src/hooks/useUpdater.ts` | Calls `@tauri-apps/plugin-process` relaunch |
### GitHub Pages
Release history site at `https://refactoringhq.github.io/laputa-app/`. Auto-updated by the workflow after each release. The page loads `releases.json` (deployed alongside) and renders each release with date, notes, and `.dmg` download links. Linked from the in-app "Release Notes" button.

View File

@@ -7,6 +7,7 @@ How to navigate the codebase, run the app, and find what you need.
- **Node.js** 18+ and **pnpm**
- **Rust** 1.77.2+ (for the Tauri backend)
- **git** CLI (required by the git integration features)
- **qmd** (optional — for search indexing; auto-installed if missing)
## Quick Start
@@ -24,7 +25,7 @@ pnpm tauri dev
# Run tests
pnpm test # Vitest unit tests
cargo test # Rust tests (from src-tauri/)
pnpm test:e2e # Playwright E2E tests
pnpm playwright:smoke # Playwright smoke tests
```
## Directory Structure
@@ -33,39 +34,94 @@ pnpm test:e2e # Playwright E2E tests
laputa-app/
├── src/ # React frontend
│ ├── main.tsx # Entry point (renders <App />)
│ ├── App.tsx # Root component — orchestrates 4-panel layout
│ ├── App.tsx # Root component — orchestrates layout + state
│ ├── App.css # App shell layout styles
│ ├── types.ts # Shared TS types (VaultEntry, GitCommit, etc.)
│ ├── types.ts # Shared TS types (VaultEntry, Settings, etc.)
│ ├── mock-tauri.ts # Mock Tauri layer for browser testing
│ ├── theme.json # Editor theme configuration
│ ├── index.css # Global CSS variables + Tailwind setup
│ │
│ ├── components/ # UI components
│ │ ├── Sidebar.tsx # Left panel: filters + section groups
│ ├── components/ # UI components (~98 files)
│ │ ├── Sidebar.tsx # Left panel: filters + type groups
│ │ ├── SidebarParts.tsx # Sidebar subcomponents
│ │ ├── NoteList.tsx # Second panel: filtered note list
│ │ ├── Editor.tsx # Third panel: tabs + BlockNote + diff
│ │ ├── NoteItem.tsx # Individual note item
│ │ ├── PulseView.tsx # Git activity feed (replaces NoteList)
│ │ ├── Editor.tsx # Third panel: tabs + editor orchestration
│ │ ├── EditorContent.tsx # Editor content area
│ │ ├── EditorRightPanel.tsx # Right panel toggle
│ │ ├── editorSchema.tsx # BlockNote schema + wikilink type
│ │ ├── RawEditorView.tsx # CodeMirror raw editor
│ │ ├── Inspector.tsx # Fourth panel: metadata + relationships
│ │ ├── DynamicPropertiesPanel.tsx # Editable frontmatter properties
│ │ ├── EditableValue.tsx # Inline value editor component
│ │ ├── DiffView.tsx # Git diff viewer
│ │ ├── ResizeHandle.tsx # Draggable panel divider
│ │ ├── StatusBar.tsx # Bottom status bar
│ │ ├── QuickOpenPalette.tsx # Cmd+P command palette
│ │ ├── CreateNoteDialog.tsx # New note modal
│ │ ├── AIChatPanel.tsx # AI chat (API-based)
│ │ ├── AiPanel.tsx # AI agent (Claude CLI subprocess)
│ │ ├── AiMessage.tsx # Agent message display
│ │ ├── AiActionCard.tsx # Agent tool action cards
│ │ ├── SearchPanel.tsx # Search interface
│ │ ├── SettingsPanel.tsx # App settings
│ │ ├── StatusBar.tsx # Bottom bar: vault picker + sync
│ │ ├── CommandPalette.tsx # Cmd+K command launcher
│ │ ├── TabBar.tsx # Tab management
│ │ ├── BreadcrumbBar.tsx # Breadcrumb + word count + actions
│ │ ├── WelcomeScreen.tsx # Onboarding screen
│ │ ├── GitHubVaultModal.tsx # GitHub vault clone/create
│ │ ├── GitHubDeviceFlow.tsx # GitHub OAuth device flow
│ │ ├── ThemePropertyEditor.tsx # Interactive theme editor
│ │ ├── ConflictResolverModal.tsx # Git conflict resolution
│ │ ├── CommitDialog.tsx # Git commit modal
│ │ ├── Toast.tsx # Toast notifications
│ │ ├── Editor.css # Editor layout styles
│ │ ├── EditorTheme.css # BlockNote theme overrides
│ │ ── ui/ # shadcn/ui primitives (button, dialog, etc.)
│ │ ├── CreateNoteDialog.tsx # New note modal
│ │ ├── CreateTypeDialog.tsx # New type modal
│ │ ├── UpdateBanner.tsx # In-app update notification
│ │ ── inspector/ # Inspector sub-panels
│ │ │ ├── BacklinksPanel.tsx
│ │ │ ├── RelationshipsPanel.tsx
│ │ │ ├── GitHistoryPanel.tsx
│ │ │ └── ...
│ │ └── ui/ # shadcn/ui primitives
│ │ ├── button.tsx, dialog.tsx, input.tsx, ...
│ │
│ ├── hooks/ # Custom React hooks
│ │ ├── useVaultLoader.ts # Loads vault entries, git status, content
│ │ ├── useNoteActions.ts # Tab management, frontmatter CRUD, navigation
│ │ ── useTheme.ts # Flattens theme.json into CSS variables
│ ├── hooks/ # Custom React hooks (~87 files)
│ │ ├── useVaultLoader.ts # Loads vault entries + content
│ │ ├── useVaultSwitcher.ts # Multi-vault management
│ │ ── useVaultConfig.ts # Per-vault UI settings
│ │ ├── useNoteActions.ts # Tab management, navigation, CRUD
│ │ ├── useTabManagement.ts # Tab ordering + lifecycle
│ │ ├── useAIChat.ts # AI chat state
│ │ ├── useAiAgent.ts # AI agent state + tool tracking
│ │ ├── useAiActivity.ts # MCP UI bridge listener
│ │ ├── useAutoSync.ts # Auto git pull/push
│ │ ├── useConflictResolver.ts # Git conflict handling
│ │ ├── useEditorSave.ts # Auto-save with debounce
│ │ ├── useTheme.ts # Flatten theme.json → CSS vars
│ │ ├── useThemeManager.ts # Vault theme lifecycle
│ │ ├── useIndexing.ts # Search indexing management
│ │ ├── useNoteSearch.ts # Note search
│ │ ├── useCommandRegistry.ts # Command palette registry
│ │ ├── useAppCommands.ts # App-level commands
│ │ ├── useAppKeyboard.ts # Keyboard shortcuts
│ │ ├── useSettings.ts # App settings
│ │ ├── useOnboarding.ts # First-launch flow
│ │ ├── useCodeMirror.ts # CodeMirror raw editor
│ │ ├── useMcpBridge.ts # MCP WebSocket client
│ │ ├── useMcpStatus.ts # MCP registration status
│ │ ├── useUpdater.ts # In-app updates
│ │ └── ...
│ │
│ ├── utils/ # Pure utility functions
│ │ ├── frontmatter.ts # TypeScript YAML frontmatter parser
│ │ ── wikilinks.ts # Wikilink preprocessing + word count
│ ├── utils/ # Pure utility functions (~48 files)
│ │ ├── wikilinks.ts # Wikilink preprocessing pipeline
│ │ ── frontmatter.ts # TypeScript YAML parser
│ │ ├── ai-agent.ts # Agent stream utilities
│ │ ├── ai-chat.ts # Chat API client + token estimation
│ │ ├── ai-context.ts # Context snapshot builder
│ │ ├── noteListHelpers.ts # Sorting, filtering, date formatting
│ │ ├── themeSchema.ts # Theme editor schema builder
│ │ ├── configMigration.ts # localStorage → vault config migration
│ │ ├── iconRegistry.ts # Phosphor icon registry
│ │ ├── propertyTypes.ts # Property type definitions
│ │ ├── vaultListStore.ts # Vault list persistence
│ │ ├── vaultConfigStore.ts # Vault config store
│ │ └── ...
│ │
│ ├── lib/
│ │ └── utils.ts # Tailwind merge + cn() helper
@@ -78,28 +134,58 @@ laputa-app/
│ ├── build.rs # Tauri build script
│ ├── tauri.conf.json # Tauri app configuration
│ ├── capabilities/ # Tauri v2 security capabilities
│ │ └── default.json
│ ├── src/
│ │ ├── main.rs # Entry point (calls lib::run())
│ │ ├── lib.rs # Tauri command registration (9 commands)
│ │ ├── vault.rs # Vault scanning + markdown parsing
│ │ ├── frontmatter.rs # YAML frontmatter manipulation
│ │ └── git.rs # Git CLI operations
│ │ ├── lib.rs # Tauri setup + command registration (61 commands)
│ │ ├── commands.rs # All Tauri command handlers
│ │ ├── vault/ # Vault module
│ │ │ ├── mod.rs # Core types, parse_md_file, scan_vault
│ │ │ ├── cache.rs # Git-based incremental caching
│ │ │ ├── parsing.rs # Text processing + title extraction
│ │ │ ├── trash.rs # Trash auto-purge
│ │ │ ├── rename.rs # Rename + cross-vault wikilink update
│ │ │ ├── image.rs # Image attachment saving
│ │ │ ├── migration.rs # Frontmatter migration
│ │ │ └── getting_started.rs # Getting Started vault creation
│ │ ├── frontmatter/ # Frontmatter module
│ │ │ ├── mod.rs, yaml.rs, ops.rs
│ │ ├── git/ # Git module
│ │ │ ├── mod.rs, commit.rs, status.rs, history.rs
│ │ │ ├── conflict.rs, remote.rs, pulse.rs
│ │ ├── github/ # GitHub module
│ │ │ ├── mod.rs, auth.rs, api.rs, clone.rs
│ │ ├── theme/ # Theme module
│ │ │ ├── mod.rs, create.rs, defaults.rs, seed.rs
│ │ ├── search.rs # qmd search integration
│ │ ├── indexing.rs # qmd indexing + progress streaming
│ │ ├── claude_cli.rs # Claude CLI subprocess management
│ │ ├── ai_chat.rs # Direct Anthropic API client
│ │ ├── mcp.rs # MCP server lifecycle + registration
│ │ ├── settings.rs # App settings persistence
│ │ ├── vault_config.rs # Per-vault UI config
│ │ ├── vault_list.rs # Vault list persistence
│ │ └── menu.rs # Native macOS menu bar
│ └── icons/ # App icons
├── e2e/ # Playwright E2E tests
│ ├── app.spec.ts # App loading tests
│ ├── core-flows.spec.ts # Main user workflows
│ ├── keyboard-shortcuts.spec.ts
│ ├── quick-open.spec.ts
── screenshot.spec.ts # Visual regression screenshots
└── ...
├── mcp-server/ # MCP bridge (Node.js)
│ ├── index.js # MCP server entry (stdio, 14 tools)
│ ├── vault.js # Vault file operations
│ ├── ws-bridge.js # WebSocket bridge (ports 9710, 9711)
│ ├── test.js # MCP server tests
── package.json
├── e2e/ # Playwright E2E tests (~26 specs)
├── tests/smoke/ # Smoke tests (~10 specs)
├── design/ # Per-task design files
├── demo-vault-v2/ # Getting Started demo vault
├── scripts/ # Build/utility scripts
├── package.json # Frontend dependencies + scripts
├── vite.config.ts # Vite bundler config
├── tsconfig.json # TypeScript config
├── playwright.config.ts # E2E test config
├── CLAUDE.md # Project instructions for Claude
├── ui-design.pen # Master design file
├── CLAUDE.md # Project instructions
└── docs/ # This documentation
```
@@ -109,9 +195,10 @@ laputa-app/
| File | Why it matters |
|------|---------------|
| `src/App.tsx` | The root component. Shows how the 4-panel layout is assembled and how state flows between components. |
| `src/App.tsx` | Root component. Shows the 4-panel layout, state flow, and how all features connect. |
| `src/types.ts` | All shared TypeScript types. Read this first to understand the data model. |
| `src-tauri/src/lib.rs` | All 9 Tauri commands in one place. This is the frontend-backend API surface. |
| `src-tauri/src/commands.rs` | All 61 Tauri command handlers. This is the frontend-backend API surface. |
| `src-tauri/src/lib.rs` | Tauri setup, command registration, startup tasks, WebSocket bridge lifecycle. |
### Data layer
@@ -119,31 +206,55 @@ laputa-app/
|------|---------------|
| `src/hooks/useVaultLoader.ts` | How vault data is loaded and managed. The Tauri/mock branching pattern. |
| `src/hooks/useNoteActions.ts` | Tab management, wikilink navigation, frontmatter CRUD. The biggest hook. |
| `src/hooks/useVaultSwitcher.ts` | Multi-vault management, vault switching, Getting Started vault. |
| `src/mock-tauri.ts` | Mock data for browser testing. Shows the shape of all Tauri responses. |
### Backend
| File | Why it matters |
|------|---------------|
| `src-tauri/src/vault.rs` | Vault scanning, frontmatter parsing, entity type inference. The core backend logic. |
| `src-tauri/src/frontmatter.rs` | YAML manipulation — how properties are updated/deleted in files. |
| `src-tauri/src/git.rs` | All git operations. Shells out to git CLI. |
| `src-tauri/src/vault/mod.rs` | Vault scanning, frontmatter parsing, entity type inference, relationship extraction. |
| `src-tauri/src/vault/cache.rs` | Git-based incremental caching — how large vaults load fast. |
| `src-tauri/src/frontmatter/ops.rs` | YAML manipulation — how properties are updated/deleted in files. |
| `src-tauri/src/git/` | All git operations (commit, pull, push, conflicts, pulse). |
| `src-tauri/src/github/` | GitHub OAuth device flow + repo clone/create. |
| `src-tauri/src/search.rs` | qmd search integration (keyword/semantic/hybrid). |
| `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing. |
### Editor
| File | Why it matters |
|------|---------------|
| `src/components/Editor.tsx` | BlockNote setup, custom wikilink schema, tab bar, breadcrumb bar, diff toggle. |
| `src/utils/wikilinks.ts` | The wikilink preprocessing pipeline (markdown → BlockNote blocks with wikilinks). |
| `src/components/EditorTheme.css` | BlockNote CSS overrides for typography and styling. |
| `src/components/Editor.tsx` | BlockNote setup, tab bar, breadcrumb bar, diff/raw toggle. |
| `src/components/editorSchema.tsx` | Custom wikilink inline content type definition. |
| `src/utils/wikilinks.ts` | Wikilink preprocessing pipeline (markdown ↔ BlockNote). |
| `src/components/RawEditorView.tsx` | CodeMirror 6 raw markdown editor. |
### Styling
### AI
| File | Why it matters |
|------|---------------|
| `src/index.css` | All CSS custom properties (colors, spacing). The design token source of truth. |
| `src/components/AiPanel.tsx` | AI agent panel — Claude CLI with tool execution, reasoning, actions. |
| `src/components/AIChatPanel.tsx` | AI chat panel — API-based chat without tools. |
| `src/hooks/useAiAgent.ts` | Agent state: messages, streaming, tool tracking, file detection. |
| `src/utils/ai-context.ts` | Context snapshot builder for AI conversations. |
### Styling & Themes
| File | Why it matters |
|------|---------------|
| `src/index.css` | All CSS custom properties. The design token source of truth. |
| `src/theme.json` | Editor-specific theme (fonts, headings, lists, code blocks). |
| `src/hooks/useTheme.ts` | Converts theme.json into CSS variables for the editor. |
| `src/hooks/useThemeManager.ts` | Vault theme lifecycle (switch, create, apply, live preview). |
| `docs/THEMING.md` | Full theme system documentation. |
### Settings & Config
| File | Why it matters |
|------|---------------|
| `src/hooks/useSettings.ts` | App settings (API keys, GitHub token, sync interval). |
| `src/hooks/useVaultConfig.ts` | Per-vault UI preferences (zoom, view mode, colors). |
| `src/components/SettingsPanel.tsx` | Settings UI including GitHub OAuth connection. |
## Architecture Patterns
@@ -169,13 +280,15 @@ No global state management (no Redux, no Context). `App.tsx` owns the state and
```typescript
type SidebarSelection =
| { kind: 'filter'; filter: 'all' | 'favorites' }
| { kind: 'filter'; filter: SidebarFilter }
| { kind: 'sectionGroup'; type: string }
| { kind: 'entity'; entry: VaultEntry }
| { kind: 'topic'; entry: VaultEntry }
```
This pattern makes it easy to handle all selection states exhaustively.
### Command Registry
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. The native macOS menu bar also triggers commands via `useMenuEvents`.
## Running Tests
@@ -183,26 +296,28 @@ This pattern makes it easy to handle all selection states exhaustively.
# Unit tests (fast, no browser)
pnpm test
# Rust tests
cd src-tauri && cargo test
# Unit tests with coverage (must pass ≥70%)
pnpm test:coverage
# E2E tests (requires dev server)
pnpm test:e2e
# Rust tests
cargo test
# Rust coverage (must pass ≥85% line coverage)
cargo llvm-cov --manifest-path src-tauri/Cargo.toml --no-clean --fail-under-lines 85
# Playwright smoke tests (requires dev server)
BASE_URL="http://localhost:5173" pnpm playwright:smoke
# Single Playwright test
npx playwright test e2e/screenshot.spec.ts
# Visual verification screenshots
npx playwright test e2e/screenshot.spec.ts
# Screenshots saved to test-results/
BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
```
## Common Tasks
### Add a new Tauri command
1. Write the Rust function in `vault.rs`, `git.rs`, or a new module
2. Add `#[tauri::command]` wrapper in `lib.rs`
1. Write the Rust function in the appropriate module (`vault/`, `git/`, etc.)
2. Add a command handler in `commands.rs`
3. Register it in the `generate_handler![]` macro in `lib.rs`
4. Call it from the frontend via `invoke()` in the appropriate hook
5. Add a mock handler in `mock-tauri.ts`
@@ -217,6 +332,25 @@ npx playwright test e2e/screenshot.spec.ts
### Add a new entity type
1. Create the folder in the vault (e.g., `~/Laputa/mytype/`)
2. Add the folder → type mapping in `vault.rs:parse_md_file()` (the `match` on folder names)
3. The sidebar section groups are defined as `SECTION_GROUPS` in `Sidebar.tsx` — add it there
4. Update `CreateNoteDialog.tsx` type options if users should be able to create it
2. Create a type document: `type/mytype.md` with `Is A: Type` frontmatter (icon, color, order, etc.)
3. The sidebar section groups are auto-generated from type documents — no code change needed if `visible: true`
4. Update `CreateNoteDialog.tsx` type options if users should be able to create it from the dialog
### Add a command palette entry
1. Register the command in `useAppCommands.ts` via the command registry
2. Add a corresponding menu bar item in `menu.rs` for discoverability
3. If it has a keyboard shortcut, register it in `useAppKeyboard.ts`
### Add or modify a theme
1. **Vault-based** (preferred): Create/edit a markdown note in `theme/` with `Is A: Theme` frontmatter
2. **Programmatic**: Edit defaults in `src-tauri/src/theme/defaults.rs`
3. See `docs/THEMING.md` for the full property reference
### Work with the AI agent
1. **Agent system prompt**: Edit `src/utils/ai-agent.ts` (inline system prompt string)
2. **Context building**: Edit `src/utils/ai-context.ts` for what data is sent to the agent
3. **Tool action display**: Edit `src/components/AiActionCard.tsx`
4. **Claude CLI arguments**: Edit `src-tauri/src/claude_cli.rs` (`run_agent_stream()`)

View File

@@ -10,6 +10,8 @@
* - get_vault_context: vault structure overview (types, note count, folders)
* - get_note: parsed frontmatter + content (convenience over raw cat)
* - open_note: signal Laputa UI to open a note as a tab
* - highlight_editor: visually highlight a UI element (editor, tab, etc.)
* - refresh_vault: trigger vault rescan so new/modified files appear
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
@@ -94,6 +96,28 @@ const TOOLS = [
required: ['path'],
},
},
{
name: 'highlight_editor',
description: 'Visually highlight a UI element in Laputa (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.',
inputSchema: {
type: 'object',
properties: {
element: { type: 'string', enum: ['editor', 'tab', 'properties', 'notelist'], description: 'Which UI element to highlight' },
path: { type: 'string', description: 'Optional note path to associate with the highlight' },
},
required: ['element'],
},
},
{
name: 'refresh_vault',
description: 'Trigger a vault rescan so new or modified files appear immediately in the Laputa note list.',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Optional specific note path that changed' },
},
},
},
]
const TOOL_HANDLERS = {
@@ -101,6 +125,8 @@ const TOOL_HANDLERS = {
get_vault_context: handleVaultContext,
get_note: handleGetNote,
open_note: handleOpenNote,
highlight_editor: handleHighlightEditor,
refresh_vault: handleRefreshVault,
}
async function handleSearchNotes(args) {
@@ -122,14 +148,27 @@ async function handleGetNote(args) {
}
function handleOpenNote(args) {
// Refresh vault first so the new/modified note appears in the note list,
// then signal the UI to open it in a tab.
broadcastUiAction('vault_changed', { path: args.path })
broadcastUiAction('open_tab', { path: args.path })
return { content: [{ type: 'text', text: `Opening ${args.path} in Laputa` }] }
}
function handleHighlightEditor(args) {
broadcastUiAction('highlight', { element: args.element, path: args.path })
return { content: [{ type: 'text', text: `Highlighting ${args.element}` }] }
}
function handleRefreshVault(args) {
broadcastUiAction('vault_changed', { path: args?.path })
return { content: [{ type: 'text', text: 'Vault refresh triggered' }] }
}
// --- Server setup ---
const server = new Server(
{ name: 'laputa-mcp-server', version: '0.2.0' },
{ name: 'laputa-mcp-server', version: '0.3.0' },
{ capabilities: { tools: {} } },
)

View File

@@ -4,8 +4,7 @@ import fs from 'node:fs/promises'
import path from 'node:path'
import os from 'node:os'
import {
readNote, createNote, searchNotes, appendToNote, findMarkdownFiles,
editNoteFrontmatter, deleteNote, linkNotes, listNotes, vaultContext,
findMarkdownFiles, getNote, searchNotes, vaultContext,
} from './vault.js'
let tmpDir
@@ -65,39 +64,23 @@ describe('findMarkdownFiles', () => {
})
})
describe('readNote', () => {
it('should read a note by relative path', async () => {
const content = await readNote(tmpDir, 'project/test-project.md')
assert.ok(content.includes('Test Project'))
assert.ok(content.includes('is_a: Project'))
describe('getNote', () => {
it('should read a note with parsed frontmatter', async () => {
const note = await getNote(tmpDir, 'project/test-project.md')
assert.equal(note.path, 'project/test-project.md')
assert.equal(note.frontmatter.title, 'Test Project')
assert.equal(note.frontmatter.is_a, 'Project')
assert.ok(note.content.includes('test project for the MCP server'))
})
it('should throw for missing notes', async () => {
await assert.rejects(
() => readNote(tmpDir, 'nonexistent.md'),
() => getNote(tmpDir, 'nonexistent.md'),
{ code: 'ENOENT' }
)
})
})
describe('createNote', () => {
it('should create a note with frontmatter', async () => {
const absPath = await createNote(tmpDir, 'note/new-note.md', 'My New Note', { is_a: 'Note' })
assert.ok(absPath.endsWith('new-note.md'))
const content = await fs.readFile(absPath, 'utf-8')
assert.ok(content.includes('title: My New Note'))
assert.ok(content.includes('is_a: Note'))
assert.ok(content.includes('# My New Note'))
})
it('should create parent directories', async () => {
const absPath = await createNote(tmpDir, 'deep/nested/dir/note.md', 'Deep Note')
const content = await fs.readFile(absPath, 'utf-8')
assert.ok(content.includes('# Deep Note'))
})
})
describe('searchNotes', () => {
it('should find notes matching title', async () => {
const results = await searchNotes(tmpDir, 'Test Project')
@@ -121,123 +104,6 @@ describe('searchNotes', () => {
})
})
describe('appendToNote', () => {
it('should append text to a note', async () => {
await appendToNote(tmpDir, 'note/daily-log.md', '## Evening Update\nFinished testing.')
const content = await readNote(tmpDir, 'note/daily-log.md')
assert.ok(content.includes('## Evening Update'))
assert.ok(content.includes('Finished testing.'))
})
})
describe('editNoteFrontmatter', () => {
it('should merge a patch into frontmatter', async () => {
const updated = await editNoteFrontmatter(tmpDir, 'project/test-project.md', { status: 'Completed', priority: 'High' })
assert.equal(updated.status, 'Completed')
assert.equal(updated.priority, 'High')
assert.equal(updated.title, 'Test Project')
})
it('should preserve existing frontmatter fields', async () => {
const content = await readNote(tmpDir, 'project/test-project.md')
assert.ok(content.includes('is_a: Project'))
assert.ok(content.includes('status: Completed'))
})
it('should throw for missing file', async () => {
await assert.rejects(
() => editNoteFrontmatter(tmpDir, 'nonexistent.md', { foo: 'bar' }),
{ code: 'ENOENT' }
)
})
})
describe('deleteNote', () => {
it('should delete an existing note', async () => {
const delPath = 'note/to-delete.md'
await createNote(tmpDir, delPath, 'To Delete')
const absPath = path.join(tmpDir, delPath)
// Verify it exists
await fs.access(absPath)
await deleteNote(tmpDir, delPath)
await assert.rejects(
() => fs.access(absPath),
{ code: 'ENOENT' }
)
})
it('should throw for missing file', async () => {
await assert.rejects(
() => deleteNote(tmpDir, 'nonexistent.md'),
{ code: 'ENOENT' }
)
})
})
describe('linkNotes', () => {
it('should add a target to an array property', async () => {
const linkPath = 'project/link-test.md'
await createNote(tmpDir, linkPath, 'Link Test', { is_a: 'Project' })
const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]')
assert.deepEqual(result, ['[[note/daily-log]]'])
})
it('should not duplicate existing links', async () => {
const linkPath = 'project/link-test.md'
await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]')
const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]')
assert.equal(result.length, 1)
})
it('should add multiple distinct links', async () => {
const linkPath = 'project/link-test.md'
await linkNotes(tmpDir, linkPath, 'related_to', '[[project/test-project]]')
const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[project/test-project]]')
// Should have daily-log and test-project
assert.ok(result.includes('[[note/daily-log]]'))
assert.ok(result.includes('[[project/test-project]]'))
assert.equal(result.length, 2)
})
})
describe('listNotes', () => {
it('should list all notes sorted by title', async () => {
const notes = await listNotes(tmpDir)
assert.ok(notes.length >= 3)
// Verify sorted by title
for (let i = 1; i < notes.length; i++) {
assert.ok(notes[i - 1].title.localeCompare(notes[i].title) <= 0)
}
})
it('should filter by type', async () => {
const projects = await listNotes(tmpDir, 'Project')
assert.ok(projects.length >= 1)
for (const n of projects) {
assert.equal(n.type, 'Project')
}
})
it('should return empty for unknown type', async () => {
const notes = await listNotes(tmpDir, 'UnknownType12345')
assert.equal(notes.length, 0)
})
it('should support mtime sorting', async () => {
const notes = await listNotes(tmpDir, undefined, 'mtime')
assert.ok(notes.length >= 1)
// Just verify it returns results without crashing
assert.ok(notes[0].path)
assert.ok(notes[0].title)
})
})
describe('vaultContext', () => {
it('should return types, recent notes, and vault path', async () => {
const ctx = await vaultContext(tmpDir)
@@ -264,4 +130,15 @@ describe('vaultContext', () => {
assert.ok(note.title)
}
})
it('should include folders', async () => {
const ctx = await vaultContext(tmpDir)
assert.ok(ctx.folders.includes('project/'))
assert.ok(ctx.folders.includes('note/'))
})
it('should report correct note count', async () => {
const ctx = await vaultContext(tmpDir)
assert.equal(ctx.noteCount, 3)
})
})

View File

@@ -46,10 +46,12 @@ const TOOL_HANDLERS = {
read_note: (args) => getNote(VAULT_PATH, args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
search_notes: (args) => searchNotes(VAULT_PATH, args.query, args.limit),
vault_context: () => vaultContext(VAULT_PATH),
ui_open_note: (args) => { broadcastUiAction('open_note', { path: args.path }); return { ok: true } },
ui_open_tab: (args) => { broadcastUiAction('open_tab', { path: args.path }); return { ok: true } },
ui_open_note: (args) => { broadcastUiAction('vault_changed', { path: args.path }); broadcastUiAction('open_note', { path: args.path }); return { ok: true } },
ui_open_tab: (args) => { broadcastUiAction('vault_changed', { path: args.path }); broadcastUiAction('open_tab', { path: args.path }); return { ok: true } },
ui_highlight: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
ui_set_filter: (args) => { broadcastUiAction('set_filter', { filterType: args.type }); return { ok: true } },
highlight_editor: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
refresh_vault: (args) => { broadcastUiAction('vault_changed', { path: args?.path }); return { ok: true } },
}
async function handleMessage(data) {

View File

@@ -73,6 +73,7 @@
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.0.18",
"esbuild": "^0.27.3",
@@ -86,6 +87,7 @@
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1",
"vitest": "^4.0.18"
"vitest": "^4.0.18",
"ws": "^8.19.0"
}
}

13
pnpm-lock.yaml generated
View File

@@ -168,6 +168,9 @@ importers:
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
'@vitejs/plugin-react':
specifier: ^5.1.1
version: 5.1.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2))
@@ -210,6 +213,9 @@ importers:
vitest:
specifier: ^4.0.18
version: 4.0.18(@types/node@24.10.13)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2)
ws:
specifier: ^8.19.0
version: 8.19.0
mcp-server:
dependencies:
@@ -2043,6 +2049,9 @@ packages:
'@types/use-sync-external-store@1.5.0':
resolution: {integrity: sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@typescript-eslint/eslint-plugin@8.55.0':
resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -6011,6 +6020,10 @@ snapshots:
'@types/use-sync-external-store@1.5.0': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 24.10.13
'@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2

View File

@@ -18,6 +18,7 @@ interface AIChatPanelProps {
allContent: Record<string, string>
entries?: VaultEntry[]
onClose: () => void
onNavigateWikilink?: (target: string) => void
}
function TypingIndicator() {
@@ -99,10 +100,10 @@ function ContextSearchDropdown({
)
}
function AssistantMessage({ msg, onRetry }: { msg: ChatMessage; onRetry: () => void }) {
function AssistantMessage({ msg, onRetry, onNavigateWikilink }: { msg: ChatMessage; onRetry: () => void; onNavigateWikilink?: (target: string) => void }) {
return (
<div>
<MarkdownContent content={msg.content} />
<MarkdownContent content={msg.content} onWikilinkClick={onNavigateWikilink} />
<div className="flex items-center gap-3" style={{ marginTop: 4 }}>
<button className="border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:underline"
style={{ fontSize: 11 }} onClick={() => navigator.clipboard.writeText(msg.content)}>
@@ -121,10 +122,10 @@ function AssistantMessage({ msg, onRetry }: { msg: ChatMessage; onRetry: () => v
)
}
function StreamingContent({ content }: { content: string }) {
function StreamingContent({ content, onNavigateWikilink }: { content: string; onNavigateWikilink?: (target: string) => void }) {
return (
<div style={{ marginBottom: 12 }}>
<MarkdownContent content={content} />
<MarkdownContent content={content} onWikilinkClick={onNavigateWikilink} />
</div>
)
}
@@ -176,7 +177,7 @@ function useContextNotes(entry: VaultEntry | null) {
// --- Main component ---
export function AIChatPanel({ entry, allContent, entries = [], onClose }: AIChatPanelProps) {
export function AIChatPanel({ entry, allContent, entries = [], onClose, onNavigateWikilink }: AIChatPanelProps) {
const [input, setInput] = useState('')
const [showSearch, setShowSearch] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
@@ -213,7 +214,7 @@ export function AIChatPanel({ entry, allContent, entries = [], onClose }: AIChat
<MessageList
messages={chat.messages} isStreaming={chat.isStreaming}
streamingContent={chat.streamingContent} onRetry={chat.retryMessage}
messagesEndRef={messagesEndRef}
messagesEndRef={messagesEndRef} onNavigateWikilink={onNavigateWikilink}
/>
<QuickActionsBar actions={QUICK_ACTIONS} disabled={chat.isStreaming}
@@ -284,10 +285,11 @@ function ContextBar({
}
function MessageList({
messages, isStreaming, streamingContent, onRetry, messagesEndRef,
messages, isStreaming, streamingContent, onRetry, messagesEndRef, onNavigateWikilink,
}: {
messages: ChatMessage[]; isStreaming: boolean; streamingContent: string
onRetry: (idx: number) => void; messagesEndRef: React.RefObject<HTMLDivElement | null>
onNavigateWikilink?: (target: string) => void
}) {
return (
<div className="flex-1 overflow-y-auto" style={{ padding: 12 }}>
@@ -302,10 +304,10 @@ function MessageList({
<div key={msg.id} style={{ marginBottom: 12 }}>
{msg.role === 'user'
? <UserBubble content={msg.content} />
: <AssistantMessage msg={msg} onRetry={() => onRetry(idx)} />}
: <AssistantMessage msg={msg} onRetry={() => onRetry(idx)} onNavigateWikilink={onNavigateWikilink} />}
</div>
))}
{isStreaming && streamingContent && <StreamingContent content={streamingContent} />}
{isStreaming && streamingContent && <StreamingContent content={streamingContent} onNavigateWikilink={onNavigateWikilink} />}
{isStreaming && !streamingContent && <TypingIndicator />}
<div ref={messagesEndRef} />
</div>

View File

@@ -24,6 +24,7 @@ export interface AiMessageProps {
response?: string
isStreaming?: boolean
onOpenNote?: (path: string) => void
onNavigateWikilink?: (target: string) => void
}
function ReferencePill({ reference, onClick }: {
@@ -147,10 +148,10 @@ function ActionCardsList({ actions, onOpenNote, expandedIds, onToggleExpand }: {
)
}
function ResponseBlock({ text }: { text: string }) {
function ResponseBlock({ text, onNavigateWikilink }: { text: string; onNavigateWikilink?: (target: string) => void }) {
return (
<div style={{ marginBottom: 4 }}>
<MarkdownContent content={text} />
<MarkdownContent content={text} onWikilinkClick={onNavigateWikilink} />
<button
className="flex items-center gap-1 border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
style={{ fontSize: 11, marginTop: 4 }}
@@ -175,7 +176,7 @@ function StreamingIndicator() {
)
}
export function AiMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote }: AiMessageProps) {
export function AiMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote, onNavigateWikilink }: AiMessageProps) {
// Manual override: null = follow auto behavior, true/false = user forced
const [userOverride, setUserOverride] = useState(false)
const [expandedActions, setExpandedActions] = useState<Set<string>>(new Set())
@@ -212,7 +213,7 @@ export function AiMessage({ userMessage, references, reasoning, reasoningDone, a
onToggleExpand={toggleAction}
/>
)}
{response && <ResponseBlock text={response} />}
{response && <ResponseBlock text={response} onNavigateWikilink={onNavigateWikilink} />}
{isStreaming && !response && <StreamingIndicator />}
</div>
)

View File

@@ -4,6 +4,7 @@ import { AiMessage } from './AiMessage'
import { WikilinkChatInput } from './WikilinkChatInput'
import { useAiAgent, type AiAgentMessage, type AgentFileCallbacks } from '../hooks/useAiAgent'
import { collectLinkedEntries, buildContextSnapshot, type NoteReference, type NoteListItem } from '../utils/ai-context'
import { findEntryByTarget } from '../utils/wikilinkColors'
import type { VaultEntry } from '../types'
export type { AiAgentMessage } from '../hooks/useAiAgent'
@@ -89,8 +90,8 @@ function EmptyState({ hasContext }: { hasContext: boolean }) {
)
}
function MessageHistory({ messages, isActive, onOpenNote, hasContext }: {
messages: AiAgentMessage[]; isActive: boolean; onOpenNote?: (path: string) => void; hasContext: boolean
function MessageHistory({ messages, isActive, onOpenNote, onNavigateWikilink, hasContext }: {
messages: AiAgentMessage[]; isActive: boolean; onOpenNote?: (path: string) => void; onNavigateWikilink?: (target: string) => void; hasContext: boolean
}) {
const endRef = useRef<HTMLDivElement>(null)
@@ -102,7 +103,7 @@ function MessageHistory({ messages, isActive, onOpenNote, hasContext }: {
<div className="flex-1 overflow-y-auto" style={{ padding: 12 }}>
{messages.length === 0 && !isActive && <EmptyState hasContext={hasContext} />}
{messages.map((msg, i) => (
<AiMessage key={msg.id ?? i} {...msg} onOpenNote={onOpenNote} />
<AiMessage key={msg.id ?? i} {...msg} onOpenNote={onOpenNote} onNavigateWikilink={onNavigateWikilink} />
))}
<div ref={endRef} />
</div>
@@ -167,6 +168,12 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, va
return () => window.removeEventListener('keydown', handleEscape)
}, [handleEscape])
const handleNavigateWikilink = useCallback((target: string) => {
if (!entries) return
const entry = findEntryByTarget(entries, target)
if (entry) onOpenNote?.(entry.path)
}, [entries, onOpenNote])
const handleSend = useCallback((text: string, references: NoteReference[]) => {
if (!text.trim() || isActive) return
setPendingRefs(references)
@@ -198,6 +205,7 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, va
messages={agent.messages}
isActive={isActive}
onOpenNote={onOpenNote}
onNavigateWikilink={handleNavigateWikilink}
hasContext={hasContext}
/>
<div

View File

@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { MarkdownContent } from './MarkdownContent'
import { preprocessWikilinks } from '../utils/chatWikilinks'
describe('MarkdownContent', () => {
it('renders bold text', () => {
@@ -72,4 +73,110 @@ describe('MarkdownContent', () => {
expect(bq).toBeTruthy()
expect(bq!.textContent).toContain('A quote')
})
describe('wikilinks', () => {
it('preprocessWikilinks converts [[Target]] to markdown links', () => {
expect(preprocessWikilinks('See [[My Note]]')).toBe('See [My Note](wikilink://My%20Note)')
expect(preprocessWikilinks('[[A]] and [[B]]')).toBe('[A](wikilink://A) and [B](wikilink://B)')
expect(preprocessWikilinks('`[[code]]`')).toBe('`[[code]]`')
})
it('renders [[Note Title]] as a clickable wikilink chip', () => {
const onClick = vi.fn()
const { container } = render(
<MarkdownContent content="Check out [[My Note]]" onWikilinkClick={onClick} />,
)
const wikilink = container.querySelector('.chat-wikilink')
expect(wikilink).toBeTruthy()
expect(wikilink!.textContent).toBe('My Note')
expect(wikilink!.getAttribute('data-wikilink-target')).toBe('My Note')
})
it('fires onWikilinkClick when a wikilink is clicked', () => {
const onClick = vi.fn()
const { container } = render(
<MarkdownContent content="See [[Daily Log]]" onWikilinkClick={onClick} />,
)
const wikilink = container.querySelector('.chat-wikilink')!
fireEvent.click(wikilink)
expect(onClick).toHaveBeenCalledWith('Daily Log')
})
it('renders multiple wikilinks in the same paragraph', () => {
const onClick = vi.fn()
const { container } = render(
<MarkdownContent content="See [[Note A]] and [[Note B]]" onWikilinkClick={onClick} />,
)
const wikilinks = container.querySelectorAll('.chat-wikilink')
expect(wikilinks).toHaveLength(2)
expect(wikilinks[0].textContent).toBe('Note A')
expect(wikilinks[1].textContent).toBe('Note B')
})
it('handles pipe syntax [[target|display]]', () => {
const onClick = vi.fn()
const { container } = render(
<MarkdownContent content="See [[path/to/note|My Display]]" onWikilinkClick={onClick} />,
)
const wikilink = container.querySelector('.chat-wikilink')!
expect(wikilink.textContent).toBe('My Display')
expect(wikilink.getAttribute('data-wikilink-target')).toBe('path/to/note')
fireEvent.click(wikilink)
expect(onClick).toHaveBeenCalledWith('path/to/note')
})
it('does not render wikilinks inside inline code', () => {
const onClick = vi.fn()
const { container } = render(
<MarkdownContent content="Use `[[Not a link]]` syntax" onWikilinkClick={onClick} />,
)
expect(container.querySelector('.chat-wikilink')).toBeNull()
})
it('does not render wikilinks inside code blocks', () => {
const onClick = vi.fn()
const { container } = render(
<MarkdownContent content={'```\n[[Not a link]]\n```'} onWikilinkClick={onClick} />,
)
expect(container.querySelector('.chat-wikilink')).toBeNull()
})
it('handles notes with special characters in title', () => {
const onClick = vi.fn()
const { container } = render(
<MarkdownContent content="Check [[Meeting — 2024/01/15]]" onWikilinkClick={onClick} />,
)
const wikilink = container.querySelector('.chat-wikilink')!
expect(wikilink.textContent).toBe('Meeting — 2024/01/15')
fireEvent.click(wikilink)
expect(onClick).toHaveBeenCalledWith('Meeting — 2024/01/15')
})
it('does not transform wikilinks when onWikilinkClick is not provided', () => {
const { container } = render(
<MarkdownContent content="See [[Some Note]]" />,
)
expect(container.querySelector('.chat-wikilink')).toBeNull()
expect(container.textContent).toContain('[[Some Note]]')
})
it('renders wikilinks inside list items', () => {
const onClick = vi.fn()
const { container } = render(
<MarkdownContent content={'- First [[Note A]]\n- Second [[Note B]]'} onWikilinkClick={onClick} />,
)
const wikilinks = container.querySelectorAll('.chat-wikilink')
expect(wikilinks).toHaveLength(2)
})
it('has role="link" and tabIndex for accessibility', () => {
const onClick = vi.fn()
const { container } = render(
<MarkdownContent content="See [[Accessible Note]]" onWikilinkClick={onClick} />,
)
const wikilink = container.querySelector('.chat-wikilink')!
expect(wikilink.getAttribute('role')).toBe('link')
expect(wikilink.getAttribute('tabindex')).toBe('0')
})
})
})

View File

@@ -1,18 +1,63 @@
import { memo, useMemo } from 'react'
import Markdown from 'react-markdown'
import { memo, useMemo, useCallback, type MouseEvent } from 'react'
import Markdown, { defaultUrlTransform } from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import { preprocessWikilinks, WIKILINK_SCHEME } from '../utils/chatWikilinks'
const REMARK_PLUGINS = [remarkGfm]
const REHYPE_PLUGINS = [rehypeHighlight]
export const MarkdownContent = memo(function MarkdownContent({ content }: { content: string }) {
const rendered = useMemo(() => (
<div className="ai-markdown">
<Markdown remarkPlugins={REMARK_PLUGINS} rehypePlugins={REHYPE_PLUGINS}>
{content}
function wikilinkUrlTransform(url: string): string {
if (url.startsWith(WIKILINK_SCHEME)) return url
return defaultUrlTransform(url)
}
interface MarkdownContentProps {
content: string
onWikilinkClick?: (target: string) => void
}
export const MarkdownContent = memo(function MarkdownContent({ content, onWikilinkClick }: MarkdownContentProps) {
const processedContent = useMemo(
() => onWikilinkClick ? preprocessWikilinks(content) : content,
[content, onWikilinkClick],
)
const handleClick = useCallback((e: MouseEvent) => {
const el = (e.target as HTMLElement).closest<HTMLElement>('[data-wikilink-target]')
if (el) {
e.preventDefault()
onWikilinkClick?.(el.dataset.wikilinkTarget!)
}
}, [onWikilinkClick])
const components = useMemo(() => {
if (!onWikilinkClick) return undefined
return {
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => {
if (href?.startsWith(WIKILINK_SCHEME)) {
const target = decodeURIComponent(href.slice(WIKILINK_SCHEME.length))
return (
<span className="chat-wikilink" data-wikilink-target={target} role="link" tabIndex={0}>
{children}
</span>
)
}
return <a href={href}>{children}</a>
},
}
}, [onWikilinkClick])
return (
<div className="ai-markdown" onClick={onWikilinkClick ? handleClick : undefined} role="presentation">
<Markdown
remarkPlugins={REMARK_PLUGINS}
rehypePlugins={REHYPE_PLUGINS}
components={components}
urlTransform={onWikilinkClick ? wikilinkUrlTransform : undefined}
>
{processedContent}
</Markdown>
</div>
), [content])
return rendered
)
})

View File

@@ -111,4 +111,20 @@ describe('useAIChat', () => {
expect(thirdMessage).toContain('Q2')
expect(thirdMessage).toContain('Q3')
})
it('does not pass session_id to avoid --resume (history is in prompt)', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
// First message
act(() => { result.current.sendMessage('hello') })
await act(async () => { vi.advanceTimersByTime(50) })
// Second message — session_id should still be undefined (no --resume)
act(() => { result.current.sendMessage('follow up') })
expect(streamClaudeChatMock).toHaveBeenCalledTimes(2)
// Third argument is sessionId — must be undefined for both calls
expect(streamClaudeChatMock.mock.calls[0][2]).toBeUndefined()
expect(streamClaudeChatMock.mock.calls[1][2]).toBeUndefined()
})
})

View File

@@ -18,7 +18,6 @@ export function useAIChat(
const [isStreaming, setIsStreaming] = useState(false)
const [streamingContent, setStreamingContent] = useState('')
const abortRef = useRef(false)
const sessionIdRef = useRef<string | undefined>(undefined)
const sendMessage = useCallback((text: string) => {
if (!text.trim() || isStreaming) return
@@ -34,9 +33,10 @@ export function useAIChat(
const messageWithHistory = formatMessageWithHistory(history, text.trim())
let accumulated = ''
streamClaudeChat(messageWithHistory, systemPrompt || undefined, sessionIdRef.current, {
onInit: (sid) => { sessionIdRef.current = sid },
// No session_id: each call is independent. Context is provided via
// formatted history in the prompt, avoiding --resume which causes
// double-context confusion when combined with in-prompt history.
streamClaudeChat(messageWithHistory, systemPrompt || undefined, undefined, {
onText: (chunk) => {
if (abortRef.current) return
accumulated += chunk
@@ -58,8 +58,6 @@ export function useAIChat(
setStreamingContent('')
setIsStreaming(false)
},
}).then(sid => {
if (sid) sessionIdRef.current = sid
})
}, [isStreaming, allContent, contextNotes, messages])
@@ -68,7 +66,6 @@ export function useAIChat(
setMessages([])
setIsStreaming(false)
setStreamingContent('')
sessionIdRef.current = undefined
}, [])
const retryMessage = useCallback((msgIndex: number) => {

View File

@@ -0,0 +1,148 @@
import { describe, it, expect, vi } from 'vitest'
import { detectFileOperation, parseBashFileCreation } from './useAiAgent'
import type { AgentFileCallbacks } from './useAiAgent'
const VAULT = '/Users/luca/Laputa'
function makeCallbacks() {
return {
onFileCreated: vi.fn(),
onFileModified: vi.fn(),
} satisfies AgentFileCallbacks
}
describe('detectFileOperation', () => {
it('calls onFileCreated for Write tool with .md in vault', () => {
const cb = makeCallbacks()
detectFileOperation('Write', JSON.stringify({ file_path: `${VAULT}/note/test.md` }), VAULT, cb)
expect(cb.onFileCreated).toHaveBeenCalledWith('note/test.md')
expect(cb.onFileModified).not.toHaveBeenCalled()
})
it('calls onFileModified for Edit tool with .md in vault', () => {
const cb = makeCallbacks()
detectFileOperation('Edit', JSON.stringify({ file_path: `${VAULT}/note/test.md` }), VAULT, cb)
expect(cb.onFileModified).toHaveBeenCalledWith('note/test.md')
expect(cb.onFileCreated).not.toHaveBeenCalled()
})
it('ignores non-md files', () => {
const cb = makeCallbacks()
detectFileOperation('Write', JSON.stringify({ file_path: `${VAULT}/image.png` }), VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
})
it('ignores files outside vault', () => {
const cb = makeCallbacks()
detectFileOperation('Write', JSON.stringify({ file_path: '/tmp/other.md' }), VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
})
it('ignores unknown tool names', () => {
const cb = makeCallbacks()
detectFileOperation('Read', JSON.stringify({ file_path: `${VAULT}/note/test.md` }), VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
expect(cb.onFileModified).not.toHaveBeenCalled()
})
it('handles undefined input gracefully', () => {
const cb = makeCallbacks()
detectFileOperation('Write', undefined, VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
})
it('handles malformed JSON input gracefully', () => {
const cb = makeCallbacks()
detectFileOperation('Write', 'not-json', VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
})
it('handles undefined callbacks gracefully', () => {
expect(() => detectFileOperation('Write', JSON.stringify({ file_path: `${VAULT}/note/test.md` }), VAULT, undefined)).not.toThrow()
})
it('detects Bash redirect creating .md file in vault', () => {
const cb = makeCallbacks()
detectFileOperation('Bash', JSON.stringify({ command: `echo "# Note" > ${VAULT}/note/new.md` }), VAULT, cb)
expect(cb.onFileCreated).toHaveBeenCalledWith('note/new.md')
})
it('detects Bash append redirect creating .md file', () => {
const cb = makeCallbacks()
detectFileOperation('Bash', JSON.stringify({ command: `cat content.txt >> ${VAULT}/daily/2026-03-07.md` }), VAULT, cb)
expect(cb.onFileCreated).toHaveBeenCalledWith('daily/2026-03-07.md')
})
it('detects Bash tee command creating .md file', () => {
const cb = makeCallbacks()
detectFileOperation('Bash', JSON.stringify({ command: `echo "content" | tee ${VAULT}/note/tee-note.md` }), VAULT, cb)
expect(cb.onFileCreated).toHaveBeenCalledWith('note/tee-note.md')
})
it('ignores Bash commands without file creation', () => {
const cb = makeCallbacks()
detectFileOperation('Bash', JSON.stringify({ command: 'ls -la' }), VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
})
it('ignores Bash creating non-md files', () => {
const cb = makeCallbacks()
detectFileOperation('Bash', JSON.stringify({ command: `echo "data" > ${VAULT}/config.json` }), VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
})
it('ignores Bash creating .md files outside vault', () => {
const cb = makeCallbacks()
detectFileOperation('Bash', JSON.stringify({ command: 'echo "text" > /tmp/other.md' }), VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
})
it('uses path field when file_path is missing', () => {
const cb = makeCallbacks()
detectFileOperation('Write', JSON.stringify({ path: `${VAULT}/note/alt.md` }), VAULT, cb)
expect(cb.onFileCreated).toHaveBeenCalledWith('note/alt.md')
})
})
describe('parseBashFileCreation', () => {
it('returns null for undefined input', () => {
expect(parseBashFileCreation(undefined, VAULT)).toBeNull()
})
it('returns null for malformed JSON', () => {
expect(parseBashFileCreation('not json', VAULT)).toBeNull()
})
it('returns null when no command field', () => {
expect(parseBashFileCreation(JSON.stringify({ other: 'value' }), VAULT)).toBeNull()
})
it('returns null when command is not a string', () => {
expect(parseBashFileCreation(JSON.stringify({ command: 42 }), VAULT)).toBeNull()
})
it('parses simple redirect', () => {
const input = JSON.stringify({ command: `echo "# Title" > ${VAULT}/note.md` })
expect(parseBashFileCreation(input, VAULT)).toBe('note.md')
})
it('parses append redirect', () => {
const input = JSON.stringify({ command: `echo "line" >> ${VAULT}/sub/note.md` })
expect(parseBashFileCreation(input, VAULT)).toBe('sub/note.md')
})
it('parses tee command', () => {
const input = JSON.stringify({ command: `echo "data" | tee ${VAULT}/new.md` })
expect(parseBashFileCreation(input, VAULT)).toBe('new.md')
})
it('parses tee -a (append) command', () => {
const input = JSON.stringify({ command: `echo "extra" | tee -a ${VAULT}/new.md` })
expect(parseBashFileCreation(input, VAULT)).toBe('new.md')
})
it('returns null for non-md redirect', () => {
const input = JSON.stringify({ command: `echo "x" > ${VAULT}/file.txt` })
expect(parseBashFileCreation(input, VAULT)).toBeNull()
})
})

View File

@@ -105,7 +105,10 @@ export function useAiAgent(
if (abortRef.current.aborted) return
markReasoningDone()
setStatus('tool-executing')
toolInputMapRef.current.set(toolId, { tool: toolName, input })
// Preserve accumulated input — tool_progress events arrive with
// input=undefined AFTER the assistant message set the full input.
const prev = toolInputMapRef.current.get(toolId)
toolInputMapRef.current.set(toolId, { tool: toolName, input: input ?? prev?.input })
update(m => {
const existing = m.actions.find(a => a.toolId === toolId)
if (existing) {
@@ -205,13 +208,21 @@ function toVaultRelative(filePath: string, vaultPath: string): string | null {
}
/** Detect file operations from completed tool calls and notify callbacks. */
function detectFileOperation(
export function detectFileOperation(
toolName: string,
input: string | undefined,
vaultPath: string,
callbacks: AgentFileCallbacks | undefined,
) {
if (!callbacks) return
// Handle Bash commands that create/write .md files
if (toolName === 'Bash') {
const mdPath = parseBashFileCreation(input, vaultPath)
if (mdPath) callbacks.onFileCreated?.(mdPath)
return
}
if (toolName !== 'Write' && toolName !== 'Edit') return
const filePath = parseFilePath(input)
if (!filePath || !filePath.endsWith('.md')) return
@@ -224,6 +235,23 @@ function detectFileOperation(
}
}
/** Detect .md file creation from a Bash command string. */
export function parseBashFileCreation(input: string | undefined, vaultPath: string): string | null {
if (!input) return null
try {
const parsed = JSON.parse(input)
const cmd = parsed.command ?? parsed.cmd
if (typeof cmd !== 'string') return null
// Match redirect patterns: > file.md, >> file.md, tee file.md, cat > file.md
const match = cmd.match(/(?:>|>>|tee\s+(?:-a\s+)?)\s*["']?([^\s"'|;]+\.md)["']?/)
if (!match) return null
const filePath = match[1]
return toVaultRelative(filePath, vaultPath)
} catch {
return null
}
}
/** Generate a human-readable label for a tool call. */
function formatToolLabel(toolName: string, input?: string): string {
// Native Claude Code tools

View File

@@ -246,6 +246,22 @@
}
.ai-markdown a:hover { text-decoration: underline; }
.ai-markdown .chat-wikilink {
display: inline;
color: var(--primary);
background: color-mix(in srgb, var(--primary) 10%, transparent);
border-radius: 4px;
padding: 0 4px;
cursor: pointer;
font-weight: 500;
text-decoration: none;
transition: background 0.15s ease;
}
.ai-markdown .chat-wikilink:hover {
background: color-mix(in srgb, var(--primary) 20%, transparent);
text-decoration: underline;
}
.ai-markdown hr {
border: none;
border-top: 1px solid var(--border);

View File

@@ -23,6 +23,12 @@ describe('buildAgentSystemPrompt', () => {
expect(prompt).toContain('Vault context:')
expect(prompt).toContain('Recent notes: foo, bar')
})
it('instructs AI to use wikilink syntax', () => {
const prompt = buildAgentSystemPrompt()
expect(prompt).toContain('[[')
expect(prompt).toMatch(/wikilink/i)
})
})
// --- streamClaudeAgent ---
@@ -44,7 +50,7 @@ describe('streamClaudeAgent', () => {
// Wait for the setTimeout mock response
await new Promise(r => setTimeout(r, 400))
expect(onText).toHaveBeenCalledWith(expect.stringContaining('Claude CLI'))
expect(onText).toHaveBeenCalledWith(expect.stringContaining('Build Laputa App'))
expect(onDone).toHaveBeenCalled()
expect(onError).not.toHaveBeenCalled()
expect(onToolStart).not.toHaveBeenCalled()

View File

@@ -17,6 +17,7 @@ You have full shell access. Use bash for file operations, search, bulk edits.
Use the provided MCP tools for: full-text search (search_notes), vault orientation (get_vault_context), parsed note reading (get_note), and opening notes in the UI (open_note).
When you create or edit a note, call open_note(path) so the user sees it in Laputa.
When you mention or reference a note by name, always use [[Note Title]] wikilink syntax so the user can click to open it.
Be concise and helpful. When you've completed a task, briefly summarize what you did.`
export function buildAgentSystemPrompt(vaultContext?: string): string {
@@ -57,7 +58,7 @@ export async function streamClaudeAgent(
): Promise<void> {
if (!isTauri()) {
setTimeout(() => {
callbacks.onText('AI Agent requires the Claude CLI. Install it and run the native app.')
callbacks.onText('This note is related to [[Build Laputa App]] and [[Matteo Cellini]]. The AI Agent requires the Claude CLI for full functionality.')
callbacks.onDone()
}, 300)
return

View File

@@ -52,6 +52,14 @@ describe('buildSystemPrompt', () => {
expect(result.prompt).toContain('Hello world')
expect(result.totalTokens).toBeGreaterThan(0)
})
it('instructs AI to use wikilink syntax', () => {
const notes = [makeEntry('/test.md', 'Test Note')]
const content = { '/test.md': 'content' }
const result = buildSystemPrompt(notes, content)
expect(result.prompt).toContain('[[')
expect(result.prompt).toMatch(/wikilink/i)
})
})
// --- nextMessageId ---

View File

@@ -34,6 +34,7 @@ export function buildSystemPrompt(
const preamble = [
'You are a helpful AI assistant integrated into Laputa, a personal knowledge management app.',
'The user has selected the following notes as context. Use them to answer questions accurately.',
'When you mention or reference a note by name, always use [[Note Title]] wikilink syntax so the user can click to open it.',
'',
].join('\n')

View File

@@ -0,0 +1,35 @@
const WIKILINK_SCHEME = 'wikilink://'
export { WIKILINK_SCHEME }
function encodeTarget(target: string): string {
return encodeURIComponent(target).replace(/\(/g, '%28').replace(/\)/g, '%29')
}
function escapeLinkText(text: string): string {
return text.replace(/[[\]]/g, '\\$&')
}
function replaceWikilinksInText(text: string): string {
return text.replace(/\[\[([^\]]+)\]\]/g, (_, inner: string) => {
const pipeIdx = inner.indexOf('|')
const target = pipeIdx !== -1 ? inner.slice(0, pipeIdx) : inner
const display = pipeIdx !== -1 ? inner.slice(pipeIdx + 1) : inner
return `[${escapeLinkText(display)}](${WIKILINK_SCHEME}${encodeTarget(target)})`
})
}
/** Convert [[Target]] to markdown links, but skip code blocks and inline code. */
export function preprocessWikilinks(md: string): string {
const segments: string[] = []
const codeRegex = /(```[\s\S]*?```|`[^`\n]+`)/g
let lastIndex = 0
let match
while ((match = codeRegex.exec(md)) !== null) {
segments.push(replaceWikilinksInText(md.slice(lastIndex, match.index)))
segments.push(match[0])
lastIndex = match.index + match[0].length
}
segments.push(replaceWikilinksInText(md.slice(lastIndex)))
return segments.join('')
}

View File

@@ -0,0 +1,46 @@
import { test, expect } from '@playwright/test'
import { sendShortcut } from './helpers'
test.describe('AI chat wikilink rendering', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(500)
})
test('[[Note]] in AI response renders as clickable wikilink', async ({ page }) => {
// Select a note first so the AI panel has context
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
await noteItem.click()
await page.waitForTimeout(500)
// Open AI Chat with Ctrl+I
await sendShortcut(page, 'i', ['Control'])
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3000 })
// Send a message to get a mock response containing wikilinks
const input = page.locator('input[placeholder*="Ask"]')
await input.fill('Tell me about this note')
await page.getByTestId('agent-send').click()
// Wait for mock response (contains [[Build Laputa App]] and [[Matteo Cellini]])
const wikilink = page.locator('.chat-wikilink').first()
await expect(wikilink).toBeVisible({ timeout: 5000 })
// Verify wikilink text and attributes
await expect(wikilink).toHaveText('Build Laputa App')
await expect(wikilink).toHaveAttribute('data-wikilink-target', 'Build Laputa App')
await expect(wikilink).toHaveAttribute('role', 'link')
// Verify second wikilink
const secondWikilink = page.locator('.chat-wikilink').nth(1)
await expect(secondWikilink).toHaveText('Matteo Cellini')
// Verify multiple wikilinks rendered
const allWikilinks = page.locator('.chat-wikilink')
await expect(allWikilinks).toHaveCount(2)
// Verify wikilink has pointer cursor (is styled as clickable)
const cursor = await wikilink.evaluate(el => getComputedStyle(el).cursor)
expect(cursor).toBe('pointer')
})
})

View File

@@ -0,0 +1,114 @@
import { test, expect } from '@playwright/test'
import { WebSocketServer } from 'ws'
const NEW_NOTE_PATH = '/Users/luca/Laputa/note/ai-created-note.md'
const NEW_NOTE_TITLE = 'AI Created Note'
const NEW_NOTE_CONTENT = `---
title: ${NEW_NOTE_TITLE}
type: Note
---
# ${NEW_NOTE_TITLE}
This note was created by the AI agent.
`
function broadcast(wss: WebSocketServer, data: Record<string, unknown>) {
const msg = JSON.stringify(data)
for (const client of wss.clients) {
if (client.readyState === 1) client.send(msg)
}
}
/** Wait until at least one WebSocket client connects. */
function waitForClient(wss: WebSocketServer, timeoutMs = 10000): Promise<void> {
if (wss.clients.size > 0) return Promise.resolve()
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('No WS client connected')), timeoutMs)
wss.on('connection', () => { clearTimeout(timer); resolve() })
})
}
test.describe('AI-created note visibility', () => {
let wss: WebSocketServer
/** When true, the intercepted list_vault response includes the new note. */
let injectNote = false
test.beforeEach(async () => {
wss = new WebSocketServer({ port: 9711 })
})
test.afterEach(async () => {
wss.close()
await new Promise(r => setTimeout(r, 200))
})
test('vault_changed + open_tab from MCP makes note visible and opens tab', async ({ page }) => {
// Intercept list_vault API calls. On reload (after vault_changed),
// the injected note will be included in the response.
await page.route('**/api/vault/list*', async (route) => {
const response = await route.fetch()
if (!injectNote) {
await route.fulfill({ response })
return
}
const entries = await response.json()
const now = Date.now()
entries.push({
path: NEW_NOTE_PATH, filename: 'ai-created-note.md',
title: NEW_NOTE_TITLE, isA: null,
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
cadence: null, archived: false, trashed: false, trashedAt: null,
modifiedAt: now, createdAt: now, fileSize: NEW_NOTE_CONTENT.length,
snippet: 'This note was created by the AI agent.', wordCount: 10,
relationships: {}, icon: null, color: null, order: null,
sidebarLabel: null, template: null, sort: null, view: null,
visible: null, outgoingLinks: [], properties: {},
})
await route.fulfill({ json: entries })
})
// Intercept content fetch for the new note
await page.route('**/api/vault/content*ai-created-note*', async (route) => {
await route.fulfill({ json: { content: NEW_NOTE_CONTENT } })
})
// Intercept all-content to include new note
await page.route('**/api/vault/all-content*', async (route) => {
const response = await route.fetch()
if (!injectNote) {
await route.fulfill({ response })
return
}
const data = await response.json()
data[NEW_NOTE_PATH] = NEW_NOTE_CONTENT
await route.fulfill({ json: data })
})
await page.goto('/')
// Wait for note list to render
const noteList = page.locator('.app__note-list')
await expect(noteList).toBeVisible()
await expect(noteList.getByText(NEW_NOTE_TITLE)).not.toBeVisible()
// Enable injection for subsequent reloads
injectNote = true
// Wait for the frontend's useAiActivity WS to connect to our bridge
await waitForClient(wss)
// Simulate MCP broadcasting vault_changed then open_tab
// (This is what happens when Claude Code calls open_note via MCP)
broadcast(wss, { type: 'ui_action', action: 'vault_changed', path: 'note/ai-created-note.md' })
// Verify: note appears in note list after vault reload
await expect(noteList.getByText(NEW_NOTE_TITLE)).toBeVisible({ timeout: 8000 })
// Send open_tab after vault is reloaded so the entry is in entriesByPath
broadcast(wss, { type: 'ui_action', action: 'open_tab', path: NEW_NOTE_PATH })
// Verify: note content is displayed in the editor (proves tab opened)
await expect(page.getByText('This note was created by the AI agent.')).toBeVisible({ timeout: 8000 })
})
})