v1.5.0: Inline subagent panels, Bedrock compaction fixes, context window debug view
New features: - Inline subagent panels with expand/collapse and click-to-toggle - /context slash command to inspect bedrock_message_history - Child-to-parent question routing with auto-answer for subagents - Subagent token usage and cost merging into parent conversation - Randomized session-colored user avatar silhouettes Bug fixes: - Bedrock: remove orphaned tool_results after compaction - Bedrock: self-healing exchange lookup for out-of-order streaming - Bedrock: append continuation prompt when conversation ends with assistant - Duration sanity check rejects epoch-time artifacts from session restore - Cache hit rate calculation uses actual total_input_tokens - Hide "Time to first token" when value is zero Improvements: - Demote verbose bedrock-debug logs to debug/trace levels - Bedrock tool usage counting falls back to action counting - Remove logout menu item from workspace menu Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
6f54e2cb30
commit
f278e53b7e
@@ -714,7 +714,14 @@ impl AIConversation {
|
||||
})?;
|
||||
|
||||
let duration = finish_time.signed_duration_since(start_time);
|
||||
Some(duration.num_milliseconds())
|
||||
let ms = duration.num_milliseconds();
|
||||
|
||||
// Sanity check: reject durations that are clearly wrong (> 24 hours
|
||||
// likely means start_time defaulted to epoch during session restore).
|
||||
if ms < 0 || ms > 86_400_000 {
|
||||
return None;
|
||||
}
|
||||
Some(ms)
|
||||
}
|
||||
|
||||
pub fn token_usage(&self) -> &[ModelTokenUsage] {
|
||||
@@ -1363,6 +1370,45 @@ impl AIConversation {
|
||||
})
|
||||
}
|
||||
|
||||
/// Counts all tool-call actions across the entire conversation.
|
||||
pub fn count_all_actions(&self) -> usize {
|
||||
self.all_exchanges()
|
||||
.into_iter()
|
||||
.flat_map(|exchange| {
|
||||
exchange
|
||||
.output_status
|
||||
.output()
|
||||
.into_iter()
|
||||
.map(|output| output.get().actions().count())
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Counts RequestCommandOutput actions across the entire conversation.
|
||||
pub fn count_command_actions(&self) -> usize {
|
||||
self.all_exchanges()
|
||||
.into_iter()
|
||||
.flat_map(|exchange| {
|
||||
exchange
|
||||
.output_status
|
||||
.output()
|
||||
.into_iter()
|
||||
.map(|output| {
|
||||
output
|
||||
.get()
|
||||
.actions()
|
||||
.filter(|a| {
|
||||
matches!(
|
||||
a.action,
|
||||
super::AIAgentActionType::RequestCommandOutput { .. }
|
||||
)
|
||||
})
|
||||
.count()
|
||||
})
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
pub fn contains_action(&self, action_id: &AIAgentActionId) -> bool {
|
||||
self.task_store.tasks().any(|task| {
|
||||
task.exchanges()
|
||||
@@ -2606,52 +2652,83 @@ impl AIConversation {
|
||||
mask: Some(mask),
|
||||
}) => {
|
||||
let task_id = TaskId::new(task_id);
|
||||
log::info!(
|
||||
"[bedrock-debug] AppendToMessageContent: task_id={:?}, message_id={:?}",
|
||||
log::debug!(
|
||||
"[bedrock] AppendToMessageContent: task_id={:?}, message_id={:?}",
|
||||
task_id,
|
||||
message.id
|
||||
);
|
||||
let exchange_id = match self.added_exchanges_by_response.get(response_stream_id) {
|
||||
|
||||
// Self-healing exchange lookup: if no exchange exists yet for this
|
||||
// task (e.g. the initial AddMessagesToTask was dropped or arrived
|
||||
// out of order), lazily create one so streaming doesn't break.
|
||||
let (exchange_id, created_exchange) = match self
|
||||
.added_exchanges_by_response
|
||||
.get(response_stream_id)
|
||||
{
|
||||
Some(exchanges) => {
|
||||
log::info!(
|
||||
"[bedrock-debug] AppendToMessageContent: found {} exchanges for stream",
|
||||
exchanges.len()
|
||||
);
|
||||
for ex in exchanges.iter() {
|
||||
log::info!(
|
||||
"[bedrock-debug] exchange: task_id={:?}, exchange_id={:?}",
|
||||
ex.task_id,
|
||||
ex.exchange_id
|
||||
);
|
||||
}
|
||||
match exchanges.iter().find_map(|new_exchange| {
|
||||
(new_exchange.task_id == task_id).then_some(new_exchange.exchange_id)
|
||||
}) {
|
||||
Some(id) => id,
|
||||
Some(id) => (id, false),
|
||||
None => {
|
||||
log::error!(
|
||||
"[bedrock-debug] AppendToMessageContent: ExchangeNotFound - no exchange with matching task_id"
|
||||
log::warn!(
|
||||
"[bedrock] AppendToMessageContent: no exchange for task_id={:?}, creating one",
|
||||
task_id
|
||||
);
|
||||
return Err(UpdateConversationError::ExchangeNotFound);
|
||||
// Remove target task first to avoid borrow conflicts,
|
||||
// same pattern as AddMessagesToTask (line 2462).
|
||||
let mut task = self
|
||||
.task_store
|
||||
.remove(&task_id)
|
||||
.ok_or(UpdateConversationError::TaskNotFound)?;
|
||||
let existing_exchange_id = exchanges.last().exchange_id;
|
||||
let existing_exchange = self
|
||||
.get_task(&exchanges.last().task_id)
|
||||
.ok_or(UpdateConversationError::TaskNotFound)?
|
||||
.exchange(existing_exchange_id)
|
||||
.ok_or(UpdateConversationError::ExchangeNotFound)?;
|
||||
let new_exchange_id = task.append_new_exchange(existing_exchange);
|
||||
self.task_store.insert(task);
|
||||
(new_exchange_id, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
log::error!(
|
||||
"[bedrock-debug] AppendToMessageContent: NoPendingRequest - no exchanges for this stream_id"
|
||||
"[bedrock] AppendToMessageContent: NoPendingRequest - no exchanges for this stream_id"
|
||||
);
|
||||
return Err(UpdateConversationError::NoPendingRequest);
|
||||
}
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"[bedrock-debug] AppendToMessageContent: found exchange_id={:?}",
|
||||
exchange_id
|
||||
// Register the newly created exchange so subsequent appends find it
|
||||
if created_exchange {
|
||||
self.added_exchanges_by_response
|
||||
.get_mut(response_stream_id)
|
||||
.ok_or(UpdateConversationError::NoPendingRequest)?
|
||||
.push(AddedExchange {
|
||||
task_id: task_id.clone(),
|
||||
exchange_id,
|
||||
});
|
||||
let is_hidden = self.hidden_exchanges.contains(&exchange_id);
|
||||
ctx.emit(BlocklistAIHistoryEvent::AppendedExchange {
|
||||
response_stream_id: Some(response_stream_id.clone()),
|
||||
exchange_id,
|
||||
task_id: task_id.clone(),
|
||||
terminal_view_id,
|
||||
conversation_id: self.id,
|
||||
is_hidden,
|
||||
});
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
"[bedrock] AppendToMessageContent: exchange_id={:?} (created={})",
|
||||
exchange_id,
|
||||
created_exchange
|
||||
);
|
||||
|
||||
let current_todo_list = self.todo_lists.last().cloned();
|
||||
let current_comment_state = self.code_review.as_ref().cloned();
|
||||
// Update the message and get the updated todos op, if any.
|
||||
let todos_op = match self.task_store.modify_task(&task_id, |task| {
|
||||
task.append_to_message_content(
|
||||
message,
|
||||
@@ -2663,25 +2740,21 @@ impl AIConversation {
|
||||
.map(|msg| msg.todos_op().cloned())
|
||||
}) {
|
||||
Some(result) => match result {
|
||||
Ok(todos_op) => {
|
||||
log::info!("[bedrock-debug] AppendToMessageContent: append succeeded");
|
||||
todos_op
|
||||
}
|
||||
Ok(todos_op) => todos_op,
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"[bedrock-debug] AppendToMessageContent: append_to_message_content failed: {e:?}"
|
||||
"[bedrock] AppendToMessageContent failed: {e:?}"
|
||||
);
|
||||
return Err(e.into());
|
||||
}
|
||||
},
|
||||
None => {
|
||||
log::error!(
|
||||
"[bedrock-debug] AppendToMessageContent: TaskNotFound in task_store"
|
||||
"[bedrock] AppendToMessageContent: TaskNotFound in task_store"
|
||||
);
|
||||
return Err(UpdateConversationError::TaskNotFound);
|
||||
}
|
||||
};
|
||||
// Update todo list if needed
|
||||
if let Some(todos_op) = todos_op {
|
||||
update_todo_list_from_todo_op(&mut self.todo_lists, todos_op);
|
||||
ctx.emit(BlocklistAIHistoryEvent::UpdatedTodoList { terminal_view_id });
|
||||
@@ -3149,6 +3222,39 @@ impl AIConversation {
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Merges a child subagent's token usage and cost into this conversation's totals.
|
||||
/// Does NOT affect context_window_tokens (parent's context is independent).
|
||||
pub fn merge_child_usage_raw(
|
||||
&mut self,
|
||||
child_token_usage: &HashMap<String, TokenUsage>,
|
||||
child_request_cost: RequestCost,
|
||||
) {
|
||||
self.total_request_cost += child_request_cost;
|
||||
|
||||
for (model_id, child_usage) in child_token_usage {
|
||||
let entry = self
|
||||
.total_token_usage_by_model
|
||||
.entry(model_id.clone())
|
||||
.or_insert_with(|| TokenUsage {
|
||||
model_id: model_id.clone(),
|
||||
total_input: 0,
|
||||
output: 0,
|
||||
input_cache_read: 0,
|
||||
input_cache_write: 0,
|
||||
cost_in_cents: 0.0,
|
||||
});
|
||||
entry.total_input += child_usage.total_input;
|
||||
entry.output += child_usage.output;
|
||||
entry.input_cache_read += child_usage.input_cache_read;
|
||||
entry.input_cache_write += child_usage.input_cache_write;
|
||||
entry.cost_in_cents += child_usage.cost_in_cents;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn total_token_usage_by_model(&self) -> &HashMap<String, TokenUsage> {
|
||||
&self.total_token_usage_by_model
|
||||
}
|
||||
|
||||
pub fn total_input_tokens(&self) -> u32 {
|
||||
self.total_token_usage_by_model
|
||||
.values()
|
||||
|
||||
@@ -758,8 +758,25 @@ impl Task {
|
||||
.enumerate()
|
||||
.find(|(_, m)| message.id == m.id)
|
||||
else {
|
||||
log::error!("Message not found for append client action.");
|
||||
return Err(UpdateTaskError::MessageNotFound);
|
||||
// Self-healing: if the message doesn't exist yet (e.g. the initial
|
||||
// AddMessagesToTask was dropped or arrived out of order), treat this
|
||||
// append as an implicit add so streaming doesn't break.
|
||||
log::warn!(
|
||||
"[bedrock] append_to_message_content: message_id={} not found, treating as implicit add",
|
||||
message.id
|
||||
);
|
||||
self.add_messages(
|
||||
vec![message],
|
||||
exchange_id,
|
||||
current_todo_list,
|
||||
current_comments,
|
||||
false,
|
||||
)?;
|
||||
return self
|
||||
.try_get_source()?
|
||||
.messages
|
||||
.last()
|
||||
.ok_or(UpdateTaskError::MessageNotFound);
|
||||
};
|
||||
let updated_message =
|
||||
FieldMaskOperation::append(&api::MESSAGE_DESCRIPTOR, existing_message, &message, mask)
|
||||
|
||||
@@ -430,8 +430,155 @@ fn extract_input_messages(request: &api::Request) -> Vec<api::Message> {
|
||||
/// Call this on the combined (history + new input) messages before
|
||||
/// sending to `build_converse_request`.
|
||||
pub fn sanitize_messages_for_bedrock(messages: &mut Vec<ConversationMessage>) {
|
||||
remove_orphaned_tool_results(messages);
|
||||
ensure_starts_with_user_message(messages);
|
||||
ensure_tool_results_paired(messages);
|
||||
ensure_ends_with_user_message(messages);
|
||||
}
|
||||
|
||||
/// Bedrock requires the conversation to end with a user message.
|
||||
/// If the last message is an assistant message (e.g. after compaction),
|
||||
/// append a continuation prompt.
|
||||
fn ensure_ends_with_user_message(messages: &mut Vec<ConversationMessage>) {
|
||||
if messages.last().is_some_and(|m| m.role == MessageRole::Assistant) {
|
||||
log::info!("[bedrock] Appending continuation prompt (conversation ended with assistant message)");
|
||||
messages.push(ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text("Continue.".to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes tool_result content that references tool_use IDs not present in any
|
||||
/// preceding assistant message. This happens after compaction when the history
|
||||
/// is replaced with a summary but the next request still carries tool_results
|
||||
/// from the old (now-discarded) exchanges.
|
||||
fn remove_orphaned_tool_results(messages: &mut Vec<ConversationMessage>) {
|
||||
use std::collections::HashSet;
|
||||
|
||||
// Collect all tool_use IDs from assistant messages.
|
||||
let mut valid_tool_use_ids = HashSet::new();
|
||||
for msg in messages.iter() {
|
||||
if msg.role == MessageRole::Assistant {
|
||||
collect_tool_use_ids_into(&msg.content, &mut valid_tool_use_ids);
|
||||
}
|
||||
}
|
||||
|
||||
if valid_tool_use_ids.is_empty() {
|
||||
// No tool_use in history — remove ALL tool_results from user messages.
|
||||
let before_count = messages.len();
|
||||
messages.retain(|msg| {
|
||||
if msg.role != MessageRole::User {
|
||||
return true;
|
||||
}
|
||||
!is_pure_tool_result(&msg.content)
|
||||
});
|
||||
|
||||
// Also strip tool_result parts from MultiPart user messages.
|
||||
for msg in messages.iter_mut() {
|
||||
if msg.role != MessageRole::User {
|
||||
continue;
|
||||
}
|
||||
strip_tool_result_parts(&mut msg.content);
|
||||
}
|
||||
|
||||
if messages.len() != before_count {
|
||||
log::info!(
|
||||
"[bedrock] Removed {} orphaned tool_result message(s) (no tool_use in history)",
|
||||
before_count - messages.len()
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove tool_results whose IDs aren't in valid_tool_use_ids.
|
||||
for msg in messages.iter_mut() {
|
||||
if msg.role != MessageRole::User {
|
||||
continue;
|
||||
}
|
||||
strip_orphaned_tool_result_parts(&mut msg.content, &valid_tool_use_ids);
|
||||
}
|
||||
|
||||
// Remove messages that became empty after stripping.
|
||||
messages.retain(|msg| !is_empty_content(&msg.content));
|
||||
}
|
||||
|
||||
fn is_pure_tool_result(content: &MessageContent) -> bool {
|
||||
matches!(content, MessageContent::ToolResult { .. })
|
||||
}
|
||||
|
||||
fn strip_tool_result_parts(content: &mut MessageContent) {
|
||||
if let MessageContent::MultiPart(parts) = content {
|
||||
parts.retain(|p| !matches!(p, ContentPart::ToolResult { .. }));
|
||||
if parts.len() == 1 {
|
||||
let part = parts.remove(0);
|
||||
*content = match part {
|
||||
ContentPart::Text(t) => MessageContent::Text(t),
|
||||
ContentPart::ToolUse { tool_use_id, name, input } => {
|
||||
MessageContent::ToolUse { tool_use_id, name, input }
|
||||
}
|
||||
ContentPart::ToolResult { tool_use_id, content: c, is_error } => {
|
||||
MessageContent::ToolResult { tool_use_id, content: c, is_error }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_orphaned_tool_result_parts(
|
||||
content: &mut MessageContent,
|
||||
valid_ids: &std::collections::HashSet<String>,
|
||||
) {
|
||||
match content {
|
||||
MessageContent::ToolResult { tool_use_id, .. } => {
|
||||
if !valid_ids.contains(tool_use_id) {
|
||||
*content = MessageContent::Text(String::new());
|
||||
}
|
||||
}
|
||||
MessageContent::MultiPart(parts) => {
|
||||
parts.retain(|p| match p {
|
||||
ContentPart::ToolResult { tool_use_id, .. } => valid_ids.contains(tool_use_id),
|
||||
_ => true,
|
||||
});
|
||||
if parts.len() == 1 {
|
||||
let part = parts.remove(0);
|
||||
*content = match part {
|
||||
ContentPart::Text(t) => MessageContent::Text(t),
|
||||
ContentPart::ToolUse { tool_use_id, name, input } => {
|
||||
MessageContent::ToolUse { tool_use_id, name, input }
|
||||
}
|
||||
ContentPart::ToolResult { tool_use_id, content: c, is_error } => {
|
||||
MessageContent::ToolResult { tool_use_id, content: c, is_error }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty_content(content: &MessageContent) -> bool {
|
||||
match content {
|
||||
MessageContent::Text(t) => t.is_empty(),
|
||||
MessageContent::MultiPart(parts) => parts.is_empty(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_tool_use_ids_into(content: &MessageContent, ids: &mut std::collections::HashSet<String>) {
|
||||
match content {
|
||||
MessageContent::ToolUse { tool_use_id, .. } => {
|
||||
ids.insert(tool_use_id.clone());
|
||||
}
|
||||
MessageContent::MultiPart(parts) => {
|
||||
for p in parts {
|
||||
if let ContentPart::ToolUse { tool_use_id, .. } = p {
|
||||
ids.insert(tool_use_id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_starts_with_user_message(messages: &mut Vec<ConversationMessage>) {
|
||||
|
||||
@@ -124,10 +124,10 @@ pub fn bedrock_stream_to_response_events(
|
||||
event_count += 1;
|
||||
match event {
|
||||
StreamEvent::MessageStart(_) => {
|
||||
log::info!("[bedrock-debug] Event #{event_count}: MessageStart");
|
||||
log::debug!("[bedrock] Event #{event_count}: MessageStart");
|
||||
}
|
||||
StreamEvent::ContentBlockStart(block_start) => {
|
||||
log::info!("[bedrock-debug] Event #{event_count}: ContentBlockStart");
|
||||
log::debug!("[bedrock] Event #{event_count}: ContentBlockStart");
|
||||
if let Some(start) = block_start.start() {
|
||||
match start {
|
||||
ContentBlockStart::ToolUse(tool_start) => {
|
||||
@@ -166,11 +166,11 @@ pub fn bedrock_stream_to_response_events(
|
||||
}
|
||||
}
|
||||
StreamEvent::ContentBlockDelta(delta) => {
|
||||
log::info!("[bedrock-debug] Event #{event_count}: ContentBlockDelta");
|
||||
log::trace!("[bedrock] Event #{event_count}: ContentBlockDelta");
|
||||
if let Some(d) = delta.delta() {
|
||||
match d {
|
||||
ContentBlockDelta::Text(text) => {
|
||||
log::info!("[bedrock-debug] Event #{event_count}: TextDelta ({} chars): {:?}", text.len(), &text[..text.len().min(80)]);
|
||||
log::debug!("[bedrock] TextDelta ({} chars)", text.len());
|
||||
history_text.push_str(text);
|
||||
if text_flushed {
|
||||
let msg_id = current_text_message_id.as_ref().unwrap();
|
||||
@@ -182,6 +182,10 @@ pub fn bedrock_stream_to_response_events(
|
||||
yield Ok(append);
|
||||
} else {
|
||||
buffered_text.push_str(text);
|
||||
// Buffer a few initial deltas so the first
|
||||
// AddMessagesToTask carries enough content for
|
||||
// the exchange to be fully registered before
|
||||
// subsequent AppendToMessageContent events arrive.
|
||||
if buffered_text.len() >= 1 {
|
||||
let msg_id = Uuid::new_v4().to_string();
|
||||
current_text_message_id = Some(msg_id.clone());
|
||||
@@ -209,7 +213,7 @@ pub fn bedrock_stream_to_response_events(
|
||||
}
|
||||
}
|
||||
StreamEvent::ContentBlockStop(_) => {
|
||||
log::info!("[bedrock-debug] Event #{event_count}: ContentBlockStop (tool_use_id={:?})", if current_tool_use_id.is_empty() { "none" } else { ¤t_tool_use_id });
|
||||
log::debug!("[bedrock] Event #{event_count}: ContentBlockStop (tool_use_id={:?})", if current_tool_use_id.is_empty() { "none" } else { ¤t_tool_use_id });
|
||||
if !current_tool_use_id.is_empty() {
|
||||
// Skip suggest_next_prompt — its executor hangs forever
|
||||
// waiting for UI interaction that doesn't exist in the
|
||||
@@ -382,7 +386,7 @@ pub fn bedrock_stream_to_response_events(
|
||||
&model_id,
|
||||
);
|
||||
log::info!(
|
||||
"[bedrock] Stream finished: model={model_id}, input_tokens={input_tokens}, output_tokens={output_tokens}, cache_read={cache_read_input_tokens}, cache_write={cache_write_input_tokens}, cost_cents={cost:.4}"
|
||||
"[bedrock] Stream finished: {event_count} events, model={model_id}, input_tokens={input_tokens}, output_tokens={output_tokens}, cache_read={cache_read_input_tokens}, cache_write={cache_write_input_tokens}, cost_cents={cost:.4}"
|
||||
);
|
||||
|
||||
// Build and store the assistant message into bedrock_messages_sent
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::ai::agent::{AIAgentActionResultType, AIAgentActionType};
|
||||
use crate::ai::blocklist::orchestration_events::OrchestrationEventService;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use crate::ai::blocklist::BlocklistAIPermissions;
|
||||
use ai::agent::action_result::{AskUserQuestionAnswerItem, AskUserQuestionResult};
|
||||
use futures::{future::BoxFuture, FutureExt};
|
||||
@@ -51,6 +53,52 @@ impl AskUserQuestionExecutor {
|
||||
}
|
||||
};
|
||||
|
||||
// For child agent conversations, route the question to the parent
|
||||
// for silent auto-answer instead of presenting UI to the user.
|
||||
if let Some(parent_conversation_id) = self.parent_conversation_id(input.conversation_id, ctx)
|
||||
{
|
||||
let question_text = questions
|
||||
.iter()
|
||||
.map(|q| q.question.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let options: Vec<String> = questions
|
||||
.iter()
|
||||
.filter_map(|q| q.multiple_choice_options())
|
||||
.flatten()
|
||||
.map(|o| o.label.clone())
|
||||
.collect();
|
||||
|
||||
OrchestrationEventService::handle(ctx).update(ctx, |service, ctx| {
|
||||
service.route_subagent_question_to_parent(
|
||||
input.conversation_id,
|
||||
parent_conversation_id,
|
||||
question_text,
|
||||
options,
|
||||
0,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
// Wait for the parent's answer to arrive via the same channel
|
||||
let receiver = self.result_rx.1.clone();
|
||||
return ActionExecution::new_async(
|
||||
async move { receiver.recv().await },
|
||||
|result, _ctx| match result {
|
||||
Ok(AskUserQuestionDecision::Completed(answers)) => {
|
||||
AIAgentActionResultType::AskUserQuestion(
|
||||
AskUserQuestionResult::Success { answers },
|
||||
)
|
||||
}
|
||||
Ok(AskUserQuestionDecision::Cancelled) | Err(_) => {
|
||||
AIAgentActionResultType::AskUserQuestion(
|
||||
AskUserQuestionResult::Cancelled,
|
||||
)
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if self.should_autoexecute(input, ctx) {
|
||||
let question_ids = questions
|
||||
.iter()
|
||||
@@ -80,6 +128,16 @@ impl AskUserQuestionExecutor {
|
||||
)
|
||||
}
|
||||
|
||||
fn parent_conversation_id(
|
||||
&self,
|
||||
conversation_id: crate::ai::agent::conversation::AIConversationId,
|
||||
ctx: &ModelContext<Self>,
|
||||
) -> Option<crate::ai::agent::conversation::AIConversationId> {
|
||||
let history_model = BlocklistAIHistoryModel::as_ref(ctx);
|
||||
let conversation = history_model.conversation(&conversation_id)?;
|
||||
conversation.parent_conversation_id()
|
||||
}
|
||||
|
||||
pub(super) fn preprocess_action(
|
||||
&mut self,
|
||||
_action: PreprocessActionInput,
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
//! Shows a collapsible panel with the subagent's status, a mini-transcript of
|
||||
//! recent messages, and controls to expand to full view or cancel.
|
||||
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxyui::elements::{
|
||||
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element, Empty, Flex,
|
||||
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element, Empty, Flex, Hoverable,
|
||||
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
|
||||
};
|
||||
use galaxyui::{AppContext, SingletonEntity};
|
||||
@@ -13,6 +12,8 @@ use pathfinder_color::ColorU;
|
||||
use warp_multi_agent_api as api;
|
||||
|
||||
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
|
||||
use crate::ai::agent::AIAgentActionId;
|
||||
use crate::ai::blocklist::block::AIBlockAction;
|
||||
use crate::ai::blocklist::inline_action::inline_action_header::{
|
||||
ICON_MARGIN, INLINE_ACTION_HEADER_VERTICAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING,
|
||||
};
|
||||
@@ -51,6 +52,7 @@ impl SubagentPanelState {
|
||||
/// Renders the inline subagent panel for a child conversation.
|
||||
pub fn render_subagent_inline_panel(
|
||||
state: &SubagentPanelState,
|
||||
action_id: &AIAgentActionId,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
@@ -71,14 +73,28 @@ pub fn render_subagent_inline_panel(
|
||||
|
||||
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
|
||||
// Header — always visible
|
||||
column.add_child(render_panel_header(
|
||||
&agent_name,
|
||||
&status,
|
||||
state,
|
||||
panel_bg,
|
||||
app,
|
||||
));
|
||||
// Header — always visible, click to toggle expand/collapse
|
||||
let header_mouse_state = state.header_mouse_state.clone();
|
||||
let toggle_action_id = action_id.clone();
|
||||
let header_status = status.clone();
|
||||
let header_expanded = state.is_expanded;
|
||||
column.add_child(
|
||||
Hoverable::new(header_mouse_state, move |_mouse_state| {
|
||||
render_panel_header(
|
||||
&agent_name,
|
||||
&header_status,
|
||||
header_expanded,
|
||||
panel_bg,
|
||||
app,
|
||||
)
|
||||
})
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(AIBlockAction::ToggleSubagentPanel {
|
||||
action_id: toggle_action_id.clone(),
|
||||
});
|
||||
})
|
||||
.finish(),
|
||||
);
|
||||
|
||||
// Body (mini-transcript) — only when expanded
|
||||
if state.is_expanded {
|
||||
@@ -106,7 +122,7 @@ pub fn render_subagent_inline_panel(
|
||||
fn render_panel_header(
|
||||
agent_name: &str,
|
||||
status: &ConversationStatus,
|
||||
state: &SubagentPanelState,
|
||||
is_expanded: bool,
|
||||
_background: ColorU,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
@@ -168,7 +184,7 @@ fn render_panel_header(
|
||||
// Right: collapse/expand chevron
|
||||
let mut right_side = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
let chevron_icon = if state.is_expanded {
|
||||
let chevron_icon = if is_expanded {
|
||||
Icon::ChevronDown
|
||||
} else {
|
||||
Icon::ChevronRight
|
||||
|
||||
@@ -398,6 +398,7 @@ pub(super) struct AIBlockStateHandles {
|
||||
/// A given citation should only appear once per block.
|
||||
footer_citation_chip_handles: HashMap<AIAgentCitation, MouseStateHandle>,
|
||||
orchestration_navigation_card_handles: HashMap<AIAgentActionId, MouseStateHandle>,
|
||||
pub(super) subagent_panel_states: HashMap<AIAgentActionId, super::agent_view::subagent_inline_panel::SubagentPanelState>,
|
||||
|
||||
references_section_collapsible_handle: MouseStateHandle,
|
||||
|
||||
@@ -1369,6 +1370,10 @@ impl AIBlock {
|
||||
me.run_secret_redaction_on_user_query(me.client_ids.conversation_id, ctx);
|
||||
me.spawn_link_detection(ctx);
|
||||
|
||||
// Create summarization view immediately if this block has a SummarizeConversation input,
|
||||
// so the "Summarizing..." UI appears before the API response starts streaming.
|
||||
me.maybe_create_summarization_view_from_input(ctx);
|
||||
|
||||
if me.model.status(ctx).is_streaming() {
|
||||
me.model
|
||||
.on_updated_output(Box::new(Self::on_output_status_update), ctx);
|
||||
@@ -4237,6 +4242,27 @@ impl AIBlock {
|
||||
});
|
||||
}
|
||||
|
||||
// Create subagent panel state for finished StartAgent actions
|
||||
if let Some(AIActionStatus::Finished(result)) =
|
||||
action_model.as_ref(ctx).get_action_status(action_id)
|
||||
{
|
||||
if let AIAgentActionResultType::StartAgent(
|
||||
crate::ai::agent::StartAgentResult::Success { agent_id, .. },
|
||||
) = &result.result
|
||||
{
|
||||
if !me.state_handles.subagent_panel_states.contains_key(action_id) {
|
||||
if let Some(conversation_id) =
|
||||
crate::ai::blocklist::agent_view::orchestration_conversation_links::conversation_id_for_agent_id(agent_id, ctx)
|
||||
{
|
||||
me.state_handles.subagent_panel_states.insert(
|
||||
action_id.clone(),
|
||||
super::agent_view::subagent_inline_panel::SubagentPanelState::new(conversation_id),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let action_statuses = me
|
||||
.requested_action_ids
|
||||
.iter()
|
||||
@@ -5825,6 +5851,9 @@ pub enum AIBlockAction {
|
||||
OpenCommentInGitHub {
|
||||
url: String,
|
||||
},
|
||||
ToggleSubagentPanel {
|
||||
action_id: AIAgentActionId,
|
||||
},
|
||||
}
|
||||
|
||||
impl TypedActionView for AIBlock {
|
||||
@@ -6474,6 +6503,11 @@ impl TypedActionView for AIBlock {
|
||||
initial_index,
|
||||
});
|
||||
}
|
||||
AIBlockAction::ToggleSubagentPanel { action_id } => {
|
||||
if let Some(state) = self.state_handles.subagent_panel_states.get_mut(action_id) {
|
||||
state.is_expanded = !state.is_expanded;
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
@@ -3403,30 +3403,46 @@ pub struct FindContext<'a> {
|
||||
pub state: &'a FindState,
|
||||
}
|
||||
|
||||
/// Renders a user avatar with profile image or display name.
|
||||
/// A palette of colors for the user avatar silhouette, randomly selected per session.
|
||||
const USER_AVATAR_PALETTE: &[ColorU] = &[
|
||||
ColorU { r: 99, g: 179, b: 237, a: 255 }, // blue
|
||||
ColorU { r: 129, g: 230, b: 217, a: 255 }, // teal
|
||||
ColorU { r: 183, g: 148, b: 244, a: 255 }, // purple
|
||||
ColorU { r: 252, g: 165, b: 165, a: 255 }, // red/coral
|
||||
ColorU { r: 251, g: 191, b: 36, a: 255 }, // amber
|
||||
ColorU { r: 110, g: 231, b: 183, a: 255 }, // green
|
||||
ColorU { r: 249, g: 168, b: 212, a: 255 }, // pink
|
||||
ColorU { r: 253, g: 186, b: 116, a: 255 }, // orange
|
||||
];
|
||||
|
||||
fn session_avatar_color() -> ColorU {
|
||||
use std::sync::OnceLock;
|
||||
use rand::Rng;
|
||||
static COLOR: OnceLock<ColorU> = OnceLock::new();
|
||||
*COLOR.get_or_init(|| {
|
||||
let idx = rand::thread_rng().gen_range(0..USER_AVATAR_PALETTE.len());
|
||||
USER_AVATAR_PALETTE[idx]
|
||||
})
|
||||
}
|
||||
|
||||
/// Renders a user avatar as a silhouette icon with a session-random color.
|
||||
pub fn render_user_avatar(
|
||||
user_display_name: &str,
|
||||
profile_image_path: Option<&String>,
|
||||
_user_display_name: &str,
|
||||
_profile_image_path: Option<&String>,
|
||||
avatar_color: Option<ColorU>,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let background = avatar_color.unwrap_or_else(|| blended_colors::accent(theme).into());
|
||||
let background = avatar_color.unwrap_or_else(session_avatar_color);
|
||||
let avatar = Avatar::new(
|
||||
profile_image_path
|
||||
.map(|url| AvatarContent::Image {
|
||||
url: url.to_owned(),
|
||||
display_name: user_display_name.to_owned(),
|
||||
})
|
||||
.unwrap_or(AvatarContent::DisplayName(user_display_name.to_owned())),
|
||||
AvatarContent::Icon(Icon::User),
|
||||
UiComponentStyles {
|
||||
width: Some(icon_size(app)),
|
||||
height: Some(icon_size(app)),
|
||||
font_family_id: Some(appearance.ui_font_family()),
|
||||
font_size: Some(appearance.monospace_font_size() - 2.),
|
||||
background: Some(background.into()),
|
||||
font_color: Some(blended_colors::text_main(theme, background)),
|
||||
font_color: Some(ColorU::white()),
|
||||
border_radius: Some(CornerRadius::with_all(Radius::Percentage(50.))),
|
||||
..Default::default()
|
||||
},
|
||||
|
||||
@@ -432,28 +432,42 @@ pub(super) fn render_start_agent(
|
||||
}
|
||||
}
|
||||
if let Some(card_data) = child_conversation_card_data {
|
||||
let navigation_card_handle = props
|
||||
.state_handles
|
||||
.orchestration_navigation_card_handles
|
||||
.get(action_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| {
|
||||
log::error!(
|
||||
"Missing orchestration navigation card handle for StartAgent action {:?}",
|
||||
action_id
|
||||
);
|
||||
MouseStateHandle::default()
|
||||
});
|
||||
let status_icon = card_data.status.status_icon_and_color(theme);
|
||||
column.add_child(render_conversation_navigation_card_row(
|
||||
&card_data.agent_name,
|
||||
Some(&card_data.title),
|
||||
Some(status_icon),
|
||||
card_data.conversation_id,
|
||||
navigation_card_handle,
|
||||
true,
|
||||
app,
|
||||
));
|
||||
// Render inline subagent panel instead of navigation card
|
||||
if let Some(panel_state) =
|
||||
props.state_handles.subagent_panel_states.get(action_id)
|
||||
{
|
||||
column.add_child(
|
||||
crate::ai::blocklist::agent_view::subagent_inline_panel::render_subagent_inline_panel(
|
||||
panel_state,
|
||||
action_id,
|
||||
app,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Fallback: render the navigation card if no panel state exists yet
|
||||
let navigation_card_handle = props
|
||||
.state_handles
|
||||
.orchestration_navigation_card_handles
|
||||
.get(action_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| {
|
||||
log::error!(
|
||||
"Missing orchestration navigation card handle for StartAgent action {:?}",
|
||||
action_id
|
||||
);
|
||||
MouseStateHandle::default()
|
||||
});
|
||||
let status_icon = card_data.status.status_icon_and_color(theme);
|
||||
column.add_child(render_conversation_navigation_card_row(
|
||||
&card_data.agent_name,
|
||||
Some(&card_data.title),
|
||||
Some(status_icon),
|
||||
card_data.conversation_id,
|
||||
navigation_card_handle,
|
||||
true,
|
||||
app,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return column
|
||||
|
||||
@@ -78,7 +78,9 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use warp_multi_agent_api::{message, Task, ToolType};
|
||||
|
||||
use super::orchestration_events::{OrchestrationEventService, OrchestrationEventServiceEvent};
|
||||
use super::orchestration_events::{
|
||||
OrchestrationEventService, OrchestrationEventServiceEvent, PendingEventDetail,
|
||||
};
|
||||
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -1507,6 +1509,9 @@ impl BlocklistAIController {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle local subagent events before standard orchestration events.
|
||||
self.handle_subagent_events(conversation_id, ctx);
|
||||
|
||||
if self
|
||||
.in_flight_response_streams
|
||||
.has_active_stream_for_conversation(conversation_id, ctx)
|
||||
@@ -1555,6 +1560,67 @@ impl BlocklistAIController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Processes local subagent events (questions, answers, summaries) for a conversation.
|
||||
fn handle_subagent_events(
|
||||
&mut self,
|
||||
conversation_id: AIConversationId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let events = OrchestrationEventService::handle(ctx).update(ctx, |svc, _ctx| {
|
||||
svc.drain_subagent_events(&conversation_id)
|
||||
});
|
||||
|
||||
for event in events {
|
||||
match event.detail {
|
||||
PendingEventDetail::SubagentQuestion {
|
||||
source_conversation_id,
|
||||
question_text,
|
||||
options,
|
||||
..
|
||||
} => {
|
||||
// Auto-answer: pick the first option, or echo the question text
|
||||
// as a default answer. In a future version, this could invoke the
|
||||
// parent LLM for a contextual answer.
|
||||
let answer = options.first().cloned().unwrap_or_else(|| {
|
||||
format!("Proceed with: {}", question_text)
|
||||
});
|
||||
|
||||
OrchestrationEventService::handle(ctx).update(ctx, |svc, ctx| {
|
||||
svc.route_answer_to_subagent(source_conversation_id, answer, ctx);
|
||||
});
|
||||
}
|
||||
PendingEventDetail::SubagentAnswer {
|
||||
answer_text, ..
|
||||
} => {
|
||||
// This fires on the child's controller — complete its pending question.
|
||||
self.complete_ask_user_question_with_answer(answer_text, ctx);
|
||||
}
|
||||
PendingEventDetail::SubagentCompletionSummary { .. } => {
|
||||
// Summary is consumed by the inline panel renderer directly.
|
||||
// No controller action needed.
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Completes this controller's pending AskUserQuestion with the answer from the parent.
|
||||
fn complete_ask_user_question_with_answer(
|
||||
&self,
|
||||
answer_text: String,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
use ai::agent::action_result::AskUserQuestionAnswerItem;
|
||||
|
||||
let executor = self.action_model.as_ref(ctx).ask_user_question_executor(ctx);
|
||||
let answer_item = AskUserQuestionAnswerItem::Answered {
|
||||
question_id: String::new(),
|
||||
selected_options: vec![answer_text.clone()],
|
||||
other_text: answer_text,
|
||||
};
|
||||
executor.as_ref(ctx).complete(vec![answer_item]);
|
||||
}
|
||||
|
||||
pub fn resume_conversation(
|
||||
&mut self,
|
||||
conversation_id: AIConversationId,
|
||||
|
||||
@@ -259,8 +259,8 @@ impl ResponseStream {
|
||||
let event_type_name = match &response_event.r#type {
|
||||
Some(warp_multi_agent_api::response_event::Type::Init(_)) => "Init",
|
||||
Some(warp_multi_agent_api::response_event::Type::ClientActions(a)) => {
|
||||
log::info!(
|
||||
"[bedrock-debug] ResponseStream received ClientActions with {} actions",
|
||||
log::debug!(
|
||||
"[bedrock] ResponseStream received ClientActions with {} actions",
|
||||
a.actions.len()
|
||||
);
|
||||
"ClientActions"
|
||||
|
||||
@@ -552,6 +552,57 @@ impl OrchestrationEventService {
|
||||
LifecycleEventType::Idle,
|
||||
result,
|
||||
);
|
||||
|
||||
// Emit completion summary to parent and merge costs
|
||||
let (parent_id, summary) = {
|
||||
let history_model = BlocklistAIHistoryModel::as_ref(ctx);
|
||||
let parent_id = history_model
|
||||
.conversation(&conversation_id)
|
||||
.and_then(|c| c.parent_conversation_id());
|
||||
let summary = parent_id.and_then(|_| {
|
||||
let conv = history_model.conversation(&conversation_id)?;
|
||||
let messages = conv.all_linearized_messages();
|
||||
messages.iter().rev().find_map(|msg| {
|
||||
let message_content = msg.message.as_ref()?;
|
||||
match message_content {
|
||||
warp_multi_agent_api::message::Message::AgentOutput(output)
|
||||
if !output.text.is_empty() =>
|
||||
{
|
||||
let text = if output.text.len() > 500 {
|
||||
format!("{}...", &output.text[..497])
|
||||
} else {
|
||||
output.text.clone()
|
||||
};
|
||||
Some(text)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
});
|
||||
(parent_id, summary)
|
||||
};
|
||||
if let Some(parent_id) = parent_id {
|
||||
// Merge child's token usage and costs into parent
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _ctx| {
|
||||
let child_usage: Option<(HashMap<String, api::response_event::stream_finished::TokenUsage>, crate::ai::agent::RequestCost)> = history_model
|
||||
.conversation(&conversation_id)
|
||||
.map(|c| (c.total_token_usage_by_model().clone(), c.total_request_cost()));
|
||||
if let Some((token_usage, request_cost)) = child_usage {
|
||||
if let Some(parent) = history_model.conversation_mut(&parent_id) {
|
||||
parent.merge_child_usage_raw(&token_usage, request_cost);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(summary_text) = summary {
|
||||
self.route_subagent_completion_summary(
|
||||
conversation_id,
|
||||
parent_id,
|
||||
summary_text,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
(Some(ConversationStatus::InProgress), ConversationStatus::Error) => {
|
||||
let result = self.dispatch_lifecycle_event(
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
use crate::ai::bedrock::convert::{ContentPart, ConversationMessage, MessageContent, MessageRole};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::ui_components::blended_colors;
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
Container, CornerRadius, CrossAxisAlignment, Flex, ParentElement, Radius, Text,
|
||||
},
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
|
||||
pub struct ContextWindowView {
|
||||
messages: Vec<ConversationMessage>,
|
||||
}
|
||||
|
||||
impl ContextWindowView {
|
||||
pub fn new(messages: Vec<ConversationMessage>) -> Self {
|
||||
Self { messages }
|
||||
}
|
||||
}
|
||||
|
||||
impl View for ContextWindowView {
|
||||
fn ui_name() -> &'static str {
|
||||
"ContextWindowView"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let font_size = appearance.ui_font_size();
|
||||
let text_color = blended_colors::text_main(theme, theme.surface_2());
|
||||
let label_color = blended_colors::text_sub(theme, theme.surface_2());
|
||||
|
||||
let mut column = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_spacing(6.0);
|
||||
|
||||
// Header
|
||||
let total_chars: usize = self
|
||||
.messages
|
||||
.iter()
|
||||
.map(|m| match &m.content {
|
||||
MessageContent::Text(t) => t.len(),
|
||||
MessageContent::ToolUse { input, .. } => input.to_string().len(),
|
||||
MessageContent::ToolResult { content, .. } => content.len(),
|
||||
MessageContent::MultiPart(parts) => parts
|
||||
.iter()
|
||||
.map(|p| match p {
|
||||
ContentPart::Text(t) => t.len(),
|
||||
ContentPart::ToolUse { input, .. } => input.to_string().len(),
|
||||
ContentPart::ToolResult { content, .. } => content.len(),
|
||||
})
|
||||
.sum(),
|
||||
})
|
||||
.sum();
|
||||
let estimated_tokens = total_chars / 4;
|
||||
|
||||
let header_text = format!(
|
||||
"Context Window: {} messages, ~{} tokens est.",
|
||||
self.messages.len(),
|
||||
format_tokens(estimated_tokens as u32),
|
||||
);
|
||||
column = column.with_child(
|
||||
Text::new(
|
||||
header_text,
|
||||
appearance.ui_font_family(),
|
||||
font_size + 1.0,
|
||||
)
|
||||
.with_color(label_color)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
// Messages — show full content, no truncation.
|
||||
// The parent blocklist handles scrolling.
|
||||
for (i, msg) in self.messages.iter().enumerate() {
|
||||
let role_str = match msg.role {
|
||||
MessageRole::User => "USER",
|
||||
MessageRole::Assistant => "ASST",
|
||||
};
|
||||
|
||||
// Role header
|
||||
let header_line = format!("--- [{}] Message #{} ---", role_str, i);
|
||||
column = column.with_child(
|
||||
Text::new(header_line, appearance.ui_font_family(), font_size)
|
||||
.with_color(label_color)
|
||||
.soft_wrap(true)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
// Full content
|
||||
let content_text = match &msg.content {
|
||||
MessageContent::Text(t) => t.clone(),
|
||||
MessageContent::ToolUse { name, tool_use_id, input } => {
|
||||
format!(
|
||||
"[ToolUse] name={}, id={}\ninput={}",
|
||||
name, tool_use_id, input
|
||||
)
|
||||
}
|
||||
MessageContent::ToolResult {
|
||||
tool_use_id,
|
||||
content,
|
||||
is_error,
|
||||
} => {
|
||||
format!(
|
||||
"[ToolResult] id={}, error={}\n{}",
|
||||
tool_use_id, is_error, content
|
||||
)
|
||||
}
|
||||
MessageContent::MultiPart(parts) => {
|
||||
let mut out = String::new();
|
||||
for (pi, p) in parts.iter().enumerate() {
|
||||
match p {
|
||||
ContentPart::Text(t) => {
|
||||
out.push_str(&format!("[Part {} Text] {}\n", pi, t));
|
||||
}
|
||||
ContentPart::ToolUse { name, tool_use_id, input } => {
|
||||
out.push_str(&format!(
|
||||
"[Part {} ToolUse] name={}, id={}, input={}\n",
|
||||
pi, name, tool_use_id, input
|
||||
));
|
||||
}
|
||||
ContentPart::ToolResult { tool_use_id, content, is_error } => {
|
||||
out.push_str(&format!(
|
||||
"[Part {} ToolResult] id={}, error={}\n{}\n",
|
||||
pi, tool_use_id, is_error, content
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
};
|
||||
|
||||
column = column.with_child(
|
||||
Text::new(content_text, appearance.ui_font_family(), font_size)
|
||||
.with_color(text_color)
|
||||
.soft_wrap(true)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
if self.messages.is_empty() {
|
||||
column = column.with_child(
|
||||
Text::new(
|
||||
"(empty - no messages in bedrock history)".to_string(),
|
||||
appearance.ui_font_family(),
|
||||
font_size,
|
||||
)
|
||||
.with_color(label_color)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
Container::new(column.finish())
|
||||
.with_uniform_padding(12.0)
|
||||
.with_background(theme.surface_2())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.0)))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ContextWindowView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl TypedActionView for ContextWindowView {
|
||||
type Action = ();
|
||||
|
||||
fn handle_action(&mut self, _action: &Self::Action, _ctx: &mut ViewContext<Self>) {}
|
||||
}
|
||||
|
||||
fn format_tokens(tokens: u32) -> String {
|
||||
if tokens >= 1_000_000 {
|
||||
format!("{:.1}M", tokens as f64 / 1_000_000.0)
|
||||
} else if tokens >= 1_000 {
|
||||
format!("{:.1}k", tokens as f64 / 1_000.0)
|
||||
} else {
|
||||
format!("{tokens}")
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,8 @@ pub struct ConversationUsageInfo {
|
||||
pub total_cache_read_tokens: u32,
|
||||
/// Cumulative cache write tokens (session total).
|
||||
pub total_cache_write_tokens: u32,
|
||||
/// Cumulative total input tokens across all requests (session total).
|
||||
pub total_input_tokens: u32,
|
||||
}
|
||||
|
||||
/// Timing information for the last set of agent responses
|
||||
@@ -278,15 +280,15 @@ impl ConversationUsageView {
|
||||
));
|
||||
}
|
||||
|
||||
// Cache hit rate
|
||||
let cache_miss = self.usage_info.current_context_tokens
|
||||
.saturating_sub(self.usage_info.total_cache_read_tokens);
|
||||
let total_input = self.usage_info.total_cache_read_tokens + cache_miss;
|
||||
if total_input > 0 {
|
||||
let hit_rate = (self.usage_info.total_cache_read_tokens as f32 / total_input as f32) * 100.0;
|
||||
// Cache hit rate: proportion of total input tokens served from cache
|
||||
let total_input = self.usage_info.total_input_tokens;
|
||||
if total_input > 0 && self.usage_info.total_cache_read_tokens > 0 {
|
||||
let hit_rate = (self.usage_info.total_cache_read_tokens as f32
|
||||
/ total_input as f32)
|
||||
* 100.0;
|
||||
labels.push(render_label_text("Cache hit rate", appearance));
|
||||
values.push(render_value_text(
|
||||
format!("{:.0}%", hit_rate),
|
||||
format!("{:.0}%", hit_rate.min(100.0)),
|
||||
appearance,
|
||||
));
|
||||
}
|
||||
@@ -410,14 +412,16 @@ impl ConversationUsageView {
|
||||
));
|
||||
values.push(render_section_header("".to_string(), appearance));
|
||||
|
||||
labels.push(render_label_text("Time to first token", appearance));
|
||||
values.push(render_value_text(
|
||||
format!(
|
||||
"{:.1} seconds",
|
||||
timing.time_to_first_token_ms as f64 / 1000.0
|
||||
),
|
||||
appearance,
|
||||
));
|
||||
if timing.time_to_first_token_ms > 0 {
|
||||
labels.push(render_label_text("Time to first token", appearance));
|
||||
values.push(render_value_text(
|
||||
format!(
|
||||
"{:.1} seconds",
|
||||
timing.time_to_first_token_ms as f64 / 1000.0
|
||||
),
|
||||
appearance,
|
||||
));
|
||||
}
|
||||
|
||||
labels.push(render_label_text("Total agent response time", appearance));
|
||||
values.push(render_value_text(
|
||||
|
||||
@@ -2,6 +2,7 @@ use galaxy_core::ui::theme::{Fill, GalaxyTheme};
|
||||
use galaxy_core::ui::Icon;
|
||||
use galaxyui::Element;
|
||||
|
||||
pub mod context_window_view;
|
||||
pub mod conversation_usage_view;
|
||||
|
||||
pub fn icon_for_context_window_usage(context_window_usage: f32) -> Icon {
|
||||
|
||||
@@ -387,6 +387,15 @@ pub const COST: StaticCommand = StaticCommand {
|
||||
argument: None,
|
||||
};
|
||||
|
||||
pub const CONTEXT: StaticCommand = StaticCommand {
|
||||
name: "/context",
|
||||
description: "Show current context window contents (debug)",
|
||||
icon_path: "bundled/svg/bar-chart-04.svg",
|
||||
availability: Availability::AGENT_VIEW.union(Availability::AI_ENABLED),
|
||||
auto_enter_ai_mode: false,
|
||||
argument: None,
|
||||
};
|
||||
|
||||
pub const CONVERSATIONS: StaticCommand = StaticCommand {
|
||||
name: "/conversations",
|
||||
description: "Open conversation history",
|
||||
@@ -506,6 +515,7 @@ fn all_commands() -> Vec<StaticCommand> {
|
||||
ADD_MCP,
|
||||
ADD_PROMPT.clone(),
|
||||
ADD_RULE,
|
||||
CONTEXT,
|
||||
COST,
|
||||
FEEDBACK.clone(),
|
||||
INDEX,
|
||||
|
||||
@@ -698,6 +698,20 @@ impl Input {
|
||||
ctx.dispatch_typed_action(&TerminalAction::ToggleUsageFooter);
|
||||
}
|
||||
}
|
||||
_context if command.name == commands::CONTEXT.name => {
|
||||
let history = BlocklistAIHistoryModel::handle(ctx);
|
||||
let conversation = history
|
||||
.as_ref(ctx)
|
||||
.active_conversation(self.terminal_view_id);
|
||||
if conversation.is_none() {
|
||||
show_error_toast(
|
||||
"Cannot show context: no active conversation".to_owned(),
|
||||
ctx,
|
||||
);
|
||||
} else {
|
||||
ctx.dispatch_typed_action(&TerminalAction::ToggleContextView);
|
||||
}
|
||||
}
|
||||
_fork if command.name == commands::FORK.name => {
|
||||
let Some(conversation_id) = self
|
||||
.ai_context_model
|
||||
|
||||
@@ -2586,6 +2586,9 @@ pub struct TerminalView {
|
||||
/// Cached view ids for usage footers keyed by the AI block view id that owns them.
|
||||
usage_footer_view_ids: HashMap<EntityId, EntityId>,
|
||||
|
||||
/// View ID of the context window debug view, if visible.
|
||||
context_view_id: Option<EntityId>,
|
||||
|
||||
// Whether the block onboarding view is active or not.
|
||||
block_onboarding_active: bool,
|
||||
|
||||
@@ -4089,6 +4092,7 @@ impl TerminalView {
|
||||
active_filter_editor_block_index: None,
|
||||
rich_content_views: Vec::new(),
|
||||
usage_footer_view_ids: Default::default(),
|
||||
context_view_id: None,
|
||||
block_onboarding_active: false,
|
||||
onboarding_agentic_suggestions_block: None,
|
||||
onboarding_prompt_block: None,
|
||||
@@ -5621,6 +5625,18 @@ impl TerminalView {
|
||||
};
|
||||
|
||||
let tool_usage = conversation.tool_usage_metadata();
|
||||
// For Bedrock conversations, the server doesn't send tool_usage_metadata.
|
||||
// Fall back to counting actions from the conversation exchanges.
|
||||
let tool_call_count = if tool_usage.total_tool_calls() > 0 {
|
||||
tool_usage.total_tool_calls()
|
||||
} else {
|
||||
conversation.count_all_actions() as i32
|
||||
};
|
||||
let commands_executed = if tool_usage.run_command_stats.commands_executed > 0 {
|
||||
tool_usage.run_command_stats.commands_executed
|
||||
} else {
|
||||
conversation.count_command_actions() as i32
|
||||
};
|
||||
let time_to_first_token_ms = conversation.time_to_first_token_for_last_user_query_ms();
|
||||
let total_agent_response_time_ms =
|
||||
conversation.total_agent_response_time_since_last_user_query_ms();
|
||||
@@ -5631,20 +5647,22 @@ impl TerminalView {
|
||||
let total_cache_read_tokens: u32 = token_usage_list.iter().map(|u| u.input_cache_read).sum();
|
||||
let total_cache_write_tokens: u32 =
|
||||
token_usage_list.iter().map(|u| u.input_cache_write).sum();
|
||||
let total_input_tokens: u32 = token_usage_list.iter().map(|u| u.total_input).sum();
|
||||
let estimated_cost_cents: f32 = token_usage_list.iter().map(|u| u.cost_in_cents).sum();
|
||||
|
||||
let conversation_usage_info = ConversationUsageInfo {
|
||||
tool_calls: tool_usage.total_tool_calls(),
|
||||
tool_calls: tool_call_count,
|
||||
models: conversation.token_usage().to_vec(),
|
||||
context_window_usage: conversation.context_window_usage(),
|
||||
files_changed: tool_usage.apply_file_diff_stats.files_changed,
|
||||
lines_added: tool_usage.apply_file_diff_stats.lines_added,
|
||||
lines_removed: tool_usage.apply_file_diff_stats.lines_removed,
|
||||
commands_executed: tool_usage.run_command_stats.commands_executed,
|
||||
commands_executed,
|
||||
current_context_tokens: conversation.current_context_tokens(),
|
||||
estimated_cost_cents,
|
||||
total_cache_read_tokens,
|
||||
total_cache_write_tokens,
|
||||
total_input_tokens,
|
||||
};
|
||||
|
||||
let timing_info = TimingInfo {
|
||||
@@ -5729,6 +5747,51 @@ impl TerminalView {
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_context_view(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
use crate::ai::blocklist::usage::context_window_view::ContextWindowView;
|
||||
|
||||
// If already showing, remove it
|
||||
if let Some(view_id) = self.context_view_id.take() {
|
||||
let mut model = self.model.lock();
|
||||
model.block_list_mut().remove_rich_content(view_id);
|
||||
drop(model);
|
||||
self.rich_content_views.retain(|rc| rc.view_id() != view_id);
|
||||
ctx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the active conversation's bedrock_message_history
|
||||
let conversation_id = self
|
||||
.agent_view_controller
|
||||
.as_ref(ctx)
|
||||
.agent_view_state()
|
||||
.active_conversation_id();
|
||||
|
||||
let Some(conversation_id) = conversation_id else {
|
||||
return;
|
||||
};
|
||||
|
||||
let messages = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation(&conversation_id)
|
||||
.map(|conv| conv.bedrock_message_history().to_vec())
|
||||
.unwrap_or_default();
|
||||
|
||||
let context_view = ctx.add_view(|_| ContextWindowView::new(messages));
|
||||
let view_id = context_view.id();
|
||||
self.context_view_id = Some(view_id);
|
||||
|
||||
self.insert_rich_content(
|
||||
None,
|
||||
context_view,
|
||||
None,
|
||||
RichContentInsertionPosition::Append {
|
||||
insert_below_long_running_block: true,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Returns true if the window is wide enough to auto-open side panels.
|
||||
pub fn can_auto_open_panel(&self) -> bool {
|
||||
self.size_info.pane_width_px().as_f32() > MINIMUM_WIDTH_TO_AUTO_OPEN_PANE
|
||||
@@ -24407,6 +24470,7 @@ impl TypedActionView for TerminalView {
|
||||
| AwsCliNotInstalledBanner(_)
|
||||
| ExecuteRewindFromInlineMenu { .. }
|
||||
| ToggleUsageFooter
|
||||
| ToggleContextView
|
||||
| RevealChildAgent { .. }
|
||||
| OpenCLIAgentRichInput
|
||||
| ToggleSessionRecording => Empty,
|
||||
@@ -25428,6 +25492,9 @@ impl TypedActionView for TerminalView {
|
||||
ToggleUsageFooter => {
|
||||
self.toggle_usage_footer(ctx);
|
||||
}
|
||||
ToggleContextView => {
|
||||
self.toggle_context_view(ctx);
|
||||
}
|
||||
RevealChildAgent { conversation_id } => {
|
||||
ctx.emit(Event::RevealChildAgent {
|
||||
conversation_id: *conversation_id,
|
||||
|
||||
@@ -422,6 +422,8 @@ pub enum TerminalAction {
|
||||
AwsCliNotInstalledBanner(AwsCliNotInstalledBannerAction),
|
||||
/// Toggle the usage footer on the last AI block in the active conversation.
|
||||
ToggleUsageFooter,
|
||||
/// Toggle the context window debug view showing bedrock_message_history.
|
||||
ToggleContextView,
|
||||
/// Reveal a hidden child agent pane from the orchestrator status card.
|
||||
RevealChildAgent {
|
||||
conversation_id: AIConversationId,
|
||||
@@ -702,6 +704,7 @@ impl fmt::Debug for TerminalAction {
|
||||
AwsBedrockLoginBanner(action) => write!(f, "AwsBedrockLoginBanner({action:?})"),
|
||||
AwsCliNotInstalledBanner(action) => write!(f, "AwsCliNotInstalledBanner({action:?})"),
|
||||
ToggleUsageFooter => write!(f, "ToggleUsageFooter"),
|
||||
ToggleContextView => write!(f, "ToggleContextView"),
|
||||
RevealChildAgent { .. } => write!(f, "RevealChildAgent"),
|
||||
ToggleSessionRecording => write!(f, "ToggleSessionRecording"),
|
||||
OpenCLIAgentRichInput => write!(f, "OpenCLIAgentRichInput"),
|
||||
|
||||
@@ -8326,13 +8326,6 @@ impl Workspace {
|
||||
.into_item(),
|
||||
);
|
||||
|
||||
if !self.auth_state.is_anonymous_or_logged_out() {
|
||||
items.push(
|
||||
MenuItemFields::new("Log out")
|
||||
.with_on_select_action(WorkspaceAction::LogOut)
|
||||
.into_item(),
|
||||
);
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
|
||||
@@ -265,6 +265,7 @@ impl From<&gql_usage::ConversationUsage> for ConversationUsageInfo {
|
||||
estimated_cost_cents: 0.0,
|
||||
total_cache_read_tokens: 0,
|
||||
total_cache_write_tokens: 0,
|
||||
total_input_tokens: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user