fix: persist AI panel chats across reopen

This commit is contained in:
lucaronin
2026-04-20 10:28:48 +02:00
parent c86a6ab9c5
commit 9536efee0e
11 changed files with 250 additions and 72 deletions

View File

@@ -77,6 +77,12 @@ describe('AiPanel', () => {
expect(onClose).toHaveBeenCalled()
})
it('starts a new AI chat when the header action is clicked', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
fireEvent.click(screen.getByTitle('New AI chat'))
expect(mockClearConversation).toHaveBeenCalledOnce()
})
it('renders empty state without context', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
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(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
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()

View File

@@ -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<HTMLDivElement>(null)
const panelRef = useRef<HTMLElement>(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 (
<aside
@@ -99,7 +94,13 @@ export function AiPanel({
data-testid="ai-panel"
data-ai-active={isActive || undefined}
>
<AiPanelHeader agentLabel={agentLabel} agentReady={defaultAiAgentReady} legacyCopy={useLegacyAiExperience} onClose={onClose} onClear={agent.clearConversation} />
<AiPanelHeader
agentLabel={agentLabel}
agentReady={defaultAiAgentReady}
legacyCopy={useLegacyAiExperience}
onClose={onClose}
onNewChat={handleNewChat}
/>
{activeEntry && (
<AiPanelContextBar activeEntry={activeEntry} linkedCount={linkedEntries.length} />
)}
@@ -128,3 +129,48 @@ export function AiPanel({
</aside>
)
}
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 (
<AiPanelView
controller={controller}
onClose={onClose}
onOpenNote={onOpenNote}
defaultAiAgent={providedDefaultAiAgent}
defaultAiAgentReady={providedDefaultAiAgentReady}
activeEntry={activeEntry}
entries={entries}
/>
)
}

View File

@@ -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 (
<div
@@ -132,8 +132,9 @@ export function AiPanelHeader({
</div>
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
onClick={onClear}
title="New conversation"
onClick={onNewChat}
aria-label="New AI chat"
title="New AI chat"
>
<Plus size={16} />
</button>

View File

@@ -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 (
<div
className="shrink-0 flex flex-col min-h-0"
style={{ width: inspectorWidth, minWidth: 240, height: '100%' }}
>
<AiPanel
<AiPanelView
controller={aiPanelController}
onClose={() => 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}
/>
</div>
)

View File

@@ -24,6 +24,18 @@ interface UseAiPanelControllerArgs {
onVaultChanged?: () => void
}
export interface AiPanelController {
agent: ReturnType<typeof useCliAiAgent>
input: string
setInput: React.Dispatch<React.SetStateAction<string>>
linkedEntries: ReturnType<typeof useAiPanelContextSnapshot>['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,
}
}

View File

@@ -3,33 +3,53 @@ import { useCallback, useEffect } from 'react'
interface UseAiPanelFocusArgs {
inputRef: React.RefObject<HTMLDivElement | null>
panelRef: React.RefObject<HTMLElement | null>
hasMessages: boolean
isActive: boolean
onClose: () => void
}
function focusPreferredElement(
panelRef: React.RefObject<HTMLElement | null>,
inputRef: React.RefObject<HTMLDivElement | null>,
shouldFocusPanel: boolean,
) {
if (shouldFocusPanel) {
panelRef.current?.focus()
return
}
inputRef.current?.focus()
}
function shouldHandleEscape(
event: KeyboardEvent,
panelRef: React.RefObject<HTMLElement | null>,
): 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()

View File

@@ -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 },

View File

@@ -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<string, unknown> = {}) {
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))

View File

@@ -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()
}

View File

@@ -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)
})
})

View File

@@ -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)