Compare commits
7 Commits
v0.2026030
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a00b8aac7 | ||
|
|
b7d2304282 | ||
|
|
50b5fa9c2e | ||
|
|
963e7cf111 | ||
|
|
c9a5d20c12 | ||
|
|
586e1fcde5 | ||
|
|
75d67623ce |
@@ -1 +1 @@
|
||||
33549
|
||||
66070
|
||||
|
||||
@@ -54,7 +54,11 @@ fn parse_file_status(code: &str) -> &str {
|
||||
|
||||
/// Get the pulse (commit activity feed) for a vault, showing only .md file changes.
|
||||
/// `skip` offsets into the commit list for pagination; `limit` caps how many to return.
|
||||
pub fn get_vault_pulse(vault_path: &str, limit: usize, skip: usize) -> Result<Vec<PulseCommit>, String> {
|
||||
pub fn get_vault_pulse(
|
||||
vault_path: &str,
|
||||
limit: usize,
|
||||
skip: usize,
|
||||
) -> Result<Vec<PulseCommit>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
if !vault.join(".git").exists() {
|
||||
|
||||
@@ -32,7 +32,6 @@ const VIEW_GO_BACK: &str = "view-go-back";
|
||||
const VIEW_GO_FORWARD: &str = "view-go-forward";
|
||||
|
||||
const GO_ALL_NOTES: &str = "go-all-notes";
|
||||
const GO_FAVORITES: &str = "go-favorites";
|
||||
const GO_ARCHIVED: &str = "go-archived";
|
||||
const GO_TRASH: &str = "go-trash";
|
||||
const GO_CHANGES: &str = "go-changes";
|
||||
@@ -75,7 +74,6 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
VIEW_GO_BACK,
|
||||
VIEW_GO_FORWARD,
|
||||
GO_ALL_NOTES,
|
||||
GO_FAVORITES,
|
||||
GO_ARCHIVED,
|
||||
GO_TRASH,
|
||||
GO_CHANGES,
|
||||
@@ -249,9 +247,6 @@ fn build_go_menu(app: &App) -> MenuResult {
|
||||
let all_notes = MenuItemBuilder::new("All Notes")
|
||||
.id(GO_ALL_NOTES)
|
||||
.build(app)?;
|
||||
let favorites = MenuItemBuilder::new("Favorites")
|
||||
.id(GO_FAVORITES)
|
||||
.build(app)?;
|
||||
let archived = MenuItemBuilder::new("Archived")
|
||||
.id(GO_ARCHIVED)
|
||||
.build(app)?;
|
||||
@@ -268,7 +263,6 @@ fn build_go_menu(app: &App) -> MenuResult {
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "Go")
|
||||
.item(&all_notes)
|
||||
.item(&favorites)
|
||||
.item(&archived)
|
||||
.item(&trash)
|
||||
.item(&changes)
|
||||
@@ -454,7 +448,6 @@ mod tests {
|
||||
VIEW_GO_BACK,
|
||||
VIEW_GO_FORWARD,
|
||||
GO_ALL_NOTES,
|
||||
GO_FAVORITES,
|
||||
GO_ARCHIVED,
|
||||
GO_TRASH,
|
||||
GO_CHANGES,
|
||||
|
||||
@@ -158,10 +158,7 @@ fn parse_files_at(vault: &Path, rel_paths: &[String]) -> Vec<VaultEntry> {
|
||||
|
||||
/// Machine-local files that should never be git-tracked in any vault.
|
||||
/// These are either caches with absolute paths or per-machine settings.
|
||||
const UNTRACKED_FILES: &[&str] = &[
|
||||
".laputa-cache.json",
|
||||
".laputa/settings.json",
|
||||
];
|
||||
const UNTRACKED_FILES: &[&str] = &[".laputa-cache.json", ".laputa/settings.json"];
|
||||
|
||||
/// Ensure machine-local files are excluded from git via `.git/info/exclude`
|
||||
/// and un-tracked if they were previously committed (git rm --cached).
|
||||
@@ -184,7 +181,11 @@ fn ensure_cache_excluded(vault: &Path) {
|
||||
|
||||
if !to_add.is_empty() {
|
||||
to_add.sort();
|
||||
let separator = if existing.ends_with('\n') || existing.is_empty() { "" } else { "\n" };
|
||||
let separator = if existing.ends_with('\n') || existing.is_empty() {
|
||||
""
|
||||
} else {
|
||||
"\n"
|
||||
};
|
||||
let additions = to_add.join("\n");
|
||||
let _ = fs::write(&exclude_path, format!("{existing}{separator}{additions}\n"));
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ describe('App', () => {
|
||||
await waitFor(() => {
|
||||
// "All Notes" should be rendered as the selected nav item
|
||||
expect(screen.getByText('All Notes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Favorites')).toBeInTheDocument()
|
||||
expect(screen.getByText('Archive')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
30
src/App.tsx
30
src/App.tsx
@@ -230,19 +230,26 @@ function App() {
|
||||
|
||||
useNavigationGestures({ onGoBack: handleGoBack, onGoForward: handleGoForward })
|
||||
|
||||
// O(1) path lookup map — rebuilt only when vault.entries changes
|
||||
const entriesByPath = useMemo(() => {
|
||||
const map = new Map<string, VaultEntry>()
|
||||
for (const e of vault.entries) map.set(e.path, e)
|
||||
return map
|
||||
}, [vault.entries])
|
||||
|
||||
// MCP UI bridge: react to AI-driven open/highlight/vault-change events
|
||||
const openNoteByPath = useCallback((path: string) => {
|
||||
const entry = vault.entries.find(e => e.path === path || e.path === `${resolvedPath}/${path}`)
|
||||
const entry = entriesByPath.get(path) ?? entriesByPath.get(`${resolvedPath}/${path}`)
|
||||
if (entry) {
|
||||
notes.handleSelectNote(entry)
|
||||
} else {
|
||||
// Entry not yet in vault (just created) — reload then open
|
||||
vault.reloadVault().then(freshEntries => {
|
||||
const fresh = freshEntries.find((e: VaultEntry) => e.path === path || e.path === `${resolvedPath}/${path}`)
|
||||
const fresh = (freshEntries as VaultEntry[]).find(e => e.path === path || e.path === `${resolvedPath}/${path}`)
|
||||
if (fresh) notes.handleSelectNote(fresh)
|
||||
})
|
||||
}
|
||||
}, [vault, notes, resolvedPath])
|
||||
}, [entriesByPath, vault, notes, resolvedPath])
|
||||
|
||||
const aiActivity = useAiActivity({
|
||||
onOpenNote: openNoteByPath,
|
||||
@@ -253,10 +260,18 @@ function App() {
|
||||
onVaultChanged: () => { vault.reloadVault() },
|
||||
})
|
||||
|
||||
// Stable callback for Pulse "open note" — never triggers reloadVault.
|
||||
// Pulse files always exist in the vault; if somehow not found, silently skip.
|
||||
const handlePulseOpenNote = useCallback((relativePath: string) => {
|
||||
const fullPath = `${resolvedPath}/${relativePath}`
|
||||
const entry = entriesByPath.get(fullPath) ?? entriesByPath.get(relativePath)
|
||||
if (entry) notes.handleSelectNote(entry)
|
||||
}, [entriesByPath, resolvedPath, notes])
|
||||
|
||||
// Agent file operation handlers: auto-open created notes, live-refresh modified notes
|
||||
const handleAgentFileCreated = useCallback((relativePath: string) => {
|
||||
vault.reloadVault().then(freshEntries => {
|
||||
const entry = freshEntries.find((e: VaultEntry) => e.path === relativePath || e.path === `${resolvedPath}/${relativePath}`)
|
||||
const entry = (freshEntries as VaultEntry[]).find(e => e.path === relativePath || e.path === `${resolvedPath}/${relativePath}`)
|
||||
if (entry) notes.handleSelectNote(entry)
|
||||
})
|
||||
}, [vault, notes, resolvedPath])
|
||||
@@ -315,6 +330,7 @@ function App() {
|
||||
handleUpdateFrontmatter: notes.handleUpdateFrontmatter,
|
||||
handleDeleteProperty: notes.handleDeleteProperty, setToastMessage,
|
||||
createTypeEntry: notes.createTypeEntrySilent,
|
||||
onFrontmatterPersisted: vault.loadModifiedFiles,
|
||||
})
|
||||
|
||||
const handleDeleteNote = useCallback(async (path: string) => {
|
||||
@@ -509,11 +525,7 @@ function App() {
|
||||
<>
|
||||
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
|
||||
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={(relativePath) => {
|
||||
const fullPath = `${resolvedPath}/${relativePath}`
|
||||
const entry = vault.entries.find(e => e.path === fullPath || e.path === relativePath)
|
||||
if (entry) notes.handleSelectNote(entry)
|
||||
}} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
|
||||
) : (
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
|
||||
)}
|
||||
|
||||
@@ -233,10 +233,10 @@ const mockEntries: VaultEntry[] = [
|
||||
const defaultSelection: SidebarSelection = { kind: 'filter', filter: 'all' }
|
||||
|
||||
describe('Sidebar', () => {
|
||||
it('renders top nav items (All Notes and Favorites)', () => {
|
||||
it('renders top nav items (All Notes)', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('All Notes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Favorites')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Favorites')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders section group headers only for types present in entries', () => {
|
||||
@@ -764,7 +764,7 @@ describe('Sidebar', () => {
|
||||
]
|
||||
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('All Notes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Favorites')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Favorites')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a "Customize sections" button', () => {
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import {
|
||||
FileText, Star, Wrench, Flask, Target, ArrowsClockwise,
|
||||
Users, CalendarBlank, Tag, TagSimple, Trash, StackSimple, Archive, CaretLeft, GitDiff, Pulse,
|
||||
FileText, Wrench, Flask, Target, ArrowsClockwise,
|
||||
Users, CalendarBlank, Tag, Trash, StackSimple, Archive, CaretLeft, GitDiff, Pulse,
|
||||
} from '@phosphor-icons/react'
|
||||
import { GitCommitHorizontal, SlidersHorizontal } from 'lucide-react'
|
||||
import {
|
||||
@@ -356,9 +356,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
{/* Top nav */}
|
||||
<div className="border-b border-border" style={{ padding: '4px 6px' }}>
|
||||
<NavItem icon={FileText} label="All Notes" count={activeCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'all' })} badgeClassName="bg-primary text-primary-foreground" onClick={() => onSelect({ kind: 'filter', filter: 'all' })} />
|
||||
<NavItem icon={Star} label="Favorites" isActive={isSelectionActive(selection, { kind: 'filter', filter: 'favorites' })} onClick={() => onSelect({ kind: 'filter', filter: 'favorites' })} />
|
||||
<NavItem icon={Archive} label="Archive" count={archivedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'archived' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'archived' })} />
|
||||
<NavItem icon={TagSimple} label="Untagged" disabled />
|
||||
<NavItem icon={Trash} label="Trash" count={trashedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'trash' })} activeClassName="bg-destructive/10 text-destructive" badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'trash' })} />
|
||||
{modifiedCount > 0 && (
|
||||
<NavItem icon={GitDiff} label="Changes" count={modifiedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'changes' })} activeClassName="bg-[color:var(--accent-orange)]/10 text-[var(--accent-orange)]" badgeClassName="text-white" badgeStyle={{ background: 'var(--accent-orange)' }} onClick={() => onSelect({ kind: 'filter', filter: 'changes' })} />
|
||||
|
||||
114
src/hooks/useAIChat.test.ts
Normal file
114
src/hooks/useAIChat.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
|
||||
// Capture what streamClaudeChat receives
|
||||
const streamClaudeChatMock = vi.fn<
|
||||
Parameters<typeof import('../utils/ai-chat').streamClaudeChat>,
|
||||
ReturnType<typeof import('../utils/ai-chat').streamClaudeChat>
|
||||
>()
|
||||
|
||||
vi.mock('../utils/ai-chat', async () => {
|
||||
const actual = await vi.importActual<typeof import('../utils/ai-chat')>('../utils/ai-chat')
|
||||
return {
|
||||
...actual,
|
||||
streamClaudeChat: (...args: Parameters<typeof actual.streamClaudeChat>) => {
|
||||
streamClaudeChatMock(...args)
|
||||
// Simulate async: call onDone after a tick
|
||||
const callbacks = args[3]
|
||||
setTimeout(() => {
|
||||
callbacks.onInit?.('test-session')
|
||||
callbacks.onText('mock response')
|
||||
callbacks.onDone()
|
||||
}, 10)
|
||||
return Promise.resolve('test-session')
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import { useAIChat } from './useAIChat'
|
||||
|
||||
beforeEach(() => {
|
||||
streamClaudeChatMock.mockClear()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('useAIChat', () => {
|
||||
const emptyContent: Record<string, string> = {}
|
||||
|
||||
it('sends first message without history', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
|
||||
expect(streamClaudeChatMock).toHaveBeenCalledTimes(1)
|
||||
const message = streamClaudeChatMock.mock.calls[0][0]
|
||||
// First message: no history, so just the plain text
|
||||
expect(message).toBe('hello')
|
||||
})
|
||||
|
||||
it('includes conversation history in second message', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
|
||||
// Send first message
|
||||
act(() => { result.current.sendMessage('What is Rust?') })
|
||||
// Wait for mock response
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Now messages state has: [user: What is Rust?, assistant: mock response]
|
||||
expect(result.current.messages).toHaveLength(2)
|
||||
|
||||
// Send second message
|
||||
act(() => { result.current.sendMessage('Tell me more') })
|
||||
|
||||
expect(streamClaudeChatMock).toHaveBeenCalledTimes(2)
|
||||
const secondMessage = streamClaudeChatMock.mock.calls[1][0]
|
||||
// Should contain conversation history
|
||||
expect(secondMessage).toContain('What is Rust?')
|
||||
expect(secondMessage).toContain('mock response')
|
||||
expect(secondMessage).toContain('Tell me more')
|
||||
})
|
||||
|
||||
it('resets history on clearConversation', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
|
||||
// Send a message and get response
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Clear conversation
|
||||
act(() => { result.current.clearConversation() })
|
||||
expect(result.current.messages).toHaveLength(0)
|
||||
|
||||
// Send new message — should have no history
|
||||
act(() => { result.current.sendMessage('fresh start') })
|
||||
|
||||
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
|
||||
expect(lastCall[0]).toBe('fresh start')
|
||||
})
|
||||
|
||||
it('accumulates multiple exchanges in history', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
|
||||
// Exchange 1
|
||||
act(() => { result.current.sendMessage('Q1') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Exchange 2
|
||||
act(() => { result.current.sendMessage('Q2') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Exchange 3
|
||||
act(() => { result.current.sendMessage('Q3') })
|
||||
|
||||
expect(streamClaudeChatMock).toHaveBeenCalledTimes(3)
|
||||
const thirdMessage = streamClaudeChatMock.mock.calls[2][0]
|
||||
// Should contain all prior exchanges
|
||||
expect(thirdMessage).toContain('Q1')
|
||||
expect(thirdMessage).toContain('Q2')
|
||||
expect(thirdMessage).toContain('Q3')
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import type { VaultEntry } from '../types'
|
||||
import {
|
||||
type ChatMessage, nextMessageId,
|
||||
buildSystemPrompt, streamClaudeChat,
|
||||
trimHistory, formatMessageWithHistory, MAX_HISTORY_TOKENS,
|
||||
} from '../utils/ai-chat'
|
||||
|
||||
export function useAIChat(
|
||||
@@ -29,9 +30,11 @@ export function useAIChat(
|
||||
abortRef.current = false
|
||||
|
||||
const { prompt: systemPrompt } = buildSystemPrompt(contextNotes, allContent)
|
||||
const history = trimHistory(messages, MAX_HISTORY_TOKENS)
|
||||
const messageWithHistory = formatMessageWithHistory(history, text.trim())
|
||||
let accumulated = ''
|
||||
|
||||
streamClaudeChat(text.trim(), systemPrompt || undefined, sessionIdRef.current, {
|
||||
streamClaudeChat(messageWithHistory, systemPrompt || undefined, sessionIdRef.current, {
|
||||
onInit: (sid) => { sessionIdRef.current = sid },
|
||||
|
||||
onText: (chunk) => {
|
||||
@@ -58,7 +61,7 @@ export function useAIChat(
|
||||
}).then(sid => {
|
||||
if (sid) sessionIdRef.current = sid
|
||||
})
|
||||
}, [isStreaming, allContent, contextNotes])
|
||||
}, [isStreaming, allContent, contextNotes, messages])
|
||||
|
||||
const clearConversation = useCallback(() => {
|
||||
abortRef.current = true
|
||||
|
||||
@@ -86,7 +86,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
|
||||
const { onSelect } = config
|
||||
|
||||
const selectFilter = useCallback((filter: 'all' | 'favorites' | 'archived' | 'trash' | 'changes' | 'pulse') => {
|
||||
const selectFilter = useCallback((filter: 'all' | 'archived' | 'trash' | 'changes' | 'pulse') => {
|
||||
onSelect({ kind: 'filter', filter })
|
||||
}, [onSelect])
|
||||
|
||||
|
||||
@@ -214,7 +214,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
// Navigation
|
||||
{ id: 'search-notes', label: 'Search Notes', group: 'Navigation', shortcut: '⌘P', keywords: ['find', 'open', 'quick'], enabled: true, execute: onQuickOpen },
|
||||
{ id: 'go-all', label: 'Go to All Notes', group: 'Navigation', keywords: ['filter'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'all' }) },
|
||||
{ id: 'go-favorites', label: 'Go to Favorites', group: 'Navigation', keywords: ['starred'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'favorites' }) },
|
||||
{ id: 'go-archived', label: 'Go to Archived', group: 'Navigation', keywords: [], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'archived' }) },
|
||||
{ id: 'go-trash', label: 'Go to Trash', group: 'Navigation', keywords: ['deleted'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'trash' }) },
|
||||
{ id: 'go-changes', label: 'Go to Changes', group: 'Navigation', keywords: ['git', 'modified', 'pending'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
|
||||
|
||||
@@ -50,10 +50,13 @@ describe('useEntryActions', () => {
|
||||
handleDeleteProperty,
|
||||
setToastMessage,
|
||||
createTypeEntry,
|
||||
onFrontmatterPersisted,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const onFrontmatterPersisted = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
@@ -73,6 +76,7 @@ describe('useEntryActions', () => {
|
||||
trashedAt: expect.any(Number),
|
||||
})
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Note moved to trash')
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -91,6 +95,7 @@ describe('useEntryActions', () => {
|
||||
trashedAt: null,
|
||||
})
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Note restored from trash')
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -105,6 +110,7 @@ describe('useEntryActions', () => {
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', 'archived', true)
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', { archived: true })
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Note archived')
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -119,6 +125,7 @@ describe('useEntryActions', () => {
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', 'archived', false)
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', { archived: false })
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Note unarchived')
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ interface EntryActionsConfig {
|
||||
handleDeleteProperty: (path: string, key: string) => Promise<void>
|
||||
setToastMessage: (msg: string | null) => void
|
||||
createTypeEntry: (typeName: string) => Promise<VaultEntry>
|
||||
onFrontmatterPersisted?: () => void
|
||||
}
|
||||
|
||||
function findTypeEntry(entries: VaultEntry[], typeName: string): VaultEntry | undefined {
|
||||
@@ -15,7 +16,7 @@ function findTypeEntry(entries: VaultEntry[], typeName: string): VaultEntry | un
|
||||
}
|
||||
|
||||
export function useEntryActions({
|
||||
entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, createTypeEntry,
|
||||
entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, createTypeEntry, onFrontmatterPersisted,
|
||||
}: EntryActionsConfig) {
|
||||
const handleTrashNote = useCallback(async (path: string) => {
|
||||
const now = new Date().toISOString().slice(0, 10)
|
||||
@@ -23,26 +24,30 @@ export function useEntryActions({
|
||||
await handleUpdateFrontmatter(path, 'Trashed at', now)
|
||||
updateEntry(path, { trashed: true, trashedAt: Date.now() / 1000 })
|
||||
setToastMessage('Note moved to trash')
|
||||
}, [handleUpdateFrontmatter, updateEntry, setToastMessage])
|
||||
onFrontmatterPersisted?.()
|
||||
}, [handleUpdateFrontmatter, updateEntry, setToastMessage, onFrontmatterPersisted])
|
||||
|
||||
const handleRestoreNote = useCallback(async (path: string) => {
|
||||
await handleUpdateFrontmatter(path, 'Trashed', false)
|
||||
await handleDeleteProperty(path, 'Trashed at')
|
||||
updateEntry(path, { trashed: false, trashedAt: null })
|
||||
setToastMessage('Note restored from trash')
|
||||
}, [handleUpdateFrontmatter, handleDeleteProperty, updateEntry, setToastMessage])
|
||||
onFrontmatterPersisted?.()
|
||||
}, [handleUpdateFrontmatter, handleDeleteProperty, updateEntry, setToastMessage, onFrontmatterPersisted])
|
||||
|
||||
const handleArchiveNote = useCallback(async (path: string) => {
|
||||
await handleUpdateFrontmatter(path, 'archived', true)
|
||||
updateEntry(path, { archived: true })
|
||||
setToastMessage('Note archived')
|
||||
}, [handleUpdateFrontmatter, updateEntry, setToastMessage])
|
||||
onFrontmatterPersisted?.()
|
||||
}, [handleUpdateFrontmatter, updateEntry, setToastMessage, onFrontmatterPersisted])
|
||||
|
||||
const handleUnarchiveNote = useCallback(async (path: string) => {
|
||||
await handleUpdateFrontmatter(path, 'archived', false)
|
||||
updateEntry(path, { archived: false })
|
||||
setToastMessage('Note unarchived')
|
||||
}, [handleUpdateFrontmatter, updateEntry, setToastMessage])
|
||||
onFrontmatterPersisted?.()
|
||||
}, [handleUpdateFrontmatter, updateEntry, setToastMessage, onFrontmatterPersisted])
|
||||
|
||||
const handleCustomizeType = useCallback(async (typeName: string, icon: string, color: string) => {
|
||||
let typeEntry = findTypeEntry(entries, typeName)
|
||||
|
||||
@@ -226,12 +226,6 @@ describe('dispatchMenuEvent', () => {
|
||||
expect(h.onSelectFilter).toHaveBeenCalledWith('all')
|
||||
})
|
||||
|
||||
it('go-favorites selects favorites filter', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('go-favorites', h)
|
||||
expect(h.onSelectFilter).toHaveBeenCalledWith('favorites')
|
||||
})
|
||||
|
||||
it('go-archived selects archived filter', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('go-archived', h)
|
||||
|
||||
@@ -67,7 +67,6 @@ const SIMPLE_EVENT_MAP: Record<string, SimpleHandler> = {
|
||||
|
||||
const FILTER_MAP: Record<string, SidebarFilter> = {
|
||||
'go-all-notes': 'all',
|
||||
'go-favorites': 'favorites',
|
||||
'go-archived': 'archived',
|
||||
'go-trash': 'trash',
|
||||
'go-changes': 'changes',
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock('../mock-tauri', () => ({
|
||||
isTauri: vi.fn(() => false),
|
||||
addMockEntry: vi.fn(),
|
||||
updateMockContent: vi.fn(),
|
||||
trackMockChange: vi.fn(),
|
||||
mockInvoke: vi.fn().mockResolvedValue(''),
|
||||
}))
|
||||
vi.mock('./mockFrontmatterHelpers', () => ({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke, addMockEntry, updateMockContent } from '../mock-tauri'
|
||||
import { isTauri, mockInvoke, addMockEntry, updateMockContent, trackMockChange } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { FrontmatterValue } from '../components/Inspector'
|
||||
import { useTabManagement } from './useTabManagement'
|
||||
@@ -111,12 +111,14 @@ async function invokeFrontmatter(command: string, args: Record<string, unknown>)
|
||||
function applyMockFrontmatterUpdate(path: string, key: string, value: FrontmatterValue): string {
|
||||
const content = updateMockFrontmatter(path, key, value)
|
||||
updateMockContent(path, content)
|
||||
trackMockChange(path)
|
||||
return content
|
||||
}
|
||||
|
||||
function applyMockFrontmatterDelete(path: string, key: string): string {
|
||||
const content = deleteMockFrontmatterProperty(path, key)
|
||||
updateMockContent(path, content)
|
||||
trackMockChange(path)
|
||||
return content
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
*/
|
||||
|
||||
import { MOCK_CONTENT } from './mock-content'
|
||||
import { mockHandlers, addMockEntry, updateMockContent } from './mock-handlers'
|
||||
import { mockHandlers, addMockEntry, updateMockContent, trackMockChange } from './mock-handlers'
|
||||
import { tryVaultApi } from './vault-api'
|
||||
|
||||
export { addMockEntry, updateMockContent }
|
||||
export { addMockEntry, updateMockContent, trackMockChange }
|
||||
|
||||
export function isTauri(): boolean {
|
||||
return typeof window !== 'undefined' && ('__TAURI__' in window || '__TAURI_INTERNALS__' in window)
|
||||
|
||||
@@ -347,3 +347,7 @@ export function updateMockContent(path: string, content: string): void {
|
||||
MOCK_CONTENT[path] = content
|
||||
syncWindowContent()
|
||||
}
|
||||
|
||||
export function trackMockChange(path: string): void {
|
||||
mockSavedSinceCommit.add(path)
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ export interface PulseCommit {
|
||||
deleted: number
|
||||
}
|
||||
|
||||
export type SidebarFilter = 'all' | 'favorites' | 'archived' | 'trash' | 'changes' | 'pulse'
|
||||
export type SidebarFilter = 'all' | 'archived' | 'trash' | 'changes' | 'pulse'
|
||||
|
||||
export type SidebarSelection =
|
||||
| { kind: 'filter'; filter: SidebarFilter }
|
||||
|
||||
@@ -8,6 +8,8 @@ vi.mock('../mock-tauri', () => ({
|
||||
import {
|
||||
estimateTokens, buildSystemPrompt,
|
||||
nextMessageId, checkClaudeCli, streamClaudeChat,
|
||||
trimHistory, formatMessageWithHistory,
|
||||
type ChatMessage, MAX_HISTORY_TOKENS,
|
||||
} from './ai-chat'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
@@ -73,6 +75,107 @@ describe('checkClaudeCli', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --- trimHistory ---
|
||||
|
||||
describe('trimHistory', () => {
|
||||
const msg = (role: 'user' | 'assistant', content: string): ChatMessage => ({
|
||||
role, content, id: `msg-${content}`,
|
||||
})
|
||||
|
||||
it('returns empty array for empty history', () => {
|
||||
expect(trimHistory([], 1000)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns all messages when under token limit', () => {
|
||||
const history = [msg('user', 'hi'), msg('assistant', 'hello')]
|
||||
expect(trimHistory(history, 1000)).toEqual(history)
|
||||
})
|
||||
|
||||
it('drops oldest messages when over token limit', () => {
|
||||
const history = [
|
||||
msg('user', 'a'.repeat(400)), // 100 tokens
|
||||
msg('assistant', 'b'.repeat(400)), // 100 tokens
|
||||
msg('user', 'c'.repeat(400)), // 100 tokens
|
||||
]
|
||||
const result = trimHistory(history, 200)
|
||||
// Should keep the two most recent messages (200 tokens)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].content).toBe('b'.repeat(400))
|
||||
expect(result[1].content).toBe('c'.repeat(400))
|
||||
})
|
||||
|
||||
it('keeps at least one message if it fits', () => {
|
||||
const history = [
|
||||
msg('user', 'a'.repeat(2000)), // 500 tokens
|
||||
msg('assistant', 'b'.repeat(80)), // 20 tokens
|
||||
]
|
||||
const result = trimHistory(history, 30)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].content).toBe('b'.repeat(80))
|
||||
})
|
||||
|
||||
it('returns empty when single message exceeds limit', () => {
|
||||
const history = [msg('user', 'a'.repeat(4000))] // 1000 tokens
|
||||
expect(trimHistory(history, 10)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// --- formatMessageWithHistory ---
|
||||
|
||||
describe('formatMessageWithHistory', () => {
|
||||
const msg = (role: 'user' | 'assistant', content: string): ChatMessage => ({
|
||||
role, content, id: `msg-${content}`,
|
||||
})
|
||||
|
||||
it('returns bare message when no history', () => {
|
||||
expect(formatMessageWithHistory([], 'hello')).toBe('hello')
|
||||
})
|
||||
|
||||
it('includes conversation history before the new message', () => {
|
||||
const history = [msg('user', 'What is Rust?'), msg('assistant', 'A systems language.')]
|
||||
const result = formatMessageWithHistory(history, 'How does it compare to Go?')
|
||||
expect(result).toContain('What is Rust?')
|
||||
expect(result).toContain('A systems language.')
|
||||
expect(result).toContain('How does it compare to Go?')
|
||||
})
|
||||
|
||||
it('labels user and assistant messages correctly', () => {
|
||||
const history = [msg('user', 'Q1'), msg('assistant', 'A1')]
|
||||
const result = formatMessageWithHistory(history, 'Q2')
|
||||
expect(result).toContain('[user]: Q1')
|
||||
expect(result).toContain('[assistant]: A1')
|
||||
expect(result).toContain('[user]: Q2')
|
||||
})
|
||||
|
||||
it('preserves message order', () => {
|
||||
const history = [
|
||||
msg('user', 'first'),
|
||||
msg('assistant', 'second'),
|
||||
msg('user', 'third'),
|
||||
msg('assistant', 'fourth'),
|
||||
]
|
||||
const result = formatMessageWithHistory(history, 'fifth')
|
||||
const firstIdx = result.indexOf('first')
|
||||
const secondIdx = result.indexOf('second')
|
||||
const thirdIdx = result.indexOf('third')
|
||||
const fourthIdx = result.indexOf('fourth')
|
||||
const fifthIdx = result.indexOf('fifth')
|
||||
expect(firstIdx).toBeLessThan(secondIdx)
|
||||
expect(secondIdx).toBeLessThan(thirdIdx)
|
||||
expect(thirdIdx).toBeLessThan(fourthIdx)
|
||||
expect(fourthIdx).toBeLessThan(fifthIdx)
|
||||
})
|
||||
})
|
||||
|
||||
// --- MAX_HISTORY_TOKENS ---
|
||||
|
||||
describe('MAX_HISTORY_TOKENS', () => {
|
||||
it('is a reasonable token limit', () => {
|
||||
expect(MAX_HISTORY_TOKENS).toBeGreaterThan(10_000)
|
||||
expect(MAX_HISTORY_TOKENS).toBeLessThan(200_000)
|
||||
})
|
||||
})
|
||||
|
||||
// --- streamClaudeChat ---
|
||||
|
||||
describe('streamClaudeChat', () => {
|
||||
|
||||
@@ -76,6 +76,34 @@ export function nextMessageId(): string {
|
||||
return `msg-${++msgIdCounter}-${Date.now()}`
|
||||
}
|
||||
|
||||
// --- Conversation history ---
|
||||
|
||||
/** Max tokens of history to include in each request. */
|
||||
export const MAX_HISTORY_TOKENS = 100_000
|
||||
|
||||
/** Keep the most recent messages that fit within `maxTokens`. Drops oldest first. */
|
||||
export function trimHistory(history: ChatMessage[], maxTokens: number): ChatMessage[] {
|
||||
let tokenCount = 0
|
||||
const result: ChatMessage[] = []
|
||||
for (let i = history.length - 1; i >= 0; i--) {
|
||||
const tokens = estimateTokens(history[i].content)
|
||||
if (tokenCount + tokens > maxTokens) break
|
||||
result.unshift(history[i])
|
||||
tokenCount += tokens
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Format conversation history + new message into a single prompt for the CLI. */
|
||||
export function formatMessageWithHistory(history: ChatMessage[], newMessage: string): string {
|
||||
if (history.length === 0) return newMessage
|
||||
|
||||
const lines = history.map(m => `[${m.role}]: ${m.content}`)
|
||||
lines.push(`[user]: ${newMessage}`)
|
||||
|
||||
return `<conversation_history>\n${lines.join('\n\n')}\n</conversation_history>\n\nContinue the conversation. Respond only to the latest [user] message.`
|
||||
}
|
||||
|
||||
// --- Claude CLI status ---
|
||||
|
||||
export interface ClaudeCliStatus {
|
||||
|
||||
39
tests/smoke/trashed-archived-not-saved.spec.ts
Normal file
39
tests/smoke/trashed-archived-not-saved.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { openCommandPalette, executeCommand } from './helpers'
|
||||
|
||||
test.describe('Trash/archive notes appear in Changes', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('trashing a note increments the Changes badge', async ({ page }) => {
|
||||
const sidebar = page.locator('.app__sidebar')
|
||||
|
||||
// Wait for Changes nav item (mock starts with 3 modified files)
|
||||
const changesRow = sidebar.locator('div', { hasText: /^Changes/ }).first()
|
||||
await changesRow.waitFor({ timeout: 5000 })
|
||||
|
||||
// Read the initial badge count from the Changes row
|
||||
const badge = changesRow.locator('span').last()
|
||||
const initialCount = Number(await badge.textContent())
|
||||
expect(initialCount).toBeGreaterThan(0)
|
||||
|
||||
// Click the first note in the list to select it
|
||||
const noteListContainer = page.locator('[data-testid="note-list-container"]')
|
||||
await noteListContainer.waitFor({ timeout: 5000 })
|
||||
// Click on the first visible note item (border-b items inside the list)
|
||||
const firstNote = noteListContainer.locator('.cursor-pointer').first()
|
||||
await firstNote.click()
|
||||
|
||||
// Trash the active note via command palette
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Trash Note')
|
||||
|
||||
// Wait for the badge count to increase
|
||||
await expect(async () => {
|
||||
const text = await badge.textContent()
|
||||
expect(Number(text)).toBeGreaterThan(initialCount)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user