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)