Port upstream UI polish and refresh dependencies

Bring over selected Warp UI improvements: selectable Thought text, message
timestamps, animated theme backgrounds, editable New Window shortcuts,
safer tab renaming and restoration, and text/font/discard crash fixes.

Match upstream orchestration pill geometry and tooltips, and replace expanded
subagent previews with compact links that retain live status and conversation
navigation for launches and follow-up messages.

Include the pending Cargo.lock dependency refresh.

Adapted upstream PRs: #15948, #15884, #15691, #14618, #15772, #15705,
#15605, #14895, #15996, #15771, #14893, and #14603.
This commit is contained in:
2026-09-17 23:44:47 -05:00
parent f084548b34
commit a29ee94d84
46 changed files with 2529 additions and 1479 deletions
Generated
+713 -738
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -255,6 +255,17 @@ impl ActiveAgentViewsModel {
} }
} }
/// Whether the terminal view still belongs to an attached pane, including hidden child panes.
pub(crate) fn is_terminal_view_attached(
&self,
terminal_view_id: EntityId,
ctx: &AppContext,
) -> bool {
self.agent_view_handles
.get(&terminal_view_id)
.is_some_and(|handles| handles.controller.upgrade(ctx).is_some())
}
pub fn handle_pane_focus_change( pub fn handle_pane_focus_change(
&mut self, &mut self,
window_id: WindowId, window_id: WindowId,
+1 -1
View File
@@ -11,7 +11,7 @@ pub(crate) mod orchestration_conversation_links;
pub mod orchestration_pill_bar; pub mod orchestration_pill_bar;
pub mod orchestration_pill_bar_model; pub mod orchestration_pill_bar_model;
pub mod shortcuts; pub mod shortcuts;
pub(crate) mod subagent_inline_panel; pub(crate) mod subagent_link;
mod zero_state_block; mod zero_state_block;
use std::sync::LazyLock; use std::sync::LazyLock;
@@ -65,7 +65,7 @@ use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields};
use crate::pane_group::pane::view::PaneHeaderAction; use crate::pane_group::pane::view::PaneHeaderAction;
use crate::terminal::view::TerminalAction; use crate::terminal::view::TerminalAction;
use crate::ui_components::icon_with_status::{ use crate::ui_components::icon_with_status::{
self, render_icon_with_status_with_badge_style, BadgeInnerShape, IconWithStatusVariant, render_icon_with_status_with_badge_style, BadgeInnerShape, IconWithStatusVariant,
StatusBadgeStyle, StatusBadgeStyle,
}; };
use crate::ui_components::icons::Icon; use crate::ui_components::icons::Icon;
@@ -75,7 +75,15 @@ const PILL_HEIGHT: f32 = 22.;
const PILL_RADIUS: f32 = PILL_HEIGHT / 2.; const PILL_RADIUS: f32 = PILL_HEIGHT / 2.;
const AVATAR_SIZE: f32 = 16.; const AVATAR_SIZE: f32 = 16.;
const PILL_AVATAR_SLOT_SIZE: f32 = 20.; const PILL_AVATAR_SLOT_SIZE: f32 = 20.;
const PILL_AVATAR_DISC_SIZE: f32 = PILL_AVATAR_SLOT_SIZE * icon_with_status::CIRCLE_RATIO;
/// Visible avatar disc diameter, per design.
const PILL_AVATAR_DISC_SIZE: f32 = 15.;
/// Gap between the avatar disc and each of the pill's horizontal edges. The
/// disc is dead-centre in the pill, so this is symmetric: (22 - 15) / 2 = 3.5.
const PILL_AVATAR_VERTICAL_PADDING: f32 = (PILL_HEIGHT - PILL_AVATAR_DISC_SIZE) / 2.;
/// Square box the status badge is sized and anchored against. It does *not*
/// size the avatar disc (that is [`PILL_AVATAR_DISC_SIZE`]) — it only reserves
/// the square whose bottom-right corner the badge hangs off.
const AVATAR_WITH_STATUS_TOTAL_SIZE: f32 = PILL_AVATAR_SLOT_SIZE; const AVATAR_WITH_STATUS_TOTAL_SIZE: f32 = PILL_AVATAR_SLOT_SIZE;
const PILL_LABEL_MAX_WIDTH: f32 = 83.; const PILL_LABEL_MAX_WIDTH: f32 = 83.;
const PILL_ROW_GAP: f32 = 8.; const PILL_ROW_GAP: f32 = 8.;
@@ -1590,8 +1598,17 @@ fn render_status_badge(
.finish() .finish()
} }
/// Places a pill's leading avatar content — an
/// [`AVATAR_WITH_STATUS_TOTAL_SIZE`] box built by [`render_avatar_lockup_box`],
/// with or without a status badge layered on it — in the fixed-width leading
/// slot. The slot spans the full pill height so hover swaps (avatar ↔ pin
/// button) never shift the label.
///
/// The box is bottom-aligned rather than centered: the status badge hangs off
/// its bottom-right corner and design wants that badge flush with the pill's
/// bottom edge, so the box's bottom has to be the pill's bottom.
fn render_avatar_slot(avatar: Box<dyn Element>) -> Box<dyn Element> { fn render_avatar_slot(avatar: Box<dyn Element>) -> Box<dyn Element> {
ConstrainedBox::new(Align::new(avatar).finish()) ConstrainedBox::new(Align::new(avatar).bottom_left().finish())
.with_width(PILL_AVATAR_SLOT_SIZE) .with_width(PILL_AVATAR_SLOT_SIZE)
.with_height(PILL_HEIGHT) .with_height(PILL_HEIGHT)
.finish() .finish()
@@ -1669,38 +1686,64 @@ fn render_pinned_divider(app: &AppContext) -> Box<dyn Element> {
.finish() .finish()
} }
/// Pin glyph centered in an avatar-sized hit target so swapping in and out /// The clickable pin button a child pill shows in place of its avatar while
/// on hover doesn't jitter sibling pill widths. Solid glyph when pinned, /// hovered: a circle occupying exactly the avatar disc's rect.
/// outline when unpinned. ///
fn render_pin_glyph_centered(is_pinned: bool, icon_color: ColorU) -> Box<dyn Element> { /// Placement deliberately goes through the same
let icon_variant = if is_pinned { /// [`render_avatar_slot`] / [`render_avatar_lockup_box`] pair the disc itself
/// uses, and the circle is sized off [`PILL_AVATAR_DISC_SIZE`], so the swap
/// cannot shift by a pixel and the two cannot drift apart if the disc's
/// geometry is ever retuned.
///
/// The glyph is placed by explicit padding rather than by a centering
/// container, which keeps it exact regardless of how the surrounding box
/// behaves, and lets [`PIN_GLYPH_OPTICAL_DROP`] bias it downward without
/// moving the circle.
fn render_pin_button(
is_pinned: bool,
icon_color: ColorU,
mouse_state: MouseStateHandle,
conversation_id: AIConversationId,
) -> Box<dyn Element> {
// Tint with the pill's own contrasting colour rather than a fixed
// foreground overlay. `fg_overlay_1` is the foreground at 5% opacity, and a
// selected pill's background *is* the foreground colour, so the old fill
// painted a colour onto itself and the hover state was invisible on every
// selected chip — which, since the bar anchors on the parent of whatever
// leaf you are viewing, is the common case rather than an edge case.
let hover_background = coloru_with_opacity(icon_color, PIN_BUTTON_HOVER_OPACITY);
let glyph_size = PILL_AVATAR_DISC_SIZE * PIN_GLYPH_RATIO;
let icon = if is_pinned {
Icon::PinFilled Icon::PinFilled
} else { } else {
Icon::Pin Icon::Pin
}; };
let glyph: Box<dyn Element> = let button = Hoverable::new(mouse_state, move |hover_state| {
ConstrainedBox::new(icon_variant.to_warpui_icon(icon_color.into()).finish()) let glyph = ConstrainedBox::new(icon.to_warpui_icon(icon_color.into()).finish())
.with_width(PILL_ICON_SIZE) .with_width(glyph_size)
.with_height(PILL_ICON_SIZE) .with_height(glyph_size)
.finish(); .finish();
// Top and bottom padding still sum to twice `padding`, so the circle
let centered = Flex::column() // keeps the avatar disc's rect exactly; only the glyph inside it moves.
.with_main_axis_size(MainAxisSize::Max) let padding = (PILL_AVATAR_DISC_SIZE - glyph_size) / 2.;
.with_main_axis_alignment(MainAxisAlignment::Center) let mut circle = Container::new(glyph)
.with_cross_axis_alignment(CrossAxisAlignment::Center) .with_horizontal_padding(padding)
.with_child( .with_padding_top(padding + PIN_GLYPH_OPTICAL_DROP)
Flex::row() .with_padding_bottom(padding - PIN_GLYPH_OPTICAL_DROP)
.with_main_axis_size(MainAxisSize::Max) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(
.with_main_axis_alignment(MainAxisAlignment::Center) PILL_AVATAR_DISC_SIZE / 2.,
.with_cross_axis_alignment(CrossAxisAlignment::Center) )));
.with_child(glyph) if hover_state.is_hovered() || hover_state.is_clicked() {
.finish(), circle = circle.with_background(hover_background);
) }
circle.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _app, _| {
ctx.dispatch_typed_action(OrchestrationPillBarAction::TogglePin(conversation_id));
})
.finish(); .finish();
ConstrainedBox::new(centered) render_avatar_slot(render_avatar_lockup_box(button))
.with_width(PILL_AVATAR_SLOT_SIZE)
.with_height(PILL_ICON_BUTTON_SIZE)
.finish()
} }
fn render_pill( fn render_pill(
@@ -1833,41 +1876,21 @@ fn render_pill(
theme, theme,
appearance, appearance,
), ),
None => render_avatar_slot(render_avatar_disc( None => render_pill_avatar(avatar_color, avatar_glyph, theme, appearance),
avatar_color,
avatar_glyph,
PILL_AVATAR_DISC_SIZE,
theme,
appearance,
)),
}, },
PillKind::Child => { PillKind::Child => {
if show_pin_glyph { if show_pin_glyph {
// Hovered: the leading slot becomes the clickable pin // Hovered: the leading slot becomes the clickable pin
// button. We only attach the Hoverable + TogglePin // button. The Hoverable + TogglePin click handler is
// click handler here so that when the avatar is the // attached only here so that when the avatar is the
// visible content (not hovered), clicks bubble up to // visible content (not hovered), clicks bubble up to
// the outer pill body and navigate as expected. // the outer pill body and navigate as expected.
let pin_button_mouse_state = pin_button_mouse_state.clone(); render_pin_button(
Hoverable::new(pin_button_mouse_state, move |pin_hover_state| { is_pinned,
let pin_button_hovered = text_color,
pin_hover_state.is_hovered() || pin_hover_state.is_clicked(); pin_button_mouse_state.clone(),
let mut container =
Container::new(render_pin_glyph_centered(is_pinned, text_color))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)));
if pin_button_hovered {
container =
container.with_background(internal_colors::fg_overlay_1(theme));
}
container.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _app, _| {
ctx.dispatch_typed_action(OrchestrationPillBarAction::TogglePin(
conversation_id, conversation_id,
)); )
})
.finish()
} else if let Some(ref status) = status { } else if let Some(ref status) = status {
render_avatar_with_status_overlay( render_avatar_with_status_overlay(
avatar_color, avatar_color,
@@ -1879,13 +1902,7 @@ fn render_pill(
appearance, appearance,
) )
} else { } else {
render_avatar_slot(render_avatar_disc( render_pill_avatar(avatar_color, avatar_glyph, theme, appearance)
avatar_color,
avatar_glyph,
PILL_AVATAR_DISC_SIZE,
theme,
appearance,
))
} }
} }
}; };
@@ -2066,13 +2083,123 @@ fn render_overflow_button(
SavePosition::new(button, &overflow_button_position_id(conversation_id)).finish() SavePosition::new(button, &overflow_button_position_id(conversation_id)).finish()
} }
/// Pin glyph size as a fraction of the avatar disc it sits in. Calibrated
/// against the letter it replaces: matching the letter's ink height exactly
/// read too *small*, because a thin outline carries less visual weight than a
/// solid letterform, so design asked for roughly 4px more. This is the knob to
/// nudge if it still reads wrong.
const PIN_GLYPH_RATIO: f32 = 0.71;
/// Downward nudge of the pin glyph inside its circle.
///
/// This is an *optical* correction, not a geometry one — do not "fix" it to
/// zero because the arithmetic says centered. The pin's mass is concentrated
/// in its head, so a geometrically centered glyph reads as sitting high.
///
/// Absolute pixels rather than a ratio because the pin is only ever drawn in
/// the chip's [`PILL_AVATAR_DISC_SIZE`] circle. If it gains another size, this
/// needs revisiting rather than silently scaling.
const PIN_GLYPH_OPTICAL_DROP: f32 = 1.;
/// Opacity of the pin button's hover tint, over the pill's contrasting colour.
/// A little stronger than the 5% `fg_overlay_1` used to apply, because that
/// colour is nearer the pill's own background than the contrasting one is.
const PIN_BUTTON_HOVER_OPACITY: u8 = 8;
/// Cutout-ring diameter of the status badge, per design.
const PILL_BADGE_RING_SIZE: f32 = 11.;
/// Bounding box of the status icon inside that ring, per design. The 1px it
/// leaves on each side is the visible cutout.
const PILL_BADGE_ICON_SIZE: f32 = 9.;
/// `icon_with_status` expresses badge geometry as fractions of the box the
/// badge is anchored to, so convert the designed absolute sizes once here
/// rather than restating them as ratios.
const PILL_BADGE_STYLE: StatusBadgeStyle = StatusBadgeStyle { const PILL_BADGE_STYLE: StatusBadgeStyle = StatusBadgeStyle {
ring_ratio: 0.57, ring_ratio: PILL_BADGE_RING_SIZE / AVATAR_WITH_STATUS_TOTAL_SIZE,
icon_ratio: 0.36, icon_ratio: PILL_BADGE_ICON_SIZE / AVATAR_WITH_STATUS_TOTAL_SIZE,
inner_shape: BadgeInnerShape::RoundedSquare { radius_px: 2.0 }, inner_shape: BadgeInnerShape::RoundedSquare { radius_px: 2.0 },
}; };
/// Extra overhang of the status badge past the avatar circle's bottom-right
/// edge, as a signed fraction of [`AVATAR_WITH_STATUS_TOTAL_SIZE`] added to
/// `icon_with_status`'s default overhang.
///
/// It is not a free parameter: `0.05` is exactly what cancels that helper's
/// built-in `0.19` default, putting the badge's bottom-right corner on the
/// lockup box's own bottom-right corner. Since the box is bottom-aligned in
/// the slot, that is what makes the ring flush with the pill's bottom edge.
const PILL_BADGE_OVERHANG_RATIO: f32 = 0.05; const PILL_BADGE_OVERHANG_RATIO: f32 = 0.05;
/// Top inset of the avatar disc inside the [`AVATAR_WITH_STATUS_TOTAL_SIZE`]
/// box. The box is bottom-aligned in the [`PILL_HEIGHT`]-tall slot so the
/// badge anchored to its bottom-right corner reaches the pill's bottom edge,
/// so this inset plus that bottom-alignment offset has to add up to
/// [`PILL_AVATAR_VERTICAL_PADDING`]: 2 + 1.5 = 3.5.
const PILL_AVATAR_LOCKUP_TOP_INSET: f32 =
PILL_AVATAR_VERTICAL_PADDING - (PILL_HEIGHT - AVATAR_WITH_STATUS_TOTAL_SIZE);
/// Places the avatar disc inside the square box that the status badge is
/// anchored against, applying the designed padding. Shared by the
/// plain and status-badged paths so a pill's avatar lands in exactly the same
/// spot whether or not it currently has a status.
///
/// The disc hugs the box's leading edge and sits
/// [`PILL_AVATAR_LOCKUP_TOP_INSET`] down from its top edge. Only the vertical
/// placement is ours to choose; horizontally the disc has to stay flush left,
/// because the badge is anchored to the box's bottom-right corner and every
/// pixel the disc moves right is a pixel more of it the badge's cutout ring
/// eats — enough to swallow the agent's initial.
fn render_avatar_lockup_box(disc: Box<dyn Element>) -> Box<dyn Element> {
ConstrainedBox::new(
Container::new(Align::new(disc).top_left().finish())
.with_padding_top(PILL_AVATAR_LOCKUP_TOP_INSET)
.finish(),
)
.with_width(AVATAR_WITH_STATUS_TOTAL_SIZE)
.with_height(AVATAR_WITH_STATUS_TOTAL_SIZE)
.finish()
}
/// Renders the leading avatar for a pill with no status badge.
fn render_pill_avatar(
avatar_color: ColorU,
glyph: AvatarGlyph,
theme: &WarpTheme,
appearance: &Appearance,
) -> Box<dyn Element> {
render_avatar_slot(render_avatar_lockup_box(render_avatar_disc(
avatar_color,
glyph,
PILL_AVATAR_DISC_SIZE,
theme,
appearance,
)))
}
/// Renders the leading avatar for a pill that has a status: the avatar disc
/// plus its status badge, in the same slot [`render_pill_avatar`] uses.
///
/// Geometry, in pill-content coordinates (the pill is [`PILL_HEIGHT`] = 22
/// tall with a [`PILL_RADIUS`] = 11 stadium cap, and the leading slot spans
/// x = 4..24 after [`PILL_HORIZONTAL_PADDING_LEFT`]):
/// * Lockup box: [`AVATAR_WITH_STATUS_TOTAL_SIZE`] = 20 square, bottom-aligned
/// in the 22-tall slot, so it spans y = 2..22.
/// * Avatar disc: [`PILL_AVATAR_DISC_SIZE`] = 15, inset
/// [`PILL_AVATAR_LOCKUP_TOP_INSET`] = 1.5 from the box's top and flush with
/// its left edge, so it spans y = 3.5..18.5 and x = 4..19 — dead-centre in
/// the pill, [`PILL_AVATAR_VERTICAL_PADDING`] = 3.5 above and below.
/// * Status badge: [`PILL_BADGE_RING_SIZE`] = 11 cutout ring, anchored BR-to-BR
/// with `corner_overlay_offset(20, 0.05)` = 0, so its BR lands on the lockup
/// box's BR at (24, 22) and the ring spans y = 11..22, x = 13..24. That is
/// 9 horizontally and 7.5 vertically in from the disc's top-left, per design,
/// and its bottom is flush with the pill's — an emergent property of the
/// box being bottom-aligned, not a hardcoded 22. The ring starts at
/// x = 13 > `PILL_RADIUS`, so it sits in the pill's flat-bottom region and
/// is tangent to that edge rather than clipped by the rounded cap (only
/// x < 11 is governed by the cap's arc).
/// * Status icon: [`PILL_BADGE_ICON_SIZE`] = 9 bounding box centred in the
/// ring, leaving the 1px cutout.
fn render_avatar_with_status_overlay( fn render_avatar_with_status_overlay(
avatar_color: ColorU, avatar_color: ColorU,
glyph: AvatarGlyph, glyph: AvatarGlyph,
@@ -2082,15 +2209,13 @@ fn render_avatar_with_status_overlay(
theme: &WarpTheme, theme: &WarpTheme,
appearance: &Appearance, appearance: &Appearance,
) -> Box<dyn Element> { ) -> Box<dyn Element> {
// Top-left anchor inside the helper's `total_size` box so the disc sits let avatar = render_avatar_lockup_box(render_avatar_disc(
// where Figma places it (TL of the slot, leaving the BR for the badge).
let avatar = render_avatar_disc(
avatar_color, avatar_color,
glyph, glyph,
icon_with_status::circle_size(AVATAR_WITH_STATUS_TOTAL_SIZE), PILL_AVATAR_DISC_SIZE,
theme, theme,
appearance, appearance,
); ));
let lockup = render_icon_with_status_with_badge_style( let lockup = render_icon_with_status_with_badge_style(
IconWithStatusVariant::CustomAvatar { IconWithStatusVariant::CustomAvatar {
avatar, avatar,
@@ -2104,19 +2229,9 @@ fn render_avatar_with_status_overlay(
// Cutout ring color for the local badge; ignored by the cloud path. // Cutout ring color for the local badge; ignored by the cloud path.
pill_background.into(), pill_background.into(),
); );
// Bottom-anchor the lockup in the pill so the badge BR sits flush with // Same slot helper as the no-status path, so both share one placement
// the pill's bottom edge (matches Figma). // rule and the leading slot keeps identical width across the swap.
ConstrainedBox::new( render_avatar_slot(lockup)
Flex::column()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::End)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(lockup)
.finish(),
)
.with_width(PILL_AVATAR_SLOT_SIZE)
.with_height(PILL_HEIGHT)
.finish()
} }
/// Renders the avatar circle as a colored disc with a centered glyph (letter /// Renders the avatar circle as a colored disc with a centered glyph (letter
@@ -2148,6 +2263,11 @@ fn render_avatar_disc(
weight: Weight::Bold, weight: Weight::Bold,
..Default::default() ..Default::default()
}) })
// The default 1.2 ratio pads the text box with leading, so
// centering the box leaves the letter's ink sitting high in
// the disc. At 1.0 the box is the glyph, and centering it
// centers what you can see.
.with_line_height_ratio(1.)
.finish() .finish()
} }
AvatarGlyph::Icon(icon) => { AvatarGlyph::Icon(icon) => {
@@ -2158,24 +2278,7 @@ fn render_avatar_disc(
} }
}; };
// Center the glyph on top of the disc both horizontally and vertically by let glyph_centered = ConstrainedBox::new(Align::new(glyph_element).finish())
// using `MainAxisAlignment::Center` (along axis) and
// `CrossAxisAlignment::Center` (perpendicular) on both Flex containers.
let glyph_centered = ConstrainedBox::new(
Flex::column()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(glyph_element)
.finish(),
)
.finish(),
)
.with_width(size) .with_width(size)
.with_height(size) .with_height(size)
.finish(); .finish();
@@ -1,391 +0,0 @@
//! Inline subagent panel rendered within the parent agent's chat flow.
//!
//! Shows a collapsible panel with the subagent's status, a mini-transcript of
//! recent messages, and controls to expand inline or open the full child view.
use galaxyui::elements::{
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element, Empty, Flex, Hoverable,
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
};
use galaxyui::platform::Cursor;
use galaxyui::ui_components::components::UiComponent;
use galaxyui::{AppContext, EntityId, SingletonEntity};
use pathfinder_color::ColorU;
use warp_multi_agent_api as api;
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus, StatusColorStyle};
use crate::ai::agent::AIAgentActionId;
use crate::ai::blocklist::agent_view::orchestration_conversation_links::dispatch_focus_or_open_child_agent_pane;
use crate::ai::blocklist::block::AIBlockAction;
use crate::ai::blocklist::inline_action::inline_action_header::{
ICON_MARGIN, INLINE_ACTION_HEADER_VERTICAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING,
};
use crate::ai::blocklist::inline_action::inline_action_icons::icon_size;
use crate::ai::blocklist::BlocklistAIHistoryModel;
use crate::appearance::Appearance;
use crate::ui_components::blended_colors;
use crate::ui_components::buttons::icon_button;
use crate::ui_components::icons::Icon;
const MINI_TRANSCRIPT_MAX_LINES: usize = 8;
const MINI_TRANSCRIPT_MAX_CHARS: usize = 120;
const COMPLETION_SUMMARY_MAX_CHARS: usize = 300;
const PANEL_MAX_HEIGHT: f32 = 200.;
const PANEL_CORNER_RADIUS: f32 = 8.;
/// State for a single subagent inline panel instance.
#[derive(Debug, Clone)]
pub struct SubagentPanelState {
pub conversation_id: AIConversationId,
pub is_expanded: bool,
pub header_mouse_state: MouseStateHandle,
pub open_mouse_state: MouseStateHandle,
}
impl SubagentPanelState {
pub fn new(conversation_id: AIConversationId) -> Self {
Self {
conversation_id,
// The panel exists to expose the child agent's live conversation.
// Start expanded so its responses are visible without another click.
is_expanded: true,
header_mouse_state: MouseStateHandle::default(),
open_mouse_state: MouseStateHandle::default(),
}
}
}
/// Renders the inline subagent panel for a child conversation.
pub fn render_subagent_inline_panel(
state: &SubagentPanelState,
action_id: &AIAgentActionId,
self_terminal_view_id: EntityId,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let history_model = BlocklistAIHistoryModel::as_ref(app);
let Some(conversation) = history_model.conversation(&state.conversation_id) else {
return Empty::new().finish();
};
let status = conversation.status().clone();
let agent_name = conversation.agent_name().unwrap_or("Subagent").to_string();
let panel_bg = blended_colors::neutral_2(theme);
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
// Header — always visible, click to toggle expand/collapse
let header_mouse_state = state.header_mouse_state.clone();
let open_mouse_state = state.open_mouse_state.clone();
let ui_builder = appearance.ui_builder().clone();
let toggle_action_id = action_id.clone();
let header_status = status.clone();
let header_expanded = state.is_expanded;
let toggle = Hoverable::new(header_mouse_state, move |_mouse_state| {
render_panel_header(&agent_name, &header_status, header_expanded, panel_bg, app)
})
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(AIBlockAction::ToggleSubagentPanel {
action_id: toggle_action_id.clone(),
});
})
.finish();
let child_conversation_id = state.conversation_id;
let open = icon_button(appearance, Icon::LinkExternal, false, open_mouse_state)
.with_tooltip(move || {
ui_builder
.tool_tip("Open child conversation".to_string())
.build()
.finish()
})
.build()
.on_click(move |ctx, app, _| {
dispatch_focus_or_open_child_agent_pane(
child_conversation_id,
self_terminal_view_id,
ctx,
app,
);
})
.finish();
column.add_child(
Flex::row()
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Shrinkable::new(1., toggle).finish())
.with_child(
Container::new(open)
.with_padding_left(4.)
.with_padding_right(INLINE_ACTION_HORIZONTAL_PADDING)
.finish(),
)
.finish(),
);
// Body (mini-transcript) — only when expanded
if state.is_expanded {
let transcript_lines = collect_mini_transcript(&state.conversation_id, app);
if !transcript_lines.is_empty() {
column.add_child(render_mini_transcript(&transcript_lines, panel_bg, app));
}
}
// Footer — show summary when complete
if status.is_done() {
if let Some(summary) = get_completion_summary(&state.conversation_id, app) {
column.add_child(render_summary_footer(&summary, panel_bg, app));
}
}
Container::new(column.finish())
.with_background_color(panel_bg)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(PANEL_CORNER_RADIUS)))
.with_margin_top(4.)
.with_margin_bottom(4.)
.finish()
}
fn render_panel_header(
agent_name: &str,
status: &ConversationStatus,
is_expanded: bool,
_background: ColorU,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let font_family = appearance.ui_font_family();
let font_size = appearance.monospace_font_size();
let surface = theme.surface_2();
let mut header_row = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
// Left: status icon + agent name + status text
let mut left_side = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
let (icon, icon_color) = status.status_icon_and_color(theme, StatusColorStyle::Standard);
let status_icon_element =
ConstrainedBox::new(galaxyui::elements::Icon::new(icon.into(), icon_color).finish())
.with_width(icon_size(app))
.with_height(icon_size(app))
.finish();
left_side.add_child(
Container::new(status_icon_element)
.with_margin_right(ICON_MARGIN)
.finish(),
);
let name_color: ColorU = theme.main_text_color(surface).into();
left_side.add_child(
Text::new_inline(agent_name.to_string(), font_family, font_size)
.with_color(name_color)
.finish(),
);
let status_text = match status {
ConversationStatus::InProgress => "Working...",
ConversationStatus::Success => "Complete",
ConversationStatus::Error => "Error",
ConversationStatus::TransientError => "Retrying...",
ConversationStatus::Cancelled => "Cancelled",
ConversationStatus::Blocked { .. } => "Blocked",
ConversationStatus::WaitingForEvents => "Waiting...",
};
let status_text_color = blended_colors::text_disabled(theme, surface);
left_side.add_child(
Container::new(
Text::new_inline(status_text.to_string(), font_family, font_size)
.with_color(status_text_color)
.finish(),
)
.with_margin_left(8.)
.finish(),
);
header_row.add_child(Shrinkable::new(1.0, left_side.finish()).finish());
// Right: collapse/expand chevron
let mut right_side = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
let chevron_icon = if is_expanded {
Icon::ChevronDown
} else {
Icon::ChevronRight
};
let chevron_color = blended_colors::text_disabled(theme, surface);
let chevron = ConstrainedBox::new(
galaxyui::elements::Icon::new(chevron_icon.into(), chevron_color).finish(),
)
.with_width(icon_size(app))
.with_height(icon_size(app))
.finish();
right_side.add_child(Container::new(chevron).with_margin_right(4.).finish());
header_row.add_child(right_side.finish());
Container::new(header_row.finish())
.with_padding_left(INLINE_ACTION_HORIZONTAL_PADDING)
.with_padding_right(INLINE_ACTION_HORIZONTAL_PADDING)
.with_padding_top(INLINE_ACTION_HEADER_VERTICAL_PADDING)
.with_padding_bottom(INLINE_ACTION_HEADER_VERTICAL_PADDING)
.finish()
}
fn collect_mini_transcript(conversation_id: &AIConversationId, app: &AppContext) -> Vec<String> {
let history_model = BlocklistAIHistoryModel::as_ref(app);
let Some(conversation) = history_model.conversation(conversation_id) else {
return vec![];
};
let messages = conversation.all_linearized_messages();
collect_visible_transcript(&messages, MINI_TRANSCRIPT_MAX_LINES)
}
fn collect_visible_transcript(messages: &[&api::Message], max_lines: usize) -> Vec<String> {
let mut lines = messages
.iter()
.rev()
// Filter first, then apply the visible-line limit. A tool-heavy turn can
// contain many internal messages between user/agent chat messages.
.filter_map(|message| extract_message_text(message))
.take(max_lines)
.map(|text| truncate_with_ellipsis(&text, MINI_TRANSCRIPT_MAX_CHARS))
.collect::<Vec<_>>();
lines.reverse();
lines
}
fn truncate_with_ellipsis(text: &str, max_chars: usize) -> String {
let mut chars = text.chars();
let prefix = chars.by_ref().take(max_chars).collect::<String>();
if chars.next().is_none() {
return prefix;
}
let visible_prefix_chars = max_chars.saturating_sub(3);
let mut truncated = prefix
.chars()
.take(visible_prefix_chars)
.collect::<String>();
truncated.push_str(&".".repeat(max_chars.min(3)));
truncated
}
fn extract_message_text(msg: &api::Message) -> Option<String> {
let message_content = msg.message.as_ref()?;
match message_content {
api::message::Message::AgentOutput(_) => extract_agent_output_text(msg),
api::message::Message::UserQuery(query) => {
if query.query.is_empty() {
None
} else {
Some(query.query.clone())
}
}
_ => None,
}
}
fn extract_agent_output_text(msg: &api::Message) -> Option<String> {
let api::message::Message::AgentOutput(output) = msg.message.as_ref()? else {
return None;
};
(!output.text.is_empty()).then(|| output.text.clone())
}
fn render_mini_transcript(
lines: &[String],
background: ColorU,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let text_color = blended_colors::text_disabled(theme, background);
let font_family = appearance.ui_font_family();
let font_size = appearance.monospace_font_size() - 1.;
let mut column = Flex::column();
for line in lines {
let prefixed = format!("> {line}");
column.add_child(
Text::new_inline(prefixed, font_family, font_size)
.with_color(text_color)
.finish(),
);
}
ConstrainedBox::new(
Container::new(column.finish())
.with_padding_left(INLINE_ACTION_HORIZONTAL_PADDING)
.with_padding_right(INLINE_ACTION_HORIZONTAL_PADDING)
.with_padding_top(4.)
.with_padding_bottom(4.)
.finish(),
)
.with_max_height(PANEL_MAX_HEIGHT)
.finish()
}
fn get_completion_summary(conversation_id: &AIConversationId, app: &AppContext) -> Option<String> {
let history_model = BlocklistAIHistoryModel::as_ref(app);
let conversation = history_model.conversation(conversation_id)?;
if !conversation.status().is_done() {
return None;
}
completion_summary_from_messages(&conversation.all_linearized_messages())
}
fn completion_summary_from_messages(messages: &[&api::Message]) -> Option<String> {
messages.iter().rev().find_map(|message| {
extract_agent_output_text(message)
.map(|text| truncate_with_ellipsis(&text, COMPLETION_SUMMARY_MAX_CHARS))
})
}
fn render_summary_footer(summary: &str, _background: ColorU, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let surface = theme.surface_2();
let text_color: ColorU = theme.main_text_color(surface).into();
let label_color = blended_colors::text_disabled(theme, surface);
let font_family = appearance.ui_font_family();
let font_size = appearance.monospace_font_size();
let mut row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Start);
row.add_child(
Text::new_inline("Summary: ".to_string(), font_family, font_size)
.with_color(label_color)
.finish(),
);
row.add_child(
Shrinkable::new(
1.0,
Text::new_inline(summary.to_string(), font_family, font_size)
.with_color(text_color)
.finish(),
)
.finish(),
);
Container::new(row.finish())
.with_padding_left(INLINE_ACTION_HORIZONTAL_PADDING)
.with_padding_right(INLINE_ACTION_HORIZONTAL_PADDING)
.with_padding_top(6.)
.with_padding_bottom(6.)
.finish()
}
#[cfg(test)]
#[path = "subagent_inline_panel_tests.rs"]
mod tests;
@@ -1,128 +0,0 @@
use warp_multi_agent_api as api;
use super::{
collect_visible_transcript, completion_summary_from_messages, extract_message_text,
truncate_with_ellipsis, SubagentPanelState,
};
use crate::ai::agent::conversation::AIConversationId;
fn message(content: api::message::Message) -> api::Message {
api::Message {
message: Some(content),
..Default::default()
}
}
fn user_query(text: &str) -> api::Message {
message(api::message::Message::UserQuery(api::message::UserQuery {
query: text.to_string(),
..Default::default()
}))
}
fn system_query() -> api::Message {
message(api::message::Message::SystemQuery(
api::message::SystemQuery::default(),
))
}
fn agent_output(text: &str) -> api::Message {
message(api::message::Message::AgentOutput(
api::message::AgentOutput {
text: text.to_string(),
},
))
}
fn message_refs(messages: &[api::Message]) -> Vec<&api::Message> {
messages.iter().collect()
}
#[test]
fn truncate_with_ellipsis_preserves_short_text() {
assert_eq!(
truncate_with_ellipsis("Galaxy terminal", 20),
"Galaxy terminal"
);
}
#[test]
fn truncate_with_ellipsis_is_unicode_safe() {
let truncated = truncate_with_ellipsis("🚀🚀🚀🚀🚀 Galaxy", 8);
assert_eq!(truncated.chars().count(), 8);
assert!(truncated.ends_with("..."));
}
#[test]
fn truncate_with_ellipsis_handles_tiny_limits() {
assert_eq!(truncate_with_ellipsis("Galaxy", 2), "..");
}
#[test]
fn new_panel_starts_expanded_so_agent_chat_is_visible() {
let state = SubagentPanelState::new(AIConversationId::new());
assert!(state.is_expanded);
}
#[test]
fn transcript_shows_user_and_agent_messages_but_hides_system_queries() {
let system = system_query();
let user = user_query("Please check the build");
let agent = agent_output("The build is still running.");
assert_eq!(extract_message_text(&system), None);
assert_eq!(
extract_message_text(&user).as_deref(),
Some("Please check the build")
);
assert_eq!(
extract_message_text(&agent).as_deref(),
Some("The build is still running.")
);
assert_eq!(extract_message_text(&user_query("")), None);
assert_eq!(extract_message_text(&agent_output("")), None);
}
#[test]
fn hidden_system_messages_do_not_displace_agent_responses() {
let mut messages = vec![agent_output("Visible response to a system request")];
messages.extend((0..32).map(|_| system_query()));
let refs = message_refs(&messages);
assert_eq!(
collect_visible_transcript(&refs, 8),
vec!["Visible response to a system request"]
);
}
#[test]
fn transcript_limits_visible_messages_and_keeps_chronological_order() {
let messages = (0..10)
.map(|index| agent_output(&format!("response {index}")))
.collect::<Vec<_>>();
let refs = message_refs(&messages);
assert_eq!(
collect_visible_transcript(&refs, 8),
(2..10)
.map(|index| format!("response {index}"))
.collect::<Vec<_>>()
);
}
#[test]
fn completion_summary_uses_latest_agent_response() {
let messages = vec![
agent_output("Final assistant answer"),
user_query("A trailing user message"),
system_query(),
];
let refs = message_refs(&messages);
assert_eq!(
completion_summary_from_messages(&refs).as_deref(),
Some("Final assistant answer")
);
}
@@ -0,0 +1,110 @@
//! Compact links to existing child conversations in launch and follow-up output.
use galaxyui::elements::{
ChildAnchor, ConstrainedBox, Container, CrossAxisAlignment, Empty, Flex, Hoverable,
MainAxisSize, MouseStateHandle, ParentAnchor, ParentElement, Text,
};
use galaxyui::platform::Cursor;
use galaxyui::{AppContext, Element, EntityId, SingletonEntity};
use pathfinder_geometry::vector::vec2f;
use super::orchestration_conversation_links::{
dispatch_focus_or_open_child_agent_pane, is_conversation_open_in_other_visible_view,
};
use super::orchestration_pill_bar::render_static_agent_pill;
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus, StatusColorStyle};
use crate::ai::blocklist::BlocklistAIHistoryModel;
use crate::appearance::Appearance;
use crate::ui_components::blended_colors;
#[derive(Debug, Clone)]
pub struct SubagentLinkState {
pub conversation_id: AIConversationId,
mouse_state: MouseStateHandle,
}
impl SubagentLinkState {
pub fn new(conversation_id: AIConversationId) -> Self {
Self {
conversation_id,
mouse_state: MouseStateHandle::default(),
}
}
}
pub fn render_subagent_link(
state: &SubagentLinkState,
self_terminal_view_id: EntityId,
app: &AppContext,
) -> Box<dyn Element> {
let Some(conversation) =
BlocklistAIHistoryModel::as_ref(app).conversation(&state.conversation_id)
else {
return Empty::new().finish();
};
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let name = conversation
.agent_name()
.filter(|name| !name.is_empty())
.unwrap_or("Agent");
let status = conversation.status();
let status_text = match status {
ConversationStatus::InProgress => "Working…",
ConversationStatus::Success => "Complete",
ConversationStatus::Error => "Error",
ConversationStatus::TransientError => "Retrying…",
ConversationStatus::Cancelled => "Cancelled",
ConversationStatus::Blocked { .. } => "Blocked",
ConversationStatus::WaitingForEvents => "Waiting…",
};
let (icon, color) = status.status_icon_and_color(theme, StatusColorStyle::Standard);
let row = Flex::row()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(8.)
.with_child(render_static_agent_pill(name, app))
.with_child(
ConstrainedBox::new(icon.to_warpui_icon(color.into()).finish())
.with_width(12.)
.with_height(12.)
.finish(),
)
.with_child(
Text::new(status_text.to_string(), appearance.ui_font_family(), 12.)
.with_color(blended_colors::text_disabled(theme, theme.surface_2()))
.finish(),
)
.finish();
let conversation_id = state.conversation_id;
let tooltip = if is_conversation_open_in_other_visible_view(
conversation_id,
self_terminal_view_id,
app,
) {
format!("Focus {name}'s pane")
} else {
format!("Open {name} in a new pane")
};
let link = Hoverable::new(state.mouse_state.clone(), move |_| row)
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, app, _| {
dispatch_focus_or_open_child_agent_pane(
conversation_id,
self_terminal_view_id,
ctx,
app,
);
})
.finish();
Container::new(appearance.ui_builder().overlay_tool_tip_on_element(
tooltip,
state.mouse_state.clone(),
link,
ParentAnchor::TopLeft,
ChildAnchor::BottomLeft,
vec2f(0., -4.),
))
.with_vertical_padding(4.)
.finish()
}
+29 -27
View File
@@ -27,7 +27,7 @@ use std::sync::Arc;
use ai::agent::action::{AskUserQuestionItem, InsertReviewComment, RunAgentsRequest}; use ai::agent::action::{AskUserQuestionItem, InsertReviewComment, RunAgentsRequest};
use base64::Engine as _; use base64::Engine as _;
use chrono::Duration; use chrono::{DateTime, Duration, Local};
use cli_controller::{CLISubagentController, CLISubagentEvent, LongRunningCommandControlState}; use cli_controller::{CLISubagentController, CLISubagentEvent, LongRunningCommandControlState};
use find::FindState; use find::FindState;
use galaxy_agent_core::RuntimeActivityStatus; use galaxy_agent_core::RuntimeActivityStatus;
@@ -197,6 +197,7 @@ use crate::ui_components::icons::Icon;
use crate::util::link_detection::*; use crate::util::link_detection::*;
#[cfg(feature = "local_fs")] #[cfg(feature = "local_fs")]
use crate::util::openable_file_type::{is_supported_image_file, FileTarget}; use crate::util::openable_file_type::{is_supported_image_file, FileTarget};
use crate::util::time_format::format_message_timestamp;
use crate::view_components::action_button::{ use crate::view_components::action_button::{
ActionButton, ActionButtonTheme, ButtonSize, KeystrokeSource, NakedTheme, PrimaryTheme, ActionButton, ActionButtonTheme, ButtonSize, KeystrokeSource, NakedTheme, PrimaryTheme,
SecondaryTheme, SecondaryTheme,
@@ -465,6 +466,7 @@ pub(super) struct AIBlockStateHandles {
/// Mouse state handle for the overflow menu button /// Mouse state handle for the overflow menu button
overflow_menu_handle: MouseStateHandle, overflow_menu_handle: MouseStateHandle,
query_timestamp_tooltip_handle: MouseStateHandle,
menu_accept_button_handle: MouseStateHandle, menu_accept_button_handle: MouseStateHandle,
menu_reject_button_handle: MouseStateHandle, menu_reject_button_handle: MouseStateHandle,
@@ -485,8 +487,8 @@ pub(super) struct AIBlockStateHandles {
/// multiple skill banners in the same block don't share hover/click state. /// multiple skill banners in the same block don't share hover/click state.
skill_button_handles: HashMap<AIAgentActionId, MouseStateHandle>, skill_button_handles: HashMap<AIAgentActionId, MouseStateHandle>,
usage_button_handle: MouseStateHandle, usage_button_handle: MouseStateHandle,
subagent_panel_states: subagent_link_states:
HashMap<AIAgentActionId, super::agent_view::subagent_inline_panel::SubagentPanelState>, HashMap<AIAgentActionId, super::agent_view::subagent_link::SubagentLinkState>,
} }
#[derive(Default, Clone, Debug)] #[derive(Default, Clone, Debug)]
@@ -1404,13 +1406,13 @@ impl AIBlock {
|me, _, event, ctx| { |me, _, event, ctx| {
if me if me
.state_handles .state_handles
.subagent_panel_states .subagent_link_states
.values() .values()
.any(|state| history_event_affects_conversation(event, state.conversation_id)) .any(|state| history_event_affects_conversation(event, state.conversation_id))
{ {
// Child conversations live on a different terminal // Child conversations live on a different terminal
// surface, so they bypass the parent block's normal // surface, so they bypass the parent block's normal
// terminal-surface filter. Repaint the inline transcript // terminal-surface filter. Repaint the conversation link
// and status whenever one of those children changes. // and status whenever one of those children changes.
ctx.notify(); ctx.notify();
} }
@@ -2236,12 +2238,10 @@ impl AIBlock {
.map(|child| child.id()); .map(|child| child.id());
if let Some(child_id) = child_id { if let Some(child_id) = child_id {
self.state_handles self.state_handles
.subagent_panel_states .subagent_link_states
.entry(action.id.clone()) .entry(action.id.clone())
.or_insert_with(|| { .or_insert_with(|| {
super::agent_view::subagent_inline_panel::SubagentPanelState::new( super::agent_view::subagent_link::SubagentLinkState::new(child_id)
child_id,
)
}); });
} }
} }
@@ -4727,6 +4727,10 @@ impl AIBlock {
self.model.status(app) self.model.status(app)
} }
pub fn query_sent_at(&self, app: &AppContext) -> Option<DateTime<Local>> {
self.model.query_sent_at(app)
}
/// Returns `true` if this AI block contains user input. /// Returns `true` if this AI block contains user input.
pub fn has_user_input(&self, app: &AppContext) -> bool { pub fn has_user_input(&self, app: &AppContext) -> bool {
self.model self.model
@@ -4952,7 +4956,7 @@ impl AIBlock {
}); });
} }
// Create subagent panel state for finished StartAgent actions // Create subagent link state for finished StartAgent actions
if let Some(AIActionStatus::Finished(result)) = if let Some(AIActionStatus::Finished(result)) =
action_model action_model
.as_ref(ctx) .as_ref(ctx)
@@ -4962,13 +4966,13 @@ impl AIBlock {
crate::ai::agent::StartAgentResult::Success { agent_id, .. }, crate::ai::agent::StartAgentResult::Success { agent_id, .. },
) = &result.result ) = &result.result
{ {
if !me.state_handles.subagent_panel_states.contains_key(action_id) { if !me.state_handles.subagent_link_states.contains_key(action_id) {
if let Some(conversation_id) = if let Some(conversation_id) =
crate::ai::blocklist::agent_view::orchestration_conversation_links::conversation_id_for_agent_id(agent_id, ctx) crate::ai::blocklist::agent_view::orchestration_conversation_links::conversation_id_for_agent_id(agent_id, ctx)
{ {
me.state_handles.subagent_panel_states.insert( me.state_handles.subagent_link_states.insert(
action_id.clone(), action_id.clone(),
super::agent_view::subagent_inline_panel::SubagentPanelState::new(conversation_id), super::agent_view::subagent_link::SubagentLinkState::new(conversation_id),
); );
} }
} }
@@ -5117,7 +5121,7 @@ impl AIBlock {
} }
/// Registers the direct-provider child linkage needed to render a live /// Registers the direct-provider child linkage needed to render a live
/// StartAgent panel while the action is still waiting for child output. /// StartAgent conversation link while the action is still waiting for child output.
fn register_start_agent_executor_subscription( fn register_start_agent_executor_subscription(
action_model: &ModelHandle<BlocklistAIActionModel>, action_model: &ModelHandle<BlocklistAIActionModel>,
ctx: &mut ViewContext<Self>, ctx: &mut ViewContext<Self>,
@@ -5139,12 +5143,10 @@ impl AIBlock {
} }
me.state_handles me.state_handles
.subagent_panel_states .subagent_link_states
.entry(action_id.clone()) .entry(action_id.clone())
.or_insert_with(|| { .or_insert_with(|| {
super::agent_view::subagent_inline_panel::SubagentPanelState::new( super::agent_view::subagent_link::SubagentLinkState::new(*child_conversation_id)
*child_conversation_id,
)
}); });
ctx.notify(); ctx.notify();
}); });
@@ -6643,6 +6645,7 @@ pub enum AIBlockAction {
/// Copy the content from the previous user query. /// Copy the content from the previous user query.
/// Note that this block may not have the user query. /// Note that this block may not have the user query.
CopyQuery, CopyQuery,
CopyTimestamp,
/// Copy all AI output from the previous user query to the next user query. /// Copy all AI output from the previous user query to the next user query.
/// Note that this contains more than just this block, since from the user perspective everything after the user query appears like one block. /// Note that this contains more than just this block, since from the user perspective everything after the user query appears like one block.
CopyOutput, CopyOutput,
@@ -6696,9 +6699,6 @@ pub enum AIBlockAction {
OpenCommentInGitHub { OpenCommentInGitHub {
url: String, url: String,
}, },
ToggleSubagentPanel {
action_id: AIAgentActionId,
},
ToggleIsUsageFooterExpanded, ToggleIsUsageFooterExpanded,
} }
@@ -6711,12 +6711,6 @@ impl TypedActionView for AIBlock {
self.is_usage_footer_expanded = !self.is_usage_footer_expanded; self.is_usage_footer_expanded = !self.is_usage_footer_expanded;
ctx.notify(); ctx.notify();
} }
AIBlockAction::ToggleSubagentPanel { action_id } => {
if let Some(state) = self.state_handles.subagent_panel_states.get_mut(action_id) {
state.is_expanded = !state.is_expanded;
ctx.notify();
}
}
AIBlockAction::SetCollapsibleBlockPinnedToBottom { AIBlockAction::SetCollapsibleBlockPinnedToBottom {
message_id, message_id,
pinned_to_bottom, pinned_to_bottom,
@@ -7119,6 +7113,14 @@ impl TypedActionView for AIBlock {
ctx.clipboard() ctx.clipboard()
.write(ClipboardContent::plain_text(prompt_text)); .write(ClipboardContent::plain_text(prompt_text));
} }
AIBlockAction::CopyTimestamp => {
if let Some(timestamp) = self.query_sent_at(ctx) {
ctx.clipboard()
.write(ClipboardContent::plain_text(format_message_timestamp(
&timestamp,
)));
}
}
AIBlockAction::CopyOutput => { AIBlockAction::CopyOutput => {
// Copy all AI output from preceding user query until the next user query // Copy all AI output from preceding user query until the next user query
let output_text = self.get_output_text_since_preceding_user_query(ctx); let output_text = self.get_output_text_since_preceding_user_query(ctx);
+4 -1
View File
@@ -1,7 +1,7 @@
mod helper; mod helper;
mod model_impl; mod model_impl;
use chrono::TimeDelta; use chrono::{DateTime, Local, TimeDelta};
use galaxy_core::features::FeatureFlag; use galaxy_core::features::FeatureFlag;
use galaxyui::{AppContext, ViewContext}; use galaxyui::{AppContext, ViewContext};
pub use helper::AIBlockModelHelper; pub use helper::AIBlockModelHelper;
@@ -182,6 +182,9 @@ pub trait AIBlockModel {
fn time_since_request_start(&self, _app: &AppContext) -> Option<TimeDelta> { fn time_since_request_start(&self, _app: &AppContext) -> Option<TimeDelta> {
None None
} }
fn query_sent_at(&self, _app: &AppContext) -> Option<DateTime<Local>> {
None
}
/// Returns the [`LLMId`] for the base model used to generate output in this block. /// Returns the [`LLMId`] for the base model used to generate output in this block.
fn base_model<'a>(&'a self, app: &'a AppContext) -> Option<&'a LLMId>; fn base_model<'a>(&'a self, app: &'a AppContext) -> Option<&'a LLMId>;
@@ -1,7 +1,7 @@
use std::marker::PhantomData; use std::marker::PhantomData;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use chrono::{Local, TimeDelta}; use chrono::{DateTime, Local, TimeDelta};
use galaxyui::{AppContext, Entity, SingletonEntity, View, ViewContext}; use galaxyui::{AppContext, Entity, SingletonEntity, View, ViewContext};
use history_model::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel}; use history_model::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
use session_sharing_protocol::common::ParticipantId; use session_sharing_protocol::common::ParticipantId;
@@ -15,6 +15,7 @@ use crate::ai::agent::{
use crate::ai::blocklist::history_model; use crate::ai::blocklist::history_model;
use crate::ai::blocklist::model::{AIRequestType, PassiveRequestType}; use crate::ai::blocklist::model::{AIRequestType, PassiveRequestType};
use crate::ai::llms::LLMId; use crate::ai::llms::LLMId;
use crate::util::time_format::is_trustworthy_message_timestamp;
/// Standard [`AIBlock`] impl for live outputs corresponding to an `OutputStream`. /// Standard [`AIBlock`] impl for live outputs corresponding to an `OutputStream`.
pub struct AIBlockModelImpl<V> { pub struct AIBlockModelImpl<V> {
@@ -146,6 +147,13 @@ where
} }
} }
fn query_sent_at(&self, app: &AppContext) -> Option<DateTime<Local>> {
self.exchange(app)
.ok()
.map(|exchange| exchange.start_time)
.filter(is_trustworthy_message_timestamp)
}
fn base_model<'a>(&'a self, app: &'a AppContext) -> Option<&'a LLMId> { fn base_model<'a>(&'a self, app: &'a AppContext) -> Option<&'a LLMId> {
let exchange = self.exchange(app); let exchange = self.exchange(app);
match exchange { match exchange {
+4
View File
@@ -1004,6 +1004,10 @@ impl View for AIBlock {
user_display_name: &avatar_display_name, user_display_name: &avatar_display_name,
profile_image_path: profile_image_path.as_ref(), profile_image_path: profile_image_path.as_ref(),
avatar_color, avatar_color,
query_sent_at: self.query_sent_at(app),
query_timestamp_tooltip_handle: &self
.state_handles
.query_timestamp_tooltip_handle,
query_and_index: Some((&query_for_display, input_index)), query_and_index: Some((&query_for_display, input_index)),
query_prefix_highlight_len, query_prefix_highlight_len,
detected_links_state: &self.detected_links_state, detected_links_state: &self.detected_links_state,
@@ -1,13 +1,16 @@
//! Rendering functions for orchestration-related output items (messaging & agent management). //! Rendering functions for orchestration-related output items (messaging & agent management).
use galaxyui::elements::{ use galaxyui::elements::{
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex, FormattedTextElement, ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex,
Hoverable, MouseStateHandle, ParentElement, Radius, Shrinkable, Text, FormattedTextElement, Hoverable, MouseStateHandle, OffsetPositioning, ParentAnchor,
ParentElement, ParentOffsetBounds, Radius, Shrinkable, Stack, Text,
}; };
use galaxyui::platform::Cursor; use galaxyui::platform::Cursor;
use galaxyui::ui_components::components::UiComponent;
use galaxyui::{AppContext, Element, SingletonEntity}; use galaxyui::{AppContext, Element, SingletonEntity};
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine}; use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use pathfinder_color::ColorU; use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use super::common::render_scrollable_collapsible_content; use super::common::render_scrollable_collapsible_content;
use super::output::{action_icon, Props}; use super::output::{action_icon, Props};
@@ -24,6 +27,7 @@ use crate::ai::blocklist::agent_view::orchestration_avatar::OrchestrationAvatar;
use crate::ai::blocklist::agent_view::orchestration_conversation_links::{ use crate::ai::blocklist::agent_view::orchestration_conversation_links::{
conversation_id_for_agent_id, conversation_id_for_agent_id_in_orchestration, conversation_id_for_agent_id, conversation_id_for_agent_id_in_orchestration,
conversation_navigation_card_with_icon, dispatch_focus_or_open_child_agent_pane, conversation_navigation_card_with_icon, dispatch_focus_or_open_child_agent_pane,
is_conversation_open_in_other_visible_view,
}; };
use crate::ai::blocklist::block::model::AIBlockModelHelper; use crate::ai::blocklist::block::model::AIBlockModelHelper;
use crate::ai::blocklist::block::{ use crate::ai::blocklist::block::{
@@ -197,6 +201,20 @@ fn transcript_metadata(recipients: &[OrchestrationParticipant], subject: &str) -
} }
} }
/// Hover tooltip copy for the clickable child-agent avatar in an
/// orchestration transcript row. The avatar is a bare letter disc, so
/// without this the click affordance (open/focus the child's pane) is
/// undiscoverable. Wording mirrors the pill bar's overflow-menu copy
/// ("Open in new pane" / "Focus pane") so the same navigation reads the
/// same way everywhere.
fn transcript_avatar_tooltip(display_name: &str, is_open_in_other_pane: bool) -> String {
if is_open_in_other_pane {
format!("Focus {display_name}'s pane")
} else {
format!("Open {display_name} in a new pane")
}
}
struct TranscriptRowData<'a> { struct TranscriptRowData<'a> {
participant: &'a OrchestrationParticipant, participant: &'a OrchestrationParticipant,
recipients: &'a [OrchestrationParticipant], recipients: &'a [OrchestrationParticipant],
@@ -330,7 +348,30 @@ fn render_transcript_row(
// open a new pane. // open a new pane.
let mouse_state = mouse_state.clone(); let mouse_state = mouse_state.clone();
let self_terminal_view_id = props.terminal_view_id; let self_terminal_view_id = props.terminal_view_id;
Hoverable::new(mouse_state, move |_| avatar) let tooltip_label = transcript_avatar_tooltip(
&data.participant.display_name,
is_conversation_open_in_other_visible_view(conversation_id, self_terminal_view_id, app),
);
let ui_builder = appearance.ui_builder().clone();
Hoverable::new(mouse_state, move |state| {
// Tooltip overlay, positioned above the avatar on hover. Same
// pattern as `render_force_refresh_inline` in `common.rs`; the
// overlay layer keeps it from being clipped by the scrollable
// transcript content.
let mut stack = Stack::new().with_child(avatar);
if state.is_hovered() {
stack.add_positioned_overlay_child(
ui_builder.tool_tip(tooltip_label).build().finish(),
OffsetPositioning::offset_from_parent(
vec2f(0., -4.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::TopLeft,
ChildAnchor::BottomLeft,
),
);
}
stack.finish()
})
.with_cursor(Cursor::PointingHand) .with_cursor(Cursor::PointingHand)
.on_click(move |ctx, app, _| { .on_click(move |ctx, app, _| {
dispatch_focus_or_open_child_agent_pane( dispatch_focus_or_open_child_agent_pane(
@@ -448,16 +489,15 @@ pub(super) fn render_send_message(
) -> Box<dyn Element> { ) -> Box<dyn Element> {
let message_row = let message_row =
render_send_message_body(props, action_id, address, subject, message, message_id, app); render_send_message_body(props, action_id, address, subject, message, message_id, app);
let Some(panel) = props.state_handles.subagent_panel_states.get(action_id) else { let Some(panel) = props.state_handles.subagent_link_states.get(action_id) else {
return message_row; return message_row;
}; };
Flex::column() Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(message_row) .with_child(message_row)
.with_child( .with_child(
crate::ai::blocklist::agent_view::subagent_inline_panel::render_subagent_inline_panel( crate::ai::blocklist::agent_view::subagent_link::render_subagent_link(
panel, panel,
action_id,
props.terminal_view_id, props.terminal_view_id,
app, app,
) )
@@ -716,11 +756,10 @@ pub(super) fn render_start_agent(
column.add_child(body); column.add_child(body);
} }
} }
if let Some(panel_state) = props.state_handles.subagent_panel_states.get(action_id) { if let Some(panel_state) = props.state_handles.subagent_link_states.get(action_id) {
column.add_child( column.add_child(
crate::ai::blocklist::agent_view::subagent_inline_panel::render_subagent_inline_panel( crate::ai::blocklist::agent_view::subagent_link::render_subagent_link(
panel_state, panel_state,
action_id,
props.terminal_view_id, props.terminal_view_id,
app, app,
), ),
@@ -810,11 +849,10 @@ pub(super) fn render_start_agent(
column.add_child(body); column.add_child(body);
} }
} }
if let Some(panel_state) = props.state_handles.subagent_panel_states.get(action_id) { if let Some(panel_state) = props.state_handles.subagent_link_states.get(action_id) {
column.add_child( column.add_child(
crate::ai::blocklist::agent_view::subagent_inline_panel::render_subagent_inline_panel( crate::ai::blocklist::agent_view::subagent_link::render_subagent_link(
panel_state, panel_state,
action_id,
props.terminal_view_id, props.terminal_view_id,
app, app,
), ),
@@ -8,7 +8,8 @@ use super::{
agent_display_name_from_id, child_conversation_card_data_for_result, participant_for_agent_id, agent_display_name_from_id, child_conversation_card_data_for_result, participant_for_agent_id,
render_conversation_navigation_card_row, start_agent_cancelled_prefix, render_conversation_navigation_card_row, start_agent_cancelled_prefix,
start_agent_error_prefix, start_agent_in_progress_prefix, start_agent_success_suffix, start_agent_error_prefix, start_agent_in_progress_prefix, start_agent_success_suffix,
transcript_metadata, ChildConversationCardData, OrchestrationAvatar, OrchestrationParticipant, transcript_avatar_tooltip, transcript_metadata, ChildConversationCardData, OrchestrationAvatar,
OrchestrationParticipant,
}; };
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus}; use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
use crate::ai::agent::{StartAgentExecutionMode, StartAgentResult}; use crate::ai::agent::{StartAgentExecutionMode, StartAgentResult};
@@ -440,6 +441,27 @@ fn participant_for_restored_child_run_id_resolves_to_agent_name() {
}); });
} }
/// The clickable letter avatar is otherwise indistinguishable from
/// decoration, so the hover tooltip must name the child agent and the
/// action taken on click.
#[test]
fn transcript_avatar_tooltip_describes_opening_a_new_pane() {
assert_eq!(
transcript_avatar_tooltip("Scout", false),
"Open Scout in a new pane"
);
}
/// Clicking the avatar focuses the existing pane when the child is
/// already open elsewhere, so the tooltip must not promise a new pane.
#[test]
fn transcript_avatar_tooltip_describes_focusing_an_open_pane() {
assert_eq!(
transcript_avatar_tooltip("Scout", true),
"Focus Scout's pane"
);
}
#[test] #[test]
fn transcript_metadata_uses_transcript_copy_without_technical_labels() { fn transcript_metadata_uses_transcript_copy_without_technical_labels() {
let recipients = vec![OrchestrationParticipant { let recipients = vec![OrchestrationParticipant {
@@ -3879,7 +3879,6 @@ fn render_collapsible_text_block_section(
let appearance = Appearance::as_ref(app); let appearance = Appearance::as_ref(app);
let theme = appearance.theme(); let theme = appearance.theme();
let text_color = blended_colors::text_disabled(theme, theme.surface_2()); let text_color = blended_colors::text_disabled(theme, theme.surface_2());
let selectable = false;
let is_streaming = props.model.status(app).is_streaming(); let is_streaming = props.model.status(app).is_streaming();
let mut container = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); let mut container = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
@@ -3912,7 +3911,7 @@ fn render_collapsible_text_block_section(
starting_image_section_index: &mut image_section_index, starting_image_section_index: &mut image_section_index,
sections, sections,
text_color, text_color,
selectable, selectable: true,
find_context: props.find_context, find_context: props.find_context,
current_working_directory: props.current_working_directory, current_working_directory: props.current_working_directory,
shell_launch_data: props.shell_launch_data, shell_launch_data: props.shell_launch_data,
+28 -4
View File
@@ -2,12 +2,15 @@
//! //!
//! Queries are not rendered in blocks corresponding to requested command or requested action responses. //! Queries are not rendered in blocks corresponding to requested command or requested action responses.
use chrono::{DateTime, Local};
use galaxy_core::features::FeatureFlag; use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::theme::color::internal_colors; use galaxy_core::ui::theme::color::internal_colors;
use pathfinder_color::ColorU; use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use warpui::elements::{ use warpui::elements::{
Container, CornerRadius, DispatchEventResult, EventHandler, Flex, MainAxisAlignment, ChildAnchor, Container, CornerRadius, DispatchEventResult, EventHandler, Flex,
MainAxisSize, ParentElement, Radius, Shrinkable, Wrap, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentAnchor, ParentElement, Radius,
Shrinkable, Wrap,
}; };
use warpui::fonts::{Properties, Style, Weight}; use warpui::fonts::{Properties, Style, Weight};
use warpui::ui_components::chip::Chip; use warpui::ui_components::chip::Chip;
@@ -21,6 +24,7 @@ use crate::ai::blocklist::AttachmentType;
use crate::appearance::Appearance; use crate::appearance::Appearance;
use crate::ui_components::blended_colors; use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon; use crate::ui_components::icons::Icon;
use crate::util::time_format::format_message_timestamp;
/// Data required to render the AI block query component. /// Data required to render the AI block query component.
#[derive(Copy, Clone, Debug)] #[derive(Copy, Clone, Debug)]
@@ -28,6 +32,8 @@ pub(super) struct Props<'a> {
pub(super) user_display_name: &'a String, pub(super) user_display_name: &'a String,
pub(super) profile_image_path: Option<&'a String>, pub(super) profile_image_path: Option<&'a String>,
pub(super) avatar_color: Option<ColorU>, pub(super) avatar_color: Option<ColorU>,
pub(super) query_sent_at: Option<DateTime<Local>>,
pub(super) query_timestamp_tooltip_handle: &'a MouseStateHandle,
pub(super) query_and_index: Option<(&'a str, usize)>, pub(super) query_and_index: Option<(&'a str, usize)>,
pub(super) query_prefix_highlight_len: Option<usize>, pub(super) query_prefix_highlight_len: Option<usize>,
pub(super) detected_links_state: &'a DetectedLinksState, pub(super) detected_links_state: &'a DetectedLinksState,
@@ -45,6 +51,8 @@ pub(super) fn maybe_render(props: Props, app: &AppContext) -> Option<Box<dyn Ele
props.user_display_name, props.user_display_name,
props.profile_image_path, props.profile_image_path,
props.avatar_color, props.avatar_color,
props.query_sent_at,
Some(props.query_timestamp_tooltip_handle.clone()),
props.detected_links_state, props.detected_links_state,
props.secret_redaction_state, props.secret_redaction_state,
input_index, input_index,
@@ -64,6 +72,8 @@ pub(crate) fn render_query(
user_display_name: &str, user_display_name: &str,
profile_image_path: Option<&String>, profile_image_path: Option<&String>,
avatar_color: Option<ColorU>, avatar_color: Option<ColorU>,
query_sent_at: Option<DateTime<Local>>,
query_timestamp_tooltip_handle: Option<MouseStateHandle>,
detected_links_state: &DetectedLinksState, detected_links_state: &DetectedLinksState,
secret_redaction_state: &SecretRedactionState, secret_redaction_state: &SecretRedactionState,
input_index: usize, input_index: usize,
@@ -74,14 +84,29 @@ pub(crate) fn render_query(
find_context: Option<FindContext>, find_context: Option<FindContext>,
app: &AppContext, app: &AppContext,
) -> Box<dyn Element> { ) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let avatar = Container::new(render_user_avatar( let avatar = Container::new(render_user_avatar(
user_display_name, user_display_name,
profile_image_path, profile_image_path,
avatar_color, avatar_color,
app, app,
)) ))
.with_margin_right(16.)
.finish(); .finish();
let avatar = if let Some((timestamp, tooltip_handle)) =
query_sent_at.zip(query_timestamp_tooltip_handle)
{
appearance.ui_builder().overlay_tool_tip_on_element(
format!("Message sent {}", format_message_timestamp(&timestamp)),
tooltip_handle,
avatar,
ParentAnchor::TopLeft,
ChildAnchor::BottomLeft,
vec2f(0., -8.),
)
} else {
avatar
};
let avatar = Container::new(avatar).with_margin_right(16.).finish();
let properties = Properties { let properties = Properties {
style: Style::Normal, style: Style::Normal,
@@ -103,7 +128,6 @@ pub(crate) fn render_query(
app, app,
); );
let appearance = Appearance::as_ref(app);
let mut query = Flex::column().with_child(text_element.finish()); let mut query = Flex::column().with_child(text_element.finish());
if FeatureFlag::ImageAsContext.is_enabled() { if FeatureFlag::ImageAsContext.is_enabled() {
+20
View File
@@ -2167,6 +2167,26 @@ impl BlocklistAIHistoryModel {
); );
} }
/// Clears a closed surface without notifying a controller whose conversations moved elsewhere.
pub(crate) fn clear_conversations_for_closed_terminal_surface(
&mut self,
terminal_surface_id: EntityId,
ctx: &mut ModelContext<Self>,
) {
if self
.live_conversation_ids_for_terminal_surface
.get(&terminal_surface_id)
.is_none_or(Vec::is_empty)
{
self.active_conversation_for_terminal_surface
.remove(&terminal_surface_id);
self.live_conversation_ids_for_terminal_surface
.remove(&terminal_surface_id);
return;
}
self.clear_conversations_for_terminal_surface(terminal_surface_id, ctx);
}
/// Handle removing a conversation from the history model, blocklist and in-memory. /// Handle removing a conversation from the history model, blocklist and in-memory.
pub fn remove_conversation( pub fn remove_conversation(
&mut self, &mut self,
@@ -2267,6 +2267,87 @@ fn test_restore_conversations_dedup_children_by_parent() {
}); });
} }
#[test]
fn closed_surface_cleanup_preserves_transferred_conversation() {
App::test((), |mut app| async move {
let old_surface = EntityId::new();
let new_surface = EntityId::new();
let conversation = AIConversation::new(false, false);
let conversation_id = conversation.id();
let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
history.update(&mut app, |model, ctx| {
model.restore_conversations(new_surface, vec![conversation], ctx);
model.set_active_conversation_id(conversation_id, new_surface, ctx);
model
.active_conversation_for_terminal_surface
.insert(old_surface, conversation_id);
model
.live_conversation_ids_for_terminal_surface
.insert(old_surface, Vec::new());
});
let events = Arc::new(Mutex::new(Vec::new()));
app.update(|ctx| {
let events = events.clone();
ctx.subscribe_to_model(&history, move |_, event, _| {
events.lock().unwrap().push(event.clone());
});
});
history.update(&mut app, |model, ctx| {
model.clear_conversations_for_closed_terminal_surface(old_surface, ctx);
});
history.read(&app, |model, _| {
assert_eq!(
model
.active_conversation_for_terminal_surface
.get(&old_surface),
None
);
assert!(!model
.live_conversation_ids_for_terminal_surface
.contains_key(&old_surface));
assert_eq!(
model.active_conversation_id(new_surface),
Some(conversation_id)
);
assert_eq!(
model.terminal_surface_id_for_conversation(&conversation_id),
Some(new_surface)
);
});
assert!(events.lock().unwrap().is_empty());
});
}
#[test]
fn closed_surface_cleanup_clears_owned_conversations() {
App::test((), |mut app| async move {
let surface_id = EntityId::new();
let conversation = AIConversation::new(false, false);
let conversation_id = conversation.id();
let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
history.update(&mut app, |model, ctx| {
model.restore_conversations(surface_id, vec![conversation], ctx);
model.set_active_conversation_id(conversation_id, surface_id, ctx);
model.clear_conversations_for_closed_terminal_surface(surface_id, ctx);
});
history.read(&app, |model, _| {
assert_eq!(model.active_conversation_id(surface_id), None);
assert_eq!(
model.terminal_surface_id_for_conversation(&conversation_id),
None
);
assert_eq!(
model
.cleared_conversation_ids_for_terminal_surface
.get(&surface_id),
Some(&vec![conversation_id])
);
});
});
}
#[test] #[test]
fn test_all_cleared_conversations_includes_terminal_view_id() { fn test_all_cleared_conversations_includes_terminal_view_id() {
App::test((), |mut app| async move { App::test((), |mut app| async move {
+7
View File
@@ -61,6 +61,13 @@ impl ChatGPTAuthModel {
Self { state } Self { state }
} }
#[cfg(test)]
pub(crate) fn new_for_test() -> Self {
Self {
state: ChatGPTAuthState::NotConnected,
}
}
pub(crate) fn state(&self) -> &ChatGPTAuthState { pub(crate) fn state(&self) -> &ChatGPTAuthState {
&self.state &self.state
} }
+11 -1
View File
@@ -978,7 +978,17 @@ fn make_new_elements_menu_items(ctx: &AppContext) -> Vec<MenuItem> {
MenuItem::Custom(CustomMenuItem::new( MenuItem::Custom(CustomMenuItem::new(
"New Window", "New Window",
open_new_window, open_new_window,
no_updates, move |_props: &MenuItemProperties, ctx: &mut AppContext| {
let mut changes = MenuItemPropertyChanges::default();
let trigger = Trigger::Custom(CustomAction::AddWindow.into());
let binding = ctx
.get_key_bindings()
.find(|b| b.trigger == &trigger || b.original_trigger == Some(&trigger));
if let Some(binding) = binding {
changes.keystroke = Some(bindings::trigger_to_keystroke(binding.trigger));
}
changes
},
Some(Keystroke::parse("cmd-n").expect("Valid keystroke")), Some(Keystroke::parse("cmd-n").expect("Valid keystroke")),
)), )),
MenuItem::Custom(CustomMenuItem::new( MenuItem::Custom(CustomMenuItem::new(
+6 -4
View File
@@ -7655,16 +7655,18 @@ impl TypedActionView for CodeReviewView {
ctx, ctx,
); );
} }
} else { } else if let Some(standardized_path) = self
let file_path = self.discard_dialog_state.discard_file_paths[0].clone(); .discard_dialog_state
if let Some(standardized_path) = self.to_standardized_path(&file_path) { .discard_file_paths
.first()
.and_then(|file_path| self.to_standardized_path(file_path))
{
self.discard_file( self.discard_file(
standardized_path, standardized_path,
self.discard_dialog_state.stash_changes_enabled, self.discard_dialog_state.stash_changes_enabled,
ctx, ctx,
); );
} }
}
self.discard_dialog_state.show_discard_confirm_dialog = false; self.discard_dialog_state.show_discard_confirm_dialog = false;
self.discard_dialog_state.discard_file_paths.clear(); self.discard_dialog_state.discard_file_paths.clear();
self.discard_dialog_state.selected_files.clear(); self.discard_dialog_state.selected_files.clear();
@@ -852,6 +852,24 @@ fn test_setup_dropdown_without_branches_only_has_uncommitted_changes() {
}); });
} }
#[test]
fn test_duplicate_single_file_discard_confirmation_is_ignored() {
App::test((), |mut app| async move {
let ctx = TestContext::new(&mut app, "test.txt", "line 1\nline 2\nline 3");
ctx.code_review_view.update(&mut app, |view, view_ctx| {
view.discard_dialog_state.operation_type = DiscardOperationType::FileUncommittedChanges;
view.discard_dialog_state.show_discard_confirm_dialog = false;
view.discard_dialog_state.discard_file_paths.clear();
view.handle_action(&CodeReviewAction::ConfirmDiscardFile, view_ctx);
assert!(!view.discard_dialog_state.show_discard_confirm_dialog);
assert!(view.discard_dialog_state.discard_file_paths.is_empty());
});
});
}
#[test] #[test]
fn test_on_close_then_on_open_reinitializes_repo_state() { fn test_on_close_then_on_open_reinitializes_repo_state() {
App::test((), |mut app| async move { App::test((), |mut app| async move {
+32 -4
View File
@@ -3167,14 +3167,21 @@ impl PaneGroup {
pane_group pane_group
} }
/// Returns the terminal view currently owning `conversation_id`, even if /// Returns the conversation's owner, excluding panes detached for undo-close.
/// that owner lives outside this pane group.
fn terminal_view_id_for_owned_conversation( fn terminal_view_id_for_owned_conversation(
&self, &self,
conversation_id: AIConversationId, conversation_id: AIConversationId,
ctx: &AppContext, ctx: &AppContext,
) -> Option<EntityId> { ) -> Option<EntityId> {
BlocklistAIHistoryModel::as_ref(ctx).terminal_surface_id_for_conversation(&conversation_id) BlocklistAIHistoryModel::as_ref(ctx)
.terminal_surface_id_for_conversation(&conversation_id)
.filter(|terminal_view_id| {
// Undo-close retains views and their conversations after their panes detach.
self.find_pane_id_for_terminal_view(*terminal_view_id, ctx)
.is_some_and(|pane_id| !self.is_pane_hidden_for_close(pane_id))
|| ActiveAgentViewsModel::as_ref(ctx)
.is_terminal_view_attached(*terminal_view_id, ctx)
})
} }
fn pane_id_for_owned_conversation( fn pane_id_for_owned_conversation(
@@ -4619,6 +4626,8 @@ impl PaneGroup {
ctx: &mut ViewContext<Self>, ctx: &mut ViewContext<Self>,
) -> bool { ) -> bool {
let tracked_child_pane = self.child_agent_panes.remove(&conversation_id); let tracked_child_pane = self.child_agent_panes.remove(&conversation_id);
self.pending_remote_child_hydrations
.retain(|_, child_id| *child_id != conversation_id);
let split_off_child_pane = self.child_agent_origin.as_ref().and_then(|origin| { let split_off_child_pane = self.child_agent_origin.as_ref().and_then(|origin| {
(origin.conversation_id == conversation_id) (origin.conversation_id == conversation_id)
.then(|| self.pane_id_for_conversation_owner(conversation_id, ctx)) .then(|| self.pane_id_for_conversation_owner(conversation_id, ctx))
@@ -6993,7 +7002,8 @@ impl PaneGroup {
if let Some(owner_view_id) = BlocklistAIHistoryModel::as_ref(ctx) if let Some(owner_view_id) = BlocklistAIHistoryModel::as_ref(ctx)
.terminal_surface_id_for_conversation(&conversation_id) .terminal_surface_id_for_conversation(&conversation_id)
{ {
ctx.dispatch_typed_action(&WorkspaceAction::FocusTerminalViewInWorkspace { // Workspace navigation reads this pane group, so it must wait until we return.
ctx.dispatch_typed_action_deferred(WorkspaceAction::FocusTerminalViewInWorkspace {
terminal_view_id: owner_view_id, terminal_view_id: owner_view_id,
}); });
return; return;
@@ -7558,6 +7568,7 @@ impl PaneGroup {
/// Reattach all panes to this group. This is called when a closed tab is restored. /// Reattach all panes to this group. This is called when a closed tab is restored.
pub fn reattach_panes(&mut self, ctx: &mut ViewContext<Self>) { pub fn reattach_panes(&mut self, ctx: &mut ViewContext<Self>) {
self.remove_transferred_child_agent_panes(ctx);
let pane_ids = self.pane_contents.keys().copied().collect_vec(); let pane_ids = self.pane_contents.keys().copied().collect_vec();
for pane_id in pane_ids { for pane_id in pane_ids {
let Some(pane) = self.pane_contents.get(&pane_id) else { let Some(pane) = self.pane_contents.get(&pane_id) else {
@@ -7568,6 +7579,23 @@ impl PaneGroup {
} }
} }
fn remove_transferred_child_agent_panes(&mut self, ctx: &mut ViewContext<Self>) {
let transferred_children = self
.child_agent_panes
.iter()
.filter_map(|(conversation_id, pane_id)| {
let owner = BlocklistAIHistoryModel::as_ref(ctx)
.terminal_surface_id_for_conversation(conversation_id)?;
let terminal_view = self.terminal_view_from_pane_id(*pane_id, ctx)?;
(owner != terminal_view.id()).then_some(*conversation_id)
})
.collect_vec();
for conversation_id in transferred_children {
self.discard_child_agent_pane_for_conversation(conversation_id, ctx);
}
}
/// Attempts to attach a pane, calling pre_attach first. /// Attempts to attach a pane, calling pre_attach first.
/// Returns true if attachment succeeded, false if pre_attach prevented it. /// Returns true if attachment succeeded, false if pre_attach prevented it.
fn try_attach_pane(&self, pane: &dyn AnyPaneContent, ctx: &mut ViewContext<Self>) -> bool { fn try_attach_pane(&self, pane: &dyn AnyPaneContent, ctx: &mut ViewContext<Self>) -> bool {
+96
View File
@@ -2098,6 +2098,102 @@ fn test_ensure_hidden_child_agent_pane_skips_child_owned_by_another_pane_group()
}); });
} }
#[test]
fn test_ensure_hidden_child_agent_pane_restores_child_from_detached_group() {
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
App::test((), |mut app| async move {
initialize_app(&mut app);
let closed_pane_group = mock_pane_group(&mut app, Default::default());
let reopened_pane_group = mock_pane_group(&mut app, Default::default());
let (parent_conversation, child_conversation_id, previous_owner) = closed_pane_group
.update(&mut app, |panes, ctx| {
let parent_pane_id = panes.focused_pane_id(ctx);
let parent_conversation_id = start_parent_conversation(panes, parent_pane_id, ctx);
let mut child = AIConversation::new(false, false);
child.set_parent_conversation_id(parent_conversation_id);
child.set_agent_name("architect".to_string());
let child_conversation_id = child.id();
panes.create_hidden_child_agent_pane(child, parent_pane_id, ctx);
let previous_owner = panes
.terminal_view_from_pane_id(
panes.child_agent_panes[&child_conversation_id],
ctx,
)
.unwrap();
let parent_conversation = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&parent_conversation_id)
.unwrap()
.clone();
panes.swap_active_pane_to_conversation(parent_pane_id, child_conversation_id, ctx);
panes.detach_panes(ctx);
(parent_conversation, child_conversation_id, previous_owner)
});
let restored_child = reopened_pane_group.update(&mut app, |panes, ctx| {
let parent_pane_id = panes.focused_pane_id(ctx);
let parent_view = panes
.terminal_view_from_pane_id(parent_pane_id, ctx)
.unwrap();
let parent_conversation_id =
restore_conversation_for_terminal_view(parent_view.id(), parent_conversation, ctx);
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history.set_active_conversation_id(parent_conversation_id, parent_view.id(), ctx);
});
assert!(
panes.ensure_hidden_child_agent_pane_for_conversation(child_conversation_id, ctx)
);
let child_pane_id = *panes
.child_agent_panes
.get(&child_conversation_id)
.expect("an undo-retained owner must not prevent restoring the child");
panes.swap_active_pane_to_conversation(parent_pane_id, child_conversation_id, ctx);
assert_eq!(panes.focused_pane_id(ctx), child_pane_id);
let child_view = panes
.terminal_view_from_pane_id(child_pane_id, ctx)
.unwrap();
assert_eq!(
child_view.as_ref(ctx).active_conversation_id(ctx),
Some(child_conversation_id)
);
child_view
});
assert_ne!(restored_child.id(), previous_owner.id());
closed_pane_group.update(&mut app, |panes, ctx| {
panes.reattach_panes(ctx);
assert!(!panes.child_agent_panes.contains_key(&child_conversation_id));
assert_eq!(
panes.find_pane_id_for_terminal_view(previous_owner.id(), ctx),
None
);
assert_eq!(panes.visible_pane_count(), 1);
assert!(
panes.ensure_hidden_child_agent_pane_for_conversation(child_conversation_id, ctx)
);
});
closed_pane_group.update(&mut app, |panes, ctx| {
panes.clean_up_panes(ctx);
});
app.read(|ctx| {
assert_eq!(
BlocklistAIHistoryModel::as_ref(ctx)
.terminal_surface_id_for_conversation(&child_conversation_id),
Some(restored_child.id())
);
assert_eq!(
restored_child.as_ref(ctx).active_conversation_id(ctx),
Some(child_conversation_id)
);
});
});
}
#[test] #[test]
fn test_entering_parent_agent_view_skips_child_owned_by_another_pane_group() { fn test_entering_parent_agent_view_skips_child_owned_by_another_pane_group() {
let _agent_view = FeatureFlag::AgentView.override_enabled(true); let _agent_view = FeatureFlag::AgentView.override_enabled(true);
+4 -2
View File
@@ -423,8 +423,10 @@ impl PaneContent for TerminalPane {
// Only immediately clear conversations and delete blocks if the session is being // Only immediately clear conversations and delete blocks if the session is being
// permanently closed. // permanently closed.
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| { BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
history_model history_model.clear_conversations_for_closed_terminal_surface(
.clear_conversations_for_terminal_surface(self.terminal_view(ctx).id(), ctx); self.terminal_view(ctx).id(),
ctx,
);
}); });
self.delete_blocks(ctx); self.delete_blocks(ctx);
} }
-5
View File
@@ -117,11 +117,6 @@ pub const FUNDAMENTALS_KEYBINDINGS: &[&str] = &[
/// anywhere else in the code. /// anywhere else in the code.
pub fn get_additional_keybindings() -> Vec<CommandBinding> { pub fn get_additional_keybindings() -> Vec<CommandBinding> {
vec![ vec![
CommandBinding::new(
"workspace:new_window".into(),
"Open New Window".into(),
Some(Keystroke::parse("cmd-n").expect("Valid keystroke")),
),
CommandBinding::new( CommandBinding::new(
"workspace:hide_warp".into(), "workspace:hide_warp".into(),
"Hide Galaxy".into(), "Hide Galaxy".into(),
+6 -1
View File
@@ -5,7 +5,9 @@ use galaxyui::elements::{
}; };
use galaxyui::fonts::Weight; use galaxyui::fonts::Weight;
use galaxyui::text_layout::TextAlignment; use galaxyui::text_layout::TextAlignment;
use galaxyui::{AppContext, Entity, SingletonEntity, View, ViewContext, ViewHandle}; use galaxyui::{
AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use warpui::assets::asset_cache::AssetSource; use warpui::assets::asset_cache::AssetSource;
use super::settings_page::{ use super::settings_page::{
@@ -31,6 +33,9 @@ impl AboutPageView {
impl Entity for AboutPageView { impl Entity for AboutPageView {
type Event = SettingsPageEvent; type Event = SettingsPageEvent;
} }
impl TypedActionView for AboutPageView {
type Action = ();
}
impl View for AboutPageView { impl View for AboutPageView {
fn ui_name() -> &'static str { fn ui_name() -> &'static str {
+1 -1
View File
@@ -1175,7 +1175,7 @@ impl SettingsView {
}); });
// About page // About page
let about_page_handle = ctx.add_view(AboutPageView::new); let about_page_handle = ctx.add_typed_action_view(AboutPageView::new);
// AI page // AI page
let ai_page_handle = ctx.add_typed_action_view(AISettingsPageView::new); let ai_page_handle = ctx.add_typed_action_view(AISettingsPageView::new);
+28
View File
@@ -530,6 +530,7 @@ use crate::workspace::view::cloud_agent_capacity_modal::CloudAgentCapacityModalV
use crate::workspace::{ use crate::workspace::{
CommandSearchOptions, ForkAIConversationParams, ForkFromExchange, CommandSearchOptions, ForkAIConversationParams, ForkFromExchange,
ForkedConversationDestination, OneTimeModalModel, ToastStack, WorkspaceAction, ForkedConversationDestination, OneTimeModalModel, ToastStack, WorkspaceAction,
WorkspaceRegistry,
}; };
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent}; use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
use crate::workspaces::workspace::CustomerType; use crate::workspaces::workspace::CustomerType;
@@ -1428,6 +1429,9 @@ pub enum ContextMenuAction {
CopyAIBlockQuery { CopyAIBlockQuery {
ai_block_view_id: EntityId, ai_block_view_id: EntityId,
}, },
CopyAIBlockTimestamp {
ai_block_view_id: EntityId,
},
/// Copy the AI block output text /// Copy the AI block output text
CopyAIBlockOutput { CopyAIBlockOutput {
ai_block_view_id: EntityId, ai_block_view_id: EntityId,
@@ -1528,6 +1532,7 @@ impl fmt::Debug for ContextMenuAction {
StopSharing => f.write_str("StopSharing"), StopSharing => f.write_str("StopSharing"),
CopyAIDebuggingLink { .. } => f.write_str("CopyAIDebuggingLink"), CopyAIDebuggingLink { .. } => f.write_str("CopyAIDebuggingLink"),
CopyAIBlockQuery { .. } => f.write_str("CopyAIBlockPrompt"), CopyAIBlockQuery { .. } => f.write_str("CopyAIBlockPrompt"),
CopyAIBlockTimestamp { .. } => f.write_str("CopyAIBlockTimestamp"),
CopyAIBlockOutput { .. } => f.write_str("CopyAIBlockOutput"), CopyAIBlockOutput { .. } => f.write_str("CopyAIBlockOutput"),
CopyAIBlock { .. } => f.write_str("CopyAIBlockBoth"), CopyAIBlock { .. } => f.write_str("CopyAIBlockBoth"),
CopyAIBlockConversation { .. } => f.write_str("CopyAIBlockConversation"), CopyAIBlockConversation { .. } => f.write_str("CopyAIBlockConversation"),
@@ -12149,8 +12154,18 @@ impl TerminalView {
// case, we want the block to be focused because otherwise, // case, we want the block to be focused because otherwise,
// users get stuck as they'd otherwise need to click into the // users get stuck as they'd otherwise need to click into the
// box to respond to whether or not they want to update oh my zsh. // box to respond to whether or not they want to update oh my zsh.
//
// Skipped while a tab or tab-group rename editor is focused. Taking focus
// would end the rename and lose user inputs (#14241).
let inline_rename_editor_is_focused = WorkspaceRegistry::as_ref(ctx)
.get(self.window_id, ctx)
.is_some_and(|workspace| {
workspace.as_ref(ctx).is_inline_rename_editor_focused(ctx)
});
if !inline_rename_editor_is_focused {
self.focus_terminal(ctx); self.focus_terminal(ctx);
} }
}
ModelEvent::AfterBlockStarted { ModelEvent::AfterBlockStarted {
command, command,
is_for_in_band_command, is_for_in_band_command,
@@ -24998,6 +25013,19 @@ impl TerminalView {
} }
} }
} }
CopyAIBlockTimestamp { ai_block_view_id } => {
for rich_content in self.rich_content_views.iter() {
if let Some(ai_metadata) = rich_content
.ai_block_metadata()
.filter(|metadata| metadata.ai_block_handle.id() == *ai_block_view_id)
{
ai_metadata.ai_block_handle.update(ctx, |block, ctx| {
block.handle_action(&AIBlockAction::CopyTimestamp, ctx);
});
break;
}
}
}
CopyAIBlockOutput { ai_block_view_id } => { CopyAIBlockOutput { ai_block_view_id } => {
// Copy only the current AI block's output // Copy only the current AI block's output
for rich_content in self.rich_content_views.iter() { for rich_content in self.rich_content_views.iter() {
@@ -74,6 +74,8 @@ fn render_user_query(
&user_display_name, &user_display_name,
profile_image_url.as_ref(), profile_image_url.as_ref(),
None, None,
None,
None,
&Default::default(), &Default::default(),
&Default::default(), &Default::default(),
0, 0,
+21
View File
@@ -119,6 +119,27 @@ impl TerminalView {
.into_item(), .into_item(),
); );
} }
let has_query_timestamp = self.rich_content_views.iter().any(|rich_content| {
rich_content
.ai_block_metadata()
.filter(|metadata| metadata.ai_block_handle.id() == ai_block_view_id)
.is_some_and(|metadata| {
metadata
.ai_block_handle
.as_ref(ctx)
.query_sent_at(ctx)
.is_some()
})
});
if has_query_timestamp {
items.push(
MenuItemFields::new("Copy timestamp")
.with_on_select_action(TerminalAction::ContextMenu(
ContextMenuAction::CopyAIBlockTimestamp { ai_block_view_id },
))
.into_item(),
);
}
items.push(MenuItem::Separator); items.push(MenuItem::Separator);
items.push( items.push(
MenuItemFields::new("Save as prompt") MenuItemFields::new("Save as prompt")
+73 -1
View File
@@ -51,6 +51,7 @@ use crate::server::ids::{ClientId, SyncId};
use crate::server::server_api::ai::SpawnAgentRequest; use crate::server::server_api::ai::SpawnAgentRequest;
use crate::settings::import::model::ImportedConfigModel; use crate::settings::import::model::ImportedConfigModel;
use crate::settings::{AISettings, AppEditorSettings, WarpPromptSeparator}; use crate::settings::{AISettings, AppEditorSettings, WarpPromptSeparator};
use crate::tab::NewSessionMenuItem;
use crate::terminal::alt_screen::should_intercept_mouse; use crate::terminal::alt_screen::should_intercept_mouse;
use crate::terminal::block_list_element::{SnackbarPoint, SnackbarTranslationMode}; use crate::terminal::block_list_element::{SnackbarPoint, SnackbarTranslationMode};
use crate::terminal::block_list_viewport::{ClampingMode, ScrollLines}; use crate::terminal::block_list_viewport::{ClampingMode, ScrollLines};
@@ -84,7 +85,8 @@ use crate::test_util::terminal::{
}; };
use crate::test_util::{add_window_with_terminal, assert_eventually}; use crate::test_util::{add_window_with_terminal, assert_eventually};
use crate::view_components::find::FindWithinBlockState; use crate::view_components::find::FindWithinBlockState;
use crate::workspace::ToastStack; use crate::workspace::view::tests::{initialize_app as initialize_workspace_app, mock_workspace};
use crate::workspace::{ToastStack, WorkspaceAction};
use crate::ActiveAgentViewsModel; use crate::ActiveAgentViewsModel;
fn add_window_with_cloud_mode_terminal(app: &mut App) -> ViewHandle<TerminalView> { fn add_window_with_cloud_mode_terminal(app: &mut App) -> ViewHandle<TerminalView> {
@@ -8427,3 +8429,73 @@ fn hidden_tool_control_changes_preserve_pinned_and_unpinned_scroll() {
}); });
}); });
} }
#[test]
fn visible_bootstrap_block_leaves_focus_on_tab_rename_editor() {
App::test((), |mut app| async move {
initialize_workspace_app(&mut app);
let workspace = mock_workspace(&mut app);
let (window, terminal) = workspace.update(&mut app, |workspace, ctx| {
workspace.rename_tab(0, ctx);
let terminal = workspace
.active_tab_pane_group()
.as_ref(ctx)
.active_session_view(ctx)
.expect("tab should contain a terminal");
(ctx.window_id(), terminal)
});
assert!(workspace.read(&app, |workspace, ctx| {
workspace.is_inline_rename_editor_focused(ctx)
}));
let focused_before = app.focused_view_id(window);
terminal.update(&mut app, |view, ctx| {
view.handle_terminal_event(&ModelEvent::VisibleBootstrapBlock, ctx);
});
assert_eq!(app.focused_view_id(window), focused_before);
assert!(workspace.read(&app, |workspace, ctx| {
workspace.is_inline_rename_editor_focused(ctx)
}));
});
}
#[test]
fn visible_bootstrap_block_leaves_focus_on_tab_group_rename_editor() {
let _grouped_tabs_guard = FeatureFlag::GroupedTabs.override_enabled(true);
App::test((), |mut app| async move {
initialize_workspace_app(&mut app);
let workspace = mock_workspace(&mut app);
let (window, terminal, group_id) = workspace.update(&mut app, |workspace, ctx| {
workspace.handle_action(
&WorkspaceAction::SelectNewSessionMenuItem(NewSessionMenuItem::CreateNewTabGroup),
ctx,
);
let group_id = workspace.tabs[0]
.group_id
.expect("active tab should be assigned to the new group");
let terminal = workspace
.active_tab_pane_group()
.as_ref(ctx)
.active_session_view(ctx)
.expect("new tab group should contain a terminal");
(ctx.window_id(), terminal, group_id)
});
workspace.update(&mut app, |workspace, ctx| {
workspace.rename_tab_group(group_id, ctx);
});
assert!(workspace.read(&app, |workspace, ctx| {
workspace.is_inline_rename_editor_focused(ctx)
}));
let focused_before = app.focused_view_id(window);
terminal.update(&mut app, |view, ctx| {
view.handle_terminal_event(&ModelEvent::VisibleBootstrapBlock, ctx);
});
assert_eq!(app.focused_view_id(window), focused_before);
assert!(workspace.read(&app, |workspace, ctx| {
workspace.is_inline_rename_editor_focused(ctx)
}));
});
}
+9 -5
View File
@@ -76,6 +76,8 @@ fn circle_padding(total: f32) -> f32 {
(circle_size(total) - icon_size(total)) / 2. (circle_size(total) - icon_size(total)) / 2.
} }
/// Returns the diameter of the status badge's cutout ring, i.e. the badge's
/// full painted footprint.
fn badge_size(total: f32, style: StatusBadgeStyle) -> f32 { fn badge_size(total: f32, style: StatusBadgeStyle) -> f32 {
total * style.ring_ratio total * style.ring_ratio
} }
@@ -141,8 +143,11 @@ pub(crate) enum IconWithStatusVariant {
is_ambient: bool, is_ambient: bool,
}, },
/// A pre-rendered avatar with an optional status overlay (cloud lobe when /// A pre-rendered avatar with an optional status overlay (cloud lobe when
/// ambient). Caller must size `avatar` to `circle_size(total_size)` so the /// ambient). The overlay is anchored to the `total_size` box's bottom-right
/// overlay's overhang matches the other variants. /// corner however big `avatar` is, so pass either an avatar sized to
/// `circle_size(total_size)` (to match the overhang of the other variants)
/// or an element that already fills the `total_size` box and places its own
/// artwork inside it.
CustomAvatar { CustomAvatar {
avatar: Box<dyn Element>, avatar: Box<dyn Element>,
status: Option<ConversationStatus>, status: Option<ConversationStatus>,
@@ -444,9 +449,8 @@ fn render_with_optional_status_badge(
status_container_background: GalaxyThemeFill, status_container_background: GalaxyThemeFill,
) -> Box<dyn Element> { ) -> Box<dyn Element> {
let Some(status) = status else { let Some(status) = status else {
// No status badge: still occupy the full `total_size` footprint so the agent // Reserve the same footprint with or without a badge. ConstrainedBox does not
// circle (which is only `circle_size(total)` wide) sits centered in the box // center the circle; callers that need centering must wrap it in Align.
// the caller reserved.
return ConstrainedBox::new(circle) return ConstrainedBox::new(circle)
.with_width(total_size) .with_width(total_size)
.with_height(total_size) .with_height(total_size)
-1
View File
@@ -383,7 +383,6 @@ pub fn custom_tag_to_keystroke(custom: CustomTag) -> Option<Keystroke> {
} }
} }
// This is one of the app's hardcoded keybindings.
CustomAction::AddWindow => Keystroke::parse(cmd_or_ctrl_shift("n")).ok(), CustomAction::AddWindow => Keystroke::parse(cmd_or_ctrl_shift("n")).ok(),
CustomAction::ToggleGalaxyDrive => { CustomAction::ToggleGalaxyDrive => {
if OperatingSystem::get().is_mac() { if OperatingSystem::get().is_mac() {
+8
View File
@@ -2,6 +2,14 @@ use std::ops::Sub;
use chrono::{DateTime, Duration, Local, Utc}; use chrono::{DateTime, Duration, Local, Utc};
pub fn format_message_timestamp(datetime: &DateTime<Local>) -> String {
datetime.format("%-m/%-d at %-I:%M %p").to_string()
}
pub fn is_trustworthy_message_timestamp(datetime: &DateTime<Local>) -> bool {
datetime != &DateTime::<Local>::default()
}
// Some conversion ratios for time units. // Some conversion ratios for time units.
const SEC_TO_MS: f64 = 1000.; const SEC_TO_MS: f64 = 1000.;
const MIN_TO_MS: f64 = 60. * SEC_TO_MS; const MIN_TO_MS: f64 = 60. * SEC_TO_MS;
+21
View File
@@ -1,5 +1,26 @@
use chrono::TimeZone;
use super::*; use super::*;
#[test]
fn format_message_timestamp_uses_friendly_local_time() {
let morning = Local.with_ymd_and_hms(2026, 8, 10, 9, 2, 0).unwrap();
let noon = Local.with_ymd_and_hms(2026, 12, 31, 12, 45, 0).unwrap();
let midnight = Local.with_ymd_and_hms(2026, 12, 31, 0, 5, 0).unwrap();
assert_eq!(format_message_timestamp(&morning), "8/10 at 9:02 AM");
assert_eq!(format_message_timestamp(&noon), "12/31 at 12:45 PM");
assert_eq!(format_message_timestamp(&midnight), "12/31 at 12:05 AM");
}
#[test]
fn default_message_timestamp_is_not_trustworthy() {
assert!(!is_trustworthy_message_timestamp(&DateTime::default()));
assert!(is_trustworthy_message_timestamp(
&Local.with_ymd_and_hms(2026, 8, 10, 9, 22, 0).unwrap()
));
}
#[test] #[test]
fn test_format_sigfigs() { fn test_format_sigfigs() {
assert_eq!(format_sigfigs(0.000456, 2,), "0.00046"); assert_eq!(format_sigfigs(0.000456, 2,), "0.00046");
+14 -12
View File
@@ -69,11 +69,11 @@ use crate::workspace::view::{
LEFT_PANEL_AGENT_CONVERSATIONS_BINDING_NAME, LEFT_PANEL_GLOBAL_SEARCH_BINDING_NAME, LEFT_PANEL_AGENT_CONVERSATIONS_BINDING_NAME, LEFT_PANEL_GLOBAL_SEARCH_BINDING_NAME,
LEFT_PANEL_PROJECT_EXPLORER_BINDING_NAME, LEFT_PANEL_WARP_DRIVE_BINDING_NAME, LEFT_PANEL_PROJECT_EXPLORER_BINDING_NAME, LEFT_PANEL_WARP_DRIVE_BINDING_NAME,
NEW_AGENT_TAB_BINDING_NAME, NEW_AMBIENT_AGENT_TAB_BINDING_NAME, NEW_FILE_BINDING_NAME, NEW_AGENT_TAB_BINDING_NAME, NEW_AMBIENT_AGENT_TAB_BINDING_NAME, NEW_FILE_BINDING_NAME,
NEW_TAB_BINDING_NAME, NEW_TERMINAL_TAB_BINDING_NAME, OPEN_GLOBAL_SEARCH_BINDING_NAME, NEW_TAB_BINDING_NAME, NEW_TERMINAL_TAB_BINDING_NAME, NEW_WINDOW_BINDING_NAME,
TOGGLE_CONVERSATION_LIST_VIEW_BINDING_NAME, TOGGLE_NOTIFICATION_MAILBOX_BINDING_NAME, OPEN_GLOBAL_SEARCH_BINDING_NAME, TOGGLE_CONVERSATION_LIST_VIEW_BINDING_NAME,
TOGGLE_PROJECT_EXPLORER_BINDING_NAME, TOGGLE_RIGHT_PANEL_BINDING_NAME, TOGGLE_NOTIFICATION_MAILBOX_BINDING_NAME, TOGGLE_PROJECT_EXPLORER_BINDING_NAME,
TOGGLE_TAB_CONFIGS_MENU_BINDING_NAME, TOGGLE_VERTICAL_TABS_PANEL_BINDING_NAME, TOGGLE_RIGHT_PANEL_BINDING_NAME, TOGGLE_TAB_CONFIGS_MENU_BINDING_NAME,
TOGGLE_WARP_DRIVE_BINDING_NAME, TOGGLE_VERTICAL_TABS_PANEL_BINDING_NAME, TOGGLE_WARP_DRIVE_BINDING_NAME,
}; };
pub fn init(app: &mut AppContext) { pub fn init(app: &mut AppContext) {
@@ -307,15 +307,17 @@ pub fn init(app: &mut AppContext) {
"Switch to previous tab", "Switch to previous tab",
id!("Workspace") & id!("Workspace_MultipleTabs"), id!("Workspace") & id!("Workspace_MultipleTabs"),
), ),
FixedBinding::custom(
CustomAction::AddWindow,
WorkspaceAction::AddWindow,
"Create New Window",
id!("Workspace"),
)
.with_enabled(|| ContextFlag::CreateNewSession.is_enabled()),
]); ]);
app.register_editable_bindings([EditableBinding::new(
NEW_WINDOW_BINDING_NAME,
BindingDescription::new("Create New Window"),
WorkspaceAction::AddWindow,
)
.with_custom_action(CustomAction::AddWindow)
.with_context_predicate(id!("Workspace"))
.with_enabled(|| ContextFlag::CreateNewSession.is_enabled())]);
app.register_editable_bindings([EditableBinding::new( app.register_editable_bindings([EditableBinding::new(
NEW_FILE_BINDING_NAME, NEW_FILE_BINDING_NAME,
BindingDescription::new("New File"), BindingDescription::new("New File"),
+26 -2
View File
@@ -98,6 +98,7 @@ use galaxyui::{
AppContext, Entity, EntityId, FocusContext, ModelHandle, SingletonEntity, TypedActionView, AppContext, Entity, EntityId, FocusContext, ModelHandle, SingletonEntity, TypedActionView,
UpdateModel, UpdateView, View, ViewAsRef, ViewContext, ViewHandle, WeakViewHandle, WindowId, UpdateModel, UpdateView, View, ViewAsRef, ViewContext, ViewHandle, WeakViewHandle, WindowId,
}; };
use instant::Instant;
use itertools::Itertools; use itertools::Itertools;
use lazy_static::lazy_static; use lazy_static::lazy_static;
pub(crate) use onboarding::OnboardingTutorial; pub(crate) use onboarding::OnboardingTutorial;
@@ -630,6 +631,7 @@ pub(crate) const TOGGLE_CONVERSATION_LIST_VIEW_BINDING_NAME: &str =
pub(crate) const NEW_TAB_BINDING_NAME: &str = "workspace:new_tab"; pub(crate) const NEW_TAB_BINDING_NAME: &str = "workspace:new_tab";
pub(crate) const NEW_TERMINAL_TAB_BINDING_NAME: &str = "workspace:new_terminal_tab"; pub(crate) const NEW_TERMINAL_TAB_BINDING_NAME: &str = "workspace:new_terminal_tab";
pub(crate) const NEW_FILE_BINDING_NAME: &str = "workspace:new_file"; pub(crate) const NEW_FILE_BINDING_NAME: &str = "workspace:new_file";
pub(crate) const NEW_WINDOW_BINDING_NAME: &str = "workspace:new_window";
pub(crate) const NEW_AGENT_TAB_BINDING_NAME: &str = "workspace:new_agent_tab"; pub(crate) const NEW_AGENT_TAB_BINDING_NAME: &str = "workspace:new_agent_tab";
pub(crate) const NEW_AMBIENT_AGENT_TAB_BINDING_NAME: &str = "workspace:new_ambient_agent_tab"; pub(crate) const NEW_AMBIENT_AGENT_TAB_BINDING_NAME: &str = "workspace:new_ambient_agent_tab";
pub(crate) const TOGGLE_TAB_CONFIGS_MENU_BINDING_NAME: &str = "workspace:toggle_tab_configs_menu"; pub(crate) const TOGGLE_TAB_CONFIGS_MENU_BINDING_NAME: &str = "workspace:toggle_tab_configs_menu";
@@ -1034,6 +1036,7 @@ pub struct Workspace {
import_modal: ViewHandle<ImportModal>, import_modal: ViewHandle<ImportModal>,
theme_chooser_view: ViewHandle<ThemeChooser>, theme_chooser_view: ViewHandle<ThemeChooser>,
previous_theme: Option<ThemeKind>, previous_theme: Option<ThemeKind>,
background_image_animation_start_time: Instant,
reward_modal: ViewHandle<Modal<RewardView>>, reward_modal: ViewHandle<Modal<RewardView>>,
reward_modal_pending: Option<RewardKind>, reward_modal_pending: Option<RewardKind>,
pub(crate) current_workspace_state: WorkspaceState, pub(crate) current_workspace_state: WorkspaceState,
@@ -1462,10 +1465,15 @@ impl Workspace {
.is_any_tab_group_being_renamed() .is_any_tab_group_being_renamed()
{ {
match event { match event {
EditorEvent::Blurred | EditorEvent::Enter => { EditorEvent::Enter => {
self.finish_tab_group_rename(ctx); self.finish_tab_group_rename(ctx);
} }
EditorEvent::Escape => { // Blur discards rather than commits. Focus can leave this editor without
// the user ever ending the rename — #14241 is one such case — and
// committing then writes a half-typed fragment as the group's real,
// persisted name. Discarding loses nothing the user cannot retype, and
// Enter remains the way to confirm.
EditorEvent::Blurred | EditorEvent::Escape => {
self.cancel_tab_group_rename(ctx); self.cancel_tab_group_rename(ctx);
} }
_ => {} _ => {}
@@ -3354,6 +3362,7 @@ impl Workspace {
ctrl_tab_palette, ctrl_tab_palette,
mouse_states: Default::default(), mouse_states: Default::default(),
previous_theme: None, previous_theme: None,
background_image_animation_start_time: Instant::now(),
settings_pane, settings_pane,
theme_chooser_view, theme_chooser_view,
reward_modal, reward_modal,
@@ -7131,6 +7140,18 @@ impl Workspace {
} }
} }
pub(crate) fn is_inline_rename_editor_focused(&self, ctx: &AppContext) -> bool {
match ctx.focused_view_id(self.window_id) {
Some(id) if id == self.tab_rename_editor.id() => {
self.current_workspace_state.is_tab_being_renamed()
}
Some(id) if id == self.tab_group_rename_editor.id() => self
.current_workspace_state
.is_any_tab_group_being_renamed(),
_ => false,
}
}
/// Opens the inline rename editor over the given group's header. /// Opens the inline rename editor over the given group's header.
pub fn rename_tab_group(&mut self, group_id: TabGroupId, ctx: &mut ViewContext<Self>) { pub fn rename_tab_group(&mut self, group_id: TabGroupId, ctx: &mut ViewContext<Self>) {
let Some(group) = self.tab_groups.get(&group_id) else { let Some(group) = self.tab_groups.get(&group_id) else {
@@ -27101,6 +27122,9 @@ impl View for Workspace {
.cover() .cover()
.with_opacity(opacity_ratio) .with_opacity(opacity_ratio)
.with_corner_radius(window_corner_radius) .with_corner_radius(window_corner_radius)
.enable_animation_with_start_time(
self.background_image_animation_start_time,
)
.finish(), .finish(),
) )
.finish(), .finish(),
+55 -1
View File
@@ -28,6 +28,8 @@ use crate::ai::agent_tips::AITipModel;
use crate::ai::ambient_agents::github_auth_notifier::GitHubAuthNotifier; use crate::ai::ambient_agents::github_auth_notifier::GitHubAuthNotifier;
use crate::ai::blocklist::agent_view::orchestration_pill_bar_model::OrchestrationPillBarModel; use crate::ai::blocklist::agent_view::orchestration_pill_bar_model::OrchestrationPillBarModel;
use crate::ai::blocklist::{BlocklistAIHistoryModel, BlocklistAIPermissions}; use crate::ai::blocklist::{BlocklistAIHistoryModel, BlocklistAIPermissions};
#[cfg(not(target_family = "wasm"))]
use crate::ai::chatgpt_auth::ChatGPTAuthModel;
use crate::ai::document::ai_document_model::AIDocumentModel; use crate::ai::document::ai_document_model::AIDocumentModel;
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
use crate::ai::facts::manager::AIFactManager; use crate::ai::facts::manager::AIFactManager;
@@ -178,6 +180,8 @@ pub(crate) fn initialize_app(app: &mut App) {
app.add_singleton_model(AgentNotificationsModel::new); app.add_singleton_model(AgentNotificationsModel::new);
app.add_singleton_model(AgentConversationsModel::new); app.add_singleton_model(AgentConversationsModel::new);
app.add_singleton_model(SessionPermissionsManager::new); app.add_singleton_model(SessionPermissionsManager::new);
#[cfg(not(target_family = "wasm"))]
app.add_singleton_model(|_| ChatGPTAuthModel::new_for_test());
app.add_singleton_model(LLMPreferences::new); app.add_singleton_model(LLMPreferences::new);
app.add_singleton_model(HarnessAvailabilityModel::new); app.add_singleton_model(HarnessAvailabilityModel::new);
app.add_singleton_model(|ctx| AITipModel::new_for_agent_tips(ctx)); app.add_singleton_model(|ctx| AITipModel::new_for_agent_tips(ctx));
@@ -255,8 +259,9 @@ pub(crate) fn initialize_app(app: &mut App) {
app.add_singleton_model(|ctx| { app.add_singleton_model(|ctx| {
CodebaseIndexManager::new_for_test(ServerApiProvider::as_ref(ctx).get(), ctx) CodebaseIndexManager::new_for_test(ServerApiProvider::as_ref(ctx).get(), ctx)
}); });
app.add_singleton_model(|ctx| PersistedWorkspace::new(vec![], HashMap::new(), None, ctx)); // PersistedWorkspace subscribes to project-context updates during construction.
app.add_singleton_model(|_| ProjectContextModel::default()); app.add_singleton_model(|_| ProjectContextModel::default());
app.add_singleton_model(|ctx| PersistedWorkspace::new(vec![], HashMap::new(), None, ctx));
app.add_singleton_model(|_| PricingInfoModel::new()); app.add_singleton_model(|_| PricingInfoModel::new());
app.add_singleton_model(AIDocumentModel::new); app.add_singleton_model(AIDocumentModel::new);
app.add_singleton_model(|_| History::new(vec![])); app.add_singleton_model(|_| History::new(vec![]));
@@ -4257,3 +4262,52 @@ fn test_pin_tab_on_grouped_tab_extracts_then_pins() {
}); });
}); });
} }
/// Regression test for #14241.
///
/// Creating a tab group opens the inline name editor and also spawns a terminal. About
/// a second later that terminal's bootstrap block becomes visible and takes focus, which
/// blurs the editor while the user is still typing. Blur used to be treated as
/// confirmation, so whatever fragment had been typed became the group's name — and was
/// persisted.
///
/// A rename the user never finished must not be committed.
#[test]
fn test_tab_group_rename_blur_does_not_commit_unfinished_name() {
let _grouped_tabs_guard = FeatureFlag::GroupedTabs.override_enabled(true);
App::test((), |mut app| async move {
initialize_app(&mut app);
let workspace = mock_workspace(&mut app);
workspace.update(&mut app, |workspace, ctx| {
workspace.handle_action(
&WorkspaceAction::SelectNewSessionMenuItem(NewSessionMenuItem::CreateNewTabGroup),
ctx,
);
let group_id = workspace.tabs[0]
.group_id
.expect("active tab should be assigned to the new group");
assert_eq!(workspace.tab_groups[&group_id].name, None);
workspace.rename_tab_group(group_id, ctx);
// The user gets three characters in before the terminal is ready.
workspace
.tab_group_rename_editor
.update(ctx, |editor, ctx| {
editor.clear_buffer_and_reset_undo_stack(ctx);
editor.user_insert("Bui", ctx);
});
// The auto-created terminal takes focus; the editor blurs with no user intent
// to finish.
workspace.handle_tab_group_rename_editor_event(&EditorEvent::Blurred, ctx);
assert_eq!(
workspace.tab_groups[&group_id].name, None,
"a rename interrupted by the terminal stealing focus must not be committed"
);
});
});
}
@@ -1,14 +1,18 @@
use std::cell::RefCell; use std::cell::RefCell;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::Vector2F; use pathfinder_geometry::vector::Vector2F;
use super::{ use super::{
AfterLayoutContext, AppContext, DispatchEventResult, Element, Event, EventContext, AfterLayoutContext, AppContext, DispatchEventResult, Element, Event, EventContext,
LayoutContext, PaintContext, Point, SizeConstraint, ZIndex, LayoutContext, PaintContext, Point, SelectableElement, Selection, SelectionFragment,
SizeConstraint, ZIndex,
}; };
use crate::event::{DispatchedEvent, EventDiscriminants, KeyState, ModifiersState}; use crate::event::{DispatchedEvent, EventDiscriminants, KeyState, ModifiersState};
use crate::keymap::Keystroke; use crate::keymap::Keystroke;
use crate::platform::keyboard::KeyCode; use crate::platform::keyboard::KeyCode;
use crate::text::word_boundaries::WordBoundariesPolicy;
use crate::text::{IsRect, SelectionDirection, SelectionType};
type Handler = Box<dyn FnMut(&mut EventContext, &AppContext, Vector2F) -> DispatchEventResult>; type Handler = Box<dyn FnMut(&mut EventContext, &AppContext, Vector2F) -> DispatchEventResult>;
type KeyHandler = Box<dyn FnMut(&mut EventContext, &AppContext, &Keystroke) -> DispatchEventResult>; type KeyHandler = Box<dyn FnMut(&mut EventContext, &AppContext, &Keystroke) -> DispatchEventResult>;
@@ -361,11 +365,75 @@ impl Element for EventHandler {
handled handled
} }
fn as_selectable_element(&self) -> Option<&dyn SelectableElement> {
Some(self)
}
fn origin(&self) -> Option<Point> { fn origin(&self) -> Option<Point> {
self.child.origin() self.child.origin()
} }
} }
impl SelectableElement for EventHandler {
fn get_selection(
&self,
selection_start: Vector2F,
selection_end: Vector2F,
is_rect: IsRect,
) -> Option<Vec<SelectionFragment>> {
self.child
.as_selectable_element()
.and_then(|selectable_child| {
selectable_child.get_selection(selection_start, selection_end, is_rect)
})
}
fn expand_selection(
&self,
point: Vector2F,
direction: SelectionDirection,
unit: SelectionType,
word_boundaries_policy: &WordBoundariesPolicy,
) -> Option<Vector2F> {
self.child
.as_selectable_element()
.and_then(|selectable_child| {
selectable_child.expand_selection(point, direction, unit, word_boundaries_policy)
})
}
fn is_point_semantically_before(
&self,
absolute_point: Vector2F,
absolute_point_other: Vector2F,
) -> Option<bool> {
self.child
.as_selectable_element()
.and_then(|selectable_child| {
selectable_child.is_point_semantically_before(absolute_point, absolute_point_other)
})
}
fn smart_select(
&self,
absolute_point: Vector2F,
smart_select_fn: crate::elements::SmartSelectFn,
) -> Option<(Vector2F, Vector2F)> {
self.child
.as_selectable_element()
.and_then(|selectable_child| {
selectable_child.smart_select(absolute_point, smart_select_fn)
})
}
fn calculate_clickable_bounds(&self, current_selection: Option<Selection>) -> Vec<RectF> {
self.child
.as_selectable_element()
.map(|selectable_child| selectable_child.calculate_clickable_bounds(current_selection))
.unwrap_or_default()
}
}
#[cfg(test)] #[cfg(test)]
#[path = "event_handler_test.rs"] #[path = "event_handler_test.rs"]
mod tests; mod tests;
@@ -2556,6 +2556,10 @@ impl SelectableElement for FormattedTextElement {
let line = text_frame.lines().get(text_selection_bound.row_index)?; let line = text_frame.lines().get(text_selection_bound.row_index)?;
let first_glyph = line.first_glyph()?; let first_glyph = line.first_glyph()?;
let last_glyph = line.last_glyph()?; let last_glyph = line.last_glyph()?;
// Visual glyph order can oppose logical indices, and usize::clamp panics if min > max.
if first_glyph.index > last_glyph.index {
return None;
}
// If we clicked to the right of a line, the text_selection_bound's glyph index would be one larger than the last glyph's index. // If we clicked to the right of a line, the text_selection_bound's glyph index would be one larger than the last glyph's index.
// Snap it within the line's index range so we do smart selection as if we clicked on the last glyph in the line. // Snap it within the line's index range so we do smart selection as if we clicked on the last glyph in the line.
let char_offset = text_selection_bound let char_offset = text_selection_bound
@@ -9,6 +9,7 @@ use pathfinder_color::ColorU;
use pathfinder_geometry::rect::RectF; use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::vec2f; use pathfinder_geometry::vector::vec2f;
use string_offset::ByteOffset; use string_offset::ByteOffset;
use vec1::vec1;
use super::{ use super::{
apply_secret_replacements, FormattedTextElement, FrameMouseHandlers, apply_secret_replacements, FormattedTextElement, FrameMouseHandlers,
@@ -16,9 +17,9 @@ use super::{
SecretRange, SecretRange,
}; };
use crate::elements::{Point, SelectableElement, ZIndex}; use crate::elements::{Point, SelectableElement, ZIndex};
use crate::fonts::FamilyId; use crate::fonts::{FamilyId, FontId};
use crate::text::BlockHeaderSize; use crate::text::BlockHeaderSize;
use crate::text_layout::TextFrame; use crate::text_layout::{Glyph, Line, Run, TextAlignment, TextFrame, TextStyle};
#[test] #[test]
fn test_default_heading_font_size_multipliers() { fn test_default_heading_font_size_multipliers() {
@@ -220,3 +221,78 @@ fn smart_select_returns_none_when_point_is_outside_horizontal_bounds() {
.smart_select(point, select_first_character) .smart_select(point, select_first_character)
.is_none()); .is_none());
} }
const TEST_GLYPH_ADVANCE: f32 = 10.0;
/// Builds a single-line element whose visual first glyph has a higher logical index than
/// its visual last glyph, which RTL layout can produce.
fn text_element_with_reversed_endpoint_glyph_indices(text: &str) -> FormattedTextElement {
let char_count = text.chars().count();
let width = char_count as f32 * TEST_GLYPH_ADVANCE;
let glyphs = text
.chars()
.enumerate()
.map(|(visual_index, _)| Glyph {
id: Default::default(),
position_along_baseline: vec2f(visual_index as f32 * TEST_GLYPH_ADVANCE, 0.0),
index: char_count - 1 - visual_index,
width: TEST_GLYPH_ADVANCE,
})
.collect();
let mut line = Line::mock(vec![Run {
font_id: FontId(0),
glyphs,
styles: TextStyle::new(),
width,
}]);
line.width = width;
let text_frame = Arc::new(TextFrame::new(vec1![line], width, TextAlignment::Left));
let frame_bounds = RectF::new(vec2f(0.0, 0.0), vec2f(width, text_frame.height()));
let mouse_handlers = Rc::new(RefCell::new(FrameMouseHandlers::default()));
let formatted_text = FormattedText::new([FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text(text),
])]);
let mut element = FormattedTextElement::new(
formatted_text,
13.0,
FamilyId(0),
FamilyId(0),
ColorU::black(),
HighlightedHyperlink::default(),
);
element.origin = Some(Point::new(0.0, 0.0, ZIndex::new(0)));
element.size = Some(frame_bounds.size());
element.laid_out_text = vec![LaidOutTextFrame::Text {
text_frame,
frame_bounds,
bottom_padding: 0.0,
raw_text: text.to_string(),
mouse_handlers: mouse_handlers.clone(),
}];
element.text_frame_mouse_handlers = vec![mouse_handlers];
element.hyperlink_support = HyperlinkSupport {
highlighted_hyperlink: Arc::new(Mutex::new(None)),
hyperlink_font_color: ColorU::black(),
};
element
}
#[test]
fn smart_select_returns_none_when_line_glyph_indices_are_reversed() {
let element = text_element_with_reversed_endpoint_glyph_indices("abc");
let line = match element.laid_out_text.first() {
Some(LaidOutTextFrame::Text { text_frame, .. }) => {
text_frame.lines().first().expect("line")
}
_ => panic!("expected a text frame"),
};
assert!(
line.first_glyph().expect("first glyph").index
> line.last_glyph().expect("last glyph").index
);
assert!(element
.smart_select(vec2f(5.0, 5.0), select_first_character)
.is_none());
}
@@ -14,7 +14,7 @@ use string_offset::ByteOffset;
use super::{ use super::{
AfterLayoutContext, AppContext, ColorU, Element, Event, EventContext, LayoutContext, AfterLayoutContext, AppContext, ColorU, Element, Event, EventContext, LayoutContext,
PaintContext, Point, SelectionFragment, SizeConstraint, PaintContext, Point, SelectionFragment, SizeConstraint, ZIndex,
}; };
use crate::event::{DispatchedEvent, ModifiersState}; use crate::event::{DispatchedEvent, ModifiersState};
use crate::text::word_boundaries::WordBoundariesPolicy; use crate::text::word_boundaries::WordBoundariesPolicy;
@@ -42,6 +42,11 @@ pub struct SelectableArea {
smart_select_fn: Option<SmartSelectFn>, smart_select_fn: Option<SmartSelectFn>,
should_support_rect_select: bool, should_support_rect_select: bool,
// Upper bound of z-indexes painted by the child subtree. Mouse-down hit testing uses this
// instead of the origin's z-index so child content that starts its own layers (e.g. a clipped
// scrollable) isn't treated as covering this element; later-painted overlays still are.
child_max_z_index: Option<ZIndex>,
} }
/// Stores the selection start and end points. We include the option to store /// Stores the selection start and end points. We include the option to store
@@ -233,6 +238,7 @@ impl SelectableArea {
word_boundaries_policy: WordBoundariesPolicy::Default, word_boundaries_policy: WordBoundariesPolicy::Default,
smart_select_fn: None, smart_select_fn: None,
should_support_rect_select: false, should_support_rect_select: false,
child_max_z_index: None,
} }
} }
@@ -650,6 +656,7 @@ impl Element for SelectableArea {
ctx.current_selection = self.get_current_selection_absolute(); ctx.current_selection = self.get_current_selection_absolute();
self.child.paint(origin, ctx, app); self.child.paint(origin, ctx, app);
ctx.current_selection = None; ctx.current_selection = None;
self.child_max_z_index = Some(ctx.scene.max_active_z_index());
} }
fn dispatch_event( fn dispatch_event(
@@ -675,7 +682,8 @@ impl Element for SelectableArea {
event.raw_event(), event.raw_event(),
Event::LeftMouseDown { .. } | Event::RightMouseDown { .. } Event::LeftMouseDown { .. } | Event::RightMouseDown { .. }
) && self ) && self
.z_index() .child_max_z_index
.or_else(|| self.z_index())
.is_some_and(|z_index| event.at_z_index(z_index, ctx).is_none()) .is_some_and(|z_index| event.at_z_index(z_index, ctx).is_none())
{ {
return false; return false;
@@ -755,3 +763,7 @@ impl Element for SelectableArea {
self.origin self.origin
} }
} }
#[cfg(test)]
#[path = "selectable_area_tests.rs"]
mod tests;
@@ -0,0 +1,221 @@
use std::cell::RefCell;
use std::rc::Rc;
use pathfinder_geometry::rect::RectF;
use super::*;
use crate::elements::new_scrollable::SingleAxisConfig;
use crate::elements::{
ClippedScrollStateHandle, EventHandler, Fill, NewScrollable, SelectableElement,
};
use crate::platform::WindowStyle;
use crate::{App, Entity, EntityIdSet, Presenter, TypedActionView, WindowInvalidation};
/// Selectable element that paints a hit-recorded rect (as `Container` does), so content inside a
/// layer-starting parent registers as covered at lower z-indexes.
#[derive(Default)]
struct LayeredSelectableProbe {
origin: Option<Point>,
}
const PROBE_SIZE: f32 = 100.;
impl Element for LayeredSelectableProbe {
fn layout(
&mut self,
_constraint: SizeConstraint,
_ctx: &mut LayoutContext,
_app: &AppContext,
) -> Vector2F {
vec2f(PROBE_SIZE, PROBE_SIZE)
}
fn after_layout(&mut self, _ctx: &mut AfterLayoutContext, _app: &AppContext) {}
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, _app: &AppContext) {
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
ctx.scene
.draw_rect_with_hit_recording(RectF::new(origin, vec2f(PROBE_SIZE, PROBE_SIZE)))
.with_background(Fill::None);
}
fn size(&self) -> Option<Vector2F> {
Some(vec2f(PROBE_SIZE, PROBE_SIZE))
}
fn origin(&self) -> Option<Point> {
self.origin
}
fn dispatch_event(
&mut self,
_event: &DispatchedEvent,
_ctx: &mut EventContext,
_app: &AppContext,
) -> bool {
false
}
fn as_selectable_element(&self) -> Option<&dyn SelectableElement> {
Some(self)
}
}
impl SelectableElement for LayeredSelectableProbe {
fn get_selection(
&self,
_selection_start: Vector2F,
_selection_end: Vector2F,
_is_rect: IsRect,
) -> Option<Vec<SelectionFragment>> {
Some(vec![SelectionFragment {
text: "probe".to_string(),
origin: self.origin?,
}])
}
fn expand_selection(
&self,
_absolute_point: Vector2F,
_direction: SelectionDirection,
_unit: SelectionType,
_word_boundaries_policy: &WordBoundariesPolicy,
) -> Option<Vector2F> {
None
}
fn is_point_semantically_before(
&self,
absolute_point: Vector2F,
absolute_point_other: Vector2F,
) -> Option<bool> {
Some(
(absolute_point.y(), absolute_point.x())
< (absolute_point_other.y(), absolute_point_other.x()),
)
}
fn smart_select(
&self,
_absolute_point: Vector2F,
_smart_select_fn: crate::elements::SmartSelectFn,
) -> Option<(Vector2F, Vector2F)> {
None
}
fn calculate_clickable_bounds(&self, _current_selection: Option<Selection>) -> Vec<RectF> {
Vec::new()
}
}
#[derive(Default)]
struct ScrollableSelectionView {
selection_handle: SelectionHandle,
captured_selection: Rc<RefCell<Option<Option<String>>>>,
}
impl Entity for ScrollableSelectionView {
type Event = ();
}
impl crate::core::View for ScrollableSelectionView {
fn ui_name() -> &'static str {
"selectable_area_test_view"
}
fn render(&self, _: &AppContext) -> Box<dyn Element> {
let captured_selection = self.captured_selection.clone();
// Mirrors the element sandwich around collapsible reasoning block bodies: an
// EventHandler wrapping a clipped scrollable.
SelectableArea::new(
self.selection_handle.clone(),
move |args, _, _| {
*captured_selection.borrow_mut() = Some(args.selection);
},
EventHandler::new(
NewScrollable::vertical(
SingleAxisConfig::Clipped {
handle: ClippedScrollStateHandle::default(),
child: Box::new(LayeredSelectableProbe::default()),
},
Fill::None,
Fill::None,
Fill::None,
)
.finish(),
)
.finish(),
)
.finish()
}
}
impl TypedActionView for ScrollableSelectionView {
type Action = ();
}
/// A drag starting on content painted in a child's own layers must start a selection and yield
/// the child's fragments on mouse up. Covers the mouse-down hit test at the child subtree's max
/// z-index and `EventHandler`'s forwarding of selection queries.
#[test]
fn selection_starts_on_content_painted_in_child_layers() {
App::test((), |mut app| async move {
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
ScrollableSelectionView::default()
});
let (selection_handle, captured_selection) = view.read(&app, |view, _| {
(
view.selection_handle.clone(),
view.captured_selection.clone(),
)
});
let root_view_id = view.id();
app.update(move |ctx| {
let mut presenter = Presenter::new(window_id);
let mut updated = EntityIdSet::default();
updated.insert(root_view_id);
let invalidation = WindowInvalidation {
updated,
..Default::default()
};
presenter.invalidate(invalidation, ctx);
presenter.build_scene(vec2f(200., 200.), 1., None, ctx);
let presenter = Rc::new(RefCell::new(presenter));
ctx.simulate_window_event(
Event::LeftMouseDown {
position: vec2f(10., 10.),
modifiers: Default::default(),
click_count: 1,
is_first_mouse: false,
},
window_id,
presenter.clone(),
);
ctx.simulate_window_event(
Event::LeftMouseDragged {
position: vec2f(60., 10.),
modifiers: Default::default(),
},
window_id,
presenter.clone(),
);
ctx.simulate_window_event(
Event::LeftMouseUp {
position: vec2f(60., 10.),
modifiers: Default::default(),
},
window_id,
presenter,
);
});
assert_eq!(
*captured_selection.borrow(),
Some(Some("probe".to_string())),
"the drag should have produced a selection from the layered child",
);
assert!(!selection_handle.is_selecting());
});
}
+14 -5
View File
@@ -509,12 +509,21 @@ impl Cache {
let (glyph_id, _) = self let (glyph_id, _) = self
.glyph_for_char(font_id, 'm', false) .glyph_for_char(font_id, 'm', false)
.expect("we verify in Config::new that the font has an 'm' glyph"); .expect("we verify in Config::new that the font has an 'm' glyph");
let bounds = self // Some Windows fonts map 'm' with a horizontal advance but no outline bounding box.
.glyph_typographic_bounds(font_id, font_size, glyph_id) match self.glyph_typographic_bounds(font_id, font_size, glyph_id) {
.expect( Ok(bounds) => bounds.width(),
"we verify in Config::new that we can measure the typographic bounds of the 'm' glyph", Err(_) => match self.glyph_advance(font_id, font_size, glyph_id) {
Ok(advance) if advance.x().is_finite() && advance.x() > 0.0 => advance.x(),
advance => {
log::warn!(
"[em_width] 'm' glyph has no usable horizontal advance; falling back to \
font_size font_id={font_id:?} font_size={font_size} advance={advance:?}"
); );
bounds.width() // Scroll converts pixels with `/ em_width`; keep a positive width.
font_size.max(1.0)
}
},
}
} }
pub(crate) fn remove_glyphs_by_char_entry(&mut self, key: (FontId, char)) { pub(crate) fn remove_glyphs_by_char_entry(&mut self, key: (FontId, char)) {
+139
View File
@@ -1,4 +1,9 @@
use anyhow::{anyhow, Result};
use pathfinder_geometry::rect::RectI;
use pathfinder_geometry::vector::{vec2i, Vector2F, Vector2I};
use super::*; use super::*;
use crate::platform::{self, TextLayoutSystem};
#[test] #[test]
fn test_subpixel_alignment_computation() { fn test_subpixel_alignment_computation() {
@@ -40,3 +45,137 @@ fn test_subpixel_alignment_computation() {
assert_eq!(alignment.0, 0); assert_eq!(alignment.0, 0);
} }
} }
struct EmWidthFontDB {
bounds: Option<RectI>,
advance: Option<Vector2I>,
}
impl EmWidthFontDB {
fn cache(bounds: Option<RectI>, advance: Option<Vector2I>) -> Cache {
Cache::new(Box::new(Self { bounds, advance }))
}
}
impl platform::FontDB for EmWidthFontDB {
fn load_from_bytes(&mut self, _name: &str, _bytes: Vec<Vec<u8>>) -> Result<FamilyId> {
unimplemented!()
}
#[cfg(not(target_family = "wasm"))]
fn load_from_system(&mut self, _font_family: &str) -> Result<FamilyId> {
unimplemented!()
}
#[cfg(not(target_family = "wasm"))]
fn load_all_system_fonts(
&self,
) -> futures::future::BoxFuture<'static, Box<dyn platform::LoadedSystemFonts>> {
unimplemented!()
}
#[cfg(not(target_family = "wasm"))]
fn process_loaded_system_fonts(
&mut self,
_loaded_system_fonts: Box<dyn platform::LoadedSystemFonts>,
) -> Vec<(Option<FamilyId>, FontInfo)> {
unimplemented!()
}
fn fallback_fonts(&self, _ch: char, _font_id: FontId) -> Vec<FontId> {
unimplemented!()
}
fn select_font(&self, _family_id: FamilyId, _properties: Properties) -> FontId {
FontId(0)
}
fn font_metrics(&self, _font_id: FontId) -> Metrics {
Metrics {
units_per_em: 16,
ascent: 12,
descent: -4,
line_gap: 0,
}
}
fn glyph_advance(&self, _font_id: FontId, _glyph_id: GlyphId) -> Result<Vector2I> {
self.advance.ok_or_else(|| anyhow!("No advance for glyph"))
}
fn load_family_name_from_id(&self, _id: FamilyId) -> Option<String> {
unimplemented!()
}
fn glyph_raster_bounds(
&self,
_font_id: FontId,
_size: f32,
_glyph_id: GlyphId,
_scale: Vector2F,
_glyph_config: &crate::rendering::GlyphConfig,
) -> Result<RectI> {
unimplemented!()
}
fn glyph_typographic_bounds(&self, _font_id: FontId, glyph_id: GlyphId) -> Result<RectI> {
self.bounds
.ok_or_else(|| anyhow!("No bounding box for glyph id {glyph_id}"))
}
fn rasterize_glyph(
&self,
_font_id: FontId,
_size: f32,
_glyph_id: GlyphId,
_scale: Vector2F,
_subpixel_alignment: SubpixelAlignment,
_glyph_config: &crate::rendering::GlyphConfig,
_format: canvas::RasterFormat,
) -> Result<RasterizedGlyph> {
unimplemented!()
}
fn glyph_for_char(&self, _font_id: FontId, _char: char) -> Option<GlyphId> {
Some(0)
}
fn family_id_for_name(&self, _name: &str) -> Option<FamilyId> {
unimplemented!()
}
fn text_layout_system(&self) -> &dyn TextLayoutSystem {
unimplemented!()
}
}
#[test]
fn em_width_uses_typographic_bounds_width_when_available() {
let cache = EmWidthFontDB::cache(
Some(RectI::new(vec2i(0, 0), vec2i(10, 12))),
Some(vec2i(20, 0)),
);
assert_eq!(cache.em_width(FamilyId(0), 16.0), 10.0);
}
#[test]
fn em_width_uses_horizontal_advance_when_typographic_bounds_are_missing() {
let cache = EmWidthFontDB::cache(None, Some(vec2i(20, 0)));
assert_eq!(cache.em_width(FamilyId(0), 16.0), 20.0);
}
#[test]
fn em_width_does_not_panic_when_bounds_and_advance_are_missing() {
let cache = EmWidthFontDB::cache(None, None);
assert_eq!(cache.em_width(FamilyId(0), 16.0), 16.0);
}
#[test]
fn em_width_falls_back_when_horizontal_advance_is_zero() {
let cache = EmWidthFontDB::cache(None, Some(vec2i(0, 0)));
assert_eq!(cache.em_width(FamilyId(0), 16.0), 16.0);
}
@@ -708,3 +708,245 @@ fn test_respects_max_dimensions_for_cacheoption_bysize() {
// Assert that, when we specify a max dimension of 512, the image is resized accordingly. // Assert that, when we specify a max dimension of 512, the image is resized accordingly.
assert_eq!(image.img.dimensions(), (512, 512)); assert_eq!(image.img.dimensions(), (512, 512));
} }
#[test]
fn animated_image_get_current_frame_advances_with_elapsed_time() {
let asset_cache = new_asset_cache();
let image_cache = ImageCache::new();
// Load an animated GIF with FullAnimation behavior to get an AnimatedImage
let image = load_bundled_image(
&image_cache,
&asset_cache,
"numbers-1000ms.gif",
Vector2I::new(16, 16),
FitType::Contain,
AnimatedImageBehavior::FullAnimation,
);
let Image::Animated(animated) = image.as_ref() else {
panic!("Expected animated image but got static image!");
};
// The numbers-1000ms.gif has multiple frames with various delays
assert!(
animated.frames.len() >= 2,
"Expected at least 2 frames for testing"
);
assert!(animated.duration > 0, "Expected positive total duration");
let (_frame_0, remaining_0) = animated
.get_current_frame(0)
.expect("Should get frame at elapsed 0ms");
let (_frame_100, remaining_100) = animated
.get_current_frame(100)
.expect("Should get frame at elapsed 100ms");
let (_frame_500, remaining_500) = animated
.get_current_frame(500)
.expect("Should get frame at elapsed 500ms");
assert_ne!(
remaining_0, remaining_100,
"Remaining delay should differ at different elapsed times"
);
assert_ne!(
remaining_100, remaining_500,
"Remaining delay should differ at different elapsed times"
);
}
#[test]
fn animated_image_get_current_frame_wraps_at_duration() {
let asset_cache = new_asset_cache();
let image_cache = ImageCache::new();
let image = load_bundled_image(
&image_cache,
&asset_cache,
"numbers-1000ms.gif",
Vector2I::new(16, 16),
FitType::Contain,
AnimatedImageBehavior::FullAnimation,
);
let Image::Animated(animated) = image.as_ref() else {
panic!("Expected animated image but got static image!");
};
let duration = animated.duration;
let (frame_start, _) = animated
.get_current_frame(0)
.expect("Should get frame at start");
let (frame_end, _) = animated
.get_current_frame(duration - 1)
.expect("Should get frame near end");
// After wrapping (elapsed >= duration), should return to start of animation
let (frame_wrapped_start, _) = animated
.get_current_frame(duration)
.expect("Should get frame after one complete cycle");
let (frame_wrapped_end, _) = animated
.get_current_frame(duration * 2 - 1)
.expect("Should get frame in second cycle");
assert!(
Arc::ptr_eq(&frame_start, &frame_wrapped_start),
"Frame at start should equal frame at wrapped start"
);
assert!(
Arc::ptr_eq(&frame_end, &frame_wrapped_end),
"Frame at cycle end should equal frame at next cycle end"
);
}
#[test]
fn static_image_not_affected_by_animation_behavior_change() {
let asset_cache = new_asset_cache();
let image_cache = ImageCache::new();
// Load a static PNG with FullAnimation behavior (should remain static)
let static_full_animation = load_bundled_image(
&image_cache,
&asset_cache,
"local.png",
Vector2I::new(512, 512),
FitType::Cover,
AnimatedImageBehavior::FullAnimation,
);
// Load the same static PNG with FirstFramePreview behavior (should remain static)
let static_preview = load_bundled_image(
&image_cache,
&asset_cache,
"local.png",
Vector2I::new(512, 512),
FitType::Cover,
AnimatedImageBehavior::FirstFramePreview,
);
// Both should be static images
assert!(matches!(static_full_animation.as_ref(), Image::Static(_)));
assert!(matches!(static_preview.as_ref(), Image::Static(_)));
// Extract and verify they're the same image data
let Image::Static(full_static) = static_full_animation.as_ref() else {
unreachable!();
};
let Image::Static(preview_static) = static_preview.as_ref() else {
unreachable!();
};
// Both should have the same dimensions
assert_eq!(
full_static.img.dimensions(),
preview_static.img.dimensions()
);
}
#[test]
fn animated_gif_shows_first_frame_preview_when_requested() {
let asset_cache = new_asset_cache();
let image_cache = ImageCache::new();
// Request FirstFramePreview behavior for an animated GIF
let preview = load_bundled_image(
&image_cache,
&asset_cache,
"numbers-1000ms.gif",
Vector2I::new(16, 16),
FitType::Contain,
AnimatedImageBehavior::FirstFramePreview,
);
// Should get a static image (the first frame)
let Image::Static(_) = preview.as_ref() else {
panic!("Expected static image for FirstFramePreview");
};
// But the underlying asset in asset_cache should still be the full AnimatedBitmap
let asset: AssetState<ImageType> = asset_cache.load_asset(AssetSource::Bundled {
path: "numbers-1000ms.gif",
});
let AssetState::Loaded { data } = asset else {
panic!("Bundled asset should be available immediately!");
};
assert!(matches!(data.as_ref(), ImageType::AnimatedBitmap { .. }));
}
#[test]
fn animated_image_remaining_delay_decreases_within_frame() {
let asset_cache = new_asset_cache();
let image_cache = ImageCache::new();
let image = load_bundled_image(
&image_cache,
&asset_cache,
"numbers-1000ms.gif",
Vector2I::new(16, 16),
FitType::Contain,
AnimatedImageBehavior::FullAnimation,
);
let Image::Animated(animated) = image.as_ref() else {
panic!("Expected animated image");
};
// Assumes the gif's first frame lasts at least 50ms.
let (_, remaining_at_start) = animated
.get_current_frame(0)
.expect("Should get frame at 0ms");
let (_, remaining_at_50) = animated
.get_current_frame(50)
.expect("Should get frame at 50ms");
assert!(
remaining_at_50 < remaining_at_start,
"Remaining delay should decrease as we progress through a frame"
);
}
#[test]
fn animated_webp_also_advances_frames() {
let asset_cache = new_asset_cache();
let image_cache = ImageCache::new();
let image = load_bundled_image(
&image_cache,
&asset_cache,
"animated.webp",
Vector2I::new(16, 16),
FitType::Contain,
AnimatedImageBehavior::FullAnimation,
);
let Image::Animated(animated) = image.as_ref() else {
panic!("Expected animated image");
};
assert!(
animated.frames.len() > 1,
"WebP should have multiple frames"
);
let (frame_0, _) = animated
.get_current_frame(0)
.expect("Should get frame at 0ms");
let (frame_near_end, _) = animated
.get_current_frame(animated.duration - 1)
.expect("Should get frame near end");
assert!(
!Arc::ptr_eq(&frame_0, &frame_near_end) || animated.frames.len() == 1,
"WebP animation should traverse different frames or only have one frame"
);
let (frame_wrapped, _) = animated
.get_current_frame(animated.duration)
.expect("Should get frame after one cycle");
assert!(
Arc::ptr_eq(&frame_0, &frame_wrapped),
"Frame should wrap back to start after duration"
);
}