first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
@@ -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 descendants 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"
);
});
});
}
+33 -22
View File
@@ -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;
+71
View File
@@ -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;
}
}
+156
View File
@@ -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;
+321
View File
@@ -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);
});
});
}