first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -1,24 +1,39 @@
|
||||
use crate::ai::blocklist::usage::render_context_window_usage_icon;
|
||||
use crate::ai::blocklist::view_util::{format_cost_cents, format_token_count};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::persistence::model::{
|
||||
token_usage_category_display_name, ModelTokenUsage, FULL_TERMINAL_USE_CATEGORY,
|
||||
PRIMARY_AGENT_CATEGORY,
|
||||
};
|
||||
use crate::ui_components::blended_colors;
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxy_core::ui::Icon;
|
||||
use galaxyui::elements::ConstrainedBox;
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
Border, Container, CornerRadius, CrossAxisAlignment, Empty, Flex, MainAxisSize,
|
||||
MouseStateHandle, ParentElement, Radius, Text,
|
||||
},
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxy_core::ui::Icon;
|
||||
use galaxyui::elements::{
|
||||
Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DropShadow,
|
||||
Empty, Flex, Hoverable, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor,
|
||||
ParentElement, ParentOffsetBounds, Radius, Stack, Text,
|
||||
};
|
||||
use galaxyui::fonts::{Properties, Weight};
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::text_layout::ClipConfig;
|
||||
use galaxyui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::blocklist::agent_view::orchestration_pill_bar::{
|
||||
render_agent_avatar_disc, render_orchestrator_avatar_disc,
|
||||
};
|
||||
use crate::ai::blocklist::orchestration_topology::descendant_conversation_ids_in_spawn_order;
|
||||
use crate::ai::blocklist::usage::render_context_window_usage_icon;
|
||||
use crate::ai::blocklist::usage::rollup::{
|
||||
compute_orchestration_rollup, AgentAvatar, OrchestrationCreditRollup, PerAgentCreditEntry,
|
||||
};
|
||||
use crate::ai::blocklist::view_util::format_credits;
|
||||
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::persistence::model::{
|
||||
token_usage_category_display_name, ContextWindowSegment, ContextWindowSegmentType,
|
||||
ModelTokenUsage, FULL_TERMINAL_USE_CATEGORY, PRIMARY_AGENT_CATEGORY,
|
||||
};
|
||||
use crate::ui_components::blended_colors;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DisplayMode {
|
||||
Settings,
|
||||
@@ -26,9 +41,17 @@ pub enum DisplayMode {
|
||||
}
|
||||
|
||||
pub struct ConversationUsageInfo {
|
||||
pub credits_spent: f32,
|
||||
pub platform_credits_spent: f32,
|
||||
// Credits spent over the last block, where the block comprises
|
||||
// all agent outputs since the most recent user input.
|
||||
pub credits_spent_for_last_block: Option<f32>,
|
||||
pub tool_calls: i32,
|
||||
pub models: Vec<ModelTokenUsage>,
|
||||
pub context_window_usage: f32,
|
||||
/// Per-segment breakdown of the context window. Scaled so the segments
|
||||
/// sum to `context_window_usage`. Empty when the server did not emit it.
|
||||
pub context_window_segments: Vec<ContextWindowSegment>,
|
||||
pub files_changed: i32,
|
||||
pub lines_added: i32,
|
||||
pub lines_removed: i32,
|
||||
@@ -58,6 +81,22 @@ pub struct TimingInfo {
|
||||
pub wall_to_wall_response_time_ms: Option<i64>,
|
||||
}
|
||||
|
||||
/// Typed actions dispatched by widgets inside [`ConversationUsageView`]. The
|
||||
/// view uses a single typed action surface for the "View details" /
|
||||
/// "Hide details" toggle and the "Show N more" affordance so each row's
|
||||
/// click handler can dispatch through the regular action pipeline without
|
||||
/// borrowing the view directly.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ConversationUsageViewAction {
|
||||
/// Flip the "View details" / "Hide details" toggle.
|
||||
ToggleDetailsExpanded,
|
||||
/// Reveal the truncated rows beyond the first 5 in the per-agent
|
||||
/// breakdown.
|
||||
ShowAllAgentRows,
|
||||
/// Flip the context-window per-segment breakdown expand toggle.
|
||||
ToggleContextWindowExpanded,
|
||||
}
|
||||
|
||||
/// View to hold a conversation usage info block.
|
||||
/// This is used for both the usage footer and the usage history page in settings.
|
||||
pub struct ConversationUsageView {
|
||||
@@ -67,6 +106,33 @@ pub struct ConversationUsageView {
|
||||
/// Optional timing information for the last set of responses (only shown in the footer version of this view).
|
||||
pub timing_info: Option<TimingInfo>,
|
||||
full_terminal_use_tooltip_mouse_state: MouseStateHandle,
|
||||
/// Orchestration credit rollup context. When `Some`, the parent
|
||||
/// conversation is an orchestrator with at least one locally-loaded
|
||||
/// descendant; the rollup itself is recomputed at render time from
|
||||
/// `parent_conversation_id` so descendant updates always read fresh
|
||||
/// values.
|
||||
parent_conversation_id: Option<AIConversationId>,
|
||||
/// Local UI state: whether the "View details" toggle is currently
|
||||
/// expanded. Resets to `false` whenever the footer is rebuilt — the
|
||||
/// rich-content view backing this struct is dropped and recreated on
|
||||
/// every collapse / reopen cycle, satisfying PRODUCT invariant 6.
|
||||
details_expanded: bool,
|
||||
/// Local UI state: whether the user clicked "Show N more" to reveal the
|
||||
/// rows beyond the first 5. Resets on view rebuild for the same reason
|
||||
/// as `details_expanded`.
|
||||
show_all_clicked: bool,
|
||||
/// Per-row mouse states for the "View details" / "Hide details" link
|
||||
/// and the "Show N more" link. Stored on the view so hover/click state
|
||||
/// survives across renders.
|
||||
details_toggle_mouse_state: MouseStateHandle,
|
||||
show_more_mouse_state: MouseStateHandle,
|
||||
/// Local UI state: whether the context-window per-segment breakdown is
|
||||
/// expanded. Resets on view rebuild, like `details_expanded`.
|
||||
context_window_expanded: bool,
|
||||
/// Mouse state for the context-window breakdown toggle link.
|
||||
context_window_toggle_mouse_state: MouseStateHandle,
|
||||
/// Mouse state for the context-window "Other" segment info tooltip.
|
||||
context_window_other_tooltip_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl ConversationUsageView {
|
||||
@@ -81,11 +147,109 @@ impl ConversationUsageView {
|
||||
display_mode,
|
||||
timing_info,
|
||||
full_terminal_use_tooltip_mouse_state,
|
||||
parent_conversation_id: None,
|
||||
details_expanded: false,
|
||||
show_all_clicked: false,
|
||||
details_toggle_mouse_state: MouseStateHandle::default(),
|
||||
show_more_mouse_state: MouseStateHandle::default(),
|
||||
context_window_expanded: false,
|
||||
context_window_toggle_mouse_state: MouseStateHandle::default(),
|
||||
context_window_other_tooltip_mouse_state: MouseStateHandle::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructs the view in `DisplayMode::Footer` with orchestration
|
||||
/// credit rollup wired in. The view subscribes to
|
||||
/// [`BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated`] so it
|
||||
/// re-renders whenever any contributing conversation's usage metadata
|
||||
/// changes (PRODUCT invariant 7).
|
||||
pub fn new_footer_with_rollup(
|
||||
usage_info: ConversationUsageInfo,
|
||||
timing_info: Option<TimingInfo>,
|
||||
full_terminal_use_tooltip_mouse_state: MouseStateHandle,
|
||||
parent_conversation_id: AIConversationId,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let history_model = BlocklistAIHistoryModel::handle(ctx);
|
||||
ctx.subscribe_to_model(&history_model, move |_, history, event, ctx| {
|
||||
// Narrow to events that can actually change *this* orchestrator's
|
||||
// rollup. Without this filter the closure wakes on every history
|
||||
// event in the app (one terminal view's typing storm fans out to
|
||||
// every other rollup subscriber), and `ctx.notify()` forces a
|
||||
// full footer re-render on each wake — orders of magnitude more
|
||||
// expensive than the subtree walk below.
|
||||
//
|
||||
// `StartedNewConversation` is intentionally omitted: a freshly
|
||||
// spawned descendant always has zero credits, so the rollup
|
||||
// result is unchanged until its first
|
||||
// `ConversationUsageMetadataUpdated` event (which this filter
|
||||
// will then pick up). Invariant 8 ("new descendant’s row appears
|
||||
// when it first spends a credit") is satisfied by the
|
||||
// credits-update path, not by the spawn event itself.
|
||||
let touched_id = match event {
|
||||
BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { conversation_id }
|
||||
| BlocklistAIHistoryEvent::RemoveConversation {
|
||||
conversation_id, ..
|
||||
}
|
||||
| BlocklistAIHistoryEvent::DeletedConversation {
|
||||
conversation_id, ..
|
||||
} => *conversation_id,
|
||||
_ => return,
|
||||
};
|
||||
if touched_id == parent_conversation_id {
|
||||
ctx.notify();
|
||||
return;
|
||||
}
|
||||
// `RemoveConversation` / `DeletedConversation` fire *after* the
|
||||
// conversation is dropped from `conversations_by_id`, but the
|
||||
// `children_by_parent` index that this walker consults is not
|
||||
// cleaned up on remove, so a just-pruned descendant is still
|
||||
// listed here. That lets us correctly notify on prune (invariant
|
||||
// 9) — the render-time rollup then skips the missing
|
||||
// conversation via the loaded-descendants filter and the row
|
||||
// disappears.
|
||||
let history = history.as_ref(ctx);
|
||||
if descendant_conversation_ids_in_spawn_order(history, parent_conversation_id)
|
||||
.contains(&touched_id)
|
||||
{
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
usage_info,
|
||||
display_mode: DisplayMode::Footer,
|
||||
timing_info,
|
||||
full_terminal_use_tooltip_mouse_state,
|
||||
parent_conversation_id: Some(parent_conversation_id),
|
||||
details_expanded: false,
|
||||
show_all_clicked: false,
|
||||
details_toggle_mouse_state: MouseStateHandle::default(),
|
||||
show_more_mouse_state: MouseStateHandle::default(),
|
||||
context_window_expanded: false,
|
||||
context_window_toggle_mouse_state: MouseStateHandle::default(),
|
||||
context_window_other_tooltip_mouse_state: MouseStateHandle::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the current orchestration rollup for this view, or `None`
|
||||
/// when the view is in settings mode, the parent conversation isn't
|
||||
/// known, or the orchestrator has no locally-loaded descendants with
|
||||
/// non-zero credits. The feature is self-gating: settings-mode views
|
||||
/// and conversations without descendants short-circuit before any
|
||||
/// rollup-specific UI is built, so no feature flag is needed.
|
||||
fn rollup(&self, app: &AppContext) -> Option<OrchestrationCreditRollup> {
|
||||
if self.display_mode != DisplayMode::Footer {
|
||||
return None;
|
||||
}
|
||||
let parent_id = self.parent_conversation_id?;
|
||||
let history = BlocklistAIHistoryModel::as_ref(app);
|
||||
compute_orchestration_rollup(parent_id, history)
|
||||
}
|
||||
|
||||
/// Helper to collect models grouped by category.
|
||||
/// Returns a HashMap mapping category name to list of (model_id, is_byok) tuples.
|
||||
/// Handles both category-based fields and legacy warp_tokens/byok_tokens fields.
|
||||
/// Returns a HashMap mapping category name to list of (model_id, shows_key_icon) tuples.
|
||||
/// Handles category-based fields plus legacy token-total fallbacks.
|
||||
fn collect_models_by_category(&self) -> HashMap<String, Vec<(String, bool)>> {
|
||||
let mut entries_by_category: HashMap<String, Vec<(String, bool)>> = HashMap::new();
|
||||
|
||||
@@ -107,6 +271,14 @@ impl ConversationUsageView {
|
||||
.push((model.model_id.clone(), true));
|
||||
}
|
||||
}
|
||||
for (category, &tokens) in &model.custom_endpoint_token_usage_by_category {
|
||||
if tokens > 0 {
|
||||
entries_by_category
|
||||
.entry(category.clone())
|
||||
.or_default()
|
||||
.push((model.model_id.clone(), true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to legacy fields for backwards compatibility
|
||||
@@ -124,16 +296,32 @@ impl ConversationUsageView {
|
||||
.or_default()
|
||||
.push((model.model_id.clone(), true));
|
||||
}
|
||||
if model.custom_endpoint_tokens > 0 {
|
||||
entries_by_category
|
||||
.entry(PRIMARY_AGENT_CATEGORY.to_string())
|
||||
.or_default()
|
||||
.push((model.model_id.clone(), true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entries_by_category
|
||||
}
|
||||
|
||||
fn render_unified_layout(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
fn render_unified_layout(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let font_size = appearance.ui_font_size() + 2.;
|
||||
let text_color = blended_colors::text_main(theme, theme.surface_2());
|
||||
let context_window_breakdown_enabled = FeatureFlag::ContextWindowUsageBreakdown
|
||||
.is_enabled()
|
||||
&& !context_window_segment_display_rows(
|
||||
self.usage_info.context_window_usage,
|
||||
&self.usage_info.context_window_segments,
|
||||
)
|
||||
.is_empty();
|
||||
|
||||
let rollup = self.rollup(app);
|
||||
|
||||
let mut labels: Vec<Box<dyn Element>> = vec![];
|
||||
let mut values: Vec<Box<dyn Element>> = vec![];
|
||||
@@ -145,14 +333,50 @@ impl ConversationUsageView {
|
||||
));
|
||||
values.push(render_section_header("".to_string(), appearance));
|
||||
|
||||
if self.usage_info.estimated_cost_cents > 0.0 {
|
||||
labels.push(render_label_text("Estimated cost", appearance));
|
||||
// "Credits spent (total)" value: use the rollup total when available,
|
||||
// otherwise the orchestrator's own self total (today's behavior).
|
||||
// PRODUCT invariants 2a, 11.
|
||||
let total_credits_value = rollup
|
||||
.as_ref()
|
||||
.map(|r| r.total_credits)
|
||||
.unwrap_or(self.usage_info.credits_spent + self.usage_info.platform_credits_spent);
|
||||
|
||||
if self.display_mode == DisplayMode::Footer
|
||||
&& self.usage_info.credits_spent_for_last_block.is_some()
|
||||
{
|
||||
let last_block_credits = self.usage_info.credits_spent_for_last_block.unwrap();
|
||||
labels.push(render_label_text(
|
||||
"Credits spent (last response)",
|
||||
appearance,
|
||||
));
|
||||
values.push(render_value_text(
|
||||
format_cost_cents(self.usage_info.estimated_cost_cents),
|
||||
format_credits(last_block_credits),
|
||||
appearance,
|
||||
));
|
||||
|
||||
labels.push(render_label_text("Credits spent (total)", appearance));
|
||||
values.push(self.render_total_credits_value_row(
|
||||
total_credits_value,
|
||||
rollup.as_ref(),
|
||||
appearance,
|
||||
));
|
||||
} else {
|
||||
labels.push(render_label_text("Credits spent", appearance));
|
||||
values.push(self.render_total_credits_value_row(
|
||||
total_credits_value,
|
||||
rollup.as_ref(),
|
||||
appearance,
|
||||
));
|
||||
}
|
||||
|
||||
// Per-agent breakdown rows render immediately beneath the
|
||||
// "Credits spent (total)" row so they read as a drill-down of
|
||||
// that value, not as a separate section appended at the bottom
|
||||
// of the card. The rows are pushed into the same two-column
|
||||
// label/value layout as the rest of the usage summary; the
|
||||
// existing flex spacing handles indentation.
|
||||
self.append_per_agent_rows(&mut labels, &mut values, rollup.as_ref(), appearance);
|
||||
|
||||
labels.push(render_label_text("Tool calls", appearance));
|
||||
values.push(render_value_text(
|
||||
format_value_text(self.usage_info.tool_calls, "call"),
|
||||
@@ -204,7 +428,7 @@ impl ConversationUsageView {
|
||||
labels.push(render_label_text(&label_text, appearance));
|
||||
}
|
||||
|
||||
// Build comma-separated list of models, with BYOK indicator using Icon::Key
|
||||
// Build comma-separated list of models, with external-key indicator using Icon::Key
|
||||
let mut model_elements: Vec<Box<dyn Element>> = vec![];
|
||||
let mut sorted_models: Vec<_> = models.iter().collect();
|
||||
sorted_models.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
@@ -295,10 +519,16 @@ impl ConversationUsageView {
|
||||
}
|
||||
|
||||
labels.push(render_label_text("Context window used", appearance));
|
||||
let context_usage_str =
|
||||
format!("{}%", (self.usage_info.context_window_usage * 100.).round());
|
||||
let context_window_element = Flex::row()
|
||||
let context_usage_pct = self.usage_info.context_window_usage * 100.;
|
||||
let context_usage_str = if context_window_breakdown_enabled && self.context_window_expanded
|
||||
{
|
||||
format!("{context_usage_pct:.2}%")
|
||||
} else {
|
||||
format!("{}%", context_usage_pct.round())
|
||||
};
|
||||
let mut context_window_row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_spacing(4.)
|
||||
.with_child(
|
||||
Text::new(context_usage_str, appearance.ui_font_family(), font_size)
|
||||
@@ -314,9 +544,28 @@ impl ConversationUsageView {
|
||||
.with_width(font_size)
|
||||
.with_height(font_size)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
values.push(context_window_element);
|
||||
);
|
||||
if context_window_breakdown_enabled {
|
||||
context_window_row =
|
||||
context_window_row
|
||||
.with_spacing(8.)
|
||||
.with_child(render_toggle_link(
|
||||
self.context_window_toggle_mouse_state.clone(),
|
||||
self.context_window_expanded,
|
||||
"Hide breakdown",
|
||||
"View breakdown",
|
||||
ConversationUsageViewAction::ToggleContextWindowExpanded,
|
||||
appearance,
|
||||
));
|
||||
}
|
||||
values.push(context_window_row.finish());
|
||||
|
||||
self.append_context_window_segment_rows(
|
||||
&mut labels,
|
||||
&mut values,
|
||||
context_window_breakdown_enabled,
|
||||
appearance,
|
||||
);
|
||||
|
||||
// Space between sections
|
||||
labels.push(
|
||||
@@ -459,6 +708,234 @@ impl ConversationUsageView {
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Pushes the per-agent breakdown rows (and the optional "Show N
|
||||
/// more" link) into the two-column layout when the rollup is active
|
||||
/// and the user has expanded the details. Pushed in two-column
|
||||
/// (label, value) pairs so they slot into the existing flex layout.
|
||||
/// The label column carries the avatar + display name; the value
|
||||
/// column carries the credit value.
|
||||
fn append_per_agent_rows(
|
||||
&self,
|
||||
labels: &mut Vec<Box<dyn Element>>,
|
||||
values: &mut Vec<Box<dyn Element>>,
|
||||
rollup: Option<&OrchestrationCreditRollup>,
|
||||
appearance: &Appearance,
|
||||
) {
|
||||
let Some(rollup) = rollup else {
|
||||
return;
|
||||
};
|
||||
if !self.details_expanded {
|
||||
return;
|
||||
}
|
||||
let total_entries = rollup.per_agent.len();
|
||||
let shown_entries: usize =
|
||||
if total_entries > PER_AGENT_BREAKDOWN_TRUNCATION_CAP && !self.show_all_clicked {
|
||||
PER_AGENT_BREAKDOWN_TRUNCATION_CAP
|
||||
} else {
|
||||
total_entries
|
||||
};
|
||||
for entry in rollup.per_agent.iter().take(shown_entries) {
|
||||
let (label_el, value_el) = self.render_per_agent_row(entry, appearance);
|
||||
labels.push(label_el);
|
||||
values.push(value_el);
|
||||
}
|
||||
if total_entries > shown_entries {
|
||||
let hidden_count = total_entries - shown_entries;
|
||||
// "Show N more" sits on a row of its own. We push a value-
|
||||
// side placeholder that mirrors the link's natural line
|
||||
// height so the right column stays in lock-step with the
|
||||
// left and the subsequent "Tool calls" / value row pair
|
||||
// doesn't slip out of alignment.
|
||||
labels.push(self.render_show_more_link(hidden_count, appearance));
|
||||
values.push(render_value_text_placeholder(appearance));
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the "Credits spent (total)" value cell. When a rollup
|
||||
/// applies, the cell is a row with the value followed by a
|
||||
/// "View details ▾" / "Hide details ▴" toggle.
|
||||
fn render_total_credits_value_row(
|
||||
&self,
|
||||
total_credits: f32,
|
||||
rollup: Option<&OrchestrationCreditRollup>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let value_text = render_value_text(format_credits(total_credits), appearance);
|
||||
if rollup.is_none() {
|
||||
return value_text;
|
||||
}
|
||||
|
||||
let toggle = render_toggle_link(
|
||||
self.details_toggle_mouse_state.clone(),
|
||||
self.details_expanded,
|
||||
"Hide details",
|
||||
"View details",
|
||||
ConversationUsageViewAction::ToggleDetailsExpanded,
|
||||
appearance,
|
||||
);
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_spacing(8.)
|
||||
.with_child(value_text)
|
||||
.with_child(toggle)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Pushes the per-segment context-window breakdown rows into the
|
||||
/// two-column layout when the dev-only breakdown is enabled and
|
||||
/// expanded. Segment percentages are derived from token counts.
|
||||
fn append_context_window_segment_rows(
|
||||
&self,
|
||||
labels: &mut Vec<Box<dyn Element>>,
|
||||
values: &mut Vec<Box<dyn Element>>,
|
||||
context_window_breakdown_enabled: bool,
|
||||
appearance: &Appearance,
|
||||
) {
|
||||
if !context_window_breakdown_enabled || !self.context_window_expanded {
|
||||
return;
|
||||
}
|
||||
let theme = appearance.theme();
|
||||
let background = theme.surface_2();
|
||||
let font_size = appearance.ui_font_size() + 2.;
|
||||
let label_color = blended_colors::text_disabled(theme, background);
|
||||
let value_color = blended_colors::text_sub(theme, background);
|
||||
let rows = context_window_segment_display_rows(
|
||||
self.usage_info.context_window_usage,
|
||||
&self.usage_info.context_window_segments,
|
||||
);
|
||||
for (segment_type, pct) in rows {
|
||||
let label = Text::new(
|
||||
token_usage_category_display_name(segment_type.as_str()),
|
||||
appearance.ui_font_family(),
|
||||
font_size,
|
||||
)
|
||||
.with_color(label_color)
|
||||
.finish();
|
||||
labels.push(if segment_type == ContextWindowSegmentType::Other {
|
||||
let info_icon = render_context_window_other_info_icon(
|
||||
appearance,
|
||||
self.context_window_other_tooltip_mouse_state.clone(),
|
||||
font_size,
|
||||
);
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(label)
|
||||
.with_child(Container::new(info_icon).with_margin_left(4.).finish())
|
||||
.finish()
|
||||
} else {
|
||||
label
|
||||
});
|
||||
values.push(
|
||||
Text::new(
|
||||
format!(
|
||||
"{pct:.precision$}%",
|
||||
precision = CONTEXT_WINDOW_SEGMENT_PERCENT_DECIMAL_PLACES
|
||||
),
|
||||
appearance.ui_font_family(),
|
||||
font_size,
|
||||
)
|
||||
.with_color(value_color)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the avatar + label cell for a per-agent breakdown row,
|
||||
/// plus the credit value cell, returned as a `(label, value)` pair so
|
||||
/// the caller can append them to the existing two-column flex layout.
|
||||
///
|
||||
/// Color choices:
|
||||
/// * Agent name uses the same color as the "USAGE SUMMARY" section
|
||||
/// header (the disabled-text token) so the rollup rows read as a
|
||||
/// sub-list of that section rather than competing with primary
|
||||
/// labels.
|
||||
/// * Credit value uses the label-row color (`text_sub`) so it
|
||||
/// visually echoes the "Credits spent" label rather than the
|
||||
/// primary credit count beside it.
|
||||
///
|
||||
/// Name length: agent names are clipped to the same max width and
|
||||
/// ellipsis treatment used by the orchestration pill bar
|
||||
/// ([`PER_AGENT_LABEL_MAX_WIDTH`]) so a long child-agent name in the
|
||||
/// footer doesn't push the credit-value column off-screen.
|
||||
fn render_per_agent_row(
|
||||
&self,
|
||||
entry: &PerAgentCreditEntry,
|
||||
appearance: &Appearance,
|
||||
) -> (Box<dyn Element>, Box<dyn Element>) {
|
||||
let theme = appearance.theme();
|
||||
let bg = theme.surface_2();
|
||||
let font_size = appearance.ui_font_size() + 2.;
|
||||
const ROW_AVATAR_SIZE: f32 = 16.;
|
||||
let avatar = match entry.avatar {
|
||||
AgentAvatar::Orchestrator => {
|
||||
render_orchestrator_avatar_disc(ROW_AVATAR_SIZE, theme, appearance)
|
||||
}
|
||||
AgentAvatar::Child => {
|
||||
render_agent_avatar_disc(&entry.display_name, ROW_AVATAR_SIZE, theme, appearance)
|
||||
}
|
||||
};
|
||||
let name_text = Text::new(
|
||||
entry.display_name.clone(),
|
||||
appearance.ui_font_family(),
|
||||
font_size,
|
||||
)
|
||||
.with_color(blended_colors::text_disabled(theme, bg))
|
||||
.soft_wrap(false)
|
||||
.with_clip(ClipConfig::ellipsis())
|
||||
.finish();
|
||||
let name_element = ConstrainedBox::new(name_text)
|
||||
.with_max_width(PER_AGENT_LABEL_MAX_WIDTH)
|
||||
.finish();
|
||||
let label = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_spacing(8.)
|
||||
.with_child(avatar)
|
||||
.with_child(name_element)
|
||||
.finish();
|
||||
let value = Text::new(
|
||||
format_credits(entry.credits_spent),
|
||||
appearance.ui_font_family(),
|
||||
font_size,
|
||||
)
|
||||
.with_color(blended_colors::text_sub(theme, bg))
|
||||
.finish();
|
||||
(label, value)
|
||||
}
|
||||
|
||||
/// Renders the "Show N more" link row shown beneath the first 5
|
||||
/// per-agent rows when the breakdown has more entries than the
|
||||
/// truncation cap. Clicking the link replaces the truncated list with
|
||||
/// the full list on the next render (PRODUCT invariant 5f). Uses the
|
||||
/// same hyperlink-blue color as the "View details" toggle so the
|
||||
/// affordances visually match.
|
||||
fn render_show_more_link(
|
||||
&self,
|
||||
hidden_count: usize,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let font_size = appearance.ui_font_size() + 2.;
|
||||
let link_color = theme.ansi_fg_blue();
|
||||
let label = format!("Show {hidden_count} more");
|
||||
Hoverable::new(self.show_more_mouse_state.clone(), move |_hover_state| {
|
||||
Text::new(label.clone(), appearance.ui_font_family(), font_size)
|
||||
.with_color(link_color)
|
||||
.with_style(Properties {
|
||||
weight: Weight::Normal,
|
||||
..Default::default()
|
||||
})
|
||||
.with_selectable(false)
|
||||
.finish()
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ConversationUsageViewAction::ShowAllAgentRows);
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Render the card container with display mode-specific styling.
|
||||
fn render_card_container(
|
||||
&self,
|
||||
@@ -504,7 +981,7 @@ impl View for ConversationUsageView {
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
self.render_card_container(self.render_unified_layout(appearance), appearance)
|
||||
self.render_card_container(self.render_unified_layout(app), appearance)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,9 +990,30 @@ impl Entity for ConversationUsageView {
|
||||
}
|
||||
|
||||
impl TypedActionView for ConversationUsageView {
|
||||
type Action = ();
|
||||
type Action = ConversationUsageViewAction;
|
||||
|
||||
fn handle_action(&mut self, _action: &Self::Action, _ctx: &mut ViewContext<Self>) {}
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
ConversationUsageViewAction::ToggleDetailsExpanded => {
|
||||
self.details_expanded = !self.details_expanded;
|
||||
// Collapsing the breakdown resets the "Show N more"
|
||||
// expansion so the user lands back on the truncated list
|
||||
// the next time they expand.
|
||||
if !self.details_expanded {
|
||||
self.show_all_clicked = false;
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
ConversationUsageViewAction::ShowAllAgentRows => {
|
||||
self.show_all_clicked = true;
|
||||
ctx.notify();
|
||||
}
|
||||
ConversationUsageViewAction::ToggleContextWindowExpanded => {
|
||||
self.context_window_expanded = !self.context_window_expanded;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the main header for a usage section.
|
||||
@@ -562,3 +1060,185 @@ fn render_value_text(text: String, appearance: &Appearance) -> Box<dyn Element>
|
||||
.with_color(text_color)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Renders a hyperlink-styled expand/collapse toggle with a chevron.
|
||||
fn render_toggle_link(
|
||||
mouse_state: MouseStateHandle,
|
||||
expanded: bool,
|
||||
expanded_label: &'static str,
|
||||
collapsed_label: &'static str,
|
||||
action: ConversationUsageViewAction,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let font_size = appearance.ui_font_size() + 2.;
|
||||
let link_color = theme.ansi_fg_blue();
|
||||
let icon_size = font_size;
|
||||
let (label, icon) = if expanded {
|
||||
(expanded_label, Icon::ChevronUp)
|
||||
} else {
|
||||
(collapsed_label, Icon::ChevronDown)
|
||||
};
|
||||
Hoverable::new(mouse_state, move |_hover_state| {
|
||||
let text_element = Text::new(label.to_string(), appearance.ui_font_family(), font_size)
|
||||
.with_color(link_color)
|
||||
.with_selectable(false)
|
||||
.finish();
|
||||
let icon_element = ConstrainedBox::new(icon.to_warpui_icon(link_color.into()).finish())
|
||||
.with_width(icon_size)
|
||||
.with_height(icon_size)
|
||||
.finish();
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_spacing(4.)
|
||||
.with_child(text_element)
|
||||
.with_child(icon_element)
|
||||
.finish()
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(action.clone());
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Computes the per-segment display rows for the context-window breakdown.
|
||||
/// Each row's percentage is derived as
|
||||
/// `context_window_usage * token_count / total_positive_segment_token_count * 100`,
|
||||
/// so the segments sum to `context_window_usage`. Rows that round to zero
|
||||
/// are dropped. Rows are sorted by percentage descending with `Other` last.
|
||||
fn context_window_segment_display_rows(
|
||||
context_window_usage: f32,
|
||||
segments: &[ContextWindowSegment],
|
||||
) -> Vec<(ContextWindowSegmentType, f32)> {
|
||||
let total: u32 = segments
|
||||
.iter()
|
||||
.map(|s| s.token_count)
|
||||
.filter(|&t| t > 0)
|
||||
.sum();
|
||||
if total == 0 || context_window_usage <= 0. {
|
||||
return Vec::new();
|
||||
}
|
||||
let total_f = total as f32;
|
||||
let multiplier = 10f32.powi(CONTEXT_WINDOW_SEGMENT_PERCENT_DECIMAL_PLACES as i32);
|
||||
let mut rows: Vec<(ContextWindowSegmentType, f32)> = segments
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
if s.token_count == 0 {
|
||||
return None;
|
||||
}
|
||||
let pct = (context_window_usage * (s.token_count as f32) / total_f * 100. * multiplier)
|
||||
.round()
|
||||
/ multiplier;
|
||||
(pct != 0.).then_some((s.segment_type, pct))
|
||||
})
|
||||
.collect();
|
||||
rows.sort_by(|a, b| match (a.0, b.0) {
|
||||
(ContextWindowSegmentType::Other, _) => Ordering::Greater,
|
||||
(_, ContextWindowSegmentType::Other) => Ordering::Less,
|
||||
_ => b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal),
|
||||
});
|
||||
rows
|
||||
}
|
||||
|
||||
/// Renders the "Other" segment info icon with an unclipped overlay tooltip.
|
||||
fn render_context_window_other_info_icon(
|
||||
appearance: &Appearance,
|
||||
mouse_state: MouseStateHandle,
|
||||
font_size: f32,
|
||||
) -> Box<dyn Element> {
|
||||
let icon_size = font_size * 0.85;
|
||||
Hoverable::new(mouse_state, move |state| {
|
||||
let icon_color = appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().surface_2());
|
||||
let icon = ConstrainedBox::new(Icon::Info.to_warpui_icon(icon_color).finish())
|
||||
.with_width(icon_size)
|
||||
.with_height(icon_size)
|
||||
.finish();
|
||||
let mut stack = Stack::new();
|
||||
stack.add_child(icon);
|
||||
if state.is_hovered() {
|
||||
stack.add_positioned_overlay_child(
|
||||
render_context_window_other_tooltip(appearance),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., -6.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::TopMiddle,
|
||||
ChildAnchor::BottomMiddle,
|
||||
),
|
||||
);
|
||||
}
|
||||
stack.finish()
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Renders the explanatory tooltip for the context-window "Other" segment.
|
||||
fn render_context_window_other_tooltip(appearance: &Appearance) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let background = theme.tooltip_background();
|
||||
let text = ConstrainedBox::new(
|
||||
Text::new(
|
||||
"Includes other request context and temporary instructions added to help the agent better respond.".to_string(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size() - 2.,
|
||||
)
|
||||
.soft_wrap(true)
|
||||
.with_color(theme.main_text_color(background.into()).into_solid())
|
||||
.finish(),
|
||||
)
|
||||
.with_max_width(CONTEXT_WINDOW_OTHER_TOOLTIP_MAX_WIDTH)
|
||||
.finish();
|
||||
Container::new(text)
|
||||
.with_background_color(background)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_border(Border::all(1.).with_border_color(theme.outline().into_solid()))
|
||||
.with_horizontal_padding(8.)
|
||||
.with_vertical_padding(5.)
|
||||
.with_drop_shadow(
|
||||
DropShadow::new_with_standard_offset_and_spread(ColorU::new(0, 0, 0, 48))
|
||||
.with_offset(vec2f(0., 4.)),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Renders a placeholder value cell that occupies one full line of the
|
||||
/// value column without painting any visible text. Used opposite the
|
||||
/// "Show N more" link so the two-column flex stays row-aligned for the
|
||||
/// subsequent rows.
|
||||
///
|
||||
/// A simple `Empty` element would also keep the slot count matched, but
|
||||
/// `Empty` has zero height, so the value column collapses by one line
|
||||
/// and "Tool calls" ends up paired with "Show N more" instead of with
|
||||
/// the next labels-column row. Pushing a `Text` element with a
|
||||
/// single-space content forces a real line-height equal to the link's
|
||||
/// own line-height.
|
||||
fn render_value_text_placeholder(appearance: &Appearance) -> Box<dyn Element> {
|
||||
let font_size = appearance.ui_font_size() + 2.;
|
||||
Text::new(" ".to_string(), appearance.ui_font_family(), font_size).finish()
|
||||
}
|
||||
|
||||
/// Maximum rendered width of an agent name in a per-agent breakdown row.
|
||||
/// Mirrors `PILL_LABEL_MAX_WIDTH` in the orchestration pill bar so the
|
||||
/// footer's name treatment never exceeds what the pill bar already
|
||||
/// enforces at the top of the agent view.
|
||||
const PER_AGENT_LABEL_MAX_WIDTH: f32 = 110.;
|
||||
|
||||
/// Maximum number of rows shown in the per-agent breakdown before the
|
||||
/// "Show N more" affordance truncates the list. Matches PRODUCT
|
||||
/// invariants 5e (≤ 5 rows render in full) and 5f (> 5 rows render the
|
||||
/// first 5 followed by a "Show N more" link).
|
||||
const PER_AGENT_BREAKDOWN_TRUNCATION_CAP: usize = 5;
|
||||
|
||||
/// Decimal precision used for visible context-window segment percentages.
|
||||
const CONTEXT_WINDOW_SEGMENT_PERCENT_DECIMAL_PLACES: usize = 2;
|
||||
|
||||
/// Maximum width of the context-window "Other" tooltip before wrapping.
|
||||
const CONTEXT_WINDOW_OTHER_TOOLTIP_MAX_WIDTH: f32 = 280.;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "conversation_usage_view_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Click-handler regression tests for [`ConversationUsageView`].
|
||||
//!
|
||||
//! The original bug was that clicks on the "View details" / "Show N more"
|
||||
//! affordances did nothing because the view was created via `add_view`
|
||||
//! instead of `add_typed_action_view`, so the framework had no handler
|
||||
//! registered for `ConversationUsageViewAction::*` and silently logged
|
||||
//! `Dispatched action has no handlers: ToggleDetailsExpanded`.
|
||||
//!
|
||||
//! The fix lives at the view-creation site in `terminal/view.rs`. These
|
||||
//! tests are a defense-in-depth layer that exercises the view's
|
||||
//! `handle_action` implementation directly, so:
|
||||
//!
|
||||
//! * If the `TypedActionView` impl is removed or broken, the test won't
|
||||
//! compile (compile-time guard).
|
||||
//! * If the handler logic for toggling `details_expanded` / resetting
|
||||
//! `show_all_clicked` regresses, the assertions below will fail
|
||||
//! (runtime guard).
|
||||
//!
|
||||
//! The tests use the same `view.update(&mut app, |view, ctx|
|
||||
//! view.handle_action(...))` pattern as the existing
|
||||
//! `number_shortcut_buttons_tests.rs` so they stay decoupled from the
|
||||
//! framework's render path (which needs `Appearance` / theme singletons
|
||||
//! that aren't relevant to the handler's correctness).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use warpui::platform::WindowStyle;
|
||||
use warpui::App;
|
||||
|
||||
use super::*;
|
||||
use crate::persistence::model::{ModelTokenUsage, PRIMARY_AGENT_CATEGORY};
|
||||
|
||||
fn placeholder_usage_info() -> ConversationUsageInfo {
|
||||
ConversationUsageInfo {
|
||||
credits_spent: 0.0,
|
||||
platform_credits_spent: 0.0,
|
||||
credits_spent_for_last_block: None,
|
||||
tool_calls: 0,
|
||||
models: Vec::new(),
|
||||
context_window_usage: 0.0,
|
||||
context_window_segments: Vec::new(),
|
||||
files_changed: 0,
|
||||
lines_added: 0,
|
||||
lines_removed: 0,
|
||||
commands_executed: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers the singletons that the view touches when constructed and
|
||||
/// when `ctx.notify()` runs (theme lookups, etc.). Keep this minimal: the
|
||||
/// goal is to satisfy the runtime, not to mirror the full production app.
|
||||
fn initialize_test_app(app: &mut App) {
|
||||
app.add_singleton_model(|_| Appearance::mock());
|
||||
}
|
||||
|
||||
fn build_view(_ctx: &mut warpui::ViewContext<ConversationUsageView>) -> ConversationUsageView {
|
||||
ConversationUsageView::new(
|
||||
placeholder_usage_info(),
|
||||
DisplayMode::Footer,
|
||||
None,
|
||||
MouseStateHandle::default(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_details_expanded_flips_state_and_resets_show_all_on_collapse() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_test_app(&mut app);
|
||||
// `add_window` registers the root view via `add_typed_action_view`
|
||||
// internally, so simply standing up the window proves
|
||||
// `ConversationUsageView: TypedActionView` is wired correctly.
|
||||
let (_window_id, view) = app.add_window(WindowStyle::NotStealFocus, build_view);
|
||||
|
||||
view.read(&app, |view, _| {
|
||||
assert!(
|
||||
!view.details_expanded,
|
||||
"view starts collapsed before any action is dispatched"
|
||||
);
|
||||
assert!(
|
||||
!view.show_all_clicked,
|
||||
"show_all_clicked starts false before any action is dispatched"
|
||||
);
|
||||
});
|
||||
|
||||
// Expand the breakdown.
|
||||
view.update(&mut app, |view, ctx| {
|
||||
view.handle_action(&ConversationUsageViewAction::ToggleDetailsExpanded, ctx);
|
||||
});
|
||||
view.read(&app, |view, _| {
|
||||
assert!(
|
||||
view.details_expanded,
|
||||
"ToggleDetailsExpanded should expand the breakdown"
|
||||
);
|
||||
});
|
||||
|
||||
// Reveal-more should set the flag while keeping the view expanded.
|
||||
view.update(&mut app, |view, ctx| {
|
||||
view.handle_action(&ConversationUsageViewAction::ShowAllAgentRows, ctx);
|
||||
});
|
||||
view.read(&app, |view, _| {
|
||||
assert!(view.details_expanded, "still expanded after Show N more");
|
||||
assert!(
|
||||
view.show_all_clicked,
|
||||
"Show N more should set show_all_clicked"
|
||||
);
|
||||
});
|
||||
|
||||
// Toggling collapse should both flip the expanded flag and reset
|
||||
// the show-all state so the next expand lands on the truncated
|
||||
// list.
|
||||
view.update(&mut app, |view, ctx| {
|
||||
view.handle_action(&ConversationUsageViewAction::ToggleDetailsExpanded, ctx);
|
||||
});
|
||||
view.read(&app, |view, _| {
|
||||
assert!(
|
||||
!view.details_expanded,
|
||||
"collapsing should toggle details_expanded back off"
|
||||
);
|
||||
assert!(
|
||||
!view.show_all_clicked,
|
||||
"collapsing should reset show_all_clicked"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_endpoint_models_use_the_external_key_icon_bucket() {
|
||||
let view = ConversationUsageView::new(
|
||||
ConversationUsageInfo {
|
||||
models: vec![ModelTokenUsage {
|
||||
model_id: "Friendly alias".to_string(),
|
||||
custom_endpoint_tokens: 6,
|
||||
custom_endpoint_token_usage_by_category: HashMap::from([(
|
||||
PRIMARY_AGENT_CATEGORY.to_string(),
|
||||
6,
|
||||
)]),
|
||||
..Default::default()
|
||||
}],
|
||||
..placeholder_usage_info()
|
||||
},
|
||||
DisplayMode::Footer,
|
||||
None,
|
||||
MouseStateHandle::default(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
view.collect_models_by_category()
|
||||
.get(PRIMARY_AGENT_CATEGORY),
|
||||
Some(&vec![("Friendly alias".to_string(), true)])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_all_agent_rows_is_independent_of_details_expanded() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_test_app(&mut app);
|
||||
let (_window_id, view) = app.add_window(WindowStyle::NotStealFocus, build_view);
|
||||
|
||||
// `ShowAllAgentRows` on its own should flip `show_all_clicked`
|
||||
// even when the user hasn't expanded the breakdown yet (the
|
||||
// render path won't show rows until expanded, but the handler
|
||||
// itself shouldn't care about ordering).
|
||||
view.update(&mut app, |view, ctx| {
|
||||
view.handle_action(&ConversationUsageViewAction::ShowAllAgentRows, ctx);
|
||||
});
|
||||
view.read(&app, |view, _| {
|
||||
assert!(
|
||||
view.show_all_clicked,
|
||||
"ShowAllAgentRows should flip show_all_clicked regardless of expanded state"
|
||||
);
|
||||
assert!(
|
||||
!view.details_expanded,
|
||||
"ShowAllAgentRows must not implicitly expand details"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -4,31 +4,38 @@ use galaxyui::Element;
|
||||
|
||||
pub mod context_window_view;
|
||||
pub mod conversation_usage_view;
|
||||
pub mod rollup;
|
||||
|
||||
pub fn icon_for_context_window_usage(context_window_usage: f32) -> Icon {
|
||||
// Match the context window usage to the nearest 10% icon.
|
||||
if context_window_usage >= 0.95 {
|
||||
Icon::ConversationContext100
|
||||
} else if context_window_usage >= 0.85 {
|
||||
Icon::ConversationContext90
|
||||
} else if context_window_usage >= 0.75 {
|
||||
Icon::ConversationContext80
|
||||
} else if context_window_usage >= 0.65 {
|
||||
Icon::ConversationContext70
|
||||
} else if context_window_usage >= 0.55 {
|
||||
Icon::ConversationContext60
|
||||
} else if context_window_usage >= 0.45 {
|
||||
Icon::ConversationContext50
|
||||
} else if context_window_usage >= 0.35 {
|
||||
Icon::ConversationContext40
|
||||
} else if context_window_usage >= 0.25 {
|
||||
Icon::ConversationContext30
|
||||
} else if context_window_usage >= 0.15 {
|
||||
Icon::ConversationContext20
|
||||
} else if context_window_usage >= 0.05 {
|
||||
Icon::ConversationContext10
|
||||
// The circle's solid (white) marks represent the context *remaining*, not
|
||||
// the amount used: an empty conversation shows an all-white circle (100%
|
||||
// remaining) and counts down to an all-grey circle as the context window
|
||||
// fills up (0% remaining). So match the *remaining* fraction
|
||||
// (`1 - usage`) to the nearest 10% icon, where `ContextRemainingN`
|
||||
// brightens N% of the ring.
|
||||
let context_window_remaining = 1.0 - context_window_usage;
|
||||
if context_window_remaining >= 0.95 {
|
||||
Icon::ContextRemaining100
|
||||
} else if context_window_remaining >= 0.85 {
|
||||
Icon::ContextRemaining90
|
||||
} else if context_window_remaining >= 0.75 {
|
||||
Icon::ContextRemaining80
|
||||
} else if context_window_remaining >= 0.65 {
|
||||
Icon::ContextRemaining70
|
||||
} else if context_window_remaining >= 0.55 {
|
||||
Icon::ContextRemaining60
|
||||
} else if context_window_remaining >= 0.45 {
|
||||
Icon::ContextRemaining50
|
||||
} else if context_window_remaining >= 0.35 {
|
||||
Icon::ContextRemaining40
|
||||
} else if context_window_remaining >= 0.25 {
|
||||
Icon::ContextRemaining30
|
||||
} else if context_window_remaining >= 0.15 {
|
||||
Icon::ContextRemaining20
|
||||
} else if context_window_remaining >= 0.05 {
|
||||
Icon::ContextRemaining10
|
||||
} else {
|
||||
Icon::ConversationContext0
|
||||
Icon::ContextRemaining0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,3 +54,7 @@ pub fn render_context_window_usage_icon(
|
||||
|
||||
icon.to_galaxyui_icon(fill).finish()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Tests for the context-window usage circle icon mapping.
|
||||
//!
|
||||
//! Regression guard for the color semantics of the context-window circle:
|
||||
//! the solid (white) marks represent the context *remaining*, not the amount
|
||||
//! used. An empty conversation (0% used → 100% remaining) shows a full white
|
||||
//! circle and it counts down to an all-grey circle as the window fills up
|
||||
//! (100% used → 0% remaining).
|
||||
|
||||
use galaxy_core::ui::Icon;
|
||||
|
||||
use super::icon_for_context_window_usage;
|
||||
|
||||
#[test]
|
||||
fn empty_conversation_shows_full_white_circle() {
|
||||
// 0% used == 100% remaining -> all-white circle.
|
||||
assert_eq!(
|
||||
icon_for_context_window_usage(0.0),
|
||||
Icon::ContextRemaining100
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_context_window_shows_all_grey_circle() {
|
||||
// 100% used == 0% remaining -> all-grey circle.
|
||||
assert_eq!(icon_for_context_window_usage(1.0), Icon::ContextRemaining0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_brightness_tracks_remaining_not_used() {
|
||||
// Lightly-used conversation: lots of context remaining -> mostly white.
|
||||
assert_eq!(icon_for_context_window_usage(0.1), Icon::ContextRemaining90);
|
||||
// Half used -> half white.
|
||||
assert_eq!(icon_for_context_window_usage(0.5), Icon::ContextRemaining50);
|
||||
// Heavily used (the original report's 88%): little remaining -> mostly grey.
|
||||
assert_eq!(
|
||||
icon_for_context_window_usage(0.88),
|
||||
Icon::ContextRemaining10
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapping_is_monotonic_more_usage_never_brightens_the_circle() {
|
||||
// As usage increases, the number of bright (remaining) marks must be
|
||||
// non-increasing — the circle only ever empties as context fills.
|
||||
let icon_rank = |usage: f32| match icon_for_context_window_usage(usage) {
|
||||
Icon::ContextRemaining0 => 0,
|
||||
Icon::ContextRemaining10 => 10,
|
||||
Icon::ContextRemaining20 => 20,
|
||||
Icon::ContextRemaining30 => 30,
|
||||
Icon::ContextRemaining40 => 40,
|
||||
Icon::ContextRemaining50 => 50,
|
||||
Icon::ContextRemaining60 => 60,
|
||||
Icon::ContextRemaining70 => 70,
|
||||
Icon::ContextRemaining80 => 80,
|
||||
Icon::ContextRemaining90 => 90,
|
||||
Icon::ContextRemaining100 => 100,
|
||||
other => panic!("unexpected icon: {other:?}"),
|
||||
};
|
||||
|
||||
let mut usage = 0.0;
|
||||
let mut previous = icon_rank(usage);
|
||||
while usage <= 1.0 {
|
||||
let current = icon_rank(usage);
|
||||
assert!(
|
||||
current <= previous,
|
||||
"icon brightness increased as usage rose to {usage}: {previous} -> {current}"
|
||||
);
|
||||
previous = current;
|
||||
usage += 0.05;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Aggregates credit usage across an orchestrator and its locally-loaded
|
||||
//! descendants for the agent-mode footer rollup feature (QUALITY-671).
|
||||
//!
|
||||
//! Pure function — no I/O, no GraphQL. Walks
|
||||
//! [`BlocklistAIHistoryModel`] using the shared
|
||||
//! [`descendant_conversation_ids_in_spawn_order`] helper, sums each loaded
|
||||
//! conversation's `credits_spent`, and emits a per-agent breakdown for the
|
||||
//! footer's "View details" list.
|
||||
|
||||
use crate::ai::agent::conversation::{AIConversation, AIConversationId};
|
||||
use crate::ai::blocklist::orchestration_topology::descendant_conversation_ids_in_spawn_order;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
|
||||
/// Avatar identity for a row in the per-agent breakdown.
|
||||
///
|
||||
/// The actual rendering still requires a theme (which the rollup, being a
|
||||
/// pure function, cannot consult), so this enum only carries the structural
|
||||
/// information needed to choose a renderer at render time. The child variant
|
||||
/// reuses the orchestration pill bar's deterministic per-name color +
|
||||
/// uppercase initial via the existing avatar helpers.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AgentAvatar {
|
||||
/// The orchestrator itself. Rendered with the Oz glyph on `ansi_fg_cyan`.
|
||||
Orchestrator,
|
||||
/// A descendant agent. Rendered with the same deterministic-color +
|
||||
/// initial-letter treatment as the orchestration pill bar.
|
||||
Child,
|
||||
}
|
||||
|
||||
/// One row in the per-agent credit breakdown list.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PerAgentCreditEntry {
|
||||
pub conversation_id: AIConversationId,
|
||||
pub display_name: String,
|
||||
pub avatar: AgentAvatar,
|
||||
pub credits_spent: f32,
|
||||
}
|
||||
|
||||
/// Aggregated credit usage for an orchestrator and its locally-loaded
|
||||
/// descendants.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct OrchestrationCreditRollup {
|
||||
/// Sum of `credits_spent` across the orchestrator and every
|
||||
/// locally-loaded descendant.
|
||||
pub total_credits: f32,
|
||||
/// One entry per agent that has spent > 0 credits, sorted by
|
||||
/// `credits_spent` descending. Ties are broken by spawn order (earlier
|
||||
/// spawn first; orchestrator always sorts before its descendants in a
|
||||
/// tie).
|
||||
pub per_agent: Vec<PerAgentCreditEntry>,
|
||||
}
|
||||
|
||||
/// Computes the orchestration credit rollup for `parent_id`.
|
||||
///
|
||||
/// Returns `None` when:
|
||||
/// * the orchestrator has no locally-loaded descendants, OR
|
||||
/// * the orchestrator and every loaded descendant have spent zero credits.
|
||||
///
|
||||
/// Unloaded descendants (IDs in the topology index without a matching
|
||||
/// `AIConversation` in `conversations_by_id`) are silently skipped — see
|
||||
/// PRODUCT.md invariant 10.
|
||||
pub fn compute_orchestration_rollup(
|
||||
parent_id: AIConversationId,
|
||||
history: &BlocklistAIHistoryModel,
|
||||
) -> Option<OrchestrationCreditRollup> {
|
||||
// Descendants in spawn order so ties break naturally. The orchestrator
|
||||
// is prepended at index 0 so it sorts before its descendants at equal
|
||||
// credit totals.
|
||||
let descendant_ids = descendant_conversation_ids_in_spawn_order(history, parent_id);
|
||||
if descendant_ids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut total_credits: f32 = 0.0;
|
||||
let mut entries: Vec<(usize, PerAgentCreditEntry)> = Vec::new();
|
||||
|
||||
if let Some(orchestrator) = history.conversation(&parent_id) {
|
||||
let credits = orchestrator.credits_spent();
|
||||
total_credits += credits;
|
||||
if credits > 0.0 {
|
||||
entries.push((
|
||||
0,
|
||||
PerAgentCreditEntry {
|
||||
conversation_id: parent_id,
|
||||
display_name: orchestrator_display_name(orchestrator),
|
||||
avatar: AgentAvatar::Orchestrator,
|
||||
credits_spent: credits,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
for (spawn_idx, descendant_id) in descendant_ids.iter().enumerate() {
|
||||
let Some(descendant) = history.conversation(descendant_id) else {
|
||||
// PRODUCT invariant 10: silently skip unloaded descendants.
|
||||
continue;
|
||||
};
|
||||
let credits = descendant.credits_spent();
|
||||
total_credits += credits;
|
||||
if credits > 0.0 {
|
||||
entries.push((
|
||||
spawn_idx + 1,
|
||||
PerAgentCreditEntry {
|
||||
conversation_id: *descendant_id,
|
||||
display_name: child_display_name(descendant),
|
||||
avatar: AgentAvatar::Child,
|
||||
credits_spent: credits,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if entries.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Sort by credits descending; ties broken by spawn order ascending so
|
||||
// the earlier-spawned agent appears first.
|
||||
entries.sort_by(|a, b| {
|
||||
b.1.credits_spent
|
||||
.partial_cmp(&a.1.credits_spent)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then(a.0.cmp(&b.0))
|
||||
});
|
||||
|
||||
Some(OrchestrationCreditRollup {
|
||||
total_credits,
|
||||
per_agent: entries.into_iter().map(|(_, entry)| entry).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Display name for the orchestrator row. Prefers the explicitly assigned
|
||||
/// `agent_name`, falls back to "Orchestrator" so the row is always
|
||||
/// meaningful.
|
||||
fn orchestrator_display_name(orchestrator: &AIConversation) -> String {
|
||||
orchestrator
|
||||
.agent_name()
|
||||
.filter(|n| !n.is_empty())
|
||||
.map(|n| n.to_string())
|
||||
.unwrap_or_else(|| "Orchestrator".to_string())
|
||||
}
|
||||
|
||||
/// Display name for a child row. Mirrors the orchestration pill bar's
|
||||
/// fallback (`"Agent"`) so the breakdown stays consistent with the pill
|
||||
/// labels when an agent hasn't been named yet.
|
||||
fn child_display_name(child: &AIConversation) -> String {
|
||||
child
|
||||
.agent_name()
|
||||
.filter(|n| !n.is_empty())
|
||||
.map(|n| n.to_string())
|
||||
.unwrap_or_else(|| "Agent".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "rollup_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,321 @@
|
||||
use warpui::{App, EntityId};
|
||||
|
||||
use super::*;
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use crate::test_util::settings::initialize_history_persistence_for_tests;
|
||||
|
||||
fn set_credits(
|
||||
app: &mut App,
|
||||
history: &warpui::ModelHandle<BlocklistAIHistoryModel>,
|
||||
id: AIConversationId,
|
||||
credits: f32,
|
||||
) {
|
||||
history.update(app, |history, _| {
|
||||
history
|
||||
.conversation_mut(&id)
|
||||
.expect("conversation must be loaded")
|
||||
.set_credits_spent_for_test(credits);
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_child(
|
||||
app: &mut App,
|
||||
history: &warpui::ModelHandle<BlocklistAIHistoryModel>,
|
||||
name: &str,
|
||||
parent_id: AIConversationId,
|
||||
terminal_view_id: EntityId,
|
||||
) -> AIConversationId {
|
||||
history.update(app, |history, ctx| {
|
||||
history.start_new_child_conversation(
|
||||
terminal_view_id,
|
||||
name.to_string(),
|
||||
parent_id,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_orchestrator_has_no_descendants() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_history_persistence_for_tests(&mut app);
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
|
||||
let orchestrator_id = history.update(&mut app, |history, ctx| {
|
||||
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
|
||||
// Even if the orchestrator itself has spent credits, no descendants
|
||||
// means no rollup applies.
|
||||
set_credits(&mut app, &history, orchestrator_id, 10.0);
|
||||
|
||||
history.read(&app, |history, _| {
|
||||
assert!(compute_orchestration_rollup(orchestrator_id, history).is_none());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sums_orchestrator_and_loaded_descendants() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_history_persistence_for_tests(&mut app);
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
|
||||
let orchestrator_id = history.update(&mut app, |history, ctx| {
|
||||
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
let child_id = spawn_child(
|
||||
&mut app,
|
||||
&history,
|
||||
"DesignBot",
|
||||
orchestrator_id,
|
||||
terminal_view_id,
|
||||
);
|
||||
|
||||
set_credits(&mut app, &history, orchestrator_id, 3.0);
|
||||
set_credits(&mut app, &history, child_id, 30.0);
|
||||
|
||||
history.read(&app, |history, _| {
|
||||
let rollup = compute_orchestration_rollup(orchestrator_id, history)
|
||||
.expect("rollup should be Some");
|
||||
assert_eq!(rollup.total_credits, 33.0);
|
||||
assert_eq!(rollup.per_agent.len(), 2);
|
||||
// Child spent more, sorted first.
|
||||
assert_eq!(rollup.per_agent[0].conversation_id, child_id);
|
||||
assert_eq!(rollup.per_agent[0].credits_spent, 30.0);
|
||||
assert_eq!(rollup.per_agent[0].avatar, AgentAvatar::Child);
|
||||
assert_eq!(rollup.per_agent[0].display_name, "DesignBot");
|
||||
assert_eq!(rollup.per_agent[1].conversation_id, orchestrator_id);
|
||||
assert_eq!(rollup.per_agent[1].credits_spent, 3.0);
|
||||
assert_eq!(rollup.per_agent[1].avatar, AgentAvatar::Orchestrator);
|
||||
assert_eq!(rollup.per_agent[1].display_name, "Orchestrator");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excludes_zero_credit_descendants_from_breakdown() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_history_persistence_for_tests(&mut app);
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
|
||||
let orchestrator_id = history.update(&mut app, |history, ctx| {
|
||||
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
let alpha_id = spawn_child(
|
||||
&mut app,
|
||||
&history,
|
||||
"Alpha",
|
||||
orchestrator_id,
|
||||
terminal_view_id,
|
||||
);
|
||||
let beta_id = spawn_child(
|
||||
&mut app,
|
||||
&history,
|
||||
"Beta",
|
||||
orchestrator_id,
|
||||
terminal_view_id,
|
||||
);
|
||||
let _idle_id = spawn_child(
|
||||
&mut app,
|
||||
&history,
|
||||
"IdleChild",
|
||||
orchestrator_id,
|
||||
terminal_view_id,
|
||||
);
|
||||
|
||||
set_credits(&mut app, &history, orchestrator_id, 2.0);
|
||||
set_credits(&mut app, &history, alpha_id, 12.0);
|
||||
set_credits(&mut app, &history, beta_id, 5.0);
|
||||
|
||||
history.read(&app, |history, _| {
|
||||
let rollup = compute_orchestration_rollup(orchestrator_id, history)
|
||||
.expect("rollup should be Some");
|
||||
assert_eq!(rollup.total_credits, 19.0);
|
||||
assert_eq!(rollup.per_agent.len(), 3);
|
||||
let ordered_ids: Vec<_> = rollup
|
||||
.per_agent
|
||||
.iter()
|
||||
.map(|entry| entry.conversation_id)
|
||||
.collect();
|
||||
assert_eq!(ordered_ids, vec![alpha_id, beta_id, orchestrator_id]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolls_up_grandchildren_transitively() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_history_persistence_for_tests(&mut app);
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
|
||||
let orchestrator_id = history.update(&mut app, |history, ctx| {
|
||||
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
let child_id = spawn_child(
|
||||
&mut app,
|
||||
&history,
|
||||
"ChildA",
|
||||
orchestrator_id,
|
||||
terminal_view_id,
|
||||
);
|
||||
let grandchild_id = spawn_child(&mut app, &history, "GrandA1", child_id, terminal_view_id);
|
||||
|
||||
set_credits(&mut app, &history, orchestrator_id, 1.0);
|
||||
set_credits(&mut app, &history, child_id, 4.0);
|
||||
set_credits(&mut app, &history, grandchild_id, 9.0);
|
||||
|
||||
history.read(&app, |history, _| {
|
||||
let rollup = compute_orchestration_rollup(orchestrator_id, history)
|
||||
.expect("rollup should be Some");
|
||||
assert_eq!(rollup.total_credits, 14.0);
|
||||
let ordered_ids: Vec<_> = rollup
|
||||
.per_agent
|
||||
.iter()
|
||||
.map(|entry| entry.conversation_id)
|
||||
.collect();
|
||||
assert_eq!(ordered_ids, vec![grandchild_id, child_id, orchestrator_id]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_six_contributors_for_show_n_more_caller() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_history_persistence_for_tests(&mut app);
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
|
||||
let orchestrator_id = history.update(&mut app, |history, ctx| {
|
||||
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
set_credits(&mut app, &history, orchestrator_id, 1.0);
|
||||
|
||||
for i in 0..5 {
|
||||
let id = spawn_child(
|
||||
&mut app,
|
||||
&history,
|
||||
&format!("Agent{i}"),
|
||||
orchestrator_id,
|
||||
terminal_view_id,
|
||||
);
|
||||
// Distinct credit values so we don't rely on tie-break behavior.
|
||||
set_credits(&mut app, &history, id, (10 + i) as f32);
|
||||
}
|
||||
|
||||
history.read(&app, |history, _| {
|
||||
let rollup = compute_orchestration_rollup(orchestrator_id, history)
|
||||
.expect("rollup should be Some");
|
||||
assert_eq!(rollup.per_agent.len(), 6);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_only_orchestrator_has_zero_credits_with_loaded_children() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_history_persistence_for_tests(&mut app);
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
|
||||
let orchestrator_id = history.update(&mut app, |history, ctx| {
|
||||
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
// One spawned child, but neither it nor the orchestrator has spent
|
||||
// any credits yet.
|
||||
let _child_id = spawn_child(
|
||||
&mut app,
|
||||
&history,
|
||||
"Idle",
|
||||
orchestrator_id,
|
||||
terminal_view_id,
|
||||
);
|
||||
|
||||
history.read(&app, |history, _| {
|
||||
assert!(compute_orchestration_rollup(orchestrator_id, history).is_none());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ties_break_by_spawn_order_earlier_first() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_history_persistence_for_tests(&mut app);
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
|
||||
let orchestrator_id = history.update(&mut app, |history, ctx| {
|
||||
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
let first_id = spawn_child(
|
||||
&mut app,
|
||||
&history,
|
||||
"FirstSpawned",
|
||||
orchestrator_id,
|
||||
terminal_view_id,
|
||||
);
|
||||
let second_id = spawn_child(
|
||||
&mut app,
|
||||
&history,
|
||||
"SecondSpawned",
|
||||
orchestrator_id,
|
||||
terminal_view_id,
|
||||
);
|
||||
|
||||
// Equal credit values force a tie-break.
|
||||
set_credits(&mut app, &history, first_id, 7.0);
|
||||
set_credits(&mut app, &history, second_id, 7.0);
|
||||
|
||||
history.read(&app, |history, _| {
|
||||
let rollup = compute_orchestration_rollup(orchestrator_id, history)
|
||||
.expect("rollup should be Some");
|
||||
assert_eq!(rollup.per_agent.len(), 2);
|
||||
assert_eq!(rollup.per_agent[0].conversation_id, first_id);
|
||||
assert_eq!(rollup.per_agent[1].conversation_id, second_id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unloaded_descendant_id_is_silently_skipped() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_history_persistence_for_tests(&mut app);
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
|
||||
let orchestrator_id = history.update(&mut app, |history, ctx| {
|
||||
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
let real_child_id = spawn_child(
|
||||
&mut app,
|
||||
&history,
|
||||
"RealChild",
|
||||
orchestrator_id,
|
||||
terminal_view_id,
|
||||
);
|
||||
set_credits(&mut app, &history, real_child_id, 4.0);
|
||||
|
||||
// Manually insert a dangling parent → child mapping for an ID that
|
||||
// is not present in `conversations_by_id`. This emulates an
|
||||
// orchestration topology entry where the child's `AIConversation`
|
||||
// hasn't been hydrated locally (e.g. remote-only child agent).
|
||||
let unloaded_id = AIConversationId::new();
|
||||
history.update(&mut app, |history, _| {
|
||||
history.set_parent_for_conversation(unloaded_id, orchestrator_id);
|
||||
});
|
||||
|
||||
history.read(&app, |history, _| {
|
||||
let rollup = compute_orchestration_rollup(orchestrator_id, history)
|
||||
.expect("rollup should be Some");
|
||||
assert_eq!(rollup.total_credits, 4.0);
|
||||
assert_eq!(rollup.per_agent.len(), 1);
|
||||
assert_eq!(rollup.per_agent[0].conversation_id, real_child_id);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user