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:
Ryan Ward
2026-05-21 14:30:04 -05:00
co-authored by Claude Opus 4.6
parent 6f54e2cb30
commit f278e53b7e
22 changed files with 914 additions and 113 deletions
+137 -31
View File
@@ -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()
+19 -2
View File
@@ -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)
+147
View File
@@ -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>) {
+10 -6
View File
@@ -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 { &current_tool_use_id });
log::debug!("[bedrock] Event #{event_count}: ContentBlockStop (tool_use_id={:?})", if current_tool_use_id.is_empty() { "none" } else { &current_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
+34
View File
@@ -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();
}
+28 -12
View File
@@ -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
+67 -1
View File
@@ -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(
+1
View File
@@ -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
+69 -2
View File
@@ -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,
+3
View File
@@ -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"),
-7
View File
@@ -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
}
+1
View File
@@ -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,
}
}
}