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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user