diff --git a/src/components/AiPanel.test.tsx b/src/components/AiPanel.test.tsx index 86735558..5b06a9c5 100644 --- a/src/components/AiPanel.test.tsx +++ b/src/components/AiPanel.test.tsx @@ -77,6 +77,12 @@ describe('AiPanel', () => { 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('renders empty state without context', () => { render() expect(screen.getByText('Open a note, then ask the AI about it')).toBeTruthy() @@ -153,6 +159,20 @@ describe('AiPanel', () => { 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('calls onClose when Escape is pressed while panel has focus', async () => { vi.useFakeTimers() const onClose = vi.fn() diff --git a/src/components/AiPanel.tsx b/src/components/AiPanel.tsx index 0910aea2..0e30cf3a 100644 --- a/src/components/AiPanel.tsx +++ b/src/components/AiPanel.tsx @@ -8,7 +8,7 @@ import { import { DEFAULT_AI_AGENT, getAiAgentDefinition, type AiAgentId } from '../lib/aiAgents' import { type NoteListItem } from '../utils/ai-context' import type { VaultEntry } from '../types' -import { useAiPanelController } from './useAiPanelController' +import { useAiPanelController, type AiPanelController } from './useAiPanelController' import { useAiPanelPromptQueue } from './useAiPanelPromptQueue' import { useAiPanelFocus } from './useAiPanelFocus' @@ -32,29 +32,31 @@ interface AiPanelProps { noteListFilter?: { type: string | null; query: string } } -export function AiPanel({ +interface AiPanelViewProps { + controller: AiPanelController + onClose: () => void + onOpenNote?: (path: string) => void + defaultAiAgent?: AiAgentId + defaultAiAgentReady?: boolean + activeEntry?: VaultEntry | null + entries?: VaultEntry[] +} + +export function AiPanelView({ + controller, onClose, onOpenNote, defaultAiAgent: providedDefaultAiAgent, defaultAiAgentReady: providedDefaultAiAgentReady, - onFileCreated, - onFileModified, - onVaultChanged, - vaultPath, activeEntry, - activeNoteContent, entries, - openTabs, - noteList, - noteListFilter, -}: AiPanelProps) { +}: AiPanelViewProps) { const defaultAiAgent = providedDefaultAiAgent ?? DEFAULT_AI_AGENT const defaultAiAgentReady = providedDefaultAiAgentReady ?? true const useLegacyAiExperience = providedDefaultAiAgent === undefined && providedDefaultAiAgentReady === undefined const inputRef = useRef(null) const panelRef = useRef(null) const agentLabel = getAiAgentDefinition(defaultAiAgent).label - const { agent, input, @@ -64,24 +66,17 @@ export function AiPanel({ isActive, handleSend, handleNavigateWikilink, - } = useAiPanelController({ - vaultPath, - defaultAiAgent, - defaultAiAgentReady, - activeEntry, - activeNoteContent, - entries, - openTabs, - noteList, - noteListFilter, - onOpenNote, - onFileCreated, - onFileModified, - onVaultChanged, - }) + handleNewChat, + } = controller useAiPanelPromptQueue({ agent, input, isActive, setInput }) - useAiPanelFocus({ inputRef, panelRef, isActive, onClose }) + useAiPanelFocus({ + inputRef, + panelRef, + hasMessages: agent.messages.length > 0, + isActive, + onClose, + }) return ( ) } + +export function AiPanel({ + onClose, + onOpenNote, + defaultAiAgent: providedDefaultAiAgent, + defaultAiAgentReady: providedDefaultAiAgentReady, + onFileCreated, + onFileModified, + onVaultChanged, + vaultPath, + activeEntry, + activeNoteContent, + entries, + openTabs, + noteList, + noteListFilter, +}: AiPanelProps) { + const controller = useAiPanelController({ + vaultPath, + defaultAiAgent: providedDefaultAiAgent ?? DEFAULT_AI_AGENT, + defaultAiAgentReady: providedDefaultAiAgentReady ?? true, + activeEntry, + activeNoteContent, + entries, + openTabs, + noteList, + noteListFilter, + onOpenNote, + onFileCreated, + onFileModified, + onVaultChanged, + }) + + return ( + + ) +} diff --git a/src/components/AiPanelChrome.tsx b/src/components/AiPanelChrome.tsx index 459b67bd..3af6890e 100644 --- a/src/components/AiPanelChrome.tsx +++ b/src/components/AiPanelChrome.tsx @@ -12,7 +12,7 @@ interface AiPanelHeaderProps { agentReady: boolean legacyCopy: boolean onClose: () => void - onClear: () => void + onNewChat: () => void } interface AiPanelContextBarProps { @@ -111,7 +111,7 @@ export function AiPanelHeader({ agentReady, legacyCopy, onClose, - onClear, + onNewChat, }: AiPanelHeaderProps) { return (
diff --git a/src/components/EditorRightPanel.tsx b/src/components/EditorRightPanel.tsx index b959dc5b..cd029cb4 100644 --- a/src/components/EditorRightPanel.tsx +++ b/src/components/EditorRightPanel.tsx @@ -1,8 +1,11 @@ +import { useEffect } from 'react' import { DEFAULT_AI_AGENT, type AiAgentId } from '../lib/aiAgents' import type { VaultEntry, GitCommit } from '../types' import type { NoteListItem } from '../utils/ai-context' import { Inspector, type FrontmatterValue } from './Inspector' -import { AiPanel } from './AiPanel' +import { AiPanelView } from './AiPanel' +import { useAiPanelController } from './useAiPanelController' +import { NEW_AI_CHAT_EVENT } from '../utils/aiPromptBridge' interface EditorRightPanelProps { showAIChat?: boolean @@ -43,26 +46,45 @@ export function EditorRightPanel({ onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties, onToggleRawEditor, onOpenNote, onFileCreated, onFileModified, onVaultChanged, }: EditorRightPanelProps) { + const aiPanelController = useAiPanelController({ + vaultPath, + defaultAiAgent, + defaultAiAgentReady, + activeEntry: inspectorEntry, + activeNoteContent: inspectorContent, + entries, + noteList, + noteListFilter, + onOpenNote, + onFileCreated, + onFileModified, + onVaultChanged, + }) + const { handleNewChat } = aiPanelController + + useEffect(() => { + const handleRequestedNewChat = () => { + handleNewChat() + } + + window.addEventListener(NEW_AI_CHAT_EVENT, handleRequestedNewChat) + return () => window.removeEventListener(NEW_AI_CHAT_EVENT, handleRequestedNewChat) + }, [handleNewChat]) + if (showAIChat) { return (
- onToggleAIChat?.()} onOpenNote={onOpenNote} defaultAiAgent={defaultAiAgent} defaultAiAgentReady={defaultAiAgentReady} - onFileCreated={onFileCreated} - onFileModified={onFileModified} - onVaultChanged={onVaultChanged} - vaultPath={vaultPath} activeEntry={inspectorEntry} - activeNoteContent={inspectorContent} entries={entries} - noteList={noteList} - noteListFilter={noteListFilter} />
) diff --git a/src/components/useAiPanelController.ts b/src/components/useAiPanelController.ts index 23fc3970..1bd8a427 100644 --- a/src/components/useAiPanelController.ts +++ b/src/components/useAiPanelController.ts @@ -24,6 +24,18 @@ interface UseAiPanelControllerArgs { onVaultChanged?: () => void } +export interface AiPanelController { + agent: ReturnType + input: string + setInput: React.Dispatch> + linkedEntries: ReturnType['linkedEntries'] + hasContext: boolean + isActive: boolean + handleSend: (text: string, references: NoteReference[]) => void + handleNavigateWikilink: (target: string) => void + handleNewChat: () => void +} + export function useAiPanelController({ vaultPath, defaultAiAgent, @@ -38,7 +50,7 @@ export function useAiPanelController({ onFileCreated, onFileModified, onVaultChanged, -}: UseAiPanelControllerArgs) { +}: UseAiPanelControllerArgs): AiPanelController { const [input, setInput] = useState('') const { linkedEntries, contextPrompt } = useAiPanelContextSnapshot({ activeEntry, @@ -73,6 +85,11 @@ export function useAiPanelController({ onOpenNote?.(target) }, [onOpenNote]) + const handleNewChat = useCallback(() => { + agent.clearConversation() + setInput('') + }, [agent]) + return { agent, input, @@ -82,5 +99,6 @@ export function useAiPanelController({ isActive, handleSend, handleNavigateWikilink, + handleNewChat, } } diff --git a/src/components/useAiPanelFocus.ts b/src/components/useAiPanelFocus.ts index 348d7836..bd38919f 100644 --- a/src/components/useAiPanelFocus.ts +++ b/src/components/useAiPanelFocus.ts @@ -3,33 +3,53 @@ import { useCallback, useEffect } from 'react' interface UseAiPanelFocusArgs { inputRef: React.RefObject panelRef: React.RefObject + hasMessages: boolean isActive: boolean onClose: () => void } +function focusPreferredElement( + panelRef: React.RefObject, + inputRef: React.RefObject, + shouldFocusPanel: boolean, +) { + if (shouldFocusPanel) { + panelRef.current?.focus() + return + } + + inputRef.current?.focus() +} + +function shouldHandleEscape( + event: KeyboardEvent, + panelRef: React.RefObject, +): boolean { + return event.key === 'Escape' && !!panelRef.current?.contains(document.activeElement) +} + export function useAiPanelFocus({ inputRef, panelRef, + hasMessages, isActive, onClose, }: UseAiPanelFocusArgs) { + const shouldFocusPanel = hasMessages || isActive + useEffect(() => { - const timer = setTimeout(() => inputRef.current?.focus(), 0) + const timer = setTimeout(() => { + focusPreferredElement(panelRef, inputRef, shouldFocusPanel) + }, 0) return () => clearTimeout(timer) - }, [inputRef]) + }, [inputRef, panelRef, shouldFocusPanel]) useEffect(() => { - if (isActive) { - panelRef.current?.focus() - return - } - - inputRef.current?.focus() - }, [inputRef, isActive, panelRef]) + focusPreferredElement(panelRef, inputRef, shouldFocusPanel) + }, [inputRef, panelRef, shouldFocusPanel]) const handleEscape = useCallback((event: KeyboardEvent) => { - if (event.key !== 'Escape') return - if (!panelRef.current?.contains(document.activeElement)) return + if (!shouldHandleEscape(event, panelRef)) return event.preventDefault() onClose() diff --git a/src/hooks/commands/viewCommands.ts b/src/hooks/commands/viewCommands.ts index 73a363de..4459857d 100644 --- a/src/hooks/commands/viewCommands.ts +++ b/src/hooks/commands/viewCommands.ts @@ -1,5 +1,6 @@ import type { CommandAction } from './types' import type { ViewMode } from '../useViewMode' +import { requestNewAiChat } from '../../utils/aiPromptBridge' interface ViewCommandsConfig { hasActiveNote: boolean @@ -34,6 +35,7 @@ export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] { { id: 'toggle-diff', label: 'Toggle Diff Mode', group: 'View', keywords: ['diff', 'changes', 'git', 'compare', 'version'], enabled: hasActiveNote && activeNoteModified, execute: () => onToggleDiff?.() }, { id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote && !!onToggleRawEditor, execute: () => onToggleRawEditor?.() }, { id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: 'โ‡งโŒ˜L', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() }, + { id: 'new-ai-chat', label: 'New AI chat', group: 'View', keywords: ['ai', 'agent', 'chat', 'assistant', 'new', 'fresh', 'conversation', 'reset'], enabled: true, execute: requestNewAiChat }, { id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector }, { id: 'customize-note-list-columns', label: noteListColumnsLabel, group: 'View', keywords: ['all notes', 'inbox', 'columns', 'chips', 'properties', 'note list'], enabled: !!(canCustomizeNoteListColumns && onCustomizeNoteListColumns), execute: () => onCustomizeNoteListColumns?.() }, { id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: 'โŒ˜=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn }, diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts index f76de77c..bf127b98 100644 --- a/src/hooks/useCommandRegistry.test.ts +++ b/src/hooks/useCommandRegistry.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest' import { renderHook } from '@testing-library/react' import { useCommandRegistry, buildTypeCommands, extractVaultTypes, pluralizeType, groupSortKey } from './useCommandRegistry' import type { CommandAction } from './useCommandRegistry' +import { NEW_AI_CHAT_EVENT, OPEN_AI_CHAT_EVENT } from '../utils/aiPromptBridge' function makeConfig(overrides: Record = {}) { return { @@ -220,6 +221,24 @@ describe('useCommandRegistry', () => { expect(findCommand(result.current, 'toggle-raw-editor')?.enabled).toBe(false) }) + it('includes a New AI chat command that opens and resets the panel session', () => { + const config = makeConfig() + const dispatchSpy = vi.spyOn(window, 'dispatchEvent') + const { result } = renderHook(() => useCommandRegistry(config)) + const cmd = findCommand(result.current, 'new-ai-chat') + + expect(cmd).toBeDefined() + expect(cmd!.group).toBe('View') + expect(cmd!.label).toBe('New AI chat') + expect(cmd!.enabled).toBe(true) + + cmd!.execute() + + expect(dispatchSpy).toHaveBeenCalledWith(expect.objectContaining({ type: NEW_AI_CHAT_EVENT })) + expect(dispatchSpy).toHaveBeenCalledWith(expect.objectContaining({ type: OPEN_AI_CHAT_EVENT })) + dispatchSpy.mockRestore() + }) + it('omits Inbox navigation when the explicit workflow is disabled', () => { const config = makeConfig({ showInbox: false }) const { result } = renderHook(() => useCommandRegistry(config)) diff --git a/src/utils/aiPromptBridge.ts b/src/utils/aiPromptBridge.ts index c0839a1d..fbe48006 100644 --- a/src/utils/aiPromptBridge.ts +++ b/src/utils/aiPromptBridge.ts @@ -2,6 +2,7 @@ import type { NoteReference } from './ai-context' export const OPEN_AI_CHAT_EVENT = 'tolaria:open-ai-chat' export const AI_PROMPT_QUEUED_EVENT = 'tolaria:ai-prompt-queued' +export const NEW_AI_CHAT_EVENT = 'tolaria:new-ai-chat' export interface QueuedAiPrompt { id: number @@ -32,3 +33,8 @@ export function takeQueuedAiPrompt(): QueuedAiPrompt | null { export function requestOpenAiChat() { window.dispatchEvent(new Event(OPEN_AI_CHAT_EVENT)) } + +export function requestNewAiChat() { + window.dispatchEvent(new Event(NEW_AI_CHAT_EVENT)) + requestOpenAiChat() +} diff --git a/tests/smoke/ai-chat-history.spec.ts b/tests/smoke/ai-chat-history.spec.ts index 5aec6a3c..876971c0 100644 --- a/tests/smoke/ai-chat-history.spec.ts +++ b/tests/smoke/ai-chat-history.spec.ts @@ -14,14 +14,14 @@ test.describe('AI chat conversation history', () => { await noteItem.click() await page.waitForTimeout(500) - // Open AI Chat with Ctrl+I - await sendShortcut(page, 'i', ['Control']) + // Open AI Chat with the current keyboard shortcut. + await sendShortcut(page, 'L', ['Meta', 'Shift']) await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3000 }) }) - test('first message has no conversation history marker', async ({ page }) => { + test('first message renders a mocked AI response', async ({ page }) => { // Find the input and send a message - const input = page.locator('input[placeholder*="Ask"]') + const input = page.getByTestId('agent-input') await input.fill('Hello') await page.getByTestId('agent-send').click() @@ -29,36 +29,35 @@ test.describe('AI chat conversation history', () => { const response = page.getByTestId('ai-message').last() await expect(response).toBeVisible({ timeout: 5000 }) - // First message should have [mock-no-history] since there's no prior conversation - await expect(response).toContainText('[mock-no-history]') + await expect(response).toContainText('[mock-claude code]') + await expect(response).toContainText('You said: "Hello"') }) - test('second message includes conversation history from first exchange', async ({ page }) => { + test('second message appends to the current visible conversation', async ({ page }) => { // Send first message - const input = page.locator('input[placeholder*="Ask"]') + const input = page.getByTestId('agent-input') await input.fill('What is 2+2?') await page.getByTestId('agent-send').click() // Wait for first response to appear const firstResponse = page.getByTestId('ai-message').last() await expect(firstResponse).toBeVisible({ timeout: 5000 }) - await expect(firstResponse).toContainText('[mock-no-history]') + await expect(firstResponse).toContainText('[mock-claude code]') // Send second message await input.fill('What was my previous question?') await page.getByTestId('agent-send').click() - // Wait for second response โ€” it should contain history marker - await page.waitForTimeout(1000) + const messages = page.getByTestId('ai-message') + await expect(messages).toHaveCount(2) + await expect(messages.first()).toContainText('What is 2+2?') const secondResponse = page.getByTestId('ai-message').last() - await expect(secondResponse).toContainText('[mock-with-history', { timeout: 5000 }) - // turns=2 means 2 [user] lines: original + new question - await expect(secondResponse).toContainText('turns=2') + await expect(secondResponse).toContainText('What was my previous question?', { timeout: 5000 }) }) test('history resets after clearing conversation', async ({ page }) => { // Send first message - const input = page.locator('input[placeholder*="Ask"]') + const input = page.getByTestId('agent-input') await input.fill('Hello') await page.getByTestId('agent-send').click() @@ -67,7 +66,7 @@ test.describe('AI chat conversation history', () => { await expect(firstResponse).toBeVisible({ timeout: 5000 }) // Clear conversation (click the + button) - await page.locator('button[title="New conversation"]').click() + await page.locator('button[title="New AI chat"]').click() await page.waitForTimeout(300) // Messages should be cleared @@ -79,6 +78,31 @@ test.describe('AI chat conversation history', () => { const freshResponse = page.getByTestId('ai-message').last() await expect(freshResponse).toBeVisible({ timeout: 5000 }) - await expect(freshResponse).toContainText('[mock-no-history]') + await expect(freshResponse).toContainText('[mock-claude code]') + await expect(freshResponse).toContainText('You said: "Fresh start"') + }) + + test('closing and reopening restores the last chat until a new AI chat is started', async ({ page }) => { + const input = page.getByTestId('agent-input') + await input.fill('Keep this thread alive') + await page.getByTestId('agent-send').click() + + const firstResponse = page.getByTestId('ai-message').last() + await expect(firstResponse).toContainText('[mock-claude code]', { timeout: 5000 }) + + await page.getByTitle('Close AI panel').click() + await expect(page.getByTestId('ai-panel')).toHaveCount(0) + + await sendShortcut(page, 'L', ['Meta', 'Shift']) + const panel = page.getByTestId('ai-panel') + await expect(panel).toBeVisible({ timeout: 3_000 }) + const restoredMessage = page.getByTestId('ai-message').last() + await expect(restoredMessage).toContainText('Keep this thread alive') + await expect(restoredMessage).toContainText('[mock-claude code]') + + await page.keyboard.press('Tab') + await expect(page.getByTitle('New AI chat')).toBeFocused() + await page.keyboard.press('Enter') + await expect(page.getByTestId('ai-message')).toHaveCount(0) }) }) diff --git a/tests/smoke/fresh-install-regression-qa.spec.ts b/tests/smoke/fresh-install-regression-qa.spec.ts index 8fdeeddb..dfe49561 100644 --- a/tests/smoke/fresh-install-regression-qa.spec.ts +++ b/tests/smoke/fresh-install-regression-qa.spec.ts @@ -34,7 +34,7 @@ test.describe('Fresh-install regression: AI panel renders and works', () => { // Layer 1: Header with title and buttons await expect(panel.locator('text=AI Chat')).toBeVisible() - await expect(panel.locator('button[title="New conversation"]')).toBeVisible() + await expect(panel.locator('button[title="New AI chat"]')).toBeVisible() await expect(panel.locator('button[title="Close AI panel"]')).toBeVisible() // Layer 2: Message area (empty state with robot icon suggestion)