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
@@ -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 {