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>
This commit is contained in:
@@ -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<String>,
|
||||
},
|
||||
/// A tool call finished (agent mode only).
|
||||
ToolDone { tool_id: String },
|
||||
ToolDone {
|
||||
tool_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
output: Option<String>,
|
||||
},
|
||||
/// 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<String, String> {
|
||||
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<String, String>,
|
||||
/// The tool_use id of the block currently being streamed.
|
||||
current_tool_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Core subprocess runner shared by chat and agent modes.
|
||||
fn run_claude_subprocess<F>(bin: &PathBuf, args: &[String], emit: &mut F) -> Result<String, String>
|
||||
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<F>(json: &serde_json::Value, session_id: &mut String, emit: &mut F)
|
||||
fn dispatch_event<F>(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<F>(json: &serde_json::Value, emit: &mut F)
|
||||
fn dispatch_stream_event<F>(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<String> {
|
||||
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> {
|
||||
// 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<ClaudeStreamEvent>) {
|
||||
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<ClaudeStreamEvent>) {
|
||||
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<serde_json::Value>,
|
||||
) -> (StreamState, Vec<ClaudeStreamEvent>) {
|
||||
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)]
|
||||
|
||||
@@ -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(<AiActionCard tool="create_note" label="Created test.md" status="done" />)
|
||||
render(<AiActionCard {...defaults} label="Created test.md" />)
|
||||
expect(screen.getByText('Created test.md')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows pending spinner', () => {
|
||||
render(<AiActionCard tool="search_notes" label="Searching..." status="pending" />)
|
||||
render(<AiActionCard {...defaults} tool="search_notes" label="Searching..." status="pending" />)
|
||||
expect(screen.getByTestId('status-pending')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows done check', () => {
|
||||
render(<AiActionCard tool="create_note" label="Created" status="done" />)
|
||||
render(<AiActionCard {...defaults} status="done" />)
|
||||
expect(screen.getByTestId('status-done')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows error icon', () => {
|
||||
render(<AiActionCard tool="delete_note" label="Failed" status="error" />)
|
||||
render(<AiActionCard {...defaults} tool="delete_note" label="Failed" status="error" />)
|
||||
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(<AiActionCard tool="create_note" label="Open note" path="/vault/test.md" status="done" onOpenNote={onOpenNote} />)
|
||||
fireEvent.click(screen.getByTestId('ai-action-card'))
|
||||
const toggle = vi.fn()
|
||||
render(
|
||||
<AiActionCard {...defaults} path="/vault/test.md" onOpenNote={onOpenNote} onToggle={toggle} />,
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('action-card-header'))
|
||||
expect(onOpenNote).toHaveBeenCalledWith('/vault/test.md')
|
||||
expect(toggle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('has button role when clickable', () => {
|
||||
render(<AiActionCard tool="create_note" label="Open note" path="/vault/test.md" status="done" onOpenNote={vi.fn()} />)
|
||||
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(<AiActionCard tool="create_note" label="Created" status="done" onOpenNote={onOpenNote} />)
|
||||
fireEvent.click(screen.getByTestId('ai-action-card'))
|
||||
const toggle = vi.fn()
|
||||
render(
|
||||
<AiActionCard
|
||||
{...defaults}
|
||||
path="/vault/test.md"
|
||||
onOpenNote={onOpenNote}
|
||||
onToggle={toggle}
|
||||
input='{"title":"test"}'
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('action-card-header'))
|
||||
expect(toggle).toHaveBeenCalled()
|
||||
expect(onOpenNote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is not clickable without onOpenNote', () => {
|
||||
render(<AiActionCard tool="create_note" label="Created" path="/vault/test.md" status="done" />)
|
||||
const card = screen.getByTestId('ai-action-card')
|
||||
expect(card.getAttribute('role')).toBeNull()
|
||||
it('header has button role and is focusable', () => {
|
||||
render(<AiActionCard {...defaults} />)
|
||||
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(<AiActionCard tool="ui_open_tab" label="Opened tab" status="done" />)
|
||||
render(<AiActionCard {...defaults} tool="ui_open_tab" label="Opened tab" />)
|
||||
const card = screen.getByTestId('ai-action-card')
|
||||
expect(card.style.background).toContain('0.06')
|
||||
})
|
||||
|
||||
it('uses standard background for vault tools', () => {
|
||||
render(<AiActionCard tool="create_note" label="Created" status="done" />)
|
||||
render(<AiActionCard {...defaults} />)
|
||||
const card = screen.getByTestId('ai-action-card')
|
||||
expect(card.style.background).toContain('0.1')
|
||||
})
|
||||
|
||||
// --- Expand / collapse ---
|
||||
|
||||
it('does not show details when collapsed', () => {
|
||||
render(<AiActionCard {...defaults} input='{"q":"test"}' output="found 3" expanded={false} />)
|
||||
expect(screen.queryByTestId('action-card-details')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows details when expanded with input and output', () => {
|
||||
render(<AiActionCard {...defaults} input='{"q":"test"}' output="found 3" expanded />)
|
||||
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(<AiActionCard {...defaults} input='{"q":"test"}' expanded />)
|
||||
expect(screen.getByTestId('detail-input')).toBeTruthy()
|
||||
expect(screen.queryByTestId('detail-output')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows only output when no input', () => {
|
||||
render(<AiActionCard {...defaults} output="result text" expanded />)
|
||||
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(<AiActionCard {...defaults} expanded />)
|
||||
expect(screen.queryByTestId('action-card-details')).toBeNull()
|
||||
})
|
||||
|
||||
it('formats JSON input prettily', () => {
|
||||
render(<AiActionCard {...defaults} input='{"title":"Hello","content":"world"}' expanded />)
|
||||
const inputBlock = screen.getByTestId('detail-input')
|
||||
expect(inputBlock.textContent).toContain('"title": "Hello"')
|
||||
})
|
||||
|
||||
it('truncates very long output', () => {
|
||||
const longOutput = 'x'.repeat(1000)
|
||||
render(<AiActionCard {...defaults} output={longOutput} expanded />)
|
||||
const outputBlock = screen.getByTestId('detail-output')
|
||||
expect(outputBlock.textContent!.length).toBeLessThan(1000)
|
||||
})
|
||||
|
||||
// --- Keyboard accessibility ---
|
||||
|
||||
it('expands on Enter key', () => {
|
||||
const toggle = vi.fn()
|
||||
render(<AiActionCard {...defaults} onToggle={toggle} input='{"a":1}' />)
|
||||
fireEvent.keyDown(screen.getByTestId('action-card-header'), { key: 'Enter' })
|
||||
expect(toggle).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('expands on Space key', () => {
|
||||
const toggle = vi.fn()
|
||||
render(<AiActionCard {...defaults} onToggle={toggle} input='{"a":1}' />)
|
||||
fireEvent.keyDown(screen.getByTestId('action-card-header'), { key: ' ' })
|
||||
expect(toggle).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('collapses on Escape key when expanded', () => {
|
||||
const toggle = vi.fn()
|
||||
render(<AiActionCard {...defaults} onToggle={toggle} expanded input='{"a":1}' />)
|
||||
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(<AiActionCard {...defaults} onToggle={toggle} expanded={false} input='{"a":1}' />)
|
||||
fireEvent.keyDown(screen.getByTestId('action-card-header'), { key: 'Escape' })
|
||||
expect(toggle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets aria-expanded attribute', () => {
|
||||
const { rerender } = render(<AiActionCard {...defaults} expanded={false} />)
|
||||
expect(screen.getByTestId('action-card-header').getAttribute('aria-expanded')).toBe('false')
|
||||
rerender(<AiActionCard {...defaults} expanded />)
|
||||
expect(screen.getByTestId('action-card-header').getAttribute('aria-expanded')).toBe('true')
|
||||
})
|
||||
|
||||
it('shows error output in red for error status', () => {
|
||||
render(<AiActionCard {...defaults} status="error" output="Permission denied" expanded />)
|
||||
const outputBlock = screen.getByTestId('detail-output')
|
||||
expect(outputBlock.style.color).toContain('destructive')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, IconRenderer> = {
|
||||
@@ -43,27 +49,118 @@ function StatusIndicator({ status }: { status: AiActionStatus }) {
|
||||
return <XCircle size={14} weight="fill" style={{ color: 'var(--destructive)' }} data-testid="status-error" />
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className="flex items-center gap-2 rounded"
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
fontSize: 12,
|
||||
background: isUiTool ? 'rgba(74, 158, 255, 0.06)' : 'rgba(74, 158, 255, 0.1)',
|
||||
cursor: isClickable ? 'pointer' : 'default',
|
||||
}}
|
||||
onClick={isClickable ? () => onOpenNote(path) : undefined}
|
||||
role={isClickable ? 'button' : undefined}
|
||||
data-testid="ai-action-card"
|
||||
>
|
||||
<span className="shrink-0 text-muted-foreground">{renderIcon(14)}</span>
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
<StatusIndicator status={status} />
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<div
|
||||
className="text-muted-foreground"
|
||||
style={{ fontSize: 10, fontWeight: 600, textTransform: 'uppercase', marginBottom: 2 }}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<pre
|
||||
data-testid={`detail-${label.toLowerCase()}`}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
lineHeight: 1.4,
|
||||
margin: 0,
|
||||
padding: '4px 6px',
|
||||
borderRadius: 4,
|
||||
background: 'var(--muted)',
|
||||
color: isError ? 'var(--destructive)' : 'var(--foreground)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{text}{truncated && <span className="text-muted-foreground">{'…'}</span>}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
data-testid="ai-action-card"
|
||||
className="rounded"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
background: isUiTool ? 'rgba(74, 158, 255, 0.06)' : 'rgba(74, 158, 255, 0.1)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
style={{ padding: '6px 10px', cursor: 'pointer' }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={expanded}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
data-testid="action-card-header"
|
||||
>
|
||||
<span className="shrink-0 text-muted-foreground" style={{ width: 14, display: 'flex' }}>
|
||||
{hasDetails
|
||||
? (expanded ? <CaretDown size={12} /> : <CaretRight size={12} />)
|
||||
: renderIcon(14)}
|
||||
</span>
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
<StatusIndicator status={status} />
|
||||
</div>
|
||||
{expanded && hasDetails && (
|
||||
<div
|
||||
data-testid="action-card-details"
|
||||
style={{ padding: '0 10px 8px 10px' }}
|
||||
>
|
||||
{formattedInput && <DetailBlock label="Input" content={formattedInput} />}
|
||||
{output && (
|
||||
<DetailBlock label="Output" content={output} isError={status === 'error'} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ describe('AiMessage', () => {
|
||||
<AiMessage
|
||||
userMessage="Do something"
|
||||
actions={[
|
||||
{ tool: 'create_note', label: 'Created test.md', status: 'done' },
|
||||
{ tool: 'search_notes', label: 'Searched', status: 'pending' },
|
||||
{ tool: 'create_note', toolId: 't1', label: 'Created test.md', status: 'done' },
|
||||
{ tool: 'search_notes', toolId: 't2', label: 'Searched', status: 'pending' },
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
@@ -57,11 +57,11 @@ describe('AiMessage', () => {
|
||||
render(
|
||||
<AiMessage
|
||||
userMessage="Do"
|
||||
actions={[{ tool: 'create_note', label: 'Open', path: '/vault/note.md', status: 'done' }]}
|
||||
actions={[{ tool: 'create_note', toolId: 't1', label: 'Open', path: '/vault/note.md', status: 'done' }]}
|
||||
onOpenNote={onOpenNote}
|
||||
/>,
|
||||
)
|
||||
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(<AiMessage userMessage="Ask" actions={[]} />)
|
||||
expect(screen.queryByTestId('ai-action-card')).toBeNull()
|
||||
})
|
||||
|
||||
it('expands and collapses action cards independently', () => {
|
||||
render(
|
||||
<AiMessage
|
||||
userMessage="Do"
|
||||
actions={[
|
||||
{ tool: 'search_notes', toolId: 't1', label: 'Searched', status: 'done', input: '{"q":"test"}', output: 'Found 3' },
|
||||
{ tool: 'create_note', toolId: 't2', label: 'Created', status: 'done', input: '{"title":"x"}' },
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string>
|
||||
onToggleExpand: (toolId: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1" style={{ marginBottom: 8 }}>
|
||||
{actions.map((action, i) => (
|
||||
{actions.map((action) => (
|
||||
<AiActionCard
|
||||
key={`${action.tool}-${i}`}
|
||||
key={action.toolId}
|
||||
tool={action.tool}
|
||||
label={action.label}
|
||||
path={action.path}
|
||||
status={action.status}
|
||||
input={action.input}
|
||||
output={action.output}
|
||||
expanded={expandedIds.has(action.toolId)}
|
||||
onToggle={() => 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<Set<string>>(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 (
|
||||
<div data-testid="ai-message" style={{ marginBottom: 16 }}>
|
||||
@@ -126,7 +146,14 @@ export function AiMessage({ userMessage, reasoning, actions, response, isStreami
|
||||
onToggle={() => setReasoningExpanded(!reasoningExpanded)}
|
||||
/>
|
||||
)}
|
||||
{actions.length > 0 && <ActionCardsList actions={actions} onOpenNote={onOpenNote} />}
|
||||
{actions.length > 0 && (
|
||||
<ActionCardsList
|
||||
actions={actions}
|
||||
onOpenNote={onOpenNote}
|
||||
expandedIds={expandedActions}
|
||||
onToggleExpand={toggleAction}
|
||||
/>
|
||||
)}
|
||||
{response && <ResponseBlock text={response} />}
|
||||
{isStreaming && !response && <StreamingIndicator />}
|
||||
</div>
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user