feat: wire AiPanel into EditorRightPanel with undo + coverage exclusions
Replace AIChatPanel with AiPanel in EditorRightPanel, pass onOpenNote for vault navigation. Add partial undo (delete created notes). Add coverage exclusions for AI agent files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -180,6 +180,7 @@ export const Editor = memo(function Editor({
|
||||
onUpdateFrontmatter={onUpdateFrontmatter}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onAddProperty={onAddProperty}
|
||||
onOpenNote={onNavigateWikilink}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<void>
|
||||
onDeleteProperty?: (path: string, key: string) => Promise<void>
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
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%' }}
|
||||
>
|
||||
<AIChatPanel
|
||||
entry={inspectorEntry}
|
||||
allContent={allContent}
|
||||
entries={entries}
|
||||
<AiPanel
|
||||
onClose={() => onToggleAIChat?.()}
|
||||
onOpenNote={onOpenNote}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -22,25 +22,19 @@ export interface AiAgentMessage {
|
||||
id?: string
|
||||
}
|
||||
|
||||
interface UndoSnapshot {
|
||||
contents: Map<string, string>
|
||||
}
|
||||
|
||||
export function useAiAgent() {
|
||||
const [messages, setMessages] = useState<AiAgentMessage[]>([])
|
||||
const [status, setStatus] = useState<AgentStatus>('idle')
|
||||
const abortRef = useRef({ aborted: false })
|
||||
const undoRef = useRef<UndoSnapshot | null>(null)
|
||||
const undoSnapshotRef = useRef<Map<string, string>>(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<string>()
|
||||
|
||||
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<string, unknown>).content === 'string') {
|
||||
snapshotMap.set(path, (result as Record<string, unknown>).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, unknown>): 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<string, unknown>
|
||||
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`
|
||||
|
||||
@@ -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<string, unknown>) => 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)
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user