From 399b8b58a001df08c70fa6e11c87d18b33fdca68 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Fri, 20 Feb 2026 16:06:51 +0100 Subject: [PATCH] feat: add mock AI chat panel Replaces Inspector panel when activated via Sparkle button in editor info bar. Includes context pills, message list with typing indicator, quick action pills (Summarize/Expand/Fix grammar), model selector, and mock responses with 1200ms delay. E2E test covers full flow. Co-Authored-By: Claude Opus 4.6 --- e2e/ai-chat.spec.ts | 69 +++++++ src/App.tsx | 5 + src/components/AIChatPanel.tsx | 364 +++++++++++++++++++++++++++++++++ src/components/Editor.tsx | 44 ++-- 4 files changed, 470 insertions(+), 12 deletions(-) create mode 100644 e2e/ai-chat.spec.ts create mode 100644 src/components/AIChatPanel.tsx diff --git a/e2e/ai-chat.spec.ts b/e2e/ai-chat.spec.ts new file mode 100644 index 00000000..76b3967c --- /dev/null +++ b/e2e/ai-chat.spec.ts @@ -0,0 +1,69 @@ +import { test, expect } from '@playwright/test' + +test('AI chat panel: open, send message, close', async ({ page }) => { + await page.goto('http://localhost:5173') + await page.waitForTimeout(2000) + + // Click the first note in the list — note items are cursor-pointer divs inside .app__note-list + const noteItem = page.locator('.app__note-list .cursor-pointer').first() + await noteItem.click() + await page.waitForTimeout(800) + + // Screenshot before opening AI chat + await page.screenshot({ path: 'test-results/ai-chat-before.png', fullPage: true }) + + // Find the Sparkle button in the breadcrumb/info bar and click it + const sparkleButton = page.locator('button[title="Open AI Chat"]') + await expect(sparkleButton).toBeVisible({ timeout: 5000 }) + await sparkleButton.click() + await page.waitForTimeout(300) + + // AI Chat panel should be visible + const aiChatHeader = page.locator('text=AI Chat') + await expect(aiChatHeader).toBeVisible() + + // Screenshot with AI chat panel open + await page.screenshot({ path: 'test-results/ai-chat-open.png', fullPage: true }) + + // Context pills should be visible + await expect(page.locator('text=Frontmatter')).toBeVisible() + await expect(page.locator('text=Links')).toBeVisible() + + // Type a message and send + const textarea = page.locator('textarea[placeholder="Ask about this document..."]') + await textarea.fill('Summarize this note') + await page.locator('button[title="Send message"]').click() + + // Should see user message + await expect(page.locator('text=Summarize this note')).toBeVisible() + + // Wait for typing indicator to appear + await page.waitForTimeout(300) + await page.screenshot({ path: 'test-results/ai-chat-typing.png', fullPage: true }) + + // Wait for mock response + await page.waitForTimeout(1200) + await expect(page.locator('text=words and links to')).toBeVisible({ timeout: 5000 }) + + // Screenshot with response + await page.screenshot({ path: 'test-results/ai-chat-response.png', fullPage: true }) + + // Test quick action pill + const expandButton = page.locator('button', { hasText: 'Expand' }) + await expandButton.click() + await page.waitForTimeout(1800) + await expect(page.locator('text=Add more detail to the introduction')).toBeVisible({ timeout: 5000 }) + + // Screenshot with quick action response + await page.screenshot({ path: 'test-results/ai-chat-quick-action.png', fullPage: true }) + + // Close the panel using the X button in the AI Chat header + await page.locator('button[title="Close AI Chat"]').last().click() + await page.waitForTimeout(300) + + // Inspector should be back + await expect(page.locator('text=Properties')).toBeVisible() + + // Screenshot after closing + await page.screenshot({ path: 'test-results/ai-chat-closed.png', fullPage: true }) +}) diff --git a/src/App.tsx b/src/App.tsx index 450f0ac5..4b1983f9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react' import { Sidebar } from './components/Sidebar' import { NoteList } from './components/NoteList' import { Editor } from './components/Editor' +import { AIChatPanel } from './components/AIChatPanel' import { ResizeHandle } from './components/ResizeHandle' import { CreateNoteDialog } from './components/CreateNoteDialog' import { QuickOpenPalette } from './components/QuickOpenPalette' @@ -23,6 +24,7 @@ declare global { const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' } const VAULTS = [ + { label: 'Demo v2', path: '/Users/luca/Workspace/laputa-app/demo-vault-v2' }, { label: 'Laputa', path: '/Users/luca/Laputa' }, { label: 'Demo', path: '/Users/luca/Workspace/laputa-app/demo-vault' }, ] @@ -39,6 +41,7 @@ function App() { const [showCommitDialog, setShowCommitDialog] = useState(false) const [toastMessage, setToastMessage] = useState(null) const [vaultPath, setVaultPath] = useState(VAULTS[0].path) + const [showAIChat, setShowAIChat] = useState(false) const vault = useVaultLoader(vaultPath) const notes = useNoteActions(vault.addEntry, vault.updateContent, vault.entries, setToastMessage) @@ -142,6 +145,8 @@ function App() { onUpdateFrontmatter={notes.handleUpdateFrontmatter} onDeleteProperty={notes.handleDeleteProperty} onAddProperty={notes.handleAddProperty} + showAIChat={showAIChat} + onToggleAIChat={() => setShowAIChat(c => !c)} /> diff --git a/src/components/AIChatPanel.tsx b/src/components/AIChatPanel.tsx new file mode 100644 index 00000000..b9225c2e --- /dev/null +++ b/src/components/AIChatPanel.tsx @@ -0,0 +1,364 @@ +import { useState, useRef, useEffect, useCallback } from 'react' +import type { VaultEntry } from '../types' +import { X, Plus, PaperPlaneRight, Copy, ArrowClockwise, TextIndent } from '@phosphor-icons/react' +import { Sparkle } from '@phosphor-icons/react' +import { countWords } from '../utils/wikilinks' + +interface ChatMessage { + role: 'user' | 'assistant' + content: string + id: string +} + +interface AIChatPanelProps { + entry: VaultEntry | null + allContent: Record + onClose: () => void +} + +function countWikilinks(content: string): number { + const matches = content.match(/\[\[.*?\]\]/g) + return matches ? matches.length : 0 +} + +function generateMockResponse(message: string, entry: VaultEntry | null, content: string): string { + const title = entry?.title ?? 'Untitled' + const words = countWords(content) + const links = countWikilinks(content) + const lower = message.toLowerCase() + + if (lower.includes('summarize')) { + return `This note is about **${title}**. It covers the main concepts documented in your vault. The document contains ${words} words and links to ${links} related notes.` + } + if (lower.includes('expand')) { + return `Here are some ways to expand this note:\n\n1. Add more detail to the introduction\n2. Include related examples from your vault\n3. Connect it to your quarterly goals\n4. Add a summary section at the end` + } + if (lower.includes('fix grammar') || lower.includes('grammar')) { + return `I reviewed the document for grammar issues. The writing looks clean overall — I found no major errors. Consider varying sentence length for better readability.` + } + + const bodyStart = content.replace(/^---[\s\S]*?---\n?/, '').replace(/^# [^\n]*\n?/, '').trim() + const snippet = bodyStart.slice(0, 120) + return `Based on the content of **${title}**: ${snippet}...\n\nIs there a specific aspect you would like me to focus on?` +} + +let msgIdCounter = 0 +function nextId(): string { + return `msg-${++msgIdCounter}-${Date.now()}` +} + +function TypingIndicator() { + return ( +
+
+ + + +
+
+ ) +} + +export function AIChatPanel({ entry, allContent, onClose }: AIChatPanelProps) { + const [messages, setMessages] = useState([]) + const [input, setInput] = useState('') + const [isTyping, setIsTyping] = useState(false) + const [model, setModel] = useState('sonnet-4.6') + const messagesEndRef = useRef(null) + const textareaRef = useRef(null) + + const content = entry ? (allContent[entry.path] ?? '') : '' + const wikilinkCount = countWikilinks(content) + + const scrollToBottom = useCallback(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, []) + + useEffect(() => { + scrollToBottom() + }, [messages, isTyping, scrollToBottom]) + + const sendMessage = useCallback((text: string) => { + if (!text.trim() || isTyping) return + + const userMsg: ChatMessage = { role: 'user', content: text.trim(), id: nextId() } + setMessages(prev => [...prev, userMsg]) + setInput('') + setIsTyping(true) + + setTimeout(() => { + const response = generateMockResponse(text, entry, content) + const assistantMsg: ChatMessage = { role: 'assistant', content: response, id: nextId() } + setMessages(prev => [...prev, assistantMsg]) + setIsTyping(false) + }, 1200) + }, [isTyping, entry, content]) + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + sendMessage(input) + } + }, [input, sendMessage]) + + const handleClearConversation = useCallback(() => { + setMessages([]) + setIsTyping(false) + }, []) + + const handleRetry = useCallback((msgIndex: number) => { + const userMsgIndex = msgIndex - 1 + if (userMsgIndex < 0) return + const userMsg = messages[userMsgIndex] + if (userMsg.role !== 'user') return + + setMessages(prev => prev.slice(0, msgIndex)) + setIsTyping(true) + + setTimeout(() => { + const response = generateMockResponse(userMsg.content, entry, content) + const assistantMsg: ChatMessage = { role: 'assistant', content: response, id: nextId() } + setMessages(prev => [...prev, assistantMsg]) + setIsTyping(false) + }, 1200) + }, [messages, entry, content]) + + const quickActions = [ + { label: 'Summarize', message: 'Summarize this note' }, + { label: 'Expand', message: 'Expand this note' }, + { label: 'Fix grammar', message: 'Fix grammar in this note' }, + ] + + return ( +