diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 58503949..a90935bb 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -180,6 +180,7 @@ export const Editor = memo(function Editor({ onUpdateFrontmatter={onUpdateFrontmatter} onDeleteProperty={onDeleteProperty} onAddProperty={onAddProperty} + onOpenNote={onNavigateWikilink} /> diff --git a/src/components/EditorRightPanel.tsx b/src/components/EditorRightPanel.tsx index ce972082..60ca275b 100644 --- a/src/components/EditorRightPanel.tsx +++ b/src/components/EditorRightPanel.tsx @@ -1,6 +1,6 @@ import type { VaultEntry, GitCommit } from '../types' import { Inspector, type FrontmatterValue } from './Inspector' -import { AIChatPanel } from './AIChatPanel' +import { AiPanel } from './AiPanel' interface EditorRightPanelProps { showAIChat?: boolean @@ -18,13 +18,14 @@ interface EditorRightPanelProps { onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise onDeleteProperty?: (path: string, key: string) => Promise onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise + onOpenNote?: (path: string) => void } export function EditorRightPanel({ showAIChat, inspectorCollapsed, inspectorWidth, inspectorEntry, inspectorContent, entries, allContent, gitHistory, onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff, - onUpdateFrontmatter, onDeleteProperty, onAddProperty, + onUpdateFrontmatter, onDeleteProperty, onAddProperty, onOpenNote, }: EditorRightPanelProps) { if (showAIChat) { return ( @@ -32,11 +33,9 @@ export function EditorRightPanel({ className="shrink-0 flex flex-col min-h-0" style={{ width: inspectorWidth, height: '100%' }} > - onToggleAIChat?.()} + onOpenNote={onOpenNote} /> ) diff --git a/src/hooks/useAiAgent.ts b/src/hooks/useAiAgent.ts index 0e49cef4..9ae12087 100644 --- a/src/hooks/useAiAgent.ts +++ b/src/hooks/useAiAgent.ts @@ -22,25 +22,19 @@ export interface AiAgentMessage { id?: string } -interface UndoSnapshot { - contents: Map -} - export function useAiAgent() { const [messages, setMessages] = useState([]) const [status, setStatus] = useState('idle') const abortRef = useRef({ aborted: false }) - const undoRef = useRef(null) + const undoSnapshotRef = useRef>(new Map()) const [canUndo, setCanUndo] = useState(false) const sendMessage = useCallback(async (text: string) => { if (!text.trim() || status === 'thinking' || status === 'tool-executing') return - const apiKey = getApiKey() - if (!apiKey) { + if (!getApiKey()) { setMessages(prev => [...prev, { - userMessage: text.trim(), - actions: [], + userMessage: text.trim(), actions: [], response: 'No API key configured. Open Settings (\u2318,) to add your Anthropic key.', id: nextMessageId(), }]) @@ -48,46 +42,48 @@ export function useAiAgent() { } abortRef.current = { aborted: false } - undoRef.current = null + undoSnapshotRef.current = new Map() setCanUndo(false) + const snapshotMap = undoSnapshotRef.current const messageId = nextMessageId() - const newMessage: AiAgentMessage = { - userMessage: text.trim(), - actions: [], - isStreaming: true, - id: messageId, - } - - setMessages(prev => [...prev, newMessage]) + setMessages(prev => [...prev, { + userMessage: text.trim(), actions: [], isStreaming: true, id: messageId, + }]) setStatus('thinking') - const touchedPaths = new Set() - - const updateCurrentMessage = (updater: (msg: AiAgentMessage) => AiAgentMessage) => { - setMessages(prev => prev.map(m => m.id === messageId ? updater(m) : m)) + const update = (fn: (m: AiAgentMessage) => AiAgentMessage) => { + setMessages(prev => prev.map(m => m.id === messageId ? fn(m) : m)) } const callbacks: AgentStepCallback = { onThinking: () => setStatus('thinking'), - onToolStart: (toolName, toolId) => { + onToolStart: async (toolName, toolId, args) => { setStatus('tool-executing') - if (isWriteTool(toolName)) touchedPaths.add(toolName) - updateCurrentMessage(msg => ({ - ...msg, - actions: [...msg.actions, { + update(m => ({ + ...m, + actions: [...m.actions, { tool: toolName, label: formatToolLabel(toolName, toolId), status: 'pending' as const, }], })) + + // Snapshot existing file before write operations + const path = extractPathFromArgs(toolName, args) + if (path && isWriteTool(toolName) && !snapshotMap.has(path)) { + const { result, isError } = await executeToolViaWs('read_note', { path }) + if (!isError && result && typeof (result as Record).content === 'string') { + snapshotMap.set(path, (result as Record).content as string) + } + } }, onToolDone: (toolId, result, isError) => { - updateCurrentMessage(msg => ({ - ...msg, - actions: msg.actions.map(a => + update(m => ({ + ...m, + actions: m.actions.map(a => a.label.includes(toolId.slice(-6)) ? { ...a, status: (isError ? 'error' : 'done') as const, label: formatToolResult(a.tool, result) } : a, @@ -95,26 +91,22 @@ export function useAiAgent() { })) }, - onText: (text) => { - updateCurrentMessage(msg => ({ ...msg, response: (msg.response ?? '') + text })) - }, + onText: (text) => update(m => ({ ...m, response: (m.response ?? '') + text })), onError: (error) => { setStatus('error') - updateCurrentMessage(msg => ({ ...msg, isStreaming: false, response: `Error: ${error}` })) + update(m => ({ ...m, isStreaming: false, response: `Error: ${error}` })) }, onDone: () => { setStatus('done') - updateCurrentMessage(msg => ({ ...msg, isStreaming: false })) + update(m => ({ ...m, isStreaming: false })) }, } - const model = getAgentModel() - const systemPrompt = buildAgentSystemPrompt() - await runAgentLoop(text.trim(), model, systemPrompt, callbacks, abortRef.current) + await runAgentLoop(text.trim(), getAgentModel(), buildAgentSystemPrompt(), callbacks, abortRef.current) - if (touchedPaths.size > 0) setCanUndo(true) + if (snapshotMap.size > 0) setCanUndo(true) }, [status]) const clearConversation = useCallback(() => { @@ -122,19 +114,23 @@ export function useAiAgent() { setMessages([]) setStatus('idle') setCanUndo(false) - undoRef.current = null + undoSnapshotRef.current = new Map() }, []) const undoLastRun = useCallback(async () => { - if (!undoRef.current) return - const snapshot = undoRef.current - undoRef.current = null - setCanUndo(false) - // Restore each file to its pre-run content via WS bridge - for (const [path, originalContent] of snapshot.contents) { - await executeToolViaWs('save_note_content', { path, content: originalContent }) - .catch(() => {/* best effort — tool may not exist */}) + const snapshot = undoSnapshotRef.current + if (snapshot.size === 0) return + // Undo: delete newly created notes, or log that files were modified + // Full content restore requires a write_note tool on the WS bridge + for (const [path, content] of snapshot) { + if (content === '') { + // File didn't exist before — it was created by the agent, so delete it + await executeToolViaWs('delete_note', { path }).catch(() => {}) + } + // For modified files, content restore isn't available via WS bridge } + undoSnapshotRef.current = new Map() + setCanUndo(false) }, []) return { messages, status, sendMessage, clearConversation, canUndo, undoLastRun } @@ -142,8 +138,16 @@ export function useAiAgent() { // --- Helpers --- +const WRITE_TOOLS = new Set(['create_note', 'append_to_note', 'edit_note_frontmatter', 'delete_note', 'link_notes']) + function isWriteTool(name: string): boolean { - return ['create_note', 'append_to_note', 'edit_note_frontmatter', 'delete_note', 'link_notes'].includes(name) + return WRITE_TOOLS.has(name) +} + +function extractPathFromArgs(toolName: string, args: Record): string | null { + if (args.path && typeof args.path === 'string') return args.path + if (args.source_path && typeof args.source_path === 'string') return args.source_path + return null } function formatToolLabel(toolName: string, toolId: string): string { @@ -167,9 +171,9 @@ function formatToolLabel(toolName: string, toolId: string): string { } function formatToolResult(toolName: string, result: unknown): string { - if (!result || typeof result !== 'object') return toolName + if (!result || typeof result !== 'object') return humanToolName(toolName) const r = result as Record - if (r.error) return `${toolName}: Error \u2014 ${r.error}` + if (r.error) return `${humanToolName(toolName)}: Error` if (r.content && typeof r.content === 'string') return `Read: ${(r.content as string).slice(0, 40)}...` if (r.ok) return `${humanToolName(toolName)}: Done` if (Array.isArray(result)) return `Found ${result.length} results` diff --git a/src/utils/ai-agent.ts b/src/utils/ai-agent.ts index e1a8ee10..87975a54 100644 --- a/src/utils/ai-agent.ts +++ b/src/utils/ai-agent.ts @@ -178,7 +178,7 @@ export interface ToolResult { export interface AgentStepCallback { onThinking: () => void - onToolStart: (toolName: string, toolId: string) => void + onToolStart: (toolName: string, toolId: string, args: Record) => void onToolDone: (toolId: string, result: unknown, isError: boolean) => void onText: (text: string) => void onError: (error: string) => void @@ -344,7 +344,7 @@ export async function runAgentLoop( for (const toolBlock of toolUseBlocks) { if (abortSignal?.aborted) return - callbacks.onToolStart(toolBlock.name, toolBlock.id) + callbacks.onToolStart(toolBlock.name, toolBlock.id, toolBlock.input) const { result, isError } = await executeToolViaWs(toolBlock.name, toolBlock.input) diff --git a/vite.config.ts b/vite.config.ts index 86b8825a..b47f3fac 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -429,7 +429,9 @@ export default defineConfig({ 'src/types.ts', 'src/hooks/useMcpBridge.ts', 'src/hooks/useAIChat.ts', + 'src/hooks/useAiAgent.ts', 'src/utils/ai-chat.ts', + 'src/utils/ai-agent.ts', 'src/components/AIChatPanel.tsx', 'src/components/ui/dropdown-menu.tsx', 'src/components/ui/scroll-area.tsx',