diff --git a/src/App.tsx b/src/App.tsx index 19862960..f79ac35d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -290,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() diff --git a/src/components/AiPanel.test.tsx b/src/components/AiPanel.test.tsx index 4bd241c0..ebc752c0 100644 --- a/src/components/AiPanel.test.tsx +++ b/src/components/AiPanel.test.tsx @@ -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 => ({ + 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() - 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() - 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( + + ) + 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( + + ) + 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( + + ) + expect(screen.getByText('+ 1 linked')).toBeTruthy() + }) + + it('does not show context bar when no active entry', () => { + render() + 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( + + ) + 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() + const input = screen.getByTestId('agent-input') as HTMLInputElement + expect(input.placeholder).toBe('Ask the AI agent...') + }) }) diff --git a/src/components/AiPanel.tsx b/src/components/AiPanel.tsx index 4b5b8178..58186311 100644 --- a/src/components/AiPanel.tsx +++ b/src/components/AiPanel.tsx @@ -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 } function PanelHeader({ onClose, onClear }: { onClose: () => void; onClear: () => void }) { @@ -19,7 +24,7 @@ function PanelHeader({ onClose, onClear }: { onClose: () => void; onClear: () => > - AI Agent + AI Chat