refactor: remove Anthropic API integration, CLI agent only (ADR-0028)
Remove AIChatPanel, useAIChat hook, Rust ai_chat command, and anthropic_key from settings. AI is now exclusively via Claude CLI subprocess (AiPanel). Simplifies codebase and eliminates API key management for users. ADR-0027 superseded. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -67,7 +67,7 @@ const mockCommandResults: Record<string, unknown> = {
|
||||
get_modified_files: [],
|
||||
get_note_content: mockAllContent['/vault/project/test.md'] || '',
|
||||
get_file_history: [],
|
||||
get_settings: { anthropic_key: null, openai_key: null, google_key: null, github_token: null, github_username: null, auto_pull_interval_minutes: null, telemetry_consent: true, crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, update_channel: null },
|
||||
get_settings: { openai_key: null, google_key: null, github_token: null, github_username: null, auto_pull_interval_minutes: null, telemetry_consent: true, crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, update_channel: null },
|
||||
git_pull: { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] },
|
||||
save_settings: null,
|
||||
check_vault_exists: true,
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
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 (
|
||||
<div className="flex items-start gap-2" style={{ padding: '8px 12px' }}>
|
||||
<div style={{ display: 'flex', gap: 4, padding: '10px 0' }}>
|
||||
<span className="typing-dot" />
|
||||
<span className="typing-dot" style={{ animationDelay: '0.2s' }} />
|
||||
<span className="typing-dot" style={{ animationDelay: '0.4s' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextPill({ note, onRemove }: { note: VaultEntry; onRemove: () => void }) {
|
||||
return (
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
style={{
|
||||
background: 'var(--accent-green-light)',
|
||||
borderRadius: 99, fontSize: 11,
|
||||
padding: '2px 6px 2px 8px', color: 'var(--foreground)', maxWidth: 160,
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{note.title}</span>
|
||||
<button
|
||||
className="flex items-center justify-center shrink-0 border-none bg-transparent p-0 cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
onClick={onRemove} title={`Remove ${note.title}`}
|
||||
>
|
||||
<Minus size={10} weight="bold" />
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextSearchDropdown({
|
||||
entries, contextPaths, onAdd, onClose,
|
||||
}: {
|
||||
entries: VaultEntry[]; contextPaths: Set<string>
|
||||
onAdd: (entry: VaultEntry) => void; onClose: () => void
|
||||
}) {
|
||||
const [query, setQuery] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(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 (
|
||||
<div className="absolute left-0 right-0 bg-background border border-border rounded shadow-lg z-10"
|
||||
style={{ top: '100%', maxHeight: 240, overflow: 'hidden' }}>
|
||||
<div className="flex items-center gap-1 border-b border-border" style={{ padding: '4px 8px' }}>
|
||||
<MagnifyingGlass size={12} className="text-muted-foreground shrink-0" />
|
||||
<input ref={inputRef} value={query} onChange={e => 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' }} />
|
||||
</div>
|
||||
<div style={{ overflow: 'auto', maxHeight: 200 }}>
|
||||
{filtered.map(entry => (
|
||||
<button key={entry.path}
|
||||
className="flex items-center gap-2 w-full text-left border-none bg-transparent cursor-pointer hover:bg-accent text-foreground"
|
||||
style={{ padding: '6px 10px', fontSize: 12 }}
|
||||
onClick={() => { onAdd(entry); onClose() }}>
|
||||
<span className="text-muted-foreground" style={{ fontSize: 10, minWidth: 60 }}>{entry.isA ?? 'Note'}</span>
|
||||
<span className="truncate">{entry.title}</span>
|
||||
</button>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-muted-foreground text-center" style={{ padding: 12, fontSize: 12 }}>No matching notes</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantMessage({ msg, onRetry, onNavigateWikilink }: { msg: ChatMessage; onRetry: () => void; onNavigateWikilink?: (target: string) => void }) {
|
||||
return (
|
||||
<div>
|
||||
<MarkdownContent content={msg.content} onWikilinkClick={onNavigateWikilink} />
|
||||
<div className="flex items-center gap-3" style={{ marginTop: 4 }}>
|
||||
<button className="border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:underline"
|
||||
style={{ fontSize: 11 }} onClick={() => navigator.clipboard.writeText(msg.content)}>
|
||||
<Copy size={12} style={{ marginRight: 3, verticalAlign: 'middle' }} />Copy
|
||||
</button>
|
||||
<button className="border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:underline"
|
||||
style={{ fontSize: 11 }} onClick={onRetry}>
|
||||
<ArrowClockwise size={12} style={{ marginRight: 3, verticalAlign: 'middle' }} />Retry
|
||||
</button>
|
||||
<button className="border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:underline"
|
||||
style={{ fontSize: 11 }}>
|
||||
<TextIndent size={12} style={{ marginRight: 3, verticalAlign: 'middle' }} />Insert
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StreamingContent({ content, onNavigateWikilink }: { content: string; onNavigateWikilink?: (target: string) => void }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<MarkdownContent content={content} onWikilinkClick={onNavigateWikilink} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UserBubble({ content }: { content: string }) {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div style={{
|
||||
background: 'var(--primary)', color: 'white',
|
||||
borderRadius: '12px 12px 2px 12px', maxWidth: '85%',
|
||||
padding: '8px 12px', fontSize: 13, lineHeight: 1.5, whiteSpace: 'pre-wrap',
|
||||
}}>{content}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<VaultEntry[]>([])
|
||||
|
||||
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<HTMLDivElement>(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 (
|
||||
<aside className="flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground">
|
||||
<PanelHeader onClear={chat.clearConversation} onClose={onClose} />
|
||||
|
||||
<ContextBar
|
||||
notes={ctx.contextNotes} entries={entries} contextPaths={ctx.paths}
|
||||
tokenCount={contextInfo.totalTokens} truncated={contextInfo.truncated}
|
||||
showSearch={showSearch} onToggleSearch={() => setShowSearch(!showSearch)}
|
||||
onAdd={ctx.addNote} onRemove={ctx.removeNote} onCloseSearch={() => setShowSearch(false)}
|
||||
/>
|
||||
|
||||
<MessageList
|
||||
messages={chat.messages} isStreaming={chat.isStreaming}
|
||||
streamingContent={chat.streamingContent} onRetry={chat.retryMessage}
|
||||
messagesEndRef={messagesEndRef} onNavigateWikilink={onNavigateWikilink}
|
||||
/>
|
||||
|
||||
<QuickActionsBar actions={QUICK_ACTIONS} disabled={chat.isStreaming}
|
||||
onAction={msg => { chat.sendMessage(msg); setInput('') }} />
|
||||
|
||||
<InputArea input={input} onInputChange={setInput}
|
||||
onKeyDown={handleKeyDown} onSend={handleSend} disabled={chat.isStreaming || !input.trim()} />
|
||||
|
||||
<style>{`
|
||||
.typing-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--muted-foreground);
|
||||
animation: typing-bounce 1.2s infinite ease-in-out;
|
||||
}
|
||||
@keyframes typing-bounce {
|
||||
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
40% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
`}</style>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Extracted layout sections ---
|
||||
|
||||
function PanelHeader({ onClear, onClose }: { onClear: () => void; onClose: () => void }) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center border-b border-border" style={{ height: 45, padding: '0 12px', gap: 8 }}>
|
||||
<Sparkle size={16} className="shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>AI Chat</span>
|
||||
<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"><Plus size={16} /></button>
|
||||
<button className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
onClick={onClose} title="Close AI Chat"><X size={16} /></button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextBar({
|
||||
notes, entries, contextPaths, tokenCount, truncated,
|
||||
showSearch, onToggleSearch, onAdd, onRemove, onCloseSearch,
|
||||
}: {
|
||||
notes: VaultEntry[]; entries: VaultEntry[]; contextPaths: Set<string>
|
||||
tokenCount: number; truncated: boolean
|
||||
showSearch: boolean; onToggleSearch: () => void
|
||||
onAdd: (note: VaultEntry) => void; onRemove: (path: string) => void; onCloseSearch: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="relative flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border" style={{ padding: '6px 12px' }}>
|
||||
{notes.map(note => (
|
||||
<ContextPill key={note.path} note={note} onRemove={() => onRemove(note.path)} />
|
||||
))}
|
||||
<button
|
||||
className="flex items-center gap-0.5 border border-dashed border-border bg-transparent text-muted-foreground cursor-pointer hover:text-foreground rounded-full"
|
||||
style={{ fontSize: 11, padding: '2px 8px' }} onClick={onToggleSearch} title="Add note to context">
|
||||
<Plus size={10} weight="bold" /><span>Add</span>
|
||||
</button>
|
||||
{notes.length > 0 && (
|
||||
<span className="text-muted-foreground ml-auto" style={{ fontSize: 10 }}>
|
||||
~{formatTokens(tokenCount)} tokens{truncated && ' (truncated)'}
|
||||
</span>
|
||||
)}
|
||||
{showSearch && (
|
||||
<ContextSearchDropdown entries={entries} contextPaths={contextPaths} onAdd={onAdd} onClose={onCloseSearch} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MessageList({
|
||||
messages, isStreaming, streamingContent, onRetry, messagesEndRef, onNavigateWikilink,
|
||||
}: {
|
||||
messages: ChatMessage[]; isStreaming: boolean; streamingContent: string
|
||||
onRetry: (idx: number) => void; messagesEndRef: React.RefObject<HTMLDivElement | null>
|
||||
onNavigateWikilink?: (target: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto" style={{ padding: 12 }}>
|
||||
{messages.length === 0 && !isStreaming && (
|
||||
<div className="flex flex-col items-center justify-center text-center text-muted-foreground" style={{ paddingTop: 40 }}>
|
||||
<Sparkle size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
|
||||
<p style={{ fontSize: 13, margin: '0 0 4px' }}>Ask anything about your notes</p>
|
||||
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>Powered by Claude CLI</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, idx) => (
|
||||
<div key={msg.id} style={{ marginBottom: 12 }}>
|
||||
{msg.role === 'user'
|
||||
? <UserBubble content={msg.content} />
|
||||
: <AssistantMessage msg={msg} onRetry={() => onRetry(idx)} onNavigateWikilink={onNavigateWikilink} />}
|
||||
</div>
|
||||
))}
|
||||
{isStreaming && streamingContent && <StreamingContent content={streamingContent} onNavigateWikilink={onNavigateWikilink} />}
|
||||
{isStreaming && !streamingContent && <TypingIndicator />}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function QuickActionsBar({
|
||||
actions, disabled, onAction,
|
||||
}: {
|
||||
actions: { label: string; message: string }[]; disabled: boolean; onAction: (msg: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-t border-border" style={{ padding: '8px 12px' }}>
|
||||
{actions.map(a => (
|
||||
<button key={a.label}
|
||||
className="cursor-pointer bg-transparent text-foreground hover:bg-accent transition-colors"
|
||||
style={{ fontSize: 11, border: '1px solid var(--border)', borderRadius: 99, padding: '3px 10px' }}
|
||||
onClick={() => onAction(a.message)} disabled={disabled}>
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InputArea({
|
||||
input, onInputChange, onKeyDown, onSend, disabled,
|
||||
}: {
|
||||
input: string; onInputChange: (v: string) => void
|
||||
onKeyDown: (e: React.KeyboardEvent) => void; onSend: () => void; disabled: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex shrink-0 flex-col border-t border-border" style={{ padding: '8px 12px' }}>
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea value={input} onChange={e => onInputChange(e.target.value)} onKeyDown={onKeyDown}
|
||||
placeholder="Ask about your notes..." rows={1}
|
||||
className="flex-1 resize-none border border-border bg-transparent text-foreground"
|
||||
style={{ fontSize: 13, borderRadius: 8, padding: '8px 10px', outline: 'none', lineHeight: 1.4, maxHeight: 100, fontFamily: 'inherit' }} />
|
||||
<button className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
|
||||
style={{ background: 'var(--primary)', color: 'white', borderRadius: 8, width: 32, height: 34 }}
|
||||
onClick={onSend} disabled={disabled} title="Send message">
|
||||
<PaperPlaneRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -18,7 +18,7 @@ vi.mock('../utils/url', () => ({
|
||||
}))
|
||||
|
||||
const emptySettings: Settings = {
|
||||
anthropic_key: null,
|
||||
|
||||
openai_key: null,
|
||||
google_key: null,
|
||||
github_token: null,
|
||||
@@ -32,7 +32,6 @@ const emptySettings: Settings = {
|
||||
}
|
||||
|
||||
const populatedSettings: Settings = {
|
||||
anthropic_key: 'sk-ant-api03-test123',
|
||||
openai_key: 'sk-openai-test456',
|
||||
google_key: null,
|
||||
github_token: null,
|
||||
@@ -98,7 +97,7 @@ describe('SettingsPanel', () => {
|
||||
fireEvent.click(screen.getByTestId('settings-save'))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
anthropic_key: null,
|
||||
|
||||
openai_key: 'sk-openai-test',
|
||||
google_key: null,
|
||||
github_token: null,
|
||||
@@ -119,7 +118,7 @@ describe('SettingsPanel', () => {
|
||||
fireEvent.click(screen.getByTestId('settings-save'))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
anthropic_key: null,
|
||||
|
||||
openai_key: null,
|
||||
google_key: null,
|
||||
github_token: null,
|
||||
@@ -161,7 +160,7 @@ describe('SettingsPanel', () => {
|
||||
fireEvent.keyDown(screen.getByTestId('settings-panel'), { key: 'Enter', metaKey: true })
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
anthropic_key: null,
|
||||
|
||||
openai_key: 'sk-openai-test',
|
||||
google_key: null,
|
||||
github_token: null,
|
||||
|
||||
@@ -139,7 +139,6 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
|
||||
}, [])
|
||||
|
||||
const buildSettings = useCallback((ghOverride?: { token: string | null; username: string | null }): Settings => ({
|
||||
anthropic_key: null,
|
||||
openai_key: openaiKey.trim() || null,
|
||||
google_key: googleKey.trim() || null,
|
||||
github_token: ghOverride ? ghOverride.token : (githubToken ?? null),
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
|
||||
// Capture what streamClaudeChat receives
|
||||
const streamClaudeChatMock = vi.fn<
|
||||
Parameters<typeof import('../utils/ai-chat').streamClaudeChat>,
|
||||
ReturnType<typeof import('../utils/ai-chat').streamClaudeChat>
|
||||
>()
|
||||
|
||||
vi.mock('../utils/ai-chat', async () => {
|
||||
const actual = await vi.importActual<typeof import('../utils/ai-chat')>('../utils/ai-chat')
|
||||
return {
|
||||
...actual,
|
||||
streamClaudeChat: (...args: Parameters<typeof actual.streamClaudeChat>) => {
|
||||
streamClaudeChatMock(...args)
|
||||
// Simulate async: emit text, then done
|
||||
const callbacks = args[3]
|
||||
setTimeout(() => {
|
||||
callbacks.onText('mock response')
|
||||
callbacks.onDone()
|
||||
}, 10)
|
||||
return Promise.resolve('')
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import { useAIChat } from './useAIChat'
|
||||
|
||||
beforeEach(() => {
|
||||
streamClaudeChatMock.mockClear()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('useAIChat', () => {
|
||||
it('sends first message as raw text without history', async () => {
|
||||
const { result } = renderHook(() => useAIChat([]))
|
||||
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
|
||||
expect(streamClaudeChatMock).toHaveBeenCalledTimes(1)
|
||||
const [message, , sessionId] = streamClaudeChatMock.mock.calls[0]
|
||||
// First message: raw text, no history wrapping, no session_id
|
||||
expect(message).toBe('hello')
|
||||
expect(sessionId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('embeds conversation history in second message', async () => {
|
||||
const { result } = renderHook(() => useAIChat([]))
|
||||
|
||||
// First exchange
|
||||
act(() => { result.current.sendMessage('What is 2+2?') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Second message — should include history from first exchange
|
||||
act(() => { result.current.sendMessage('What is that times 3?') })
|
||||
|
||||
expect(streamClaudeChatMock).toHaveBeenCalledTimes(2)
|
||||
const [message] = streamClaudeChatMock.mock.calls[1]
|
||||
expect(message).toContain('<conversation_history>')
|
||||
expect(message).toContain('What is 2+2?')
|
||||
expect(message).toContain('mock response')
|
||||
expect(message).toContain('What is that times 3?')
|
||||
})
|
||||
|
||||
it('accumulates history across multiple exchanges', async () => {
|
||||
const { result } = renderHook(() => useAIChat([]))
|
||||
|
||||
// Exchange 1
|
||||
act(() => { result.current.sendMessage('Q1') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Exchange 2
|
||||
act(() => { result.current.sendMessage('Q2') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Exchange 3
|
||||
act(() => { result.current.sendMessage('Q3') })
|
||||
|
||||
expect(streamClaudeChatMock).toHaveBeenCalledTimes(3)
|
||||
|
||||
// First call: no history
|
||||
expect(streamClaudeChatMock.mock.calls[0][0]).toBe('Q1')
|
||||
// Second call: history from first exchange
|
||||
const secondMsg = streamClaudeChatMock.mock.calls[1][0]
|
||||
expect(secondMsg).toContain('Q1')
|
||||
expect(secondMsg).toContain('Q2')
|
||||
// Third call: history from both exchanges
|
||||
const thirdMsg = streamClaudeChatMock.mock.calls[2][0]
|
||||
expect(thirdMsg).toContain('Q1')
|
||||
expect(thirdMsg).toContain('Q2')
|
||||
expect(thirdMsg).toContain('Q3')
|
||||
})
|
||||
|
||||
it('never passes session_id (no --resume)', async () => {
|
||||
const { result } = renderHook(() => useAIChat([]))
|
||||
|
||||
act(() => { result.current.sendMessage('Q1') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
act(() => { result.current.sendMessage('Q2') })
|
||||
|
||||
// All calls should have undefined session_id
|
||||
for (const call of streamClaudeChatMock.mock.calls) {
|
||||
expect(call[2]).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('resets history after clearConversation', async () => {
|
||||
const { result } = renderHook(() => useAIChat([]))
|
||||
|
||||
// Build up some history
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Clear
|
||||
act(() => { result.current.clearConversation() })
|
||||
expect(result.current.messages).toHaveLength(0)
|
||||
|
||||
// Next message should have no history
|
||||
act(() => { result.current.sendMessage('fresh start') })
|
||||
|
||||
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
|
||||
expect(lastCall[0]).toBe('fresh start') // raw text, no history wrapping
|
||||
expect(lastCall[2]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes system prompt on every message when context notes exist', async () => {
|
||||
const notes = [{ path: 'note.md', title: 'Test Note' }] as import('../types').VaultEntry[]
|
||||
|
||||
const { result } = renderHook(() => useAIChat(notes))
|
||||
|
||||
// First message
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Second message
|
||||
act(() => { result.current.sendMessage('follow up') })
|
||||
|
||||
// Both calls should have system prompt
|
||||
const firstSystemPrompt = streamClaudeChatMock.mock.calls[0][1]
|
||||
expect(firstSystemPrompt).toBeTruthy()
|
||||
expect(firstSystemPrompt).toContain('Test Note')
|
||||
|
||||
const secondSystemPrompt = streamClaudeChatMock.mock.calls[1][1]
|
||||
expect(secondSystemPrompt).toBeTruthy()
|
||||
expect(secondSystemPrompt).toContain('Test Note')
|
||||
})
|
||||
|
||||
it('retries with correct history (excludes retried exchange)', async () => {
|
||||
const { result } = renderHook(() => useAIChat([]))
|
||||
|
||||
// First exchange
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
expect(result.current.messages).toHaveLength(2)
|
||||
|
||||
// Retry the assistant response (index 1)
|
||||
act(() => { result.current.retryMessage(1) })
|
||||
|
||||
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
|
||||
// Should re-send the user message with no history (retrying first exchange)
|
||||
expect(lastCall[0]).toBe('hello')
|
||||
expect(lastCall[2]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads latest messages from ref (not stale closure)', async () => {
|
||||
const { result } = renderHook(() => useAIChat([]))
|
||||
|
||||
// Send first message
|
||||
act(() => { result.current.sendMessage('msg1') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Verify messages state is correct
|
||||
expect(result.current.messages).toHaveLength(2)
|
||||
expect(result.current.messages[0].content).toBe('msg1')
|
||||
expect(result.current.messages[1].content).toBe('mock response')
|
||||
|
||||
// Send second message — the ref should have the latest messages
|
||||
// even without depending on messages in useCallback deps
|
||||
act(() => { result.current.sendMessage('msg2') })
|
||||
|
||||
const secondCall = streamClaudeChatMock.mock.calls[1]
|
||||
const sentMessage = secondCall[0]
|
||||
// Must contain history from first exchange
|
||||
expect(sentMessage).toContain('[user]: msg1')
|
||||
expect(sentMessage).toContain('[assistant]: mock response')
|
||||
expect(sentMessage).toContain('[user]: msg2')
|
||||
})
|
||||
})
|
||||
@@ -1,120 +0,0 @@
|
||||
/**
|
||||
* Custom hook encapsulating AI chat state and message handling.
|
||||
* Uses Claude CLI subprocess via Tauri for streaming responses.
|
||||
*
|
||||
* Conversation continuity embeds prior exchanges in each prompt
|
||||
* (each CLI invocation is a fresh subprocess with no memory).
|
||||
* History is trimmed to MAX_HISTORY_TOKENS, dropping oldest first.
|
||||
*
|
||||
* Uses a ref (messagesRef) to read the latest messages in callbacks,
|
||||
* avoiding stale closure issues with React's useCallback memoization.
|
||||
*/
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import {
|
||||
type ChatMessage, type ChatStreamCallbacks, nextMessageId,
|
||||
buildSystemPrompt, streamClaudeChat,
|
||||
trimHistory, formatMessageWithHistory, MAX_HISTORY_TOKENS,
|
||||
} from '../utils/ai-chat'
|
||||
|
||||
interface ChatStreamRefs {
|
||||
abortRef: React.RefObject<boolean>
|
||||
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>
|
||||
setStreamingContent: React.Dispatch<React.SetStateAction<string>>
|
||||
setIsStreaming: React.Dispatch<React.SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
/** Create stream callbacks that accumulate text and update React state. */
|
||||
function makeStreamCallbacks(
|
||||
refs: ChatStreamRefs,
|
||||
): { callbacks: ChatStreamCallbacks; getAccumulated: () => string } {
|
||||
let accumulated = ''
|
||||
const callbacks: ChatStreamCallbacks = {
|
||||
onText: (chunk) => {
|
||||
if (refs.abortRef.current) return
|
||||
accumulated += chunk
|
||||
refs.setStreamingContent(accumulated)
|
||||
},
|
||||
onError: (error) => {
|
||||
if (refs.abortRef.current) return
|
||||
refs.setMessages(prev => [...prev, { role: 'assistant', content: `Error: ${error}`, id: nextMessageId() }])
|
||||
refs.setStreamingContent('')
|
||||
refs.setIsStreaming(false)
|
||||
},
|
||||
onDone: () => {
|
||||
if (refs.abortRef.current) return
|
||||
if (accumulated) {
|
||||
refs.setMessages(prev => [...prev, { role: 'assistant', content: accumulated, id: nextMessageId() }])
|
||||
}
|
||||
refs.setStreamingContent('')
|
||||
refs.setIsStreaming(false)
|
||||
},
|
||||
}
|
||||
return { callbacks, getAccumulated: () => accumulated }
|
||||
}
|
||||
|
||||
export function useAIChat(
|
||||
contextNotes: VaultEntry[],
|
||||
) {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
const [streamingContent, setStreamingContent] = useState('')
|
||||
const abortRef = useRef(false)
|
||||
const isStreamingRef = useRef(false)
|
||||
const messagesRef = useRef<ChatMessage[]>([])
|
||||
|
||||
// Keep refs in sync with state — runs after render, before next user interaction.
|
||||
useEffect(() => { messagesRef.current = messages }, [messages])
|
||||
useEffect(() => { isStreamingRef.current = isStreaming }, [isStreaming])
|
||||
|
||||
/** Internal: send text, reading history from the messages ref. */
|
||||
const doSend = useCallback((text: string, historyOverride?: ChatMessage[]) => {
|
||||
if (!text.trim() || isStreamingRef.current) return
|
||||
|
||||
const history = historyOverride ?? messagesRef.current
|
||||
|
||||
setMessages(prev => [...prev, { role: 'user', content: text.trim(), id: nextMessageId() }])
|
||||
setIsStreaming(true)
|
||||
setStreamingContent('')
|
||||
abortRef.current = false
|
||||
|
||||
// Always include system prompt (each request is a fresh subprocess).
|
||||
const systemPrompt = buildSystemPrompt(contextNotes).prompt || undefined
|
||||
|
||||
// Embed conversation history in the prompt for continuity.
|
||||
const trimmedHistory = trimHistory(history, MAX_HISTORY_TOKENS)
|
||||
const formattedMessage = formatMessageWithHistory(trimmedHistory, text.trim())
|
||||
|
||||
const { callbacks } = makeStreamCallbacks({
|
||||
abortRef, setMessages, setStreamingContent, setIsStreaming,
|
||||
})
|
||||
|
||||
streamClaudeChat(formattedMessage, systemPrompt, undefined, callbacks)
|
||||
.catch(() => { /* errors forwarded via onError */ })
|
||||
}, [contextNotes])
|
||||
|
||||
const sendMessage = useCallback((text: string) => {
|
||||
doSend(text)
|
||||
}, [doSend])
|
||||
|
||||
const clearConversation = useCallback(() => {
|
||||
abortRef.current = true
|
||||
setMessages([])
|
||||
setIsStreaming(false)
|
||||
setStreamingContent('')
|
||||
}, [])
|
||||
|
||||
const retryMessage = useCallback((msgIndex: number) => {
|
||||
const currentMessages = messagesRef.current
|
||||
const userMsgIndex = msgIndex - 1
|
||||
if (userMsgIndex < 0) return
|
||||
const userMsg = currentMessages[userMsgIndex]
|
||||
if (userMsg.role !== 'user') return
|
||||
|
||||
const historyForRetry = currentMessages.slice(0, userMsgIndex)
|
||||
setMessages(prev => prev.slice(0, userMsgIndex))
|
||||
doSend(userMsg.content, historyForRetry)
|
||||
}, [doSend])
|
||||
|
||||
return { messages, isStreaming, streamingContent, sendMessage, clearConversation, retryMessage }
|
||||
}
|
||||
@@ -144,7 +144,7 @@ export function buildViewCommands(
|
||||
{ id: 'toggle-inspector', label: 'Toggle Properties Panel', group: 'View', keywords: ['properties', 'inspector', 'panel', 'right', 'sidebar'], enabled: true, execute: onToggleInspector },
|
||||
{ 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, execute: () => onToggleRawEditor?.() },
|
||||
{ id: 'toggle-ai-chat', label: 'Toggle AI Chat', group: 'View', shortcut: '⌘I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
|
||||
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: '⌘I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
|
||||
{ id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector },
|
||||
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
|
||||
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
|
||||
|
||||
@@ -193,7 +193,6 @@ describe('dispatchMenuEvent', () => {
|
||||
expect(h.onToggleDiff).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// New View menu items
|
||||
it('view-toggle-ai-chat triggers toggle AI chat', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('view-toggle-ai-chat', h)
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Settings } from '../types'
|
||||
import { useSettings } from './useSettings'
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
anthropic_key: null,
|
||||
|
||||
openai_key: null,
|
||||
google_key: null,
|
||||
github_token: null,
|
||||
@@ -18,7 +18,6 @@ const defaultSettings: Settings = {
|
||||
}
|
||||
|
||||
const savedSettings: Settings = {
|
||||
anthropic_key: 'sk-ant-test123',
|
||||
openai_key: null,
|
||||
google_key: 'AIza-test',
|
||||
github_token: null,
|
||||
@@ -71,7 +70,6 @@ describe('useSettings', () => {
|
||||
expect(result.current.loaded).toBe(true)
|
||||
})
|
||||
|
||||
expect(result.current.settings.anthropic_key).toBe('sk-ant-test123')
|
||||
expect(result.current.settings.google_key).toBe('AIza-test')
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('get_settings', {})
|
||||
})
|
||||
@@ -84,7 +82,6 @@ describe('useSettings', () => {
|
||||
})
|
||||
|
||||
const newSettings: Settings = {
|
||||
anthropic_key: 'sk-ant-new',
|
||||
openai_key: 'sk-openai-new',
|
||||
google_key: null,
|
||||
github_token: null,
|
||||
|
||||
@@ -8,7 +8,6 @@ function tauriCall<T>(command: string, tauriArgs: Record<string, unknown>, mockA
|
||||
}
|
||||
|
||||
const EMPTY_SETTINGS: Settings = {
|
||||
anthropic_key: null,
|
||||
openai_key: null,
|
||||
google_key: null,
|
||||
github_token: null,
|
||||
|
||||
@@ -16,7 +16,7 @@ vi.mock('../lib/telemetry', () => ({
|
||||
}))
|
||||
|
||||
const baseSettings: Settings = {
|
||||
anthropic_key: null, openai_key: null, google_key: null,
|
||||
openai_key: null, google_key: null,
|
||||
github_token: null, github_username: null, auto_pull_interval_minutes: null,
|
||||
telemetry_consent: null, crash_reporting_enabled: null,
|
||||
analytics_enabled: null, anonymous_id: null, update_channel: null,
|
||||
|
||||
@@ -75,7 +75,6 @@ let mockHasChanges = true
|
||||
const mockSavedSinceCommit = new Set<string>()
|
||||
|
||||
let mockSettings: Settings = {
|
||||
anthropic_key: null,
|
||||
openai_key: null,
|
||||
google_key: null,
|
||||
github_token: null,
|
||||
@@ -197,7 +196,6 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
save_settings: (args: { settings: Settings }) => {
|
||||
const s = args.settings
|
||||
mockSettings = {
|
||||
anthropic_key: trimOrNull(s.anthropic_key),
|
||||
openai_key: trimOrNull(s.openai_key),
|
||||
google_key: trimOrNull(s.google_key),
|
||||
github_token: trimOrNull(s.github_token),
|
||||
|
||||
@@ -63,7 +63,6 @@ export interface ModifiedFile {
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
anthropic_key: string | null
|
||||
openai_key: string | null
|
||||
google_key: string | null
|
||||
github_token: string | null
|
||||
|
||||
Reference in New Issue
Block a user