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:
Test
2026-02-28 22:21:42 +01:00
parent bcfbc8e00e
commit e29be460c3
5 changed files with 65 additions and 59 deletions

View File

@@ -180,6 +180,7 @@ export const Editor = memo(function Editor({
onUpdateFrontmatter={onUpdateFrontmatter}
onDeleteProperty={onDeleteProperty}
onAddProperty={onAddProperty}
onOpenNote={onNavigateWikilink}
/>
</div>
</div>

View File

@@ -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>
)

View File

@@ -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`

View File

@@ -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)

View File

@@ -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',