generated from nhcarrigan/template
feat: batch parallel permission requests for improved UX
Implemented intelligent permission batching that detects cancelled sibling tool calls and presents them together in a single modal. This dramatically improves the user experience when multiple tools require permission. Key changes: - Track pending tool uses from Assistant messages in thread-local storage - Capture and batch sibling tools that get cancelled due to permission denials - Clear pending tools on each Result message to prevent accumulation - Use SvelteSet for reactive permission selection in the modal - Update permission modal to display count when multiple permissions requested - Fix check-all.sh to source nvm for pnpm access - Add git commit instructions to CLAUDE.md for this project Technical improvements: - Thread-local storage for cross-message tool tracking - Proper null checking in TypeScript permission handling - Clippy-compliant const initialisation for thread_local - All ESLint, TypeScript, and Rust checks passing The modal now shows both the explicitly denied tool AND any sibling tools that were called in parallel, allowing users to approve all permissions in one go instead of clicking through multiple modals.
This commit is contained in:
@@ -202,11 +202,16 @@ pub struct OutputEvent {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PermissionPromptEvent {
|
||||
pub struct PermissionPromptEventItem {
|
||||
pub id: String,
|
||||
pub tool_name: String,
|
||||
pub tool_input: serde_json::Value,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PermissionPromptEvent {
|
||||
pub permissions: Vec<PermissionPromptEventItem>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
+97
-17
@@ -16,9 +16,24 @@ use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats};
|
||||
use crate::types::{
|
||||
AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent,
|
||||
ConnectionStatus, ContentBlock, MessageCost, OutputEvent, PermissionPromptEvent,
|
||||
QuestionOption, SessionEvent, StateChangeEvent, UserQuestionEvent, WorkingDirectoryEvent,
|
||||
PermissionPromptEventItem, QuestionOption, SessionEvent, StateChangeEvent,
|
||||
UserQuestionEvent, WorkingDirectoryEvent,
|
||||
};
|
||||
use parking_lot::RwLock;
|
||||
use std::cell::RefCell;
|
||||
|
||||
thread_local! {
|
||||
/// Stores pending tool uses from the most recent Assistant message
|
||||
/// to enable batching permission requests for sibling cancelled tools
|
||||
static PENDING_TOOL_USES: RefCell<Vec<PendingToolUse>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PendingToolUse {
|
||||
tool_use_id: String,
|
||||
tool_name: String,
|
||||
tool_input: serde_json::Value,
|
||||
}
|
||||
|
||||
const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
|
||||
const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"];
|
||||
@@ -770,6 +785,26 @@ fn process_json_line(
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Store pending tool uses for permission batching (only for top-level, not subagents)
|
||||
if parent_tool_use_id.is_none() {
|
||||
PENDING_TOOL_USES.with(|pending| {
|
||||
let tool_uses: Vec<PendingToolUse> = message
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|block| match block {
|
||||
ContentBlock::ToolUse { id, name, input } => Some(PendingToolUse {
|
||||
tool_use_id: id.clone(),
|
||||
tool_name: name.clone(),
|
||||
tool_input: input.clone(),
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// Append to existing pending tools instead of replacing
|
||||
pending.borrow_mut().extend(tool_uses);
|
||||
});
|
||||
}
|
||||
|
||||
// Track message cost for display
|
||||
let mut message_cost: Option<MessageCost> = None;
|
||||
|
||||
@@ -1034,6 +1069,15 @@ fn process_json_line(
|
||||
CharacterState::Error
|
||||
};
|
||||
|
||||
// Capture pending tool uses before clearing them
|
||||
// We'll use these for permission batching if there are denials
|
||||
let captured_pending_tools = PENDING_TOOL_USES.with(|pending| {
|
||||
let tools = pending.borrow().clone();
|
||||
// Clear immediately so they don't accumulate across requests
|
||||
pending.borrow_mut().clear();
|
||||
tools
|
||||
});
|
||||
|
||||
// Log turn metrics if available
|
||||
if let Some(duration) = duration_ms {
|
||||
println!("Turn completed in {}ms", duration);
|
||||
@@ -1172,9 +1216,16 @@ fn process_json_line(
|
||||
|
||||
// Check for permission denials and emit prompts for each
|
||||
if let Some(denials) = permission_denials {
|
||||
let mut has_regular_denials = false;
|
||||
// Only process if there are actually denials
|
||||
if !denials.is_empty() {
|
||||
let mut regular_permission_requests = Vec::new();
|
||||
|
||||
for denial in denials {
|
||||
// Get denied tool IDs for later comparison
|
||||
let denied_tool_ids: Vec<String> = denials.iter()
|
||||
.map(|d| d.tool_use_id.clone())
|
||||
.collect();
|
||||
|
||||
for denial in denials {
|
||||
// Special handling for AskUserQuestion tool
|
||||
if denial.tool_name == "AskUserQuestion" {
|
||||
if let Some(questions) = denial
|
||||
@@ -1235,24 +1286,41 @@ fn process_json_line(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
has_regular_denials = true;
|
||||
let description =
|
||||
format_tool_description(&denial.tool_name, &denial.tool_input);
|
||||
let _ = app.emit(
|
||||
"claude:permission",
|
||||
PermissionPromptEvent {
|
||||
id: denial.tool_use_id.clone(),
|
||||
tool_name: denial.tool_name.clone(),
|
||||
tool_input: denial.tool_input.clone(),
|
||||
description,
|
||||
conversation_id: conversation_id.clone(),
|
||||
},
|
||||
);
|
||||
regular_permission_requests.push(PermissionPromptEventItem {
|
||||
id: denial.tool_use_id.clone(),
|
||||
tool_name: denial.tool_name.clone(),
|
||||
tool_input: denial.tool_input.clone(),
|
||||
description,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Show permission state if there were any denials (questions or regular)
|
||||
if has_regular_denials || !denials.is_empty() {
|
||||
// Check for sibling tools that may have been cancelled
|
||||
// Add them to the permission batch so they can be approved together
|
||||
for tool_use in captured_pending_tools.iter() {
|
||||
// Only add tools that weren't explicitly denied (these are likely cancelled siblings)
|
||||
if !denied_tool_ids.contains(&tool_use.tool_use_id) {
|
||||
let description = format_tool_description(&tool_use.tool_name, &tool_use.tool_input);
|
||||
regular_permission_requests.push(PermissionPromptEventItem {
|
||||
id: tool_use.tool_use_id.clone(),
|
||||
tool_name: tool_use.tool_name.clone(),
|
||||
tool_input: tool_use.tool_input.clone(),
|
||||
description,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Emit all regular permission requests as a single batched event
|
||||
if !regular_permission_requests.is_empty() {
|
||||
let _ = app.emit(
|
||||
"claude:permission",
|
||||
PermissionPromptEvent {
|
||||
permissions: regular_permission_requests,
|
||||
conversation_id: conversation_id.clone(),
|
||||
},
|
||||
);
|
||||
emit_state_change(
|
||||
app,
|
||||
CharacterState::Permission,
|
||||
@@ -1261,7 +1329,19 @@ fn process_json_line(
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Show permission state if there were any question denials
|
||||
if !denials.is_empty() {
|
||||
emit_state_change(
|
||||
app,
|
||||
CharacterState::Permission,
|
||||
None,
|
||||
conversation_id.clone(),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
} // end of else block for non-empty denials
|
||||
} // end of if let Some(denials)
|
||||
|
||||
emit_state_change(app, state, None, conversation_id.clone());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user