import { useState, useRef, useEffect, useMemo } from 'react' import type { VaultEntry } from '../types' import { X, Plus, PaperPlaneRight, Copy, ArrowClockwise, TextIndent, Sparkle, MagnifyingGlass, Minus, } from '@phosphor-icons/react' import { type ChatMessage, buildSystemPrompt, } from '../utils/ai-chat' import { useAIChat } from '../hooks/useAIChat' import { MarkdownContent } from './MarkdownContent' // --- Sub-components --- interface AIChatPanelProps { entry: VaultEntry | null entries?: VaultEntry[] onClose: () => void onNavigateWikilink?: (target: string) => void } function TypingIndicator() { return (
) } function ContextPill({ note, onRemove }: { note: VaultEntry; onRemove: () => void }) { return ( {note.title} ) } function ContextSearchDropdown({ entries, contextPaths, onAdd, onClose, }: { entries: VaultEntry[]; contextPaths: Set onAdd: (entry: VaultEntry) => void; onClose: () => void }) { const [query, setQuery] = useState('') const inputRef = useRef(null) useEffect(() => { inputRef.current?.focus() }, []) const filtered = useMemo(() => { const q = query.toLowerCase() return entries .filter(e => !contextPaths.has(e.path)) .filter(e => !q || e.title.toLowerCase().includes(q) || (e.isA ?? '').toLowerCase().includes(q)) .slice(0, 8) }, [entries, contextPaths, query]) return (
setQuery(e.target.value)} onKeyDown={e => e.key === 'Escape' && onClose()} placeholder="Search notes..." className="flex-1 border-none bg-transparent text-foreground outline-none" style={{ fontSize: 12, padding: '2px 0' }} />
{filtered.map(entry => ( ))} {filtered.length === 0 && (
No matching notes
)}
) } function AssistantMessage({ msg, onRetry, onNavigateWikilink }: { msg: ChatMessage; onRetry: () => void; onNavigateWikilink?: (target: string) => void }) { return (
) } function StreamingContent({ content, onNavigateWikilink }: { content: string; onNavigateWikilink?: (target: string) => void }) { return (
) } function UserBubble({ content }: { content: string }) { return (
{content}
) } function formatTokens(n: number): string { return n < 1000 ? String(n) : `${(n / 1000).toFixed(1)}k` } const QUICK_ACTIONS = [ { label: 'Summarize', message: 'Summarize this note' }, { label: 'Expand', message: 'Expand this note with more detail' }, { label: 'Fix grammar', message: 'Fix grammar and improve readability' }, ] // --- Context management hook --- function useContextNotes(entry: VaultEntry | null) { const [contextNotes, setContextNotes] = useState([]) useEffect(() => { if (entry) { // eslint-disable-next-line react-hooks/set-state-in-effect -- sync context when active note changes setContextNotes(prev => prev.some(n => n.path === entry.path) ? prev : [entry, ...prev]) } }, [entry?.path]) // eslint-disable-line react-hooks/exhaustive-deps const addNote = (note: VaultEntry) => { setContextNotes(prev => prev.some(n => n.path === note.path) ? prev : [...prev, note]) } const removeNote = (path: string) => { setContextNotes(prev => prev.filter(n => n.path !== path)) } const paths = useMemo(() => new Set(contextNotes.map(n => n.path)), [contextNotes]) return { contextNotes, addNote, removeNote, paths } } // --- Main component --- export function AIChatPanel({ entry, entries = [], onClose, onNavigateWikilink }: AIChatPanelProps) { const [input, setInput] = useState('') const [showSearch, setShowSearch] = useState(false) const messagesEndRef = useRef(null) const ctx = useContextNotes(entry) const chat = useAIChat(ctx.contextNotes) const contextInfo = useMemo( () => buildSystemPrompt(ctx.contextNotes), [ctx.contextNotes], ) useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [chat.messages, chat.isStreaming, chat.streamingContent]) const handleSend = () => { chat.sendMessage(input); setInput('') } const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() } } return ( ) } // --- Extracted layout sections --- function PanelHeader({ onClear, onClose }: { onClear: () => void; onClose: () => void }) { return (
AI Chat
) } function ContextBar({ notes, entries, contextPaths, tokenCount, truncated, showSearch, onToggleSearch, onAdd, onRemove, onCloseSearch, }: { notes: VaultEntry[]; entries: VaultEntry[]; contextPaths: Set tokenCount: number; truncated: boolean showSearch: boolean; onToggleSearch: () => void onAdd: (note: VaultEntry) => void; onRemove: (path: string) => void; onCloseSearch: () => void }) { return (
{notes.map(note => ( onRemove(note.path)} /> ))} {notes.length > 0 && ( ~{formatTokens(tokenCount)} tokens{truncated && ' (truncated)'} )} {showSearch && ( )}
) } function MessageList({ messages, isStreaming, streamingContent, onRetry, messagesEndRef, onNavigateWikilink, }: { messages: ChatMessage[]; isStreaming: boolean; streamingContent: string onRetry: (idx: number) => void; messagesEndRef: React.RefObject onNavigateWikilink?: (target: string) => void }) { return (
{messages.length === 0 && !isStreaming && (

Ask anything about your notes

Powered by Claude CLI

)} {messages.map((msg, idx) => (
{msg.role === 'user' ? : onRetry(idx)} onNavigateWikilink={onNavigateWikilink} />}
))} {isStreaming && streamingContent && } {isStreaming && !streamingContent && }
) } function QuickActionsBar({ actions, disabled, onAction, }: { actions: { label: string; message: string }[]; disabled: boolean; onAction: (msg: string) => void }) { return (
{actions.map(a => ( ))}
) } function InputArea({ input, onInputChange, onKeyDown, onSend, disabled, }: { input: string; onInputChange: (v: string) => void onKeyDown: (e: React.KeyboardEvent) => void; onSend: () => void; disabled: boolean }) { return (