From 80ec79cd36b53eac3950c16a9b49c604151acf63 Mon Sep 17 00:00:00 2001 From: Daniil Zinenko Date: Tue, 11 Aug 2026 13:34:31 +0200 Subject: [PATCH 1/2] Center the agent icon in the footprint render_icon_with_status reserves `render_icon_with_status` reserves a `total_size` square through `Stack::layout`'s `constraint.min`, but the brand circle inside it is only `CIRCLE_RATIO` (0.76) of that, and neither `ConstrainedBox` nor `Stack` centers a child -- both paint at the origin. The circle was left flush against the box's top-left with `total_size * 0.24` of dead space on its right and bottom. That has two visible consequences on every surface carrying an agent avatar: - The trailing gap grows by the slack. The vertical tabs sidebar rendered ~14px between the avatar and the title instead of the 8px `ICON_WITH_STATUS_GAP` asks for. - `corner_overlay_offset` anchors the status badge to the box's bottom-right, which only lands inside the circle's edge while the circle is centered. Left-aligned, the badge sat clear of the circle instead of tucked into it. Routing the circle through an `Align` restores both. The new test reads the painted rect out of the scene: the circle moves from (0, 0) to (2.88, 2.88) in a 24px footprint. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LF4ZTotC6MZwg1DAto3VRt --- app/src/ui_components/icon_with_status.rs | 42 ++++--- .../ui_components/icon_with_status_tests.rs | 103 +++++++++++++++++- 2 files changed, 121 insertions(+), 24 deletions(-) diff --git a/app/src/ui_components/icon_with_status.rs b/app/src/ui_components/icon_with_status.rs index a7e918a6c0e..0f3f45eff3a 100644 --- a/app/src/ui_components/icon_with_status.rs +++ b/app/src/ui_components/icon_with_status.rs @@ -4,8 +4,8 @@ use warp_core::ui::icons::Icon as WarpIcon; use warp_core::ui::theme::color::internal_colors; use warp_core::ui::theme::{ColorScheme, Fill as WarpThemeFill, WarpTheme}; use warpui::elements::{ - ChildAnchor, ConstrainedBox, Container, CornerRadius, Element, OffsetPositioning, ParentAnchor, - ParentElement, ParentOffsetBounds, Radius, Stack, + Align, ChildAnchor, ConstrainedBox, Container, CornerRadius, Element, OffsetPositioning, + ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Stack, }; use crate::ai::agent::conversation::{ConversationStatus, StatusColorStyle}; @@ -308,6 +308,18 @@ fn render_circle( .finish() } +/// Reserves the caller's full `total_size` square for `circle` and centers it inside. +/// `Stack` and `ConstrainedBox` paint children at the origin rather than centering, so +/// without the `Align` the circle — only `circle_size(total)` wide — sits flush against +/// the box's top-left, and the corner overlay (anchored to the box's bottom-right by +/// `corner_overlay_offset`) lands clear of the circle instead of tucked into its edge. +fn circle_centered_in_box(circle: Box, total_size: f32) -> Box { + ConstrainedBox::new(Align::new(circle).finish()) + .with_width(total_size) + .with_height(total_size) + .finish() +} + /// Builds the neutral circle: a full-`total_size` container with the glyph at /// `NEUTRAL_GLYPH_RATIO * total_size`. Used for non-agent surfaces (plain terminal, /// code, file tabs, etc.) which have no status overlay and therefore should fill the @@ -412,12 +424,7 @@ fn render_with_cloud_status_badge( }; let cloud_offset = corner_overlay_offset(total_size, overlay_extra_overhang_ratio); - let mut stack = Stack::new().with_child( - ConstrainedBox::new(circle) - .with_width(total_size) - .with_height(total_size) - .finish(), - ); + let mut stack = Stack::new().with_child(circle_centered_in_box(circle, total_size)); stack.add_positioned_child( cloud_with_status, OffsetPositioning::offset_from_parent( @@ -444,15 +451,9 @@ fn render_with_optional_status_badge( status_container_background: WarpThemeFill, ) -> Box { let Some(status) = status else { - // No status badge: still reserve the full `total_size` footprint the caller - // asked for, so badged and un-badged variants occupy identical space. - // `ConstrainedBox` only tightens constraints — it does not center — so the - // circle (which is only `circle_size(total)` wide) is painted at the box's - // top-left. Callers that need it centered must wrap it in `Align`. - return ConstrainedBox::new(circle) - .with_width(total_size) - .with_height(total_size) - .finish(); + // No status badge: still occupy the full `total_size` footprint so badged and + // un-badged variants take identical space in their caller's layout. + return circle_centered_in_box(circle, total_size); }; let (icon, color) = status.status_icon_and_color(theme, StatusColorStyle::Standard); let badge_icon_diameter = badge_icon_size(total_size, badge_style); @@ -477,12 +478,7 @@ fn render_with_optional_status_badge( .finish(); let badge_corner_offset = corner_overlay_offset(total_size, overlay_extra_overhang_ratio); - let mut stack = Stack::new().with_child( - ConstrainedBox::new(circle) - .with_width(total_size) - .with_height(total_size) - .finish(), - ); + let mut stack = Stack::new().with_child(circle_centered_in_box(circle, total_size)); stack.add_positioned_child( badge_with_ring, OffsetPositioning::offset_from_parent( diff --git a/app/src/ui_components/icon_with_status_tests.rs b/app/src/ui_components/icon_with_status_tests.rs index 6b962dfac65..b99b07c7ec1 100644 --- a/app/src/ui_components/icon_with_status_tests.rs +++ b/app/src/ui_components/icon_with_status_tests.rs @@ -1,6 +1,9 @@ use warp_core::ui::theme::Fill; -use super::{OZ_AMBIENT_BACKGROUND_COLOR, warp_agent_circle_colors}; +use super::{ + CIRCLE_RATIO, IconWithStatusVariant, OZ_AMBIENT_BACKGROUND_COLOR, circle_size, + render_icon_with_status, warp_agent_circle_colors, +}; use crate::themes::default_themes::{dark_theme, light_theme}; #[test] @@ -26,3 +29,101 @@ fn ambient_warp_agent_circle_keeps_purple_background_in_all_themes() { assert_eq!(warp_agent_circle_colors(&dark_theme(), true), expected); assert_eq!(warp_agent_circle_colors(&light_theme(), true), expected); } + +/// The brand circle covers only `CIRCLE_RATIO` of the footprint the component reserves, and +/// `corner_overlay_offset` positions the status badge against that footprint's bottom-right +/// corner. Both only line up while the circle is centered in the footprint: left-aligned, the +/// badge lands clear of the circle and the caller's trailing gap grows by the slack. +#[test] +fn brand_circle_is_centered_in_the_reserved_footprint() { + use pathfinder_geometry::vector::vec2f; + use warpui::platform::WindowStyle; + use warpui::{ + App, AppContext, Element, Entity, Presenter, TypedActionView, View, ViewContext, + WindowInvalidation, + }; + + use crate::ai::agent::conversation::ConversationStatus; + + const TOTAL_SIZE: f32 = 24.; + const EPSILON: f32 = 0.01; + + struct AgentIconTestView; + + impl AgentIconTestView { + fn new(_ctx: &mut ViewContext) -> Self { + Self + } + } + + impl Entity for AgentIconTestView { + type Event = (); + } + + impl View for AgentIconTestView { + fn ui_name() -> &'static str { + "AgentIconTestView" + } + + fn render(&self, _app: &AppContext) -> Box { + let theme = dark_theme(); + render_icon_with_status( + IconWithStatusVariant::OzAgent { + status: Some(ConversationStatus::Success), + is_ambient: false, + }, + TOTAL_SIZE, + 0., + &theme, + theme.background(), + ) + } + } + + impl TypedActionView for AgentIconTestView { + type Action = (); + } + + App::test((), |mut app| async move { + let (window_id, _view) = app.add_window(WindowStyle::NotStealFocus, AgentIconTestView::new); + let root_view_id = app + .root_view_id(window_id) + .expect("window should have a root view"); + + let mut presenter = Presenter::new(window_id); + let invalidation = WindowInvalidation { + updated: [root_view_id].into_iter().collect(), + ..Default::default() + }; + + app.update(move |ctx| { + presenter.invalidate(invalidation, ctx); + let scene = presenter.build_scene(vec2f(400., 300.), 1., None, ctx); + + let expected_diameter = circle_size(TOTAL_SIZE); + let expected_inset = TOTAL_SIZE * (1. - CIRCLE_RATIO) / 2.; + let circle_origins: Vec<_> = scene + .layers() + .flat_map(|layer| layer.rects.iter()) + .filter(|rect| { + (rect.bounds.width() - expected_diameter).abs() < EPSILON + && (rect.bounds.height() - expected_diameter).abs() < EPSILON + }) + .map(|rect| rect.bounds.origin()) + .collect(); + + let [circle_origin] = circle_origins.as_slice() else { + panic!( + "expected exactly one {expected_diameter}px brand circle in the scene, found \ + {circle_origins:?}" + ); + }; + assert!( + (circle_origin.x() - expected_inset).abs() < EPSILON + && (circle_origin.y() - expected_inset).abs() < EPSILON, + "brand circle should be centered in the {TOTAL_SIZE}px footprint at \ + ({expected_inset}, {expected_inset}), got {circle_origin:?}" + ); + }); + }); +} From 7797d55a63012c0dbbdbdbf8d51e6d045611e07b Mon Sep 17 00:00:00 2001 From: Daniil Zinenko Date: Tue, 11 Aug 2026 14:44:32 +0200 Subject: [PATCH 2/2] Correct the centering test's comment about the caller's trailing gap The comment claimed a left-aligned circle also grew the caller's trailing gap by the slack. It does not: `ConstrainedBox` reports the size it was constrained to, not its child's, so the component reserves the full footprint either way. What the misalignment actually breaks is the badge, which is anchored to the footprint's corner and so floats detached once the circle pulls away from it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LF4ZTotC6MZwg1DAto3VRt --- app/src/ui_components/icon_with_status_tests.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/ui_components/icon_with_status_tests.rs b/app/src/ui_components/icon_with_status_tests.rs index b99b07c7ec1..27c81466613 100644 --- a/app/src/ui_components/icon_with_status_tests.rs +++ b/app/src/ui_components/icon_with_status_tests.rs @@ -32,8 +32,9 @@ fn ambient_warp_agent_circle_keeps_purple_background_in_all_themes() { /// The brand circle covers only `CIRCLE_RATIO` of the footprint the component reserves, and /// `corner_overlay_offset` positions the status badge against that footprint's bottom-right -/// corner. Both only line up while the circle is centered in the footprint: left-aligned, the -/// badge lands clear of the circle and the caller's trailing gap grows by the slack. +/// corner. The two only meet while the circle is centered: left-aligned, the circle pulls up and +/// to the left while the badge stays put, so the badge floats detached instead of tucking into the +/// circle's edge. The footprint itself is reserved either way, so this is what a caller sees. #[test] fn brand_circle_is_centered_in_the_reserved_footprint() { use pathfinder_geometry::vector::vec2f;