diff --git a/src-tauri/src/claude_cli.rs b/src-tauri/src/claude_cli.rs index 5bb46a48..8baa6281 100644 --- a/src-tauri/src/claude_cli.rs +++ b/src-tauri/src/claude_cli.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::io::BufRead; use std::path::PathBuf; use std::process::{Command, Stdio}; @@ -19,9 +20,18 @@ pub enum ClaudeStreamEvent { /// Incremental text chunk. TextDelta { text: String }, /// A tool call started (agent mode only). - ToolStart { tool_name: String, tool_id: String }, + ToolStart { + tool_name: String, + tool_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + input: Option, + }, /// A tool call finished (agent mode only). - ToolDone { tool_id: String }, + ToolDone { + tool_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + output: Option, + }, /// Final result text + session ID. Result { text: String, session_id: String }, /// Something went wrong. @@ -207,6 +217,15 @@ fn build_mcp_config(vault_path: &str) -> Result { serde_json::to_string(&config).map_err(|e| format!("Failed to serialise MCP config: {e}")) } +/// Mutable state accumulated across the JSON stream for a single subprocess. +struct StreamState { + session_id: String, + /// Accumulates `input_json_delta` chunks keyed by tool_use id. + tool_inputs: HashMap, + /// The tool_use id of the block currently being streamed. + current_tool_id: Option, +} + /// Core subprocess runner shared by chat and agent modes. fn run_claude_subprocess(bin: &PathBuf, args: &[String], emit: &mut F) -> Result where @@ -223,7 +242,11 @@ where let stdout = child.stdout.take().ok_or("No stdout handle")?; let reader = std::io::BufReader::new(stdout); - let mut session_id = String::new(); + let mut state = StreamState { + session_id: String::new(), + tool_inputs: HashMap::new(), + current_tool_id: None, + }; for line in reader.lines() { let line = match line { @@ -245,7 +268,7 @@ where Err(_) => continue, // skip non-JSON lines }; - dispatch_event(&json, &mut session_id, emit); + dispatch_event(&json, &mut state, emit); } // Read stderr for potential error messages. @@ -257,7 +280,7 @@ where let status = child.wait().map_err(|e| format!("Wait failed: {e}"))?; - if !status.success() && session_id.is_empty() { + if !status.success() && state.session_id.is_empty() { let msg = if stderr_output.contains("not logged in") || stderr_output.contains("authentication") || stderr_output.contains("auth") @@ -273,11 +296,11 @@ where emit(ClaudeStreamEvent::Done); - Ok(session_id) + Ok(state.session_id) } /// Parse a single JSON line from the stream and emit the appropriate event. -fn dispatch_event(json: &serde_json::Value, session_id: &mut String, emit: &mut F) +fn dispatch_event(json: &serde_json::Value, state: &mut StreamState, emit: &mut F) where F: FnMut(ClaudeStreamEvent), { @@ -287,7 +310,7 @@ where // --- System init → capture session_id --- "system" if json["subtype"].as_str() == Some("init") => { if let Some(sid) = json["session_id"].as_str() { - *session_id = sid.to_string(); + state.session_id = sid.to_string(); emit(ClaudeStreamEvent::Init { session_id: sid.to_string(), }); @@ -296,7 +319,7 @@ where // --- Streaming partial events (text deltas, tool_use starts) --- "stream_event" => { - dispatch_stream_event(json, emit); + dispatch_stream_event(json, state, emit); } // --- Tool progress (agent mode) --- @@ -307,6 +330,18 @@ where emit(ClaudeStreamEvent::ToolStart { tool_name: name.to_string(), tool_id: id.to_string(), + input: None, + }); + } + } + + // --- Tool result (agent mode) --- + "tool_result" => { + if let Some(id) = json["tool_use_id"].as_str() { + let output = extract_tool_result_text(json); + emit(ClaudeStreamEvent::ToolDone { + tool_id: id.to_string(), + output, }); } } @@ -315,7 +350,7 @@ where "result" => { let sid = json["session_id"].as_str().unwrap_or("").to_string(); if !sid.is_empty() { - *session_id = sid.clone(); + state.session_id = sid.clone(); } let text = json["result"].as_str().unwrap_or("").to_string(); emit(ClaudeStreamEvent::Result { @@ -332,9 +367,11 @@ where if let (Some(id), Some(name)) = (block["id"].as_str(), block["name"].as_str()) { + let input = format_tool_input(&block["input"], state, id); emit(ClaudeStreamEvent::ToolStart { tool_name: name.to_string(), tool_id: id.to_string(), + input, }); } } @@ -347,7 +384,7 @@ where } /// Handle a `stream_event` (partial assistant message). -fn dispatch_stream_event(json: &serde_json::Value, emit: &mut F) +fn dispatch_stream_event(json: &serde_json::Value, state: &mut StreamState, emit: &mut F) where F: FnMut(ClaudeStreamEvent), { @@ -357,29 +394,87 @@ where match event_type { "content_block_delta" => { let delta = &event["delta"]; - if delta["type"].as_str() == Some("text_delta") { - if let Some(text) = delta["text"].as_str() { - emit(ClaudeStreamEvent::TextDelta { - text: text.to_string(), - }); + match delta["type"].as_str() { + Some("text_delta") => { + if let Some(text) = delta["text"].as_str() { + emit(ClaudeStreamEvent::TextDelta { + text: text.to_string(), + }); + } } + Some("input_json_delta") => { + if let (Some(partial), Some(ref tid)) = + (delta["partial_json"].as_str(), &state.current_tool_id) + { + state + .tool_inputs + .entry(tid.clone()) + .or_default() + .push_str(partial); + } + } + _ => {} } } "content_block_start" => { let block = &event["content_block"]; if block["type"].as_str() == Some("tool_use") { if let (Some(id), Some(name)) = (block["id"].as_str(), block["name"].as_str()) { + state.current_tool_id = Some(id.to_string()); + state.tool_inputs.entry(id.to_string()).or_default(); emit(ClaudeStreamEvent::ToolStart { tool_name: name.to_string(), tool_id: id.to_string(), + input: None, }); } } } + "content_block_stop" => { + state.current_tool_id = None; + } _ => {} } } +/// Build the tool input string, preferring accumulated delta chunks over the +/// block's `input` field (which may be empty at stream start). +fn format_tool_input( + block_input: &serde_json::Value, + state: &StreamState, + tool_id: &str, +) -> Option { + if let Some(accumulated) = state.tool_inputs.get(tool_id) { + if !accumulated.is_empty() { + return Some(accumulated.clone()); + } + } + if !block_input.is_null() && block_input.as_object().is_some_and(|o| !o.is_empty()) { + return Some(block_input.to_string()); + } + None +} + +/// Extract displayable text from a `tool_result` event. +fn extract_tool_result_text(json: &serde_json::Value) -> Option { + // String content field + if let Some(s) = json["content"].as_str() { + return Some(s.to_string()); + } + // Array of content blocks (Claude format) + if let Some(arr) = json["content"].as_array() { + let texts: Vec<&str> = arr + .iter() + .filter_map(|b| b["text"].as_str()) + .collect(); + if !texts.is_empty() { + return Some(texts.join("\n")); + } + } + // Fallback: "output" field + json["output"].as_str().map(|s| s.to_string()) +} + #[cfg(test)] mod tests { use super::*; @@ -408,12 +503,20 @@ mod tests { // --- dispatch_event / dispatch_stream_event --- + fn new_state() -> StreamState { + StreamState { + session_id: String::new(), + tool_inputs: HashMap::new(), + current_tool_id: None, + } + } + /// Run dispatch_event on the given JSON and return (session_id, events). fn run_dispatch(json: serde_json::Value) -> (String, Vec) { - let mut sid = String::new(); + let mut state = new_state(); let mut events = vec![]; - dispatch_event(&json, &mut sid, &mut |e| events.push(e)); - (sid, events) + dispatch_event(&json, &mut state, &mut |e| events.push(e)); + (state.session_id, events) } /// Run dispatch_event with a pre-set session_id. @@ -421,10 +524,23 @@ mod tests { json: serde_json::Value, initial_sid: &str, ) -> (String, Vec) { - let mut sid = initial_sid.to_string(); + let mut state = new_state(); + state.session_id = initial_sid.to_string(); let mut events = vec![]; - dispatch_event(&json, &mut sid, &mut |e| events.push(e)); - (sid, events) + dispatch_event(&json, &mut state, &mut |e| events.push(e)); + (state.session_id, events) + } + + /// Run multiple dispatch_event calls sharing state (for multi-event sequences). + fn run_dispatch_sequence( + events_json: Vec, + ) -> (StreamState, Vec) { + let mut state = new_state(); + let mut events = vec![]; + for json in &events_json { + dispatch_event(json, &mut state, &mut |e| events.push(e)); + } + (state, events) } #[test] @@ -468,7 +584,7 @@ mod tests { "event": { "type": "content_block_start", "index": 1, "content_block": { "type": "tool_use", "id": "tool_abc", "name": "read_note", "input": {} } } })); assert!( - matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id } if tool_name == "read_note" && tool_id == "tool_abc") + matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "read_note" && tool_id == "tool_abc") ); } @@ -501,7 +617,7 @@ mod tests { "type": "tool_progress", "tool_name": "search_notes", "tool_use_id": "tool_xyz" })); assert!( - matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id } if tool_name == "search_notes" && tool_id == "tool_xyz") + matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "search_notes" && tool_id == "tool_xyz") ); } @@ -523,7 +639,7 @@ mod tests { })); assert_eq!(events.len(), 1); assert!( - matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id } if tool_name == "search_notes" && tool_id == "tu_1") + matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "search_notes" && tool_id == "tu_1") ); } @@ -541,7 +657,8 @@ mod tests { } #[test] - fn dispatch_stream_event_non_text_delta_is_ignored() { + fn dispatch_stream_event_input_json_delta_accumulates_silently() { + // input_json_delta doesn't emit events directly — it accumulates in state let (_, events) = run_dispatch(serde_json::json!({ "type": "stream_event", "event": { "type": "content_block_delta", "index": 0, "delta": { "type": "input_json_delta", "partial_json": "{}" } } @@ -566,6 +683,109 @@ mod tests { assert!(events.is_empty()); } + #[test] + fn dispatch_event_handles_tool_result_string_content() { + let (_, events) = run_dispatch(serde_json::json!({ + "type": "tool_result", + "tool_use_id": "tool_abc", + "content": "Found 3 notes matching query" + })); + assert_eq!(events.len(), 1); + assert!( + matches!(&events[0], ClaudeStreamEvent::ToolDone { tool_id, output } + if tool_id == "tool_abc" && output.as_deref() == Some("Found 3 notes matching query")) + ); + } + + #[test] + fn dispatch_event_handles_tool_result_array_content() { + let (_, events) = run_dispatch(serde_json::json!({ + "type": "tool_result", + "tool_use_id": "tool_def", + "content": [{ "type": "text", "text": "Line 1" }, { "type": "text", "text": "Line 2" }] + })); + assert!( + matches!(&events[0], ClaudeStreamEvent::ToolDone { output, .. } + if output.as_deref() == Some("Line 1\nLine 2")) + ); + } + + #[test] + fn dispatch_event_tool_result_missing_tool_id_is_ignored() { + let (_, events) = run_dispatch(serde_json::json!({ + "type": "tool_result", "content": "result text" + })); + assert!(events.is_empty()); + } + + #[test] + fn dispatch_accumulates_input_json_deltas() { + let (_, events) = run_dispatch_sequence(vec![ + // Start tool_use block + serde_json::json!({ + "type": "stream_event", + "event": { "type": "content_block_start", "content_block": { "type": "tool_use", "id": "t1", "name": "search_notes", "input": {} } } + }), + // Input delta chunks + serde_json::json!({ + "type": "stream_event", + "event": { "type": "content_block_delta", "delta": { "type": "input_json_delta", "partial_json": "{\"query\":" } } + }), + serde_json::json!({ + "type": "stream_event", + "event": { "type": "content_block_delta", "delta": { "type": "input_json_delta", "partial_json": "\"test\"}" } } + }), + // Stop block + serde_json::json!({ + "type": "stream_event", + "event": { "type": "content_block_stop" } + }), + // Assistant message triggers ToolStart with accumulated input + serde_json::json!({ + "type": "assistant", + "message": { "content": [ + { "type": "tool_use", "id": "t1", "name": "search_notes", "input": { "query": "test" } } + ] } + }), + ]); + // First event: ToolStart with no input (from content_block_start) + assert!(matches!(&events[0], ClaudeStreamEvent::ToolStart { input: None, .. })); + // Second event: ToolStart with accumulated input (from assistant) + assert!( + matches!(&events[1], ClaudeStreamEvent::ToolStart { input: Some(inp), .. } + if inp == "{\"query\":\"test\"}") + ); + } + + #[test] + fn dispatch_assistant_uses_block_input_when_no_deltas() { + let (_, events) = run_dispatch(serde_json::json!({ + "type": "assistant", + "message": { "content": [ + { "type": "tool_use", "id": "tu_x", "name": "create_note", "input": { "title": "Hello", "content": "world" } } + ] } + })); + assert!( + matches!(&events[0], ClaudeStreamEvent::ToolStart { input: Some(inp), .. } + if inp.contains("title") && inp.contains("Hello")) + ); + } + + #[test] + fn content_block_stop_clears_current_tool() { + let (state, _) = run_dispatch_sequence(vec![ + serde_json::json!({ + "type": "stream_event", + "event": { "type": "content_block_start", "content_block": { "type": "tool_use", "id": "t1", "name": "x", "input": {} } } + }), + serde_json::json!({ + "type": "stream_event", + "event": { "type": "content_block_stop" } + }), + ]); + assert!(state.current_tool_id.is_none()); + } + // --- run_claude_subprocess with mock scripts --- #[cfg(unix)] diff --git a/src/components/AiActionCard.test.tsx b/src/components/AiActionCard.test.tsx index 23baf1a2..45f2a01e 100644 --- a/src/components/AiActionCard.test.tsx +++ b/src/components/AiActionCard.test.tsx @@ -2,61 +2,166 @@ import { describe, it, expect, vi } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' import { AiActionCard } from './AiActionCard' +const defaults = { + tool: 'create_note', + label: 'Creating note... (abc123)', + status: 'done' as const, + expanded: false, + onToggle: vi.fn(), +} + describe('AiActionCard', () => { it('renders label text', () => { - render() + render() expect(screen.getByText('Created test.md')).toBeTruthy() }) it('shows pending spinner', () => { - render() + render() expect(screen.getByTestId('status-pending')).toBeTruthy() }) it('shows done check', () => { - render() + render() expect(screen.getByTestId('status-done')).toBeTruthy() }) it('shows error icon', () => { - render() + render() expect(screen.getByTestId('status-error')).toBeTruthy() }) - it('is clickable when path and onOpenNote provided', () => { + it('navigates to note when no details and path+onOpenNote provided', () => { const onOpenNote = vi.fn() - render() - fireEvent.click(screen.getByTestId('ai-action-card')) + const toggle = vi.fn() + render( + , + ) + fireEvent.click(screen.getByTestId('action-card-header')) expect(onOpenNote).toHaveBeenCalledWith('/vault/test.md') + expect(toggle).not.toHaveBeenCalled() }) - it('has button role when clickable', () => { - render() - expect(screen.getByRole('button')).toBeTruthy() - }) - - it('is not clickable without path', () => { + it('toggles expand instead of navigating when details exist', () => { const onOpenNote = vi.fn() - render() - fireEvent.click(screen.getByTestId('ai-action-card')) + const toggle = vi.fn() + render( + , + ) + fireEvent.click(screen.getByTestId('action-card-header')) + expect(toggle).toHaveBeenCalled() expect(onOpenNote).not.toHaveBeenCalled() }) - it('is not clickable without onOpenNote', () => { - render() - const card = screen.getByTestId('ai-action-card') - expect(card.getAttribute('role')).toBeNull() + it('header has button role and is focusable', () => { + render() + const header = screen.getByTestId('action-card-header') + expect(header.getAttribute('role')).toBe('button') + expect(header.getAttribute('tabindex')).toBe('0') }) it('uses lighter background for ui_ tools', () => { - render() + render() const card = screen.getByTestId('ai-action-card') expect(card.style.background).toContain('0.06') }) it('uses standard background for vault tools', () => { - render() + render() const card = screen.getByTestId('ai-action-card') expect(card.style.background).toContain('0.1') }) + + // --- Expand / collapse --- + + it('does not show details when collapsed', () => { + render() + expect(screen.queryByTestId('action-card-details')).toBeNull() + }) + + it('shows details when expanded with input and output', () => { + render() + expect(screen.getByTestId('action-card-details')).toBeTruthy() + expect(screen.getByTestId('detail-input')).toBeTruthy() + expect(screen.getByTestId('detail-output')).toBeTruthy() + }) + + it('shows only input when no output', () => { + render() + expect(screen.getByTestId('detail-input')).toBeTruthy() + expect(screen.queryByTestId('detail-output')).toBeNull() + }) + + it('shows only output when no input', () => { + render() + expect(screen.queryByTestId('detail-input')).toBeNull() + expect(screen.getByTestId('detail-output')).toBeTruthy() + }) + + it('does not show details when expanded but no input or output', () => { + render() + expect(screen.queryByTestId('action-card-details')).toBeNull() + }) + + it('formats JSON input prettily', () => { + render() + const inputBlock = screen.getByTestId('detail-input') + expect(inputBlock.textContent).toContain('"title": "Hello"') + }) + + it('truncates very long output', () => { + const longOutput = 'x'.repeat(1000) + render() + const outputBlock = screen.getByTestId('detail-output') + expect(outputBlock.textContent!.length).toBeLessThan(1000) + }) + + // --- Keyboard accessibility --- + + it('expands on Enter key', () => { + const toggle = vi.fn() + render() + fireEvent.keyDown(screen.getByTestId('action-card-header'), { key: 'Enter' }) + expect(toggle).toHaveBeenCalled() + }) + + it('expands on Space key', () => { + const toggle = vi.fn() + render() + fireEvent.keyDown(screen.getByTestId('action-card-header'), { key: ' ' }) + expect(toggle).toHaveBeenCalled() + }) + + it('collapses on Escape key when expanded', () => { + const toggle = vi.fn() + render() + fireEvent.keyDown(screen.getByTestId('action-card-header'), { key: 'Escape' }) + expect(toggle).toHaveBeenCalled() + }) + + it('does not collapse on Escape when already collapsed', () => { + const toggle = vi.fn() + render() + fireEvent.keyDown(screen.getByTestId('action-card-header'), { key: 'Escape' }) + expect(toggle).not.toHaveBeenCalled() + }) + + it('sets aria-expanded attribute', () => { + const { rerender } = render() + expect(screen.getByTestId('action-card-header').getAttribute('aria-expanded')).toBe('false') + rerender() + expect(screen.getByTestId('action-card-header').getAttribute('aria-expanded')).toBe('true') + }) + + it('shows error output in red for error status', () => { + render() + const outputBlock = screen.getByTestId('detail-output') + expect(outputBlock.style.color).toContain('destructive') + }) }) diff --git a/src/components/AiActionCard.tsx b/src/components/AiActionCard.tsx index 8e217fec..3ca46f13 100644 --- a/src/components/AiActionCard.tsx +++ b/src/components/AiActionCard.tsx @@ -1,7 +1,7 @@ -import type { ReactNode } from 'react' +import { type KeyboardEvent, type ReactNode, useCallback } from 'react' import { PencilSimple, MagnifyingGlass, Link, Trash, ChartBar, Eye, Sparkle, - CircleNotch, CheckCircle, XCircle, + CircleNotch, CheckCircle, XCircle, CaretRight, CaretDown, } from '@phosphor-icons/react' export type AiActionStatus = 'pending' | 'done' | 'error' @@ -11,9 +11,15 @@ export interface AiActionCardProps { label: string path?: string status: AiActionStatus + input?: string + output?: string + expanded: boolean + onToggle: () => void onOpenNote?: (path: string) => void } +const MAX_DETAIL_LENGTH = 800 + type IconRenderer = (size: number) => ReactNode const TOOL_ICON_MAP: Record = { @@ -43,27 +49,118 @@ function StatusIndicator({ status }: { status: AiActionStatus }) { return } -export function AiActionCard({ tool, label, path, status, onOpenNote }: AiActionCardProps) { - const renderIcon = TOOL_ICON_MAP[tool] ?? DEFAULT_ICON - const isClickable = !!path && !!onOpenNote - const isUiTool = tool.startsWith('ui_') +function truncateText(text: string): { text: string; truncated: boolean } { + if (text.length <= MAX_DETAIL_LENGTH) return { text, truncated: false } + return { text: text.slice(0, MAX_DETAIL_LENGTH), truncated: true } +} +function formatInputForDisplay(raw: string): string { + try { + return JSON.stringify(JSON.parse(raw), null, 2) + } catch { + return raw + } +} + +function DetailBlock({ label, content, isError }: { + label: string; content: string; isError?: boolean +}) { + const { text, truncated } = truncateText(content) return ( -
onOpenNote(path) : undefined} - role={isClickable ? 'button' : undefined} - data-testid="ai-action-card" - > - {renderIcon(14)} - {label} - +
+
+ {label} +
+
+        {text}{truncated && {'…'}}
+      
+
+ ) +} + +export function AiActionCard({ + tool, label, path, status, input, output, expanded, onToggle, onOpenNote, +}: AiActionCardProps) { + const renderIcon = TOOL_ICON_MAP[tool] ?? DEFAULT_ICON + const isUiTool = tool.startsWith('ui_') + const hasDetails = !!(input || output) + + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onToggle() + } else if (e.key === 'Escape' && expanded) { + e.preventDefault() + onToggle() + } + }, [onToggle, expanded]) + + const handleClick = useCallback(() => { + if (path && onOpenNote && !hasDetails) { + onOpenNote(path) + } else { + onToggle() + } + }, [path, onOpenNote, hasDetails, onToggle]) + + const formattedInput = input ? formatInputForDisplay(input) : undefined + + return ( +
+
+ + {hasDetails + ? (expanded ? : ) + : renderIcon(14)} + + {label} + +
+ {expanded && hasDetails && ( +
+ {formattedInput && } + {output && ( + + )} +
+ )}
) } diff --git a/src/components/AiMessage.test.tsx b/src/components/AiMessage.test.tsx index acadfabc..aa8a040f 100644 --- a/src/components/AiMessage.test.tsx +++ b/src/components/AiMessage.test.tsx @@ -44,8 +44,8 @@ describe('AiMessage', () => { , ) @@ -57,11 +57,11 @@ describe('AiMessage', () => { render( , ) - fireEvent.click(screen.getByTestId('ai-action-card')) + fireEvent.click(screen.getByTestId('action-card-header')) expect(onOpenNote).toHaveBeenCalledWith('/vault/note.md') }) @@ -88,4 +88,28 @@ describe('AiMessage', () => { render() expect(screen.queryByTestId('ai-action-card')).toBeNull() }) + + it('expands and collapses action cards independently', () => { + render( + , + ) + const headers = screen.getAllByTestId('action-card-header') + // Both collapsed initially + expect(screen.queryByTestId('action-card-details')).toBeNull() + // Expand first card + fireEvent.click(headers[0]) + expect(screen.getAllByTestId('action-card-details')).toHaveLength(1) + // Expand second card too + fireEvent.click(headers[1]) + expect(screen.getAllByTestId('action-card-details')).toHaveLength(2) + // Collapse first card + fireEvent.click(headers[0]) + expect(screen.getAllByTestId('action-card-details')).toHaveLength(1) + }) }) diff --git a/src/components/AiMessage.tsx b/src/components/AiMessage.tsx index f7bb8501..3202cb12 100644 --- a/src/components/AiMessage.tsx +++ b/src/components/AiMessage.tsx @@ -1,12 +1,15 @@ -import { useState } from 'react' +import { useState, useCallback } from 'react' import { CaretRight, CaretDown, Brain, ArrowCounterClockwise } from '@phosphor-icons/react' import { AiActionCard, type AiActionStatus } from './AiActionCard' export interface AiAction { tool: string + toolId: string label: string path?: string status: AiActionStatus + input?: string + output?: string } export interface AiMessageProps { @@ -66,18 +69,25 @@ function ReasoningBlock({ text, expanded, onToggle }: { ) } -function ActionCardsList({ actions, onOpenNote }: { - actions: AiAction[]; onOpenNote?: (path: string) => void +function ActionCardsList({ actions, onOpenNote, expandedIds, onToggleExpand }: { + actions: AiAction[] + onOpenNote?: (path: string) => void + expandedIds: Set + onToggleExpand: (toolId: string) => void }) { return (
- {actions.map((action, i) => ( + {actions.map((action) => ( onToggleExpand(action.toolId)} onOpenNote={onOpenNote} /> ))} @@ -115,6 +125,16 @@ function StreamingIndicator() { export function AiMessage({ userMessage, reasoning, actions, response, isStreaming, onOpenNote }: AiMessageProps) { const [reasoningExpanded, setReasoningExpanded] = useState(false) + const [expandedActions, setExpandedActions] = useState>(new Set()) + + const toggleAction = useCallback((toolId: string) => { + setExpandedActions(prev => { + const next = new Set(prev) + if (next.has(toolId)) next.delete(toolId) + else next.add(toolId) + return next + }) + }, []) return (
@@ -126,7 +146,14 @@ export function AiMessage({ userMessage, reasoning, actions, response, isStreami onToggle={() => setReasoningExpanded(!reasoningExpanded)} /> )} - {actions.length > 0 && } + {actions.length > 0 && ( + + )} {response && } {isStreaming && !response && }
diff --git a/src/hooks/useAiAgent.ts b/src/hooks/useAiAgent.ts index b63ec5c0..793d31ce 100644 --- a/src/hooks/useAiAgent.ts +++ b/src/hooks/useAiAgent.ts @@ -63,23 +63,54 @@ export function useAiAgent(vaultPath: string, contextPrompt?: string) { update(m => ({ ...m, response: (m.response ?? '') + text })) }, - onToolStart: (toolName, toolId) => { + 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, { - tool: toolName, - label: formatToolLabel(toolName, toolId), - status: 'pending' as const, - }], + 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}` })) + 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: () => { diff --git a/src/utils/ai-agent.ts b/src/utils/ai-agent.ts index 4870ef21..0553c957 100644 --- a/src/utils/ai-agent.ts +++ b/src/utils/ai-agent.ts @@ -24,15 +24,16 @@ export function buildAgentSystemPrompt(vaultContext?: string): string { type ClaudeAgentStreamEvent = | { kind: 'Init'; session_id: string } | { kind: 'TextDelta'; text: string } - | { kind: 'ToolStart'; tool_name: string; tool_id: string } - | { kind: 'ToolDone'; tool_id: string } + | { kind: 'ToolStart'; tool_name: string; tool_id: string; input?: string } + | { kind: 'ToolDone'; tool_id: string; output?: string } | { kind: 'Result'; text: string; session_id: string } | { kind: 'Error'; message: string } | { kind: 'Done' } export interface AgentStreamCallbacks { onText: (text: string) => void - onToolStart: (toolName: string, toolId: string) => void + onToolStart: (toolName: string, toolId: string, input?: string) => void + onToolDone: (toolId: string, output?: string) => void onError: (message: string) => void onDone: () => void } @@ -65,7 +66,10 @@ export async function streamClaudeAgent( callbacks.onText(data.text) break case 'ToolStart': - callbacks.onToolStart(data.tool_name, data.tool_id) + callbacks.onToolStart(data.tool_name, data.tool_id, data.input) + break + case 'ToolDone': + callbacks.onToolDone(data.tool_id, data.output) break case 'Error': callbacks.onError(data.message)