Files
tolaria/src/hooks/useAiAgent.ts
Test ceee8b04ea feat: make AI chat tool use blocks expandable with input/output details
Tool call blocks in AI Chat are now clickable and expandable to show
tool name, input parameters (pretty-printed JSON), and output/result.
Collapsed by default, keyboard accessible (Tab/Enter/Space/Esc).

Backend: Rust stream events now carry tool input (accumulated from
input_json_delta chunks) and tool output (from tool_result events).
Frontend: AiActionCard is a disclosure widget with aria-expanded,
error output shown in red, long content truncated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:08:48 +01:00

158 lines
4.8 KiB
TypeScript

/**
* Hook for the AI agent panel — manages agent state and streaming.
* Uses Claude CLI subprocess with MCP tools via Tauri.
*
* States: idle -> thinking -> tool-executing -> done/error
*/
import { useState, useCallback, useRef, useEffect } from 'react'
import type { AiAction } from '../components/AiMessage'
import { streamClaudeAgent, buildAgentSystemPrompt } from '../utils/ai-agent'
import { nextMessageId } from '../utils/ai-chat'
export type AgentStatus = 'idle' | 'thinking' | 'tool-executing' | 'done' | 'error'
export interface AiAgentMessage {
userMessage: string
reasoning?: string
actions: AiAction[]
response?: string
isStreaming?: boolean
id?: string
}
export function useAiAgent(vaultPath: string, contextPrompt?: string) {
const [messages, setMessages] = useState<AiAgentMessage[]>([])
const [status, setStatus] = useState<AgentStatus>('idle')
const abortRef = useRef({ aborted: false })
const contextRef = useRef(contextPrompt)
useEffect(() => {
contextRef.current = contextPrompt
}, [contextPrompt])
const sendMessage = useCallback(async (text: string) => {
if (!text.trim() || status === 'thinking' || status === 'tool-executing') return
if (!vaultPath) {
setMessages(prev => [...prev, {
userMessage: text.trim(), actions: [],
response: 'No vault loaded. Open a vault first.',
id: nextMessageId(),
}])
return
}
abortRef.current = { aborted: false }
const messageId = nextMessageId()
setMessages(prev => [...prev, {
userMessage: text.trim(), actions: [], isStreaming: true, id: messageId,
}])
setStatus('thinking')
const update = (fn: (m: AiAgentMessage) => AiAgentMessage) => {
setMessages(prev => prev.map(m => m.id === messageId ? fn(m) : m))
}
// When a contextual prompt is provided (from buildContextualPrompt),
// use it directly — it already includes the system preamble.
const systemPrompt = contextRef.current ?? buildAgentSystemPrompt()
await streamClaudeAgent(text.trim(), systemPrompt, vaultPath, {
onText: (text) => {
if (abortRef.current.aborted) return
update(m => ({ ...m, response: (m.response ?? '') + text }))
},
onToolStart: (toolName, toolId, input) => {
if (abortRef.current.aborted) return
setStatus('tool-executing')
update(m => {
const existing = m.actions.find(a => a.toolId === toolId)
if (existing) {
// Re-emitted with input data — update the existing action
return {
...m,
actions: m.actions.map(a =>
a.toolId === toolId ? { ...a, input: input ?? a.input } : a,
),
}
}
return {
...m,
actions: [...m.actions, {
tool: toolName,
toolId,
label: formatToolLabel(toolName, toolId),
status: 'pending' as const,
input,
}],
}
})
},
onToolDone: (toolId, output) => {
if (abortRef.current.aborted) return
update(m => ({
...m,
actions: m.actions.map(a =>
a.toolId === toolId ? { ...a, status: 'done' as const, output } : a,
),
}))
},
onError: (error) => {
if (abortRef.current.aborted) return
setStatus('error')
update(m => ({
...m,
isStreaming: false,
response: (m.response ?? '') + `\nError: ${error}`,
actions: m.actions.map(a =>
a.status === 'pending' ? { ...a, status: 'error' as const } : a,
),
}))
},
onDone: () => {
if (abortRef.current.aborted) return
setStatus('done')
update(m => ({
...m,
isStreaming: false,
actions: m.actions.map(a => a.status === 'pending' ? { ...a, status: 'done' as const } : a),
}))
},
})
}, [status, vaultPath])
const clearConversation = useCallback(() => {
abortRef.current.aborted = true
setMessages([])
setStatus('idle')
}, [])
return { messages, status, sendMessage, clearConversation }
}
// --- Helpers ---
function formatToolLabel(toolName: string, toolId: string): string {
const suffix = toolId.slice(-6)
const labels: Record<string, string> = {
read_note: 'Reading note',
create_note: 'Creating note',
search_notes: 'Searching notes',
append_to_note: 'Appending to note',
edit_note_frontmatter: 'Editing frontmatter',
delete_note: 'Deleting note',
link_notes: 'Linking notes',
list_notes: 'Listing notes',
vault_context: 'Loading vault context',
ui_open_note: 'Opening note',
ui_open_tab: 'Opening tab',
ui_highlight: 'Highlighting',
ui_set_filter: 'Setting filter',
}
return `${labels[toolName] ?? toolName}... (${suffix})`
}