import { useState } from 'react' import { describe, it, expect, vi } from 'vitest' import { render as rtlRender, screen, fireEvent, act, waitFor } from '@testing-library/react' import { AiPanel, AiPanelView } from './AiPanel' import { UNSUPPORTED_INLINE_PASTE_MESSAGE } from './InlineWikilinkInput' import { TooltipProvider } from '@/components/ui/tooltip' import type { AiPanelController } from './useAiPanelController' import type { VaultEntry } from '../types' import { queueAiPrompt } from '../utils/aiPromptBridge' import type { NoteReference } from '../utils/ai-context' import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from '../utils/vaultConfigStore' const { trackEventMock } = vi.hoisted(() => ({ trackEventMock: vi.fn(), })) vi.mock('../lib/telemetry', () => ({ trackEvent: trackEventMock, })) // Mock the hooks and utils to isolate component tests let mockMessages: ReturnType['messages'] = [] let mockStatus: ReturnType['status'] = 'idle' const mockSendMessage = vi.fn() const mockStopMessage = vi.fn() const mockClearConversation = vi.fn() const mockAddLocalMarker = vi.fn() const mockUseCliAiAgent = vi.fn() vi.mock('../hooks/useCliAiAgent', () => ({ useCliAiAgent: (...args: unknown[]) => { mockUseCliAiAgent(...args) return { messages: mockMessages, status: mockStatus, sendMessage: mockSendMessage, stopMessage: mockStopMessage, clearConversation: mockClearConversation, addLocalMarker: mockAddLocalMarker, } }, })) 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, modifiedAt: 1700000000, createdAt: 1700000000, fileSize: 100, snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null, view: null, visible: null, organized: false, favorite: false, favoriteIndex: null, listPropertiesDisplay: [], outgoingLinks: [], properties: {}, hasH1: false, ...overrides, }) function render(ui: Parameters[0]) { return rtlRender(ui, { wrapper: TooltipProvider }) } function QueuedPromptTargetHarness({ onTargetChange }: { onTargetChange: (targetId: string) => void }) { const [input, setInput] = useState('') const [targetId, setTargetId] = useState('agent:claude_code') const handleTargetChange = (nextTargetId: string) => { onTargetChange(nextTargetId) setTargetId(nextTargetId) } const controller: AiPanelController = { agent: { messages: [], status: 'idle', sendMessage: (text: string, references?: NoteReference[]) => { mockSendMessage(text, references) return Promise.resolve() }, stopMessage: mockStopMessage, regenerateMessage: () => Promise.resolve(), clearConversation: () => { mockClearConversation() }, addLocalMarker: (text: string) => { mockAddLocalMarker(text) }, }, input, setInput, linkedEntries: [], hasContext: false, isActive: false, permissionMode: 'safe', handleSend: (text: string, references: NoteReference[]) => { mockSendMessage(text, references) setInput('') }, handleStop: mockStopMessage, handleNavigateWikilink: vi.fn(), handlePermissionModeChange: vi.fn(), handleNewChat: vi.fn(), } return ( ) } describe('AiPanel', () => { beforeEach(() => { mockMessages = [] mockStatus = 'idle' mockSendMessage.mockReset() mockStopMessage.mockReset() mockClearConversation.mockReset() mockAddLocalMarker.mockReset() mockUseCliAiAgent.mockReset() trackEventMock.mockClear() resetVaultConfigStore() bindVaultConfigStore({ zoom: null, view_mode: null, editor_mode: null, note_layout: null, tag_colors: null, status_colors: null, property_display_modes: null, inbox: null, allNotes: null, ai_agent_permission_mode: 'safe', }, vi.fn()) }) it('renders panel with the default CLI agent header', () => { render() expect(screen.getByText('AI Agent')).toBeTruthy() expect(screen.getByText('Claude Code · Safe')).toBeTruthy() }) it('passes the vault permission mode to the AI agent session', () => { bindVaultConfigStore({ ...getVaultConfig(), ai_agent_permission_mode: 'power_user', }, vi.fn()) render() expect(screen.getByText('Claude Code · Power User')).toBeTruthy() expect(mockUseCliAiAgent).toHaveBeenCalledWith( '/tmp/vault', undefined, undefined, expect.any(Object), expect.objectContaining({ permissionMode: 'power_user' }), ) }) it('persists permission mode changes and records a local transcript marker', () => { const save = vi.fn() bindVaultConfigStore({ ...getVaultConfig(), ai_agent_permission_mode: 'safe', }, save) mockMessages = [{ userMessage: 'Existing question', actions: [], response: 'Existing answer.', id: 'msg-existing', }] render() fireEvent.click(screen.getByRole('radio', { name: 'Power User' })) expect(getVaultConfig().ai_agent_permission_mode).toBe('power_user') expect(save).toHaveBeenLastCalledWith(expect.objectContaining({ ai_agent_permission_mode: 'power_user', })) expect(mockAddLocalMarker).toHaveBeenCalledWith( 'AI permission mode changed to Power User. It will apply to the next message.', ) expect(trackEventMock).toHaveBeenCalledWith('ai_agent_permission_mode_changed', { agent: 'claude_code', permission_mode: 'power_user', }) }) it('disables permission mode changes while the AI agent is running', () => { mockStatus = 'thinking' render() expect(screen.getByRole('radio', { name: 'Vault Safe' })).toBeDisabled() expect(screen.getByRole('radio', { name: 'Power User' })).toBeDisabled() }) it('replaces the composer send button with a stop button while the AI agent is running', () => { mockStatus = 'thinking' render() expect(screen.queryByTestId('agent-send')).toBeNull() const stopButton = screen.getByRole('button', { name: 'Stop response' }) expect(stopButton).toHaveAttribute('data-testid', 'agent-stop') expect(stopButton).not.toBeDisabled() fireEvent.click(stopButton) expect(mockStopMessage).toHaveBeenCalledOnce() }) it('renders the permission mode toggle with high contrast selected state and per-mode tooltips', async () => { render() expect(screen.getByTestId('ai-permission-mode-toggle')).toHaveClass('border', 'bg-muted') const safeMode = screen.getByRole('radio', { name: 'Vault Safe' }) const powerUserMode = screen.getByRole('radio', { name: 'Power User' }) expect(safeMode).toHaveAttribute('aria-checked', 'true') expect(safeMode).toHaveClass('bg-background', 'text-foreground', 'shadow-xs') expect(powerUserMode).toHaveClass('text-muted-foreground') fireEvent.focus(safeMode) expect(await screen.findByTestId('ai-permission-mode-tooltip')).toHaveTextContent( 'Vault Safe keeps agents limited to file, search, and edit tools.', ) fireEvent.blur(safeMode) fireEvent.focus(powerUserMode) await waitFor(() => { expect(screen.getByTestId('ai-permission-mode-tooltip')).toHaveTextContent( 'Power User also allows local shell commands for this vault.', ) }) }) it('renders data-testid ai-panel', () => { render() expect(screen.getByTestId('ai-panel')).toBeTruthy() }) it('caps long AI agent drafts inside a scrollable composer while keeping send visible', () => { render() const editor = screen.getByTestId('agent-input') editor.textContent = Array.from({ length: 40 }, (_, index) => `Line ${index + 1}`).join('\n') fireEvent.input(editor) expect(editor).toHaveClass('max-h-[120px]', 'overflow-y-auto', 'overscroll-contain') expect(editor).toHaveStyle({ maxHeight: '120px', overflowY: 'auto' }) expect(screen.getByTestId('agent-send')).toBeVisible() }) it('calls onClose when close button is clicked', () => { const onClose = vi.fn() render() const panel = screen.getByTestId('ai-panel') const buttons = panel.querySelectorAll('button') const closeBtn = Array.from(buttons).find(b => b.title?.includes('Close')) expect(closeBtn).toBeTruthy() fireEvent.click(closeBtn!) expect(onClose).toHaveBeenCalled() }) it('starts a new AI chat when the header action is clicked', () => { render() fireEvent.click(screen.getByTitle('New AI chat')) expect(mockClearConversation).toHaveBeenCalledOnce() }) it('keeps the MCP config action out of the AI panel header', () => { render() expect(screen.queryByRole('button', { name: 'Copy MCP config' })).toBeNull() }) it('renders empty state without context', () => { render() expect(screen.getByText('Open a note, then ask Claude Code about it')).toBeTruthy() }) it('renders contextual empty state when active entry is provided', () => { const entry = makeEntry({ title: 'My Note' }) render( ) expect(screen.getByText('Ask anything to Claude Code')).toBeTruthy() }) it('does not render a context bar for the active entry', () => { const entry = makeEntry({ title: 'My Note' }) render( ) expect(screen.queryByTestId('context-bar')).toBeNull() expect(screen.queryByText('My Note')).toBeNull() }) it('does not show linked count in a sub-header', () => { const linked = makeEntry({ path: '/vault/linked.md', title: 'Linked Note' }) const entry = makeEntry({ title: 'My Note', outgoingLinks: ['Linked Note'] }) render( ) expect(screen.queryByText('+ 1 linked')).toBeNull() expect(screen.queryByTestId('context-bar')).toBeNull() }) it('renders input field enabled', () => { render() const input = screen.getByTestId('agent-input') expect(input).toBeTruthy() expect(input).toHaveAttribute('contenteditable', 'true') }) it('has send button disabled when input is empty', () => { render() const sendBtn = screen.getByTestId('agent-send') expect((sendBtn as HTMLButtonElement).disabled).toBe(true) }) it('shows active agent placeholder when active entry exists', () => { const entry = makeEntry({ title: 'My Note' }) render( ) const input = screen.getByTestId('agent-input') expect(input).toHaveAttribute('aria-placeholder', 'Ask Claude Code') }) it('shows active agent placeholder when no active entry', () => { render() const input = screen.getByTestId('agent-input') expect(input).toHaveAttribute('aria-placeholder', 'Ask Claude Code') }) it('uses the selected AI agent in the placeholder', () => { render( , ) expect(screen.getByTestId('agent-input')).toHaveAttribute('aria-placeholder', 'Ask Codex') }) it('disables sending while the selected AI agent is still loading', () => { render( , ) expect(screen.getByText('Checking availability')).toBeTruthy() expect(screen.getByTestId('agent-input')).toHaveAttribute('aria-placeholder', 'Checking AI agent availability...') expect(screen.getByTestId('agent-send')).toBeDisabled() }) it('auto-focuses input on mount', async () => { vi.useFakeTimers() render() await act(() => { vi.advanceTimersByTime(1) }) const input = screen.getByTestId('agent-input') expect(document.activeElement).toBe(input) vi.useRealTimers() }) it('focuses the panel shell when reopening with existing messages', async () => { vi.useFakeTimers() mockMessages = [{ userMessage: 'Remember this', actions: [], response: 'Still here.', id: 'msg-3', }] render() await act(() => { vi.advanceTimersByTime(1) }) expect(document.activeElement).toBe(screen.getByTestId('ai-panel')) vi.useRealTimers() }) it('does not steal composer focus after a response when the send button becomes enabled', async () => { vi.useFakeTimers() mockMessages = [{ userMessage: 'First question', actions: [], response: 'First answer.', id: 'msg-3', }] render() const input = screen.getByTestId('agent-input') input.focus() input.textContent = 'f' fireEvent.input(input) await act(() => { vi.advanceTimersByTime(1) }) expect(screen.getByTestId('agent-send')).toBeEnabled() expect(document.activeElement).toBe(screen.getByTestId('agent-input')) vi.useRealTimers() }) it('calls onClose when Escape is pressed while panel has focus', async () => { vi.useFakeTimers() const onClose = vi.fn() render() await act(() => { vi.advanceTimersByTime(1) }) // Input is focused inside the panel, so Escape should trigger onClose fireEvent.keyDown(document.activeElement!, { key: 'Escape' }) expect(onClose).toHaveBeenCalledOnce() vi.useRealTimers() }) it('calls onClose when Escape is pressed on panel element', () => { const onClose = vi.fn() render() const panel = screen.getByTestId('ai-panel') panel.focus() fireEvent.keyDown(panel, { key: 'Escape' }) expect(onClose).toHaveBeenCalledOnce() }) it('clicking a wikilink in AI response calls onOpenNote with the target', () => { mockMessages = [{ userMessage: 'Tell me about notes', actions: [], response: 'Check out [[Build Laputa App]] for details.', id: 'msg-1', }] const onOpenNote = vi.fn() const { container } = render( , ) const wikilink = container.querySelector('.chat-wikilink') expect(wikilink).toBeTruthy() expect(wikilink!.textContent).toBe('Build Laputa App') fireEvent.click(wikilink!) expect(onOpenNote).toHaveBeenCalledWith('Build Laputa App') }) it('renders wikilinks with special characters and clicking works', () => { mockMessages = [{ userMessage: 'Tell me about meetings', actions: [], response: 'See [[Meeting — 2024/01/15]] and [[Pasta Carbonara]].', id: 'msg-2', }] const onOpenNote = vi.fn() const { container } = render( , ) const wikilinks = container.querySelectorAll('.chat-wikilink') expect(wikilinks).toHaveLength(2) fireEvent.click(wikilinks[0]) expect(onOpenNote).toHaveBeenCalledWith('Meeting — 2024/01/15') fireEvent.click(wikilinks[1]) expect(onOpenNote).toHaveBeenCalledWith('Pasta Carbonara') }) it('auto-sends a queued prompt from the command palette bridge', async () => { render() await act(async () => { queueAiPrompt('summarize [[alpha]]', [ { title: 'Alpha', path: '/vault/alpha.md', type: 'Project' }, ]) }) expect(mockClearConversation).toHaveBeenCalledOnce() expect(mockSendMessage).toHaveBeenCalledWith('summarize [[alpha]]', [ { title: 'Alpha', path: '/vault/alpha.md', type: 'Project' }, ]) expect(screen.getByTestId('agent-send')).toBeDisabled() }) it('retargets a queued prompt before sending when the active target differs', async () => { const onTargetChange = vi.fn() render() await act(async () => { queueAiPrompt('ask codex', [], 'agent:codex') }) await waitFor(() => { expect(onTargetChange).toHaveBeenCalledWith('agent:codex') expect(mockClearConversation).toHaveBeenCalledOnce() expect(mockSendMessage).toHaveBeenCalledWith('ask codex', []) }) }) it('surfaces an unsupported image paste notice without locking the composer', () => { const onUnsupportedAiPaste = vi.fn() const entry = makeEntry({ title: 'My Note' }) render( , ) fireEvent.paste(screen.getByTestId('agent-input'), { clipboardData: { getData: vi.fn(() => ''), files: [new File(['image'], 'paste.png', { type: 'image/png' })], items: [{ kind: 'file', type: 'image/png' }], }, }) expect(onUnsupportedAiPaste).toHaveBeenCalledWith(UNSUPPORTED_INLINE_PASTE_MESSAGE) expect(screen.getByTestId('agent-input').textContent).not.toContain('paste.png') }) })