Compare commits

...

5 Commits

Author SHA1 Message Date
Luca Rossi
e3977a6042 feat: contextual AI chat on open note (Cmd+I) (#169)
- Cmd+I toggles AI panel with context from the active note
- Context bar shows note title in AI panel when a note is open
- useAiAgent accepts optional contextPrompt used as system prompt
- ai-context.ts extracts structured context from note + related entries
- Updated AiPanel, EditorRightPanel, App, useAppCommands, useCommandRegistry
- 1292 frontend tests pass, coverage 78.96%

Co-authored-by: Test <test@test.com>
2026-03-02 05:00:29 +01:00
Luca Rossi
4cdc534c01 feat: add daily note command — Cmd+J creates or opens today's journal note (#168)
* feat: add daily note command — Cmd+J creates or opens today's journal note

Adds "Open Today's Note" command accessible via:
- Keyboard shortcut: Cmd+J
- Command Palette (Cmd+K → "Open Today's Note")
- File menu bar entry

If journal/YYYY-MM-DD.md exists, opens it. Otherwise creates it with
Journal type frontmatter and Intentions/Reflections template sections.

Also refactors useNoteActions to extract a shared persistNew callback,
reducing code duplication and keeping CodeScene complexity thresholds green.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: trigger CI for PR #168

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 03:08:15 +01:00
Luca Rossi
1a93acdce7 fix: show new (untracked) notes in Changes view (#161)
* fix: show new (untracked) notes in Changes view

Two fixes:
1. Use `git status --porcelain --untracked-files=all` so individual
   untracked files in subdirectories are listed (instead of just the
   directory name, which gets filtered out by the .md extension check).
2. Call loadModifiedFiles after persistOptimistic completes, so notes
   created via handleCreateNote/handleCreateType appear in Changes
   immediately without requiring a manual Cmd+S.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: trigger CI for PR #161

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-02 03:07:02 +01:00
Luca Rossi
9c8f7907b8 feat: enhance backlinks panel with collapsible section and paragraph context (#167)
The backlinks section in the Inspector is now collapsed by default with a
"Backlinks (N)" toggle header. Expanding reveals each backlink entry with
a paragraph preview showing the surrounding context of the [[wikilink]].

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 02:01:24 +01:00
Luca Rossi
8d9862ffef feat: allow custom sidebar label for type sections (#165)
* feat: allow custom sidebar label for type sections

- Adds sidebarLabel field to vault type config (Rust + frontend)
- Overrides auto-pluralization with user-defined label in sidebar
- Tests updated to cover custom label display

* fix: add missing sidebarLabel field to buildNewEntry and bulk mock generator

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-02 02:01:21 +01:00
30 changed files with 1137 additions and 93 deletions

View File

@@ -128,7 +128,7 @@ pub fn get_modified_files(vault_path: &str) -> Result<Vec<ModifiedFile>, String>
let vault = Path::new(vault_path);
let output = Command::new("git")
.args(["status", "--porcelain"])
.args(["status", "--porcelain", "--untracked-files=all"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git status: {}", e))?;
@@ -740,7 +740,42 @@ mod tests {
assert!(modified.len() >= 2);
let statuses: Vec<&str> = modified.iter().map(|f| f.status.as_str()).collect();
assert!(statuses.contains(&"modified") || statuses.contains(&"untracked"));
assert!(statuses.contains(&"modified"));
assert!(statuses.contains(&"untracked"));
}
#[test]
fn test_get_modified_files_untracked_in_subdirectory() {
let dir = setup_git_repo();
let vault = dir.path();
// Create initial commit so git is initialized
fs::write(vault.join("init.md"), "# Init\n").unwrap();
Command::new("git")
.args(["add", "init.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Initial"])
.current_dir(vault)
.output()
.unwrap();
// Create a new untracked file in a subdirectory (simulates new note creation)
fs::create_dir_all(vault.join("note")).unwrap();
fs::write(vault.join("note/brand-new.md"), "# Brand New\n").unwrap();
let modified = get_modified_files(vault.to_str().unwrap()).unwrap();
assert_eq!(modified.len(), 1);
assert_eq!(modified[0].status, "untracked");
assert_eq!(modified[0].relative_path, "note/brand-new.md");
assert!(
modified[0].path.ends_with("/note/brand-new.md"),
"Full path should end with relative path: {}",
modified[0].path
);
}
#[test]

View File

@@ -6,6 +6,7 @@ use tauri::{
// Custom menu item IDs that emit events to the frontend.
const APP_SETTINGS: &str = "app-settings";
const FILE_NEW_NOTE: &str = "file-new-note";
const FILE_DAILY_NOTE: &str = "file-daily-note";
const FILE_QUICK_OPEN: &str = "file-quick-open";
const FILE_SAVE: &str = "file-save";
const FILE_CLOSE_TAB: &str = "file-close-tab";
@@ -26,6 +27,7 @@ const VIEW_GO_FORWARD: &str = "view-go-forward";
const CUSTOM_IDS: &[&str] = &[
APP_SETTINGS,
FILE_NEW_NOTE,
FILE_DAILY_NOTE,
FILE_QUICK_OPEN,
FILE_SAVE,
FILE_CLOSE_TAB,
@@ -75,6 +77,10 @@ fn build_file_menu(app: &App) -> MenuResult {
.id(FILE_NEW_NOTE)
.accelerator("CmdOrCtrl+N")
.build(app)?;
let daily_note = MenuItemBuilder::new("Open Today's Note")
.id(FILE_DAILY_NOTE)
.accelerator("CmdOrCtrl+J")
.build(app)?;
let quick_open = MenuItemBuilder::new("Quick Open")
.id(FILE_QUICK_OPEN)
.accelerator("CmdOrCtrl+P")
@@ -98,6 +104,7 @@ fn build_file_menu(app: &App) -> MenuResult {
Ok(SubmenuBuilder::new(app, "File")
.item(&new_note)
.item(&daily_note)
.item(&quick_open)
.separator()
.item(&save)
@@ -245,6 +252,7 @@ mod tests {
let expected = [
"app-settings",
"file-new-note",
"file-daily-note",
"file-quick-open",
"file-save",
"file-close-tab",

View File

@@ -62,6 +62,9 @@ pub struct VaultEntry {
pub color: Option<String>,
/// Display order for Type entries in sidebar (lower = higher). None = use default order.
pub order: Option<i64>,
/// Custom sidebar section label for Type entries, overriding auto-pluralization.
#[serde(rename = "sidebarLabel")]
pub sidebar_label: Option<String>,
/// Word count of the note body (excludes frontmatter and H1 title).
#[serde(rename = "wordCount")]
pub word_count: u32,
@@ -104,6 +107,8 @@ struct Frontmatter {
color: Option<String>,
#[serde(default)]
order: Option<i64>,
#[serde(rename = "sidebar label", default)]
sidebar_label: Option<String>,
}
/// Handles YAML fields that can be either a single string or a list of strings.
@@ -147,6 +152,7 @@ const SKIP_KEYS: &[&str] = &[
"icon",
"color",
"order",
"sidebar label",
];
/// Extract all wikilink-containing fields from raw YAML frontmatter.
@@ -325,6 +331,7 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
icon: frontmatter.icon,
color: frontmatter.color,
order: frontmatter.order,
sidebar_label: frontmatter.sidebar_label,
word_count,
outgoing_links,
})
@@ -1055,6 +1062,32 @@ References:
assert_eq!(fs::read_to_string(&path).unwrap(), content);
}
// --- sidebar_label tests ---
#[test]
fn test_parse_sidebar_label_from_type_entry() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nsidebar label: News\n---\n# News\n";
let entry = parse_test_entry(&dir, "type/news.md", content);
assert_eq!(entry.sidebar_label, Some("News".to_string()));
}
#[test]
fn test_parse_sidebar_label_missing_defaults_to_none() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\n---\n# Project\n";
let entry = parse_test_entry(&dir, "type/project.md", content);
assert_eq!(entry.sidebar_label, None);
}
#[test]
fn test_sidebar_label_not_in_relationships() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nsidebar label: My Series\n---\n# Series\n";
let entry = parse_test_entry(&dir, "type/series.md", content);
assert!(entry.relationships.get("sidebar label").is_none());
}
// Frontmatter update/delete tests are in frontmatter.rs
// save_image tests are in vault/image.rs
// purge_trash tests are in vault/trash.rs

View File

@@ -142,7 +142,7 @@ function App() {
// Read at callback time, so it's always current when user presses Cmd+N.
const contentChangeRef = useRef<(path: string, content: string) => void>(() => {})
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content) })
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles })
const navHistory = useNavigationHistory()
@@ -271,6 +271,7 @@ function App() {
onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette,
onSearch: dialogs.openSearch,
onCreateNote: notes.handleCreateNoteImmediate,
onOpenDailyNote: notes.handleOpenDailyNote,
onCreateNoteOfType: notes.handleCreateNoteImmediate,
onSave: handleSave,
onOpenSettings: dialogs.openSettings,
@@ -289,6 +290,7 @@ function App() {
onSwitchTheme: themeManager.switchTheme,
onCreateTheme: async () => { await themeManager.createTheme() },
onOpenVault: vaultSwitcher.handleOpenLocalFolder,
onToggleAIChat: dialogs.toggleAIChat,
})
const { status: updateStatus, actions: updateActions } = useUpdater()

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { AiPanel } from './AiPanel'
import type { VaultEntry } from '../types'
// Mock the hooks and utils to isolate component tests
vi.mock('../hooks/useAiAgent', () => ({
@@ -16,10 +17,37 @@ vi.mock('../utils/ai-chat', () => ({
nextMessageId: () => `msg-${Date.now()}`,
}))
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
path: '/vault/note/test.md',
filename: 'test.md',
title: 'Test Note',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: 1700000000,
fileSize: 100,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
outgoingLinks: [],
...overrides,
})
describe('AiPanel', () => {
it('renders panel with AI Agent header', () => {
it('renders panel with AI Chat header', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.getByText('AI Agent')).toBeTruthy()
expect(screen.getByText('AI Chat')).toBeTruthy()
})
it('renders data-testid ai-panel', () => {
@@ -38,9 +66,44 @@ describe('AiPanel', () => {
expect(onClose).toHaveBeenCalled()
})
it('renders empty state when no messages', () => {
it('renders empty state without context', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.getByText('Ask the AI agent to work with your vault')).toBeTruthy()
expect(screen.getByText('Open a note, then ask the AI about it')).toBeTruthy()
})
it('renders contextual empty state when active entry is provided', () => {
const entry = makeEntry({ title: 'My Note' })
render(
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} allContent={{}} />
)
expect(screen.getByText('Ask about this note and its linked context')).toBeTruthy()
})
it('shows context bar with active entry title', () => {
const entry = makeEntry({ title: 'My Note' })
render(
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} allContent={{}} />
)
expect(screen.getByTestId('context-bar')).toBeTruthy()
expect(screen.getByText('My Note')).toBeTruthy()
})
it('shows linked count in context bar when entry has outgoing links', () => {
const linked = makeEntry({ path: '/vault/linked.md', title: 'Linked Note' })
const entry = makeEntry({ title: 'My Note', outgoingLinks: ['Linked Note'] })
render(
<AiPanel
onClose={vi.fn()} vaultPath="/tmp/vault"
activeEntry={entry} entries={[entry, linked]}
allContent={{}}
/>
)
expect(screen.getByText('+ 1 linked')).toBeTruthy()
})
it('does not show context bar when no active entry', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.queryByTestId('context-bar')).toBeNull()
})
it('renders input field enabled', () => {
@@ -55,4 +118,19 @@ describe('AiPanel', () => {
const sendBtn = screen.getByTestId('agent-send')
expect((sendBtn as HTMLButtonElement).disabled).toBe(true)
})
it('shows contextual placeholder when active entry exists', () => {
const entry = makeEntry({ title: 'My Note' })
render(
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} allContent={{}} />
)
const input = screen.getByTestId('agent-input') as HTMLInputElement
expect(input.placeholder).toBe('Ask about this note...')
})
it('shows generic placeholder when no active entry', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
const input = screen.getByTestId('agent-input') as HTMLInputElement
expect(input.placeholder).toBe('Ask the AI agent...')
})
})

View File

@@ -1,7 +1,9 @@
import { useState, useRef, useEffect } from 'react'
import { Robot, X, PaperPlaneRight, Plus } from '@phosphor-icons/react'
import { useState, useRef, useEffect, useMemo } from 'react'
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
import { AiMessage } from './AiMessage'
import { useAiAgent, type AiAgentMessage } from '../hooks/useAiAgent'
import { collectLinkedEntries, buildContextualPrompt } from '../utils/ai-context'
import type { VaultEntry } from '../types'
export type { AiAgentMessage } from '../hooks/useAiAgent'
@@ -9,6 +11,9 @@ interface AiPanelProps {
onClose: () => void
onOpenNote?: (path: string) => void
vaultPath: string
activeEntry?: VaultEntry | null
entries?: VaultEntry[]
allContent?: Record<string, string>
}
function PanelHeader({ onClose, onClear }: { onClose: () => void; onClear: () => void }) {
@@ -19,7 +24,7 @@ function PanelHeader({ onClose, onClear }: { onClose: () => void; onClear: () =>
>
<Robot size={16} className="shrink-0 text-muted-foreground" />
<span className="flex-1 text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>
AI Agent
AI Chat
</span>
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
@@ -39,7 +44,23 @@ function PanelHeader({ onClose, onClear }: { onClose: () => void; onClear: () =>
)
}
function EmptyState() {
function ContextBar({ activeEntry, linkedCount }: { activeEntry: VaultEntry; linkedCount: number }) {
return (
<div
className="flex shrink-0 items-center border-b border-border text-muted-foreground"
style={{ padding: '6px 12px', gap: 6, fontSize: 11 }}
data-testid="context-bar"
>
<Link size={12} className="shrink-0" />
<span className="truncate" style={{ fontWeight: 500 }}>{activeEntry.title}</span>
{linkedCount > 0 && (
<span style={{ opacity: 0.6 }}>+ {linkedCount} linked</span>
)}
</div>
)
}
function EmptyState({ hasContext }: { hasContext: boolean }) {
return (
<div
className="flex flex-col items-center justify-center text-center text-muted-foreground"
@@ -47,17 +68,23 @@ function EmptyState() {
>
<Robot size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
Ask the AI agent to work with your vault
{hasContext
? 'Ask about this note and its linked context'
: 'Open a note, then ask the AI about it'
}
</p>
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>
Creates notes, searches, edits frontmatter, and more
{hasContext
? 'Summarize, find connections, expand ideas'
: 'The AI will use the active note as context'
}
</p>
</div>
)
}
function MessageHistory({ messages, isActive, onOpenNote }: {
messages: AiAgentMessage[]; isActive: boolean; onOpenNote?: (path: string) => void
function MessageHistory({ messages, isActive, onOpenNote, hasContext }: {
messages: AiAgentMessage[]; isActive: boolean; onOpenNote?: (path: string) => void; hasContext: boolean
}) {
const endRef = useRef<HTMLDivElement>(null)
@@ -67,7 +94,7 @@ function MessageHistory({ messages, isActive, onOpenNote }: {
return (
<div className="flex-1 overflow-y-auto" style={{ padding: 12 }}>
{messages.length === 0 && !isActive && <EmptyState />}
{messages.length === 0 && !isActive && <EmptyState hasContext={hasContext} />}
{messages.map((msg, i) => (
<AiMessage key={msg.id ?? i} {...msg} onOpenNote={onOpenNote} />
))}
@@ -76,10 +103,10 @@ function MessageHistory({ messages, isActive, onOpenNote }: {
)
}
function InputBar({ input, onInputChange, onSend, onKeyDown, isActive }: {
function InputBar({ input, onInputChange, onSend, onKeyDown, isActive, hasContext }: {
input: string; onInputChange: (v: string) => void
onSend: () => void; onKeyDown: (e: React.KeyboardEvent) => void
isActive: boolean
isActive: boolean; hasContext: boolean
}) {
const sendDisabled = isActive || !input.trim()
return (
@@ -97,7 +124,7 @@ function InputBar({ input, onInputChange, onSend, onKeyDown, isActive }: {
fontSize: 13, borderRadius: 8, padding: '8px 10px',
outline: 'none', fontFamily: 'inherit',
}}
placeholder="Ask the AI agent..."
placeholder={hasContext ? 'Ask about this note...' : 'Ask the AI agent...'}
disabled={isActive}
data-testid="agent-input"
/>
@@ -121,9 +148,21 @@ function InputBar({ input, onInputChange, onSend, onKeyDown, isActive }: {
)
}
export function AiPanel({ onClose, onOpenNote, vaultPath }: AiPanelProps) {
export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries, allContent }: AiPanelProps) {
const [input, setInput] = useState('')
const agent = useAiAgent(vaultPath)
const linkedEntries = useMemo(() => {
if (!activeEntry || !entries) return []
return collectLinkedEntries(activeEntry, entries)
}, [activeEntry, entries])
const contextPrompt = useMemo(() => {
if (!activeEntry || !allContent) return undefined
return buildContextualPrompt(activeEntry, linkedEntries, allContent)
}, [activeEntry, linkedEntries, allContent])
const agent = useAiAgent(vaultPath, contextPrompt)
const hasContext = !!activeEntry
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'
@@ -146,10 +185,14 @@ export function AiPanel({ onClose, onOpenNote, vaultPath }: AiPanelProps) {
data-testid="ai-panel"
>
<PanelHeader onClose={onClose} onClear={agent.clearConversation} />
{activeEntry && (
<ContextBar activeEntry={activeEntry} linkedCount={linkedEntries.length} />
)}
<MessageHistory
messages={agent.messages}
isActive={isActive}
onOpenNote={onOpenNote}
hasContext={hasContext}
/>
<InputBar
input={input}
@@ -157,6 +200,7 @@ export function AiPanel({ onClose, onOpenNote, vaultPath }: AiPanelProps) {
onSend={handleSend}
onKeyDown={handleKeyDown}
isActive={isActive}
hasContext={hasContext}
/>
</aside>
)

View File

@@ -38,6 +38,9 @@ export function EditorRightPanel({
onClose={() => onToggleAIChat?.()}
onOpenNote={onOpenNote}
vaultPath={vaultPath}
activeEntry={inspectorEntry}
entries={entries}
allContent={allContent}
/>
</div>
)

View File

@@ -186,8 +186,11 @@ This is a test note with some words to count.
entries={[mockEntry, referrerEntry]}
/>
)
// Backlinks section is collapsed by default, but header with count is visible
expect(screen.getByText('Backlinks (1)')).toBeInTheDocument()
// Expand to see the backlink entry
fireEvent.click(screen.getByTestId('backlinks-toggle'))
expect(screen.getByText('Referrer Note')).toBeInTheDocument()
expect(screen.getByText('1')).toBeInTheDocument() // count badge
})
it('updates backlinks reactively when outgoingLinks changes', () => {
@@ -200,7 +203,7 @@ This is a test note with some words to count.
/>
)
// Initially no backlinks — section is hidden entirely
expect(screen.queryByText('Backlinks')).not.toBeInTheDocument()
expect(screen.queryByTestId('backlinks-toggle')).not.toBeInTheDocument()
// Rerender with updated outgoingLinks (simulates adding [[Test Project]] to referrer)
rerender(
@@ -211,6 +214,8 @@ This is a test note with some words to count.
entries={[mockEntry, { ...referrerEntry, outgoingLinks: ['Test Project'] }]}
/>
)
expect(screen.getByText('Backlinks (1)')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('backlinks-toggle'))
expect(screen.getByText('Referrer Note')).toBeInTheDocument()
})
@@ -223,8 +228,7 @@ This is a test note with some words to count.
entries={[mockEntry]}
/>
)
expect(screen.queryByText('No backlinks')).not.toBeInTheDocument()
expect(screen.queryByText('Backlinks')).not.toBeInTheDocument()
expect(screen.queryByTestId('backlinks-toggle')).not.toBeInTheDocument()
})
it('navigates when a backlink is clicked', () => {
@@ -235,10 +239,10 @@ This is a test note with some words to count.
entry={mockEntry}
content={mockContent}
entries={[mockEntry, referrerEntry]}
onNavigate={onNavigate}
/>
)
fireEvent.click(screen.getByTestId('backlinks-toggle'))
fireEvent.click(screen.getByText('Referrer Note'))
expect(onNavigate).toHaveBeenCalledWith('Referrer Note')
})
@@ -605,7 +609,7 @@ Status: Active
expect(screen.getByText(/← Belongs to/i)).toBeInTheDocument()
expect(screen.getByText('On Writing Well')).toBeInTheDocument()
// But NOT in Backlinks (even though outgoingLinks matches) — section hidden
expect(screen.queryByText('Backlinks')).not.toBeInTheDocument()
expect(screen.queryByTestId('backlinks-toggle')).not.toBeInTheDocument()
})
it('does not show self-references', () => {

View File

@@ -7,7 +7,8 @@ import { parseFrontmatter } from '../utils/frontmatter'
import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel } from './InspectorPanels'
import { wikilinkTarget } from '../utils/wikilink'
import type { ReferencedByItem } from './InspectorPanels'
import { extractBacklinkContext } from '../utils/wikilinks'
import type { ReferencedByItem, BacklinkItem } from './InspectorPanels'
export type FrontmatterValue = string | number | boolean | string[] | null
@@ -26,7 +27,12 @@ interface InspectorProps {
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
}
function useBacklinks(entry: VaultEntry | null, entries: VaultEntry[], referencedBy: ReferencedByItem[]): VaultEntry[] {
function useBacklinks(
entry: VaultEntry | null,
entries: VaultEntry[],
referencedBy: ReferencedByItem[],
allContent?: Record<string, string>,
): BacklinkItem[] {
return useMemo(() => {
if (!entry) return []
const matchTargets = new Set([
@@ -37,14 +43,21 @@ function useBacklinks(entry: VaultEntry | null, entries: VaultEntry[], reference
const referencedByPaths = new Set(referencedBy.map((item) => item.entry.path))
return entries.filter((e) => {
if (e.path === entry.path) return false
if (referencedByPaths.has(e.path)) return false
return e.outgoingLinks.some((target) =>
matchTargets.has(target) || matchTargets.has(target.split('/').pop() ?? '')
)
})
}, [entry, entries, referencedBy])
return entries
.filter((e) => {
if (e.path === entry.path) return false
if (referencedByPaths.has(e.path)) return false
return e.outgoingLinks.some((target) =>
matchTargets.has(target) || matchTargets.has(target.split('/').pop() ?? '')
)
})
.map((e) => ({
entry: e,
context: allContent?.[e.path]
? extractBacklinkContext(allContent[e.path], matchTargets)
: null,
}))
}, [entry, entries, referencedBy, allContent])
}
function refsMatchTargets(refs: string[], targets: Set<string>): boolean {
@@ -109,7 +122,7 @@ export function Inspector({
onViewCommitDiff, onUpdateFrontmatter, onDeleteProperty, onAddProperty,
}: InspectorProps) {
const referencedBy = useReferencedBy(entry, entries)
const backlinks = useBacklinks(entry, entries, referencedBy)
const backlinks = useBacklinks(entry, entries, referencedBy, allContent)
const frontmatter = useMemo(() => parseFrontmatter(content), [content])
const typeEntryMap = useMemo(() => {
const map: Record<string, VaultEntry> = {}

View File

@@ -420,27 +420,48 @@ describe('BacklinksPanel', () => {
expect(container.innerHTML).toBe('')
})
it('renders backlink entries', () => {
const backlinks = [
makeEntry({ title: 'Referencing Note', isA: 'Note' }),
makeEntry({ title: 'Another Note', isA: 'Project', path: '/vault/project/another.md' }),
]
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
const twoBacklinks = [
{ entry: makeEntry({ title: 'Referencing Note', isA: 'Note' }), context: null },
{ entry: makeEntry({ title: 'Another Note', isA: 'Project', path: '/vault/project/another.md' }), context: null },
]
it('renders collapsed by default with count badge', () => {
render(<BacklinksPanel typeEntryMap={{}} backlinks={twoBacklinks} onNavigate={onNavigate} />)
expect(screen.getByText('Backlinks (2)')).toBeInTheDocument()
expect(screen.queryByText('Referencing Note')).not.toBeInTheDocument()
})
it('expands to show backlink entries when toggle clicked', () => {
render(<BacklinksPanel typeEntryMap={{}} backlinks={twoBacklinks} onNavigate={onNavigate} />)
fireEvent.click(screen.getByTestId('backlinks-toggle'))
expect(screen.getByText('Referencing Note')).toBeInTheDocument()
expect(screen.getByText('Another Note')).toBeInTheDocument()
})
it('navigates when clicking backlink', () => {
const backlinks = [makeEntry({ title: 'Reference' })]
const backlinks = [{ entry: makeEntry({ title: 'Reference' }), context: null }]
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
fireEvent.click(screen.getByTestId('backlinks-toggle'))
fireEvent.click(screen.getByText('Reference'))
expect(onNavigate).toHaveBeenCalledWith('Reference')
})
it('shows count when backlinks exist', () => {
const backlinks = [makeEntry(), makeEntry({ path: '/vault/b.md', title: 'B' })]
it('shows paragraph context preview when available', () => {
const backlinks = [
{ entry: makeEntry({ title: 'Referencing Note' }), context: 'This references [[My Note]] in context.' },
]
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
expect(screen.getByText('2')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('backlinks-toggle'))
expect(screen.getByText('This references [[My Note]] in context.')).toBeInTheDocument()
})
it('collapses when toggle clicked twice', () => {
const backlinks = [{ entry: makeEntry({ title: 'Note A' }), context: null }]
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
fireEvent.click(screen.getByTestId('backlinks-toggle'))
expect(screen.getByText('Note A')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('backlinks-toggle'))
expect(screen.queryByText('Note A')).not.toBeInTheDocument()
})
})

View File

@@ -2,7 +2,7 @@ import { useMemo, useCallback, useState, useRef } from 'react'
import type { ComponentType, SVGAttributes } from 'react'
import { wikilinkTarget, wikilinkDisplay } from '../utils/wikilink'
import type { VaultEntry, GitCommit } from '../types'
import { Trash, X } from '@phosphor-icons/react'
import { CaretRight, Trash, X } from '@phosphor-icons/react'
import type { ParsedFrontmatter } from '../utils/frontmatter'
import { RELATIONSHIP_KEYS, containsWikilinks } from './DynamicPropertiesPanel'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
@@ -372,21 +372,78 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
)
}
export function BacklinksPanel({ backlinks, typeEntryMap, onNavigate }: { backlinks: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>; onNavigate: (target: string) => void }) {
export interface BacklinkItem {
entry: VaultEntry
context: string | null
}
function BacklinkEntry({ entry, context, typeEntryMap, onNavigate }: {
entry: VaultEntry
context: string | null
typeEntryMap: Record<string, VaultEntry>
onNavigate: (target: string) => void
}) {
const te = typeEntryMap[entry.isA ?? '']
const isDimmed = entry.archived || entry.trashed
return (
<button
className="flex w-full cursor-pointer flex-col items-start gap-0.5 border-none bg-transparent p-0 text-left hover:opacity-80"
onClick={() => onNavigate(entry.title)}
title={entryStatusTitle(entry)}
>
<span
className="flex items-center gap-1 text-xs font-medium"
style={{ color: isDimmed ? 'var(--muted-foreground)' : getTypeColor(entry.isA, te?.color) }}
>
{entry.trashed && <Trash size={12} className="shrink-0" />}
{entry.title}
<StatusSuffix isArchived={entry.archived} isTrashed={entry.trashed} />
</span>
{context && (
<span className="line-clamp-2 text-[11px] leading-snug text-muted-foreground">
{context}
</span>
)}
</button>
)
}
export function BacklinksPanel({ backlinks, typeEntryMap, onNavigate }: {
backlinks: BacklinkItem[]
typeEntryMap: Record<string, VaultEntry>
onNavigate: (target: string) => void
}) {
const [expanded, setExpanded] = useState(false)
if (backlinks.length === 0) return null
return (
<div>
<h4 className="font-mono-overline mb-2 text-muted-foreground">
Backlinks <span className="ml-1" style={{ fontWeight: 400 }}>{backlinks.length}</span>
</h4>
<div className="flex flex-col gap-0.5">
{backlinks.map((e) => {
const te = typeEntryMap[e.isA ?? '']
return (
<LinkButton key={e.path} label={e.title} typeColor={getTypeColor(e.isA, te?.color)} isArchived={e.archived} isTrashed={e.trashed} onClick={() => onNavigate(e.title)} title={e.trashed ? 'Trashed' : e.archived ? 'Archived' : undefined} TypeIcon={getTypeIcon(e.isA, te?.icon)} />
)
})}
</div>
<button
className="font-mono-overline mb-2 flex w-full cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left text-muted-foreground hover:text-foreground"
onClick={() => setExpanded((v) => !v)}
data-testid="backlinks-toggle"
>
<CaretRight
size={12}
className="shrink-0 transition-transform"
style={{ transform: expanded ? 'rotate(90deg)' : undefined }}
/>
Backlinks ({backlinks.length})
</button>
{expanded && (
<div className="flex flex-col gap-1.5" data-testid="backlinks-list">
{backlinks.map(({ entry, context }) => (
<BacklinkEntry
key={entry.path}
entry={entry}
context={context}
typeEntryMap={typeEntryMap}
onNavigate={onNavigate}
/>
))}
</div>
)}
</div>
)
}

View File

@@ -985,6 +985,19 @@ describe('NoteList — virtual list with large datasets', () => {
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
})
it('shows untracked (new) notes alongside modified notes in changes view', () => {
const mixedFiles = [
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },
{ path: mockEntries[2].path, relativePath: 'person/matteo-cellini.md', status: 'untracked' as const },
]
render(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={mixedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
expect(screen.queryByText('Facebook Ads Strategy')).not.toBeInTheDocument()
})
})
})

View File

@@ -39,6 +39,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -64,6 +65,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -89,6 +91,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -114,6 +117,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -139,6 +143,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -164,6 +169,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -189,6 +195,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -214,6 +221,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
]
@@ -432,6 +440,7 @@ describe('Sidebar', () => {
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -457,6 +466,7 @@ describe('Sidebar', () => {
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -553,7 +563,7 @@ describe('Sidebar', () => {
isA: 'Event', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
cadence: null, archived: false, trashed: true, trashedAt: 1700000000,
modifiedAt: 1700000000, createdAt: null, fileSize: 100, snippet: '', wordCount: 0,
relationships: {}, icon: null, color: null, order: null, outgoingLinks: [],
relationships: {}, icon: null, color: null, order: null, sidebarLabel: null, outgoingLinks: [],
},
]
render(<Sidebar entries={entriesWithTrashedOnly} selection={defaultSelection} onSelect={() => {}} />)
@@ -591,6 +601,7 @@ describe('Sidebar', () => {
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
}
render(<Sidebar entries={[...mockEntries, projectTypeEntry]} selection={defaultSelection} onSelect={() => {}} />)
@@ -598,6 +609,52 @@ describe('Sidebar', () => {
const projectLabels = screen.getAllByText('Projects')
expect(projectLabels.length).toBe(1)
})
it('uses sidebarLabel from Type entry instead of auto-pluralization', () => {
const entriesWithLabel: VaultEntry[] = [
...mockEntries,
{
path: '/vault/type/news.md', filename: 'news.md', title: 'News', isA: 'Type',
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null,
fileSize: 200, snippet: '', wordCount: 0, relationships: {},
icon: null, color: null, order: null, sidebarLabel: 'News', outgoingLinks: [],
},
{
path: '/vault/news/breaking.md', filename: 'breaking.md', title: 'Breaking Story', isA: 'News',
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null,
fileSize: 300, snippet: '', wordCount: 0, relationships: {},
icon: null, color: null, order: null, sidebarLabel: null, outgoingLinks: [],
},
]
render(<Sidebar entries={entriesWithLabel} selection={defaultSelection} onSelect={() => {}} />)
// Should show "News" (custom label), not "Newses" (auto-pluralized)
expect(screen.getByText('News')).toBeInTheDocument()
expect(screen.queryByText('Newses')).not.toBeInTheDocument()
})
it('uses sidebarLabel to override built-in type label', () => {
const entriesWithBuiltInOverride: VaultEntry[] = [
...mockEntries,
{
path: '/vault/type/person.md', filename: 'person.md', title: 'Person', isA: 'Type',
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null,
fileSize: 200, snippet: '', wordCount: 0, relationships: {},
icon: null, color: null, order: null, sidebarLabel: 'Contacts', outgoingLinks: [],
},
]
render(<Sidebar entries={entriesWithBuiltInOverride} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.getByText('Contacts')).toBeInTheDocument()
expect(screen.queryByText('People')).not.toBeInTheDocument()
})
it('falls back to auto-pluralization when sidebarLabel is null', () => {
render(<Sidebar entries={entriesWithCustomTypes} selection={defaultSelection} onSelect={() => {}} />)
// Recipe has no sidebarLabel → should auto-pluralize to "Recipes"
expect(screen.getByText('Recipes')).toBeInTheDocument()
})
})
describe('customize section visibility', () => {
@@ -705,21 +762,21 @@ describe('Sidebar', () => {
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
wordCount: 0,
relationships: {}, icon: null, color: null, order: 5, outgoingLinks: [],
relationships: {}, icon: null, color: null, order: 5, sidebarLabel: null, outgoingLinks: [],
},
{
path: '/vault/type/topic.md', filename: 'topic.md', title: 'Topic', isA: 'Type',
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
wordCount: 0,
relationships: {}, icon: null, color: null, order: 0, outgoingLinks: [],
relationships: {}, icon: null, color: null, order: 0, sidebarLabel: null, outgoingLinks: [],
},
{
path: '/vault/type/person.md', filename: 'person.md', title: 'Person', isA: 'Type',
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
wordCount: 0,
relationships: {}, icon: null, color: null, order: 1, outgoingLinks: [],
relationships: {}, icon: null, color: null, order: 1, sidebarLabel: null, outgoingLinks: [],
},
]

View File

@@ -79,11 +79,12 @@ function buildSectionGroup(type: string, typeEntryMap: Record<string, VaultEntry
const builtIn = BUILT_IN_TYPE_MAP.get(type)
const typeEntry = typeEntryMap[type]
const customColor = typeEntry?.color ?? null
const label = typeEntry?.sidebarLabel || (builtIn?.label ?? pluralizeType(type))
if (builtIn) {
const Icon = typeEntry?.icon ? resolveIcon(typeEntry.icon) : builtIn.Icon
return { ...builtIn, Icon, customColor }
return { ...builtIn, label, Icon, customColor }
}
return { label: pluralizeType(type), type, Icon: resolveIcon(typeEntry?.icon ?? null), customColor }
return { label, type, Icon: resolveIcon(typeEntry?.icon ?? null), customColor }
}
/** Build sections dynamically from actual vault entries — only types with ≥1 active note appear */

View File

@@ -4,7 +4,7 @@
*
* States: idle -> thinking -> tool-executing -> done/error
*/
import { useState, useCallback, useRef } from 'react'
import { useState, useCallback, useRef, useEffect } from 'react'
import type { AiAction } from '../components/AiMessage'
import { streamClaudeAgent, buildAgentSystemPrompt } from '../utils/ai-agent'
import { nextMessageId } from '../utils/ai-chat'
@@ -20,10 +20,14 @@ export interface AiAgentMessage {
id?: string
}
export function useAiAgent(vaultPath: string) {
export function useAiAgent(vaultPath: string, contextPrompt?: string) {
const [messages, setMessages] = useState<AiAgentMessage[]>([])
const [status, setStatus] = useState<AgentStatus>('idle')
const abortRef = useRef({ aborted: false })
const contextRef = useRef(contextPrompt)
useEffect(() => {
contextRef.current = contextPrompt
}, [contextPrompt])
const sendMessage = useCallback(async (text: string) => {
if (!text.trim() || status === 'thinking' || status === 'tool-executing') return
@@ -49,7 +53,11 @@ export function useAiAgent(vaultPath: string) {
setMessages(prev => prev.map(m => m.id === messageId ? fn(m) : m))
}
await streamClaudeAgent(text.trim(), buildAgentSystemPrompt(), vaultPath, {
// When a contextual prompt is provided (from buildContextualPrompt),
// use it directly — it already includes the system preamble.
const systemPrompt = contextRef.current ?? buildAgentSystemPrompt()
await streamClaudeAgent(text.trim(), systemPrompt, vaultPath, {
onText: (text) => {
if (abortRef.current.aborted) return
update(m => ({ ...m, response: (m.response ?? '') + text }))

View File

@@ -21,6 +21,7 @@ interface AppCommandsConfig {
onCommandPalette: () => void
onSearch: () => void
onCreateNote: () => void
onOpenDailyNote: () => void
onCreateNoteOfType: (type: string) => void
onSave: () => void
onOpenSettings: () => void
@@ -48,6 +49,7 @@ interface AppCommandsConfig {
onSwitchTheme?: (themeId: string) => void
onCreateTheme?: () => void
onOpenVault?: () => void
onToggleAIChat?: () => void
}
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
@@ -57,6 +59,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onCommandPalette: config.onCommandPalette,
onSearch: config.onSearch,
onCreateNote: config.onCreateNote,
onOpenDailyNote: config.onOpenDailyNote,
onSave: config.onSave,
onOpenSettings: config.onOpenSettings,
onTrashNote: config.onTrashNote,
@@ -67,6 +70,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onZoomReset: config.onZoomReset,
onGoBack: config.onGoBack,
onGoForward: config.onGoForward,
onToggleAIChat: config.onToggleAIChat,
activeTabPathRef: config.activeTabPathRef,
handleCloseTabRef: config.handleCloseTabRef,
})
@@ -74,6 +78,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
useMenuEvents({
onSetViewMode: config.onSetViewMode,
onCreateNote: config.onCreateNote,
onOpenDailyNote: config.onOpenDailyNote,
onQuickOpen: config.onQuickOpen,
onSave: config.onSave,
onOpenSettings: config.onOpenSettings,
@@ -112,6 +117,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onZoomReset: config.onZoomReset,
zoomLevel: config.zoomLevel,
onSelect: config.onSelect,
onOpenDailyNote: config.onOpenDailyNote,
onCloseTab: config.onCloseTab,
onGoBack: config.onGoBack,
onGoForward: config.onGoForward,
@@ -122,6 +128,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onSwitchTheme: config.onSwitchTheme,
onCreateTheme: config.onCreateTheme,
onOpenVault: config.onOpenVault,
onToggleAIChat: config.onToggleAIChat,
})
useKeyboardNavigation({

View File

@@ -21,6 +21,7 @@ function makeActions() {
onCommandPalette: vi.fn(),
onSearch: vi.fn(),
onCreateNote: vi.fn(),
onOpenDailyNote: vi.fn(),
onSave: vi.fn(),
onOpenSettings: vi.fn(),
onTrashNote: vi.fn(),
@@ -79,6 +80,13 @@ describe('useAppKeyboard', () => {
expect(actions.onCreateNote).toHaveBeenCalled()
})
it('Cmd+J triggers open daily note', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('j', { metaKey: true })
expect(actions.onOpenDailyNote).toHaveBeenCalled()
})
it('Cmd+W closes the active tab', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
@@ -174,4 +182,22 @@ describe('useAppKeyboard', () => {
fireKey('0', { metaKey: true })
expect(actions.onZoomReset).toHaveBeenCalled()
})
it('Cmd+I triggers toggle AI chat', () => {
const actions = makeActions()
const onToggleAIChat = vi.fn()
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
fireKey('i', { metaKey: true })
expect(onToggleAIChat).toHaveBeenCalled()
})
it('Cmd+I works when text input is focused', () => {
const actions = makeActions()
const onToggleAIChat = vi.fn()
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
withFocusedInput(() => {
fireKey('i', { metaKey: true })
expect(onToggleAIChat).toHaveBeenCalled()
})
})
})

View File

@@ -6,6 +6,7 @@ interface KeyboardActions {
onCommandPalette: () => void
onSearch: () => void
onCreateNote: () => void
onOpenDailyNote: () => void
onSave: () => void
onOpenSettings: () => void
onTrashNote: (path: string) => void
@@ -16,6 +17,7 @@ interface KeyboardActions {
onZoomReset: () => void
onGoBack?: () => void
onGoForward?: () => void
onToggleAIChat?: () => void
activeTabPathRef: React.MutableRefObject<string | null>
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
}
@@ -60,8 +62,8 @@ function handleCmdKey(e: KeyboardEvent, keyMap: Record<string, ShortcutHandler>)
}
export function useAppKeyboard({
onQuickOpen, onCommandPalette, onSearch, onCreateNote, onSave, onOpenSettings, onTrashNote, onArchiveNote,
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, activeTabPathRef, handleCloseTabRef,
onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote,
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, activeTabPathRef, handleCloseTabRef,
}: KeyboardActions) {
useEffect(() => {
const withActiveTab = (fn: (path: string) => void): ShortcutHandler => () => {
@@ -73,6 +75,7 @@ export function useAppKeyboard({
k: onCommandPalette,
p: onQuickOpen,
n: onCreateNote,
j: onOpenDailyNote,
s: onSave,
',': onOpenSettings,
e: withActiveTab(onArchiveNote),
@@ -85,6 +88,7 @@ export function useAppKeyboard({
'+': onZoomIn,
'-': onZoomOut,
'0': onZoomReset,
i: () => onToggleAIChat?.(),
}
const handleKeyDown = (e: KeyboardEvent) => {
@@ -100,5 +104,5 @@ export function useAppKeyboard({
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward])
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat])
}

View File

@@ -52,6 +52,7 @@ function makeConfig(overrides: Record<string, unknown> = {}) {
zoomLevel: 100,
onSelect: vi.fn(),
onCloseTab: vi.fn(),
onOpenDailyNote: vi.fn(),
...overrides,
}
}
@@ -176,6 +177,40 @@ describe('useCommandRegistry', () => {
expect(result.current.find(c => c.id === 'zoom-in')!.label).toContain('120%')
})
it('has toggle-ai-chat command with shortcut', () => {
const onToggleAIChat = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onToggleAIChat })))
const cmd = result.current.find(c => c.id === 'toggle-ai-chat')
expect(cmd).toBeDefined()
expect(cmd!.shortcut).toBe('⌘I')
expect(cmd!.group).toBe('View')
expect(cmd!.enabled).toBe(true)
})
it('calls onToggleAIChat when toggle-ai-chat executes', () => {
const onToggleAIChat = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onToggleAIChat })))
result.current.find(c => c.id === 'toggle-ai-chat')!.execute()
expect(onToggleAIChat).toHaveBeenCalled()
})
it('has open-daily-note command with shortcut', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
const cmd = result.current.find(c => c.id === 'open-daily-note')
expect(cmd).toBeDefined()
expect(cmd!.label).toBe("Open Today's Note")
expect(cmd!.shortcut).toBe('⌘J')
expect(cmd!.group).toBe('Note')
expect(cmd!.enabled).toBe(true)
})
it('calls onOpenDailyNote when open-daily-note executes', () => {
const onOpenDailyNote = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onOpenDailyNote })))
result.current.find(c => c.id === 'open-daily-note')!.execute()
expect(onOpenDailyNote).toHaveBeenCalled()
})
describe('type-aware commands', () => {
it('generates "New [Type]" commands from vault entries', () => {
const entries = [

View File

@@ -31,11 +31,13 @@ interface CommandRegistryConfig {
onCommitPush: () => void
onSetViewMode: (mode: ViewMode) => void
onToggleInspector: () => void
onToggleAIChat?: () => void
onZoomIn: () => void
onZoomOut: () => void
onZoomReset: () => void
zoomLevel: number
onSelect: (sel: SidebarSelection) => void
onOpenDailyNote: () => void
onCloseTab: (path: string) => void
onGoBack?: () => void
onGoForward?: () => void
@@ -127,9 +129,9 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
activeTabPath, entries, modifiedCount,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
onTrashNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onSetViewMode, onToggleInspector, onOpenVault,
onCommitPush, onSetViewMode, onToggleInspector, onToggleAIChat, onOpenVault,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onCloseTab,
onSelect, onOpenDailyNote, onCloseTab,
onGoBack, onGoForward, canGoBack, canGoForward,
themes, activeThemeId, onSwitchTheme, onCreateTheme,
} = config
@@ -158,6 +160,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
// Note actions (contextual)
{ id: 'create-note', label: 'Create New Note', group: 'Note', shortcut: '⌘N', keywords: ['new', 'add'], enabled: true, execute: onCreateNote },
{ id: 'open-daily-note', label: "Open Today's Note", group: 'Note', shortcut: '⌘J', keywords: ['daily', 'journal', 'today'], enabled: true, execute: onOpenDailyNote },
{ id: 'save-note', label: 'Save Note', group: 'Note', shortcut: '⌘S', keywords: ['write'], enabled: hasActiveNote, execute: onSave },
{ id: 'close-tab', label: 'Close Tab', group: 'Note', shortcut: '⌘W', keywords: [], enabled: hasActiveNote, execute: () => { if (activeTabPath) onCloseTab(activeTabPath) } },
{ id: 'trash-note', label: 'Trash Note', group: 'Note', shortcut: '⌘⌫', keywords: ['delete', 'remove'], enabled: hasActiveNote, execute: () => { if (activeTabPath) onTrashNote(activeTabPath) } },
@@ -176,6 +179,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
{ id: 'view-editor-list', label: 'Editor + Note List', group: 'View', shortcut: '⌘2', keywords: ['layout'], enabled: true, execute: () => onSetViewMode('editor-list') },
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
{ id: 'toggle-inspector', label: 'Toggle Inspector', group: 'View', keywords: ['properties', 'panel', 'right'], enabled: true, execute: onToggleInspector },
{ id: 'toggle-ai-chat', label: 'Toggle AI Chat', group: 'View', shortcut: '⌘I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
{ id: 'zoom-reset', label: 'Reset Zoom', group: 'View', shortcut: '⌘0', keywords: ['zoom', 'actual', 'default', '100'], enabled: zoomLevel !== 100, execute: onZoomReset },
@@ -196,9 +200,9 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
hasActiveNote, activeTabPath, isArchived, modifiedCount,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
onTrashNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onSetViewMode, onToggleInspector, onOpenVault,
onCommitPush, onSetViewMode, onToggleInspector, onToggleAIChat, onOpenVault,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onCloseTab,
onSelect, onOpenDailyNote, onCloseTab,
onGoBack, onGoForward, canGoBack, canGoForward,
vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenVault,
])

View File

@@ -5,6 +5,7 @@ function makeHandlers(): MenuEventHandlers {
return {
onSetViewMode: vi.fn(),
onCreateNote: vi.fn(),
onOpenDailyNote: vi.fn(),
onQuickOpen: vi.fn(),
onSave: vi.fn(),
onOpenSettings: vi.fn(),
@@ -49,6 +50,12 @@ describe('dispatchMenuEvent', () => {
expect(h.onCreateNote).toHaveBeenCalled()
})
it('file-daily-note triggers open daily note', () => {
const h = makeHandlers()
dispatchMenuEvent('file-daily-note', h)
expect(h.onOpenDailyNote).toHaveBeenCalled()
})
it('file-quick-open triggers quick open', () => {
const h = makeHandlers()
dispatchMenuEvent('file-quick-open', h)

View File

@@ -5,6 +5,7 @@ import type { ViewMode } from './useViewMode'
export interface MenuEventHandlers {
onSetViewMode: (mode: ViewMode) => void
onCreateNote: () => void
onOpenDailyNote: () => void
onQuickOpen: () => void
onSave: () => void
onOpenSettings: () => void
@@ -29,10 +30,11 @@ const VIEW_MODE_MAP: Record<string, ViewMode> = {
'view-all': 'all',
}
type SimpleHandler = 'onCreateNote' | 'onQuickOpen' | 'onSave' | 'onOpenSettings' | 'onToggleInspector' | 'onCommandPalette' | 'onZoomIn' | 'onZoomOut' | 'onZoomReset' | 'onSearch'
type SimpleHandler = 'onCreateNote' | 'onOpenDailyNote' | 'onQuickOpen' | 'onSave' | 'onOpenSettings' | 'onToggleInspector' | 'onCommandPalette' | 'onZoomIn' | 'onZoomOut' | 'onZoomReset' | 'onSearch'
const SIMPLE_EVENT_MAP: Record<string, SimpleHandler> = {
'file-new-note': 'onCreateNote',
'file-daily-note': 'onOpenDailyNote',
'file-quick-open': 'onQuickOpen',
'file-save': 'onSave',
'app-settings': 'onOpenSettings',

View File

@@ -12,6 +12,10 @@ import {
resolveNewNote,
resolveNewType,
frontmatterToEntryPatch,
todayDateString,
buildDailyNoteContent,
resolveDailyNote,
findDailyNote,
useNoteActions,
} from './useNoteActions'
import type { NoteActionsConfig } from './useNoteActions'
@@ -282,6 +286,75 @@ describe('frontmatterToEntryPatch', () => {
})
})
describe('todayDateString', () => {
it('returns date in YYYY-MM-DD format', () => {
const result = todayDateString()
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/)
})
})
describe('buildDailyNoteContent', () => {
it('generates frontmatter with Journal type and date', () => {
const content = buildDailyNoteContent('2026-03-02')
expect(content).toContain('type: Journal')
expect(content).toContain('date: 2026-03-02')
expect(content).toContain('title: 2026-03-02')
})
it('includes Intentions and Reflections sections', () => {
const content = buildDailyNoteContent('2026-03-02')
expect(content).toContain('## Intentions')
expect(content).toContain('## Reflections')
})
it('includes H1 heading with the date', () => {
const content = buildDailyNoteContent('2026-03-02')
expect(content).toContain('# 2026-03-02')
})
})
describe('resolveDailyNote', () => {
it('creates entry in journal folder with date as filename', () => {
const { entry } = resolveDailyNote('2026-03-02')
expect(entry.path).toBe('/Users/luca/Laputa/journal/2026-03-02.md')
expect(entry.filename).toBe('2026-03-02.md')
expect(entry.title).toBe('2026-03-02')
expect(entry.isA).toBe('Journal')
expect(entry.status).toBeNull()
})
it('returns content with daily note template', () => {
const { content } = resolveDailyNote('2026-03-02')
expect(content).toContain('type: Journal')
expect(content).toContain('## Intentions')
})
})
describe('findDailyNote', () => {
it('finds entry by journal path suffix', () => {
const entries = [
makeEntry({ path: '/Users/luca/Laputa/journal/2026-03-02.md' }),
makeEntry({ path: '/Users/luca/Laputa/note/other.md' }),
]
const found = findDailyNote(entries, '2026-03-02')
expect(found).toBeDefined()
expect(found!.path).toBe('/Users/luca/Laputa/journal/2026-03-02.md')
})
it('returns undefined when no matching entry exists', () => {
const entries = [makeEntry({ path: '/Users/luca/Laputa/note/other.md' })]
expect(findDailyNote(entries, '2026-03-02')).toBeUndefined()
})
it('works with different vault paths', () => {
const entries = [
makeEntry({ path: '/other/vault/journal/2026-03-02.md' }),
]
const found = findDailyNote(entries, '2026-03-02')
expect(found).toBeDefined()
})
})
describe('useNoteActions hook', () => {
const addEntry = vi.fn()
const removeEntry = vi.fn()
@@ -461,6 +534,35 @@ describe('useNoteActions hook', () => {
expect(setToastMessage).toHaveBeenCalledWith('Property updated')
})
it('handleOpenDailyNote creates a new daily note when none exists', () => {
const { result } = renderHook(() => useNoteActions(makeConfig()))
act(() => {
result.current.handleOpenDailyNote()
})
expect(addEntry).toHaveBeenCalledTimes(1)
const [createdEntry] = addEntry.mock.calls[0]
expect(createdEntry.isA).toBe('Journal')
expect(createdEntry.path).toContain('journal/')
expect(createdEntry.path).toMatch(/journal\/\d{4}-\d{2}-\d{2}\.md$/)
})
it('handleOpenDailyNote opens existing daily note instead of creating', async () => {
const today = todayDateString()
const existing = makeEntry({ path: `/Users/luca/Laputa/journal/${today}.md`, title: today })
const { result } = renderHook(() => useNoteActions(makeConfig([existing])))
await act(async () => {
result.current.handleOpenDailyNote()
await new Promise((r) => setTimeout(r, 0))
})
// Should open existing note, not create a new one
expect(addEntry).not.toHaveBeenCalled()
expect(result.current.activeTabPath).toBe(`/Users/luca/Laputa/journal/${today}.md`)
})
describe('pending save lifecycle', () => {
it('createAndPersist calls addPendingSave on start (non-Tauri)', async () => {
const addPendingSave = vi.fn()
@@ -535,6 +637,38 @@ describe('useNoteActions hook', () => {
expect(trackUnsaved).toHaveBeenCalledWith(expect.stringContaining('note/untitled-note.md'))
expect(markContentPending).toHaveBeenCalledWith(expect.stringContaining('note/untitled-note.md'), expect.stringContaining('Untitled note'))
})
it('calls onNewNotePersisted after successful disk write (non-Tauri)', async () => {
const onNewNotePersisted = vi.fn()
const config = makeConfig()
config.onNewNotePersisted = onNewNotePersisted
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
result.current.handleCreateNote('Persist Callback', 'Note')
await new Promise((r) => setTimeout(r, 0))
})
expect(onNewNotePersisted).toHaveBeenCalledTimes(1)
})
it('does not call onNewNotePersisted when disk write fails (Tauri)', async () => {
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(invoke).mockRejectedValueOnce(new Error('disk full'))
const onNewNotePersisted = vi.fn()
const config = makeConfig()
config.onNewNotePersisted = onNewNotePersisted
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
result.current.handleCreateNote('Fail Persist', 'Note')
await new Promise((r) => setTimeout(r, 0))
})
expect(onNewNotePersisted).not.toHaveBeenCalled()
})
})
describe('optimistic error recovery (Tauri mode)', () => {

View File

@@ -33,6 +33,8 @@ export interface NoteActionsConfig {
unsavedPaths?: Set<string>
/** Called when an unsaved note is created so the save system can buffer its initial content. */
markContentPending?: (path: string, content: string) => void
/** Called after a new note is persisted to disk (e.g. to refresh git status for Changes view). */
onNewNotePersisted?: () => void
}
async function performRename(
@@ -70,7 +72,7 @@ export function buildNewEntry({ path, slug, title, type, status }: NewEntryParam
aliases: [], belongsTo: [], relatedTo: [],
status, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
modifiedAt: now, createdAt: now, fileSize: 0,
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [],
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null,
}
}
@@ -122,9 +124,10 @@ const TYPE_FOLDER_MAP: Record<string, string> = {
Note: 'note', Project: 'project', Experiment: 'experiment',
Responsibility: 'responsibility', Procedure: 'procedure',
Person: 'person', Event: 'event', Topic: 'topic',
Journal: 'journal',
}
const NO_STATUS_TYPES = new Set(['Topic', 'Person'])
const NO_STATUS_TYPES = new Set(['Topic', 'Person', 'Journal'])
const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
type: { isA: null }, is_a: { isA: null }, status: { status: null }, color: { color: null },
@@ -177,6 +180,36 @@ export function resolveNewType(typeName: string): { entry: VaultEntry; content:
return { entry, content: `---\ntype: Type\n---\n\n# ${typeName}\n\n` }
}
export function todayDateString(): string {
return new Date().toISOString().split('T')[0]
}
export function buildDailyNoteContent(date: string): string {
const lines = ['---', `title: ${date}`, 'type: Journal', `date: ${date}`, '---']
return `${lines.join('\n')}\n\n# ${date}\n\n## Intentions\n\n\n\n## Reflections\n\n`
}
export function resolveDailyNote(date: string): { entry: VaultEntry; content: string } {
const entry = buildNewEntry({ path: `/Users/luca/Laputa/journal/${date}.md`, slug: date, title: date, type: 'Journal', status: null })
return { entry, content: buildDailyNoteContent(date) }
}
export function findDailyNote(entries: VaultEntry[], date: string): VaultEntry | undefined {
const suffix = `journal/${date}.md`
return entries.find(e => e.path.endsWith(suffix))
}
type PersistFn = (resolved: { entry: VaultEntry; content: string }) => void
/** Open today's daily note: navigate to it if it exists, or create + persist a new one. */
function openDailyNote(entries: VaultEntry[], selectNote: (e: VaultEntry) => void, persist: PersistFn): void {
const date = todayDateString()
const existing = findDailyNote(entries, date)
if (existing) selectNote(existing)
else persist(resolveDailyNote(date))
signalFocusEditor()
}
function findWikilinkTarget(entries: VaultEntry[], target: string): VaultEntry | undefined {
const targetLower = target.toLowerCase()
const targetAsWords = target.split('/').pop()?.replace(/-/g, ' ').toLowerCase() ?? targetLower
@@ -199,13 +232,14 @@ interface PersistCallbacks {
onFail: (p: string) => void
onStart?: (p: string) => void
onEnd?: (p: string) => void
onPersisted?: () => void
}
/** Persist to disk; track pending state via onStart/onEnd; revert on failure. */
function persistOptimistic(path: string, content: string, cbs: PersistCallbacks): void {
cbs.onStart?.(path)
persistNewNote(path, content)
.then(() => cbs.onEnd?.(path))
.then(() => { cbs.onEnd?.(path); cbs.onPersisted?.() })
.catch(() => { cbs.onEnd?.(path); cbs.onFail(path) })
}
@@ -296,13 +330,17 @@ export function useNoteActions(config: NoteActionsConfig) {
onFail: revertOptimisticNote,
onStart: addPendingSave,
onEnd: removePendingSave,
onPersisted: config.onNewNotePersisted,
}
const pendingNamesRef = useRef<Set<string>>(new Set())
const handleCreateNote = useCallback((title: string, type: string) => {
createAndPersist(resolveNewNote(title, type), addEntry, openTabWithContent, persistCbs)
}, [openTabWithContent, addEntry, revertOptimisticNote, addPendingSave, removePendingSave]) // eslint-disable-line react-hooks/exhaustive-deps -- persistCbs is stable when deps are
const persistNew: PersistFn = useCallback(
(resolved) => createAndPersist(resolved, addEntry, openTabWithContent, persistCbs),
[openTabWithContent, addEntry, revertOptimisticNote, addPendingSave, removePendingSave], // eslint-disable-line react-hooks/exhaustive-deps -- persistCbs is stable when deps are
)
const handleCreateNote = useCallback((title: string, type: string) => persistNew(resolveNewNote(title, type)), [persistNew])
const handleCreateNoteImmediate = useCallback((type?: string) => {
const noteType = type || 'Note'
@@ -319,19 +357,16 @@ export function useNoteActions(config: NoteActionsConfig) {
/** Close tab and discard entry+unsaved state if the note was never persisted. */
const handleCloseTabWithCleanup = useCallback((path: string) => {
if (unsavedPathsRef.current?.has(path)) {
removeEntry(path)
config.clearUnsaved?.(path)
}
if (unsavedPathsRef.current?.has(path)) { removeEntry(path); config.clearUnsaved?.(path) }
handleCloseTab(path)
}, [handleCloseTab, removeEntry, config.clearUnsaved]) // eslint-disable-line react-hooks/exhaustive-deps -- ref access is stable
// Keep handleCloseTabRef in sync so Cmd+W and menu events also clean up unsaved notes.
useEffect(() => { handleCloseTabRef.current = handleCloseTabWithCleanup })
const handleCreateType = useCallback((typeName: string) => {
createAndPersist(resolveNewType(typeName), addEntry, openTabWithContent, persistCbs)
}, [openTabWithContent, addEntry, revertOptimisticNote, addPendingSave, removePendingSave]) // eslint-disable-line react-hooks/exhaustive-deps -- persistCbs is stable when deps are
const handleOpenDailyNote = useCallback(() => openDailyNote(entries, handleSelectNote, persistNew), [entries, handleSelectNote, persistNew])
const handleCreateType = useCallback((typeName: string) => persistNew(resolveNewType(typeName)), [persistNew])
const fmCallbacks = { updateTab: updateTabContent, updateEntry, toast: setToastMessage }
@@ -368,6 +403,7 @@ export function useNoteActions(config: NoteActionsConfig) {
handleNavigateWikilink,
handleCreateNote,
handleCreateNoteImmediate,
handleOpenDailyNote,
handleCreateType,
handleUpdateFrontmatter: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]),
handleDeleteProperty: useCallback((path: string, key: string) => runFrontmatterOp('delete', path, key), [runFrontmatterOp]),

View File

@@ -35,6 +35,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['quarter/q1-2026', 'topic/software-development', 'person/matteo-cellini', 'person/maria-bianchi', 'person/marco-verdi'],
},
{
@@ -69,6 +70,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['essay/on-writing-well', 'essay/engineering-leadership-101', 'essay/ai-agents-primer', 'topic/growth', 'topic/writing'],
},
{
@@ -97,6 +99,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['person/matteo-cellini'],
},
{
@@ -125,6 +128,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['responsibility/grow-newsletter'],
},
{
@@ -153,6 +157,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['responsibility/manage-sponsorships'],
},
{
@@ -182,6 +187,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['topic/trading', 'topic/algorithmic-trading', 'data/ema200-backtest-results'],
},
{
@@ -211,6 +217,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['project/26q1-laputa-app', 'topic/growth', 'topic/ads'],
},
{
@@ -239,6 +246,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['project/26q1-laputa-app'],
},
{
@@ -266,6 +274,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -293,6 +302,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -320,6 +330,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -347,6 +358,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -375,6 +387,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['project/26q1-laputa-app', 'person/matteo-cellini'],
},
{
@@ -403,6 +416,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -431,6 +445,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -459,6 +474,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['responsibility/grow-newsletter'],
},
{
@@ -488,6 +504,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['responsibility/grow-newsletter', 'topic/software-development'],
},
{
@@ -516,6 +533,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['responsibility/grow-newsletter'],
},
// --- Type documents ---
@@ -542,6 +560,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: 0,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -567,6 +586,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: 1,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -592,6 +612,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: 2,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -617,6 +638,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: 3,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -642,6 +664,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: 4,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -667,6 +690,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: 5,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -692,6 +716,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: 6,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -717,6 +742,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: 7,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -742,6 +768,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: 8,
sidebarLabel: null,
outgoingLinks: [],
},
// --- Custom type documents ---
@@ -768,6 +795,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: 'cooking-pot',
color: 'orange',
order: 9,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -793,6 +821,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: 'book-open',
color: 'green',
order: 10,
sidebarLabel: null,
outgoingLinks: [],
},
// --- Instances of custom types ---
@@ -821,6 +850,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -848,6 +878,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
// --- Trashed entries ---
@@ -877,6 +908,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -904,6 +936,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -932,6 +965,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
// --- Archived entries ---
@@ -952,6 +986,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
modifiedAt: now - 86400 * 120,
createdAt: now - 86400 * 200,
@@ -980,6 +1015,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
modifiedAt: now - 86400 * 90,
createdAt: now - 86400 * 150,
@@ -1041,7 +1077,8 @@ function generateBulkEntries(count: number): VaultEntry[] {
icon: null,
color: null,
order: null,
outgoingLinks: Array.from({ length: i % 8 }, (_, j) => `note/link-target-${(i + j) % 50}`),
outgoingLinks: Array.from({ length: i % 8 }, (_j, j) => `note/link-target-${(i + j) % 50}`),
sidebarLabel: null,
})
}
return entries

View File

@@ -25,6 +25,8 @@ export interface VaultEntry {
color: string | null
/** Display order for Type entries in sidebar (lower = higher). null = use default order. */
order: number | null
/** Custom sidebar section label for Type entries, overriding auto-pluralization. */
sidebarLabel: string | null
/** All wikilink targets found in the note content. Extracted from [[target]] patterns. */
outgoingLinks: string[]
}

View File

@@ -0,0 +1,185 @@
import { describe, it, expect } from 'vitest'
import { resolveTarget, collectLinkedEntries, buildContextualPrompt } from './ai-context'
import type { VaultEntry } from '../types'
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
path: '/vault/note/test.md',
filename: 'test.md',
title: 'Test Note',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: 1700000000,
fileSize: 100,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
outgoingLinks: [],
...overrides,
})
describe('resolveTarget', () => {
const entries = [
makeEntry({ path: '/vault/a.md', title: 'Alpha', filename: 'a.md' }),
makeEntry({ path: '/vault/b.md', title: 'Beta', filename: 'beta-note.md', aliases: ['B'] }),
]
it('resolves by title (case-insensitive)', () => {
expect(resolveTarget('alpha', entries)?.path).toBe('/vault/a.md')
expect(resolveTarget('Alpha', entries)?.path).toBe('/vault/a.md')
})
it('resolves by alias (case-insensitive)', () => {
expect(resolveTarget('B', entries)?.path).toBe('/vault/b.md')
expect(resolveTarget('b', entries)?.path).toBe('/vault/b.md')
})
it('resolves by filename stem', () => {
expect(resolveTarget('beta-note', entries)?.path).toBe('/vault/b.md')
})
it('returns undefined for unresolvable target', () => {
expect(resolveTarget('nonexistent', entries)).toBeUndefined()
})
})
describe('collectLinkedEntries', () => {
const entryA = makeEntry({ path: '/vault/a.md', title: 'Alpha' })
const entryB = makeEntry({ path: '/vault/b.md', title: 'Beta' })
const entryC = makeEntry({ path: '/vault/c.md', title: 'Gamma' })
const entryD = makeEntry({ path: '/vault/d.md', title: 'Delta' })
const allEntries = [entryA, entryB, entryC, entryD]
it('returns empty array when active has no links', () => {
expect(collectLinkedEntries(entryA, allEntries)).toEqual([])
})
it('collects entries from outgoingLinks', () => {
const active = makeEntry({
path: '/vault/main.md', title: 'Main',
outgoingLinks: ['Alpha', 'Beta'],
})
const linked = collectLinkedEntries(active, [...allEntries, active])
expect(linked.map(e => e.title)).toEqual(['Alpha', 'Beta'])
})
it('collects entries from relationships', () => {
const active = makeEntry({
path: '/vault/main.md', title: 'Main',
relationships: { relatedTo: ['[[Alpha]]', '[[Gamma]]'] },
})
const linked = collectLinkedEntries(active, [...allEntries, active])
expect(linked.map(e => e.title)).toEqual(['Alpha', 'Gamma'])
})
it('collects entries from belongsTo', () => {
const active = makeEntry({
path: '/vault/main.md', title: 'Main',
belongsTo: ['[[Delta]]'],
})
const linked = collectLinkedEntries(active, [...allEntries, active])
expect(linked.map(e => e.title)).toEqual(['Delta'])
})
it('collects entries from relatedTo', () => {
const active = makeEntry({
path: '/vault/main.md', title: 'Main',
relatedTo: ['[[Beta]]'],
})
const linked = collectLinkedEntries(active, [...allEntries, active])
expect(linked.map(e => e.title)).toEqual(['Beta'])
})
it('deduplicates entries across all link sources', () => {
const active = makeEntry({
path: '/vault/main.md', title: 'Main',
outgoingLinks: ['Alpha', 'Beta'],
relationships: { people: ['[[Alpha]]'] },
belongsTo: ['[[Beta]]'],
relatedTo: ['[[Alpha]]'],
})
const linked = collectLinkedEntries(active, [...allEntries, active])
expect(linked.map(e => e.title)).toEqual(['Alpha', 'Beta'])
})
it('excludes the active note itself', () => {
const active = makeEntry({
path: '/vault/a.md', title: 'Alpha',
outgoingLinks: ['Alpha'],
})
const linked = collectLinkedEntries(active, allEntries)
expect(linked).toEqual([])
})
it('ignores unresolvable links', () => {
const active = makeEntry({
path: '/vault/main.md', title: 'Main',
outgoingLinks: ['Alpha', 'Nonexistent'],
})
const linked = collectLinkedEntries(active, [...allEntries, active])
expect(linked.map(e => e.title)).toEqual(['Alpha'])
})
})
describe('buildContextualPrompt', () => {
it('includes active note title and content', () => {
const active = makeEntry({ path: '/vault/a.md', title: 'Alpha', isA: 'Project' })
const content = { '/vault/a.md': '# Alpha\nThis is the alpha project.' }
const prompt = buildContextualPrompt(active, [], content)
expect(prompt).toContain('Alpha')
expect(prompt).toContain('Project')
expect(prompt).toContain('This is the alpha project.')
})
it('includes linked note content', () => {
const active = makeEntry({ path: '/vault/a.md', title: 'Alpha' })
const linked = makeEntry({ path: '/vault/b.md', title: 'Beta', isA: 'Person' })
const content = {
'/vault/a.md': '# Alpha\nMain note.',
'/vault/b.md': '# Beta\nLinked person.',
}
const prompt = buildContextualPrompt(active, [linked], content)
expect(prompt).toContain('Beta')
expect(prompt).toContain('Person')
expect(prompt).toContain('Linked person.')
expect(prompt).toContain('Linked Notes')
})
it('shows (no content) when content is missing', () => {
const active = makeEntry({ path: '/vault/a.md', title: 'Alpha' })
const prompt = buildContextualPrompt(active, [], {})
expect(prompt).toContain('(no content)')
})
it('truncates linked note content to 2000 chars', () => {
const active = makeEntry({ path: '/vault/a.md', title: 'Alpha' })
const linked = makeEntry({ path: '/vault/b.md', title: 'Beta' })
const longContent = 'x'.repeat(3000)
const content = {
'/vault/a.md': '# Alpha',
'/vault/b.md': longContent,
}
const prompt = buildContextualPrompt(active, [linked], content)
// The linked note content should be truncated
const betaIdx = prompt.indexOf('### Beta')
const afterBeta = prompt.slice(betaIdx)
expect(afterBeta.length).toBeLessThan(2200)
})
it('includes the system preamble', () => {
const active = makeEntry({ path: '/vault/a.md', title: 'Alpha' })
const prompt = buildContextualPrompt(active, [], { '/vault/a.md': 'content' })
expect(prompt).toContain('AI assistant integrated into Laputa')
})
})

90
src/utils/ai-context.ts Normal file
View File

@@ -0,0 +1,90 @@
/**
* AI contextual chat — builds the context note list from the active note
* and its first-degree linked notes (outgoingLinks + relationships).
*/
import type { VaultEntry } from '../types'
import { wikilinkTarget } from './wikilink'
/** Resolve a link target string to a VaultEntry by matching title, aliases, or filename stem. */
export function resolveTarget(target: string, entries: VaultEntry[]): VaultEntry | undefined {
const lower = target.toLowerCase()
return entries.find(e => {
if (e.title.toLowerCase() === lower) return true
if (e.aliases.some(a => a.toLowerCase() === lower)) return true
const stem = e.filename.replace(/\.md$/, '')
if (stem.toLowerCase() === lower) return true
return false
})
}
/** Collect first-degree linked notes from the active entry. */
export function collectLinkedEntries(
active: VaultEntry,
entries: VaultEntry[],
): VaultEntry[] {
const seen = new Set<string>([active.path])
const linked: VaultEntry[] = []
const addTarget = (target: string) => {
const entry = resolveTarget(target, entries)
if (entry && !seen.has(entry.path)) {
seen.add(entry.path)
linked.push(entry)
}
}
// outgoingLinks are raw targets (no [[ ]] wrapper)
for (const target of active.outgoingLinks) {
addTarget(target)
}
// relationships values are wikilink references like [[target]]
for (const refs of Object.values(active.relationships)) {
for (const ref of refs) {
addTarget(wikilinkTarget(ref))
}
}
// belongsTo and relatedTo are also wikilink references
for (const ref of active.belongsTo) {
addTarget(wikilinkTarget(ref))
}
for (const ref of active.relatedTo) {
addTarget(wikilinkTarget(ref))
}
return linked
}
/** Build a contextual system prompt from the active note and its linked notes. */
export function buildContextualPrompt(
active: VaultEntry,
linkedEntries: VaultEntry[],
allContent: Record<string, string>,
): string {
const parts: string[] = [
'You are an AI assistant integrated into Laputa, a personal knowledge management app.',
'The user is viewing a specific note. Use the note and its linked context to answer questions accurately.',
'You can also use MCP tools to search, read, create, or edit notes in the vault.',
'',
`## Active Note: ${active.title}`,
`Type: ${active.isA ?? 'Note'} | Path: ${active.path}`,
'',
allContent[active.path] ?? '(no content)',
]
if (linkedEntries.length > 0) {
parts.push('', '## Linked Notes')
for (const entry of linkedEntries) {
const content = allContent[entry.path]
parts.push(
'',
`### ${entry.title} (${entry.isA ?? 'Note'})`,
content ? content.slice(0, 2000) : '(no content loaded)',
)
}
}
return parts.join('\n')
}

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks, splitFrontmatter, countWords, extractOutgoingLinks } from './wikilinks'
import { preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks, splitFrontmatter, countWords, extractOutgoingLinks, extractBacklinkContext } from './wikilinks'
interface TestBlock {
type?: string
@@ -421,3 +421,67 @@ describe('extractOutgoingLinks', () => {
expect(extractOutgoingLinks(content)).toEqual(['Valid'])
})
})
describe('extractBacklinkContext', () => {
const targets = new Set(['My Note'])
it('extracts the paragraph containing a matching wikilink', () => {
const content = '---\ntitle: Test\n---\n\n# Test\n\nFirst paragraph.\n\nThis references [[My Note]] in context.\n\nThird paragraph.'
const result = extractBacklinkContext(content, targets)
expect(result).toBe('This references [[My Note]] in context.')
})
it('returns null when no matching wikilink found', () => {
const content = '---\ntitle: Test\n---\n\n# Test\n\nNo links here.'
expect(extractBacklinkContext(content, targets)).toBeNull()
})
it('returns null for empty content', () => {
expect(extractBacklinkContext('', targets)).toBeNull()
})
it('truncates long paragraphs with ellipsis', () => {
const longPara = 'A'.repeat(100) + ' [[My Note]] ' + 'B'.repeat(100)
const content = `---\ntitle: X\n---\n\n# X\n\n${longPara}`
const result = extractBacklinkContext(content, targets, 50)
expect(result).not.toBeNull()
expect(result!.length).toBe(50)
expect(result!.endsWith('\u2026')).toBe(true)
})
it('matches path-based wikilinks via last segment', () => {
const content = '---\ntitle: Test\n---\n\n# Test\n\nSee [[project/My Note]] for details.'
const result = extractBacklinkContext(content, targets)
expect(result).toBe('See [[project/My Note]] for details.')
})
it('matches aliased wikilinks [[target|display]]', () => {
const content = '---\ntitle: Test\n---\n\n# Test\n\nCheck [[My Note|the note]] here.'
const result = extractBacklinkContext(content, targets)
expect(result).toBe('Check [[My Note|the note]] here.')
})
it('skips frontmatter and title heading', () => {
const content = '---\ntitle: My Note\n---\n\n# My Note\n\nBody text with [[My Note]] link.'
const result = extractBacklinkContext(content, targets)
expect(result).toBe('Body text with [[My Note]] link.')
})
it('collapses internal whitespace', () => {
const content = '---\ntitle: X\n---\n\n# X\n\nMultiple spaces\nand newline with [[My Note]] link.'
const result = extractBacklinkContext(content, targets)
expect(result).toBe('Multiple spaces and newline with [[My Note]] link.')
})
it('returns first matching paragraph when multiple match', () => {
const content = '---\ntitle: X\n---\n\n# X\n\nFirst [[My Note]] mention.\n\nSecond [[My Note]] mention.'
const result = extractBacklinkContext(content, targets)
expect(result).toBe('First [[My Note]] mention.')
})
it('does not return paragraph when maxLength is respected', () => {
const content = '---\ntitle: X\n---\n\n# X\n\nShort [[My Note]].'
const result = extractBacklinkContext(content, targets, 200)
expect(result).toBe('Short [[My Note]].')
})
})

View File

@@ -120,6 +120,40 @@ export function extractOutgoingLinks(content: string): string[] {
return [...new Set(links)].sort()
}
/** Extract the paragraph surrounding a [[target]] wikilink match from note content.
* Searches for any target in the set, returns the first matching paragraph trimmed
* to a max length. Returns null if no match found. */
export function extractBacklinkContext(
content: string,
matchTargets: Set<string>,
maxLength = 120,
): string | null {
const [, body] = splitFrontmatter(content)
// Remove the H1 title line
const withoutTitle = body.replace(/^\s*# [^\n]+\n?/, '')
const paragraphs = withoutTitle.split(/\n{2,}/)
for (const para of paragraphs) {
const trimmed = para.trim()
if (!trimmed) continue
// Check if this paragraph contains a wikilink matching any target
const re = /\[\[([^\]]+)\]\]/g
let match
while ((match = re.exec(trimmed)) !== null) {
const inner = match[1]
const pipeIdx = inner.indexOf('|')
const target = pipeIdx !== -1 ? inner.slice(0, pipeIdx) : inner
if (matchTargets.has(target) || matchTargets.has(target.split('/').pop() ?? '')) {
// Collapse whitespace and truncate
const flat = trimmed.replace(/\s+/g, ' ')
if (flat.length <= maxLength) return flat
return flat.slice(0, maxLength - 1) + '\u2026'
}
}
}
return null
}
export function countWords(content: string): number {
const [, body] = splitFrontmatter(content)
const withoutTitle = body.replace(/^\s*# [^\n]+\n?/, '')