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:
@@ -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(
|
||||
&mut self,
|
||||
window_id: WindowId,
|
||||
|
||||
@@ -11,7 +11,7 @@ pub(crate) mod orchestration_conversation_links;
|
||||
pub mod orchestration_pill_bar;
|
||||
pub mod orchestration_pill_bar_model;
|
||||
pub mod shortcuts;
|
||||
pub(crate) mod subagent_inline_panel;
|
||||
pub(crate) mod subagent_link;
|
||||
mod zero_state_block;
|
||||
|
||||
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::terminal::view::TerminalAction;
|
||||
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,
|
||||
};
|
||||
use crate::ui_components::icons::Icon;
|
||||
@@ -75,7 +75,15 @@ const PILL_HEIGHT: f32 = 22.;
|
||||
const PILL_RADIUS: f32 = PILL_HEIGHT / 2.;
|
||||
const AVATAR_SIZE: f32 = 16.;
|
||||
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 PILL_LABEL_MAX_WIDTH: f32 = 83.;
|
||||
const PILL_ROW_GAP: f32 = 8.;
|
||||
@@ -1590,8 +1598,17 @@ fn render_status_badge(
|
||||
.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> {
|
||||
ConstrainedBox::new(Align::new(avatar).finish())
|
||||
ConstrainedBox::new(Align::new(avatar).bottom_left().finish())
|
||||
.with_width(PILL_AVATAR_SLOT_SIZE)
|
||||
.with_height(PILL_HEIGHT)
|
||||
.finish()
|
||||
@@ -1669,38 +1686,64 @@ fn render_pinned_divider(app: &AppContext) -> Box<dyn Element> {
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Pin glyph centered in an avatar-sized hit target so swapping in and out
|
||||
/// on hover doesn't jitter sibling pill widths. Solid glyph when pinned,
|
||||
/// outline when unpinned.
|
||||
fn render_pin_glyph_centered(is_pinned: bool, icon_color: ColorU) -> Box<dyn Element> {
|
||||
let icon_variant = if is_pinned {
|
||||
/// The clickable pin button a child pill shows in place of its avatar while
|
||||
/// hovered: a circle occupying exactly the avatar disc's rect.
|
||||
///
|
||||
/// Placement deliberately goes through the same
|
||||
/// [`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
|
||||
} else {
|
||||
Icon::Pin
|
||||
};
|
||||
let glyph: Box<dyn Element> =
|
||||
ConstrainedBox::new(icon_variant.to_warpui_icon(icon_color.into()).finish())
|
||||
.with_width(PILL_ICON_SIZE)
|
||||
.with_height(PILL_ICON_SIZE)
|
||||
let button = Hoverable::new(mouse_state, move |hover_state| {
|
||||
let glyph = ConstrainedBox::new(icon.to_warpui_icon(icon_color.into()).finish())
|
||||
.with_width(glyph_size)
|
||||
.with_height(glyph_size)
|
||||
.finish();
|
||||
|
||||
let centered = 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)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
ConstrainedBox::new(centered)
|
||||
.with_width(PILL_AVATAR_SLOT_SIZE)
|
||||
.with_height(PILL_ICON_BUTTON_SIZE)
|
||||
.finish()
|
||||
// Top and bottom padding still sum to twice `padding`, so the circle
|
||||
// keeps the avatar disc's rect exactly; only the glyph inside it moves.
|
||||
let padding = (PILL_AVATAR_DISC_SIZE - glyph_size) / 2.;
|
||||
let mut circle = Container::new(glyph)
|
||||
.with_horizontal_padding(padding)
|
||||
.with_padding_top(padding + PIN_GLYPH_OPTICAL_DROP)
|
||||
.with_padding_bottom(padding - PIN_GLYPH_OPTICAL_DROP)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
|
||||
PILL_AVATAR_DISC_SIZE / 2.,
|
||||
)));
|
||||
if hover_state.is_hovered() || hover_state.is_clicked() {
|
||||
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();
|
||||
render_avatar_slot(render_avatar_lockup_box(button))
|
||||
}
|
||||
|
||||
fn render_pill(
|
||||
@@ -1833,41 +1876,21 @@ fn render_pill(
|
||||
theme,
|
||||
appearance,
|
||||
),
|
||||
None => render_avatar_slot(render_avatar_disc(
|
||||
avatar_color,
|
||||
avatar_glyph,
|
||||
PILL_AVATAR_DISC_SIZE,
|
||||
theme,
|
||||
appearance,
|
||||
)),
|
||||
None => render_pill_avatar(avatar_color, avatar_glyph, theme, appearance),
|
||||
},
|
||||
PillKind::Child => {
|
||||
if show_pin_glyph {
|
||||
// Hovered: the leading slot becomes the clickable pin
|
||||
// button. We only attach the Hoverable + TogglePin
|
||||
// click handler here so that when the avatar is the
|
||||
// button. The Hoverable + TogglePin click handler is
|
||||
// attached only here so that when the avatar is the
|
||||
// visible content (not hovered), clicks bubble up to
|
||||
// the outer pill body and navigate as expected.
|
||||
let pin_button_mouse_state = pin_button_mouse_state.clone();
|
||||
Hoverable::new(pin_button_mouse_state, move |pin_hover_state| {
|
||||
let pin_button_hovered =
|
||||
pin_hover_state.is_hovered() || pin_hover_state.is_clicked();
|
||||
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,
|
||||
));
|
||||
})
|
||||
.finish()
|
||||
render_pin_button(
|
||||
is_pinned,
|
||||
text_color,
|
||||
pin_button_mouse_state.clone(),
|
||||
conversation_id,
|
||||
)
|
||||
} else if let Some(ref status) = status {
|
||||
render_avatar_with_status_overlay(
|
||||
avatar_color,
|
||||
@@ -1879,13 +1902,7 @@ fn render_pill(
|
||||
appearance,
|
||||
)
|
||||
} else {
|
||||
render_avatar_slot(render_avatar_disc(
|
||||
avatar_color,
|
||||
avatar_glyph,
|
||||
PILL_AVATAR_DISC_SIZE,
|
||||
theme,
|
||||
appearance,
|
||||
))
|
||||
render_pill_avatar(avatar_color, avatar_glyph, theme, appearance)
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -2066,13 +2083,123 @@ fn render_overflow_button(
|
||||
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 {
|
||||
ring_ratio: 0.57,
|
||||
icon_ratio: 0.36,
|
||||
ring_ratio: PILL_BADGE_RING_SIZE / AVATAR_WITH_STATUS_TOTAL_SIZE,
|
||||
icon_ratio: PILL_BADGE_ICON_SIZE / AVATAR_WITH_STATUS_TOTAL_SIZE,
|
||||
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;
|
||||
|
||||
/// 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(
|
||||
avatar_color: ColorU,
|
||||
glyph: AvatarGlyph,
|
||||
@@ -2082,15 +2209,13 @@ fn render_avatar_with_status_overlay(
|
||||
theme: &WarpTheme,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
// Top-left anchor inside the helper's `total_size` box so the disc sits
|
||||
// where Figma places it (TL of the slot, leaving the BR for the badge).
|
||||
let avatar = render_avatar_disc(
|
||||
let avatar = render_avatar_lockup_box(render_avatar_disc(
|
||||
avatar_color,
|
||||
glyph,
|
||||
icon_with_status::circle_size(AVATAR_WITH_STATUS_TOTAL_SIZE),
|
||||
PILL_AVATAR_DISC_SIZE,
|
||||
theme,
|
||||
appearance,
|
||||
);
|
||||
));
|
||||
let lockup = render_icon_with_status_with_badge_style(
|
||||
IconWithStatusVariant::CustomAvatar {
|
||||
avatar,
|
||||
@@ -2104,19 +2229,9 @@ fn render_avatar_with_status_overlay(
|
||||
// Cutout ring color for the local badge; ignored by the cloud path.
|
||||
pill_background.into(),
|
||||
);
|
||||
// Bottom-anchor the lockup in the pill so the badge BR sits flush with
|
||||
// the pill's bottom edge (matches Figma).
|
||||
ConstrainedBox::new(
|
||||
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()
|
||||
// Same slot helper as the no-status path, so both share one placement
|
||||
// rule and the leading slot keeps identical width across the swap.
|
||||
render_avatar_slot(lockup)
|
||||
}
|
||||
|
||||
/// Renders the avatar circle as a colored disc with a centered glyph (letter
|
||||
@@ -2148,6 +2263,11 @@ fn render_avatar_disc(
|
||||
weight: Weight::Bold,
|
||||
..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()
|
||||
}
|
||||
AvatarGlyph::Icon(icon) => {
|
||||
@@ -2158,27 +2278,10 @@ fn render_avatar_disc(
|
||||
}
|
||||
};
|
||||
|
||||
// Center the glyph on top of the disc both horizontally and vertically by
|
||||
// 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_height(size)
|
||||
.finish();
|
||||
let glyph_centered = ConstrainedBox::new(Align::new(glyph_element).finish())
|
||||
.with_width(size)
|
||||
.with_height(size)
|
||||
.finish();
|
||||
|
||||
Stack::new()
|
||||
.with_child(disc)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -27,7 +27,7 @@ use std::sync::Arc;
|
||||
|
||||
use ai::agent::action::{AskUserQuestionItem, InsertReviewComment, RunAgentsRequest};
|
||||
use base64::Engine as _;
|
||||
use chrono::Duration;
|
||||
use chrono::{DateTime, Duration, Local};
|
||||
use cli_controller::{CLISubagentController, CLISubagentEvent, LongRunningCommandControlState};
|
||||
use find::FindState;
|
||||
use galaxy_agent_core::RuntimeActivityStatus;
|
||||
@@ -197,6 +197,7 @@ use crate::ui_components::icons::Icon;
|
||||
use crate::util::link_detection::*;
|
||||
#[cfg(feature = "local_fs")]
|
||||
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::{
|
||||
ActionButton, ActionButtonTheme, ButtonSize, KeystrokeSource, NakedTheme, PrimaryTheme,
|
||||
SecondaryTheme,
|
||||
@@ -465,6 +466,7 @@ pub(super) struct AIBlockStateHandles {
|
||||
|
||||
/// Mouse state handle for the overflow menu button
|
||||
overflow_menu_handle: MouseStateHandle,
|
||||
query_timestamp_tooltip_handle: MouseStateHandle,
|
||||
|
||||
menu_accept_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.
|
||||
skill_button_handles: HashMap<AIAgentActionId, MouseStateHandle>,
|
||||
usage_button_handle: MouseStateHandle,
|
||||
subagent_panel_states:
|
||||
HashMap<AIAgentActionId, super::agent_view::subagent_inline_panel::SubagentPanelState>,
|
||||
subagent_link_states:
|
||||
HashMap<AIAgentActionId, super::agent_view::subagent_link::SubagentLinkState>,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Debug)]
|
||||
@@ -1404,13 +1406,13 @@ impl AIBlock {
|
||||
|me, _, event, ctx| {
|
||||
if me
|
||||
.state_handles
|
||||
.subagent_panel_states
|
||||
.subagent_link_states
|
||||
.values()
|
||||
.any(|state| history_event_affects_conversation(event, state.conversation_id))
|
||||
{
|
||||
// Child conversations live on a different terminal
|
||||
// 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.
|
||||
ctx.notify();
|
||||
}
|
||||
@@ -2236,12 +2238,10 @@ impl AIBlock {
|
||||
.map(|child| child.id());
|
||||
if let Some(child_id) = child_id {
|
||||
self.state_handles
|
||||
.subagent_panel_states
|
||||
.subagent_link_states
|
||||
.entry(action.id.clone())
|
||||
.or_insert_with(|| {
|
||||
super::agent_view::subagent_inline_panel::SubagentPanelState::new(
|
||||
child_id,
|
||||
)
|
||||
super::agent_view::subagent_link::SubagentLinkState::new(child_id)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4727,6 +4727,10 @@ impl AIBlock {
|
||||
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.
|
||||
pub fn has_user_input(&self, app: &AppContext) -> bool {
|
||||
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)) =
|
||||
action_model
|
||||
.as_ref(ctx)
|
||||
@@ -4962,13 +4966,13 @@ impl AIBlock {
|
||||
crate::ai::agent::StartAgentResult::Success { agent_id, .. },
|
||||
) = &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) =
|
||||
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(),
|
||||
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
|
||||
/// 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(
|
||||
action_model: &ModelHandle<BlocklistAIActionModel>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
@@ -5139,12 +5143,10 @@ impl AIBlock {
|
||||
}
|
||||
|
||||
me.state_handles
|
||||
.subagent_panel_states
|
||||
.subagent_link_states
|
||||
.entry(action_id.clone())
|
||||
.or_insert_with(|| {
|
||||
super::agent_view::subagent_inline_panel::SubagentPanelState::new(
|
||||
*child_conversation_id,
|
||||
)
|
||||
super::agent_view::subagent_link::SubagentLinkState::new(*child_conversation_id)
|
||||
});
|
||||
ctx.notify();
|
||||
});
|
||||
@@ -6643,6 +6645,7 @@ pub enum AIBlockAction {
|
||||
/// Copy the content from the previous user query.
|
||||
/// Note that this block may not have the user query.
|
||||
CopyQuery,
|
||||
CopyTimestamp,
|
||||
/// 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.
|
||||
CopyOutput,
|
||||
@@ -6696,9 +6699,6 @@ pub enum AIBlockAction {
|
||||
OpenCommentInGitHub {
|
||||
url: String,
|
||||
},
|
||||
ToggleSubagentPanel {
|
||||
action_id: AIAgentActionId,
|
||||
},
|
||||
ToggleIsUsageFooterExpanded,
|
||||
}
|
||||
|
||||
@@ -6711,12 +6711,6 @@ impl TypedActionView for AIBlock {
|
||||
self.is_usage_footer_expanded = !self.is_usage_footer_expanded;
|
||||
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 {
|
||||
message_id,
|
||||
pinned_to_bottom,
|
||||
@@ -7119,6 +7113,14 @@ impl TypedActionView for AIBlock {
|
||||
ctx.clipboard()
|
||||
.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(
|
||||
×tamp,
|
||||
)));
|
||||
}
|
||||
}
|
||||
AIBlockAction::CopyOutput => {
|
||||
// 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);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
mod helper;
|
||||
mod model_impl;
|
||||
|
||||
use chrono::TimeDelta;
|
||||
use chrono::{DateTime, Local, TimeDelta};
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxyui::{AppContext, ViewContext};
|
||||
pub use helper::AIBlockModelHelper;
|
||||
@@ -182,6 +182,9 @@ pub trait AIBlockModel {
|
||||
fn time_since_request_start(&self, _app: &AppContext) -> Option<TimeDelta> {
|
||||
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.
|
||||
fn base_model<'a>(&'a self, app: &'a AppContext) -> Option<&'a LLMId>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use chrono::{Local, TimeDelta};
|
||||
use chrono::{DateTime, Local, TimeDelta};
|
||||
use galaxyui::{AppContext, Entity, SingletonEntity, View, ViewContext};
|
||||
use history_model::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
|
||||
use session_sharing_protocol::common::ParticipantId;
|
||||
@@ -15,6 +15,7 @@ use crate::ai::agent::{
|
||||
use crate::ai::blocklist::history_model;
|
||||
use crate::ai::blocklist::model::{AIRequestType, PassiveRequestType};
|
||||
use crate::ai::llms::LLMId;
|
||||
use crate::util::time_format::is_trustworthy_message_timestamp;
|
||||
|
||||
/// Standard [`AIBlock`] impl for live outputs corresponding to an `OutputStream`.
|
||||
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> {
|
||||
let exchange = self.exchange(app);
|
||||
match exchange {
|
||||
|
||||
@@ -1004,6 +1004,10 @@ impl View for AIBlock {
|
||||
user_display_name: &avatar_display_name,
|
||||
profile_image_path: profile_image_path.as_ref(),
|
||||
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_prefix_highlight_len,
|
||||
detected_links_state: &self.detected_links_state,
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
//! Rendering functions for orchestration-related output items (messaging & agent management).
|
||||
|
||||
use galaxyui::elements::{
|
||||
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex, FormattedTextElement,
|
||||
Hoverable, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
|
||||
ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex,
|
||||
FormattedTextElement, Hoverable, MouseStateHandle, OffsetPositioning, ParentAnchor,
|
||||
ParentElement, ParentOffsetBounds, Radius, Shrinkable, Stack, Text,
|
||||
};
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::ui_components::components::UiComponent;
|
||||
use galaxyui::{AppContext, Element, SingletonEntity};
|
||||
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
|
||||
use super::common::render_scrollable_collapsible_content;
|
||||
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::{
|
||||
conversation_id_for_agent_id, conversation_id_for_agent_id_in_orchestration,
|
||||
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::{
|
||||
@@ -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> {
|
||||
participant: &'a OrchestrationParticipant,
|
||||
recipients: &'a [OrchestrationParticipant],
|
||||
@@ -330,17 +348,40 @@ fn render_transcript_row(
|
||||
// open a new pane.
|
||||
let mouse_state = mouse_state.clone();
|
||||
let self_terminal_view_id = props.terminal_view_id;
|
||||
Hoverable::new(mouse_state, move |_| avatar)
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, app, _| {
|
||||
dispatch_focus_or_open_child_agent_pane(
|
||||
conversation_id,
|
||||
self_terminal_view_id,
|
||||
ctx,
|
||||
app,
|
||||
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,
|
||||
),
|
||||
);
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
stack.finish()
|
||||
})
|
||||
.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()
|
||||
} else {
|
||||
avatar
|
||||
};
|
||||
@@ -448,16 +489,15 @@ pub(super) fn render_send_message(
|
||||
) -> Box<dyn Element> {
|
||||
let message_row =
|
||||
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;
|
||||
};
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(message_row)
|
||||
.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,
|
||||
action_id,
|
||||
props.terminal_view_id,
|
||||
app,
|
||||
)
|
||||
@@ -716,11 +756,10 @@ pub(super) fn render_start_agent(
|
||||
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(
|
||||
crate::ai::blocklist::agent_view::subagent_inline_panel::render_subagent_inline_panel(
|
||||
crate::ai::blocklist::agent_view::subagent_link::render_subagent_link(
|
||||
panel_state,
|
||||
action_id,
|
||||
props.terminal_view_id,
|
||||
app,
|
||||
),
|
||||
@@ -810,11 +849,10 @@ pub(super) fn render_start_agent(
|
||||
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(
|
||||
crate::ai::blocklist::agent_view::subagent_inline_panel::render_subagent_inline_panel(
|
||||
crate::ai::blocklist::agent_view::subagent_link::render_subagent_link(
|
||||
panel_state,
|
||||
action_id,
|
||||
props.terminal_view_id,
|
||||
app,
|
||||
),
|
||||
|
||||
@@ -8,7 +8,8 @@ use super::{
|
||||
agent_display_name_from_id, child_conversation_card_data_for_result, participant_for_agent_id,
|
||||
render_conversation_navigation_card_row, start_agent_cancelled_prefix,
|
||||
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::{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]
|
||||
fn transcript_metadata_uses_transcript_copy_without_technical_labels() {
|
||||
let recipients = vec![OrchestrationParticipant {
|
||||
|
||||
@@ -3879,7 +3879,6 @@ fn render_collapsible_text_block_section(
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let text_color = blended_colors::text_disabled(theme, theme.surface_2());
|
||||
let selectable = false;
|
||||
let is_streaming = props.model.status(app).is_streaming();
|
||||
|
||||
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,
|
||||
sections,
|
||||
text_color,
|
||||
selectable,
|
||||
selectable: true,
|
||||
find_context: props.find_context,
|
||||
current_working_directory: props.current_working_directory,
|
||||
shell_launch_data: props.shell_launch_data,
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
//!
|
||||
//! 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::ui::theme::color::internal_colors;
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warpui::elements::{
|
||||
Container, CornerRadius, DispatchEventResult, EventHandler, Flex, MainAxisAlignment,
|
||||
MainAxisSize, ParentElement, Radius, Shrinkable, Wrap,
|
||||
ChildAnchor, Container, CornerRadius, DispatchEventResult, EventHandler, Flex,
|
||||
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentAnchor, ParentElement, Radius,
|
||||
Shrinkable, Wrap,
|
||||
};
|
||||
use warpui::fonts::{Properties, Style, Weight};
|
||||
use warpui::ui_components::chip::Chip;
|
||||
@@ -21,6 +24,7 @@ use crate::ai::blocklist::AttachmentType;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::util::time_format::format_message_timestamp;
|
||||
|
||||
/// Data required to render the AI block query component.
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
@@ -28,6 +32,8 @@ pub(super) struct Props<'a> {
|
||||
pub(super) user_display_name: &'a String,
|
||||
pub(super) profile_image_path: Option<&'a String>,
|
||||
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_prefix_highlight_len: Option<usize>,
|
||||
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.profile_image_path,
|
||||
props.avatar_color,
|
||||
props.query_sent_at,
|
||||
Some(props.query_timestamp_tooltip_handle.clone()),
|
||||
props.detected_links_state,
|
||||
props.secret_redaction_state,
|
||||
input_index,
|
||||
@@ -64,6 +72,8 @@ pub(crate) fn render_query(
|
||||
user_display_name: &str,
|
||||
profile_image_path: Option<&String>,
|
||||
avatar_color: Option<ColorU>,
|
||||
query_sent_at: Option<DateTime<Local>>,
|
||||
query_timestamp_tooltip_handle: Option<MouseStateHandle>,
|
||||
detected_links_state: &DetectedLinksState,
|
||||
secret_redaction_state: &SecretRedactionState,
|
||||
input_index: usize,
|
||||
@@ -74,14 +84,29 @@ pub(crate) fn render_query(
|
||||
find_context: Option<FindContext>,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let avatar = Container::new(render_user_avatar(
|
||||
user_display_name,
|
||||
profile_image_path,
|
||||
avatar_color,
|
||||
app,
|
||||
))
|
||||
.with_margin_right(16.)
|
||||
.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(×tamp)),
|
||||
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 {
|
||||
style: Style::Normal,
|
||||
@@ -103,7 +128,6 @@ pub(crate) fn render_query(
|
||||
app,
|
||||
);
|
||||
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let mut query = Flex::column().with_child(text_element.finish());
|
||||
|
||||
if FeatureFlag::ImageAsContext.is_enabled() {
|
||||
|
||||
@@ -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.
|
||||
pub fn remove_conversation(
|
||||
&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]
|
||||
fn test_all_cleared_conversations_includes_terminal_view_id() {
|
||||
App::test((), |mut app| async move {
|
||||
|
||||
@@ -61,6 +61,13 @@ impl ChatGPTAuthModel {
|
||||
Self { state }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn new_for_test() -> Self {
|
||||
Self {
|
||||
state: ChatGPTAuthState::NotConnected,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn state(&self) -> &ChatGPTAuthState {
|
||||
&self.state
|
||||
}
|
||||
|
||||
+11
-1
@@ -978,7 +978,17 @@ fn make_new_elements_menu_items(ctx: &AppContext) -> Vec<MenuItem> {
|
||||
MenuItem::Custom(CustomMenuItem::new(
|
||||
"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")),
|
||||
)),
|
||||
MenuItem::Custom(CustomMenuItem::new(
|
||||
|
||||
@@ -7655,15 +7655,17 @@ impl TypedActionView for CodeReviewView {
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let file_path = self.discard_dialog_state.discard_file_paths[0].clone();
|
||||
if let Some(standardized_path) = self.to_standardized_path(&file_path) {
|
||||
self.discard_file(
|
||||
standardized_path,
|
||||
self.discard_dialog_state.stash_changes_enabled,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
} else if let Some(standardized_path) = self
|
||||
.discard_dialog_state
|
||||
.discard_file_paths
|
||||
.first()
|
||||
.and_then(|file_path| self.to_standardized_path(file_path))
|
||||
{
|
||||
self.discard_file(
|
||||
standardized_path,
|
||||
self.discard_dialog_state.stash_changes_enabled,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
self.discard_dialog_state.show_discard_confirm_dialog = false;
|
||||
self.discard_dialog_state.discard_file_paths.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]
|
||||
fn test_on_close_then_on_open_reinitializes_repo_state() {
|
||||
App::test((), |mut app| async move {
|
||||
|
||||
@@ -3167,14 +3167,21 @@ impl PaneGroup {
|
||||
pane_group
|
||||
}
|
||||
|
||||
/// Returns the terminal view currently owning `conversation_id`, even if
|
||||
/// that owner lives outside this pane group.
|
||||
/// Returns the conversation's owner, excluding panes detached for undo-close.
|
||||
fn terminal_view_id_for_owned_conversation(
|
||||
&self,
|
||||
conversation_id: AIConversationId,
|
||||
ctx: &AppContext,
|
||||
) -> 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(
|
||||
@@ -4619,6 +4626,8 @@ impl PaneGroup {
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> bool {
|
||||
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| {
|
||||
(origin.conversation_id == conversation_id)
|
||||
.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)
|
||||
.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,
|
||||
});
|
||||
return;
|
||||
@@ -7558,6 +7568,7 @@ impl PaneGroup {
|
||||
|
||||
/// 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>) {
|
||||
self.remove_transferred_child_agent_panes(ctx);
|
||||
let pane_ids = self.pane_contents.keys().copied().collect_vec();
|
||||
for pane_id in pane_ids {
|
||||
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.
|
||||
/// Returns true if attachment succeeded, false if pre_attach prevented it.
|
||||
fn try_attach_pane(&self, pane: &dyn AnyPaneContent, ctx: &mut ViewContext<Self>) -> bool {
|
||||
|
||||
@@ -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]
|
||||
fn test_entering_parent_agent_view_skips_child_owned_by_another_pane_group() {
|
||||
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
|
||||
|
||||
@@ -423,8 +423,10 @@ impl PaneContent for TerminalPane {
|
||||
// Only immediately clear conversations and delete blocks if the session is being
|
||||
// permanently closed.
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
|
||||
history_model
|
||||
.clear_conversations_for_terminal_surface(self.terminal_view(ctx).id(), ctx);
|
||||
history_model.clear_conversations_for_closed_terminal_surface(
|
||||
self.terminal_view(ctx).id(),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
self.delete_blocks(ctx);
|
||||
}
|
||||
|
||||
@@ -117,11 +117,6 @@ pub const FUNDAMENTALS_KEYBINDINGS: &[&str] = &[
|
||||
/// anywhere else in the code.
|
||||
pub fn get_additional_keybindings() -> Vec<CommandBinding> {
|
||||
vec![
|
||||
CommandBinding::new(
|
||||
"workspace:new_window".into(),
|
||||
"Open New Window".into(),
|
||||
Some(Keystroke::parse("cmd-n").expect("Valid keystroke")),
|
||||
),
|
||||
CommandBinding::new(
|
||||
"workspace:hide_warp".into(),
|
||||
"Hide Galaxy".into(),
|
||||
|
||||
@@ -5,7 +5,9 @@ use galaxyui::elements::{
|
||||
};
|
||||
use galaxyui::fonts::Weight;
|
||||
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 super::settings_page::{
|
||||
@@ -31,6 +33,9 @@ impl AboutPageView {
|
||||
impl Entity for AboutPageView {
|
||||
type Event = SettingsPageEvent;
|
||||
}
|
||||
impl TypedActionView for AboutPageView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
impl View for AboutPageView {
|
||||
fn ui_name() -> &'static str {
|
||||
|
||||
@@ -1175,7 +1175,7 @@ impl SettingsView {
|
||||
});
|
||||
|
||||
// About page
|
||||
let about_page_handle = ctx.add_view(AboutPageView::new);
|
||||
let about_page_handle = ctx.add_typed_action_view(AboutPageView::new);
|
||||
|
||||
// AI page
|
||||
let ai_page_handle = ctx.add_typed_action_view(AISettingsPageView::new);
|
||||
|
||||
@@ -530,6 +530,7 @@ use crate::workspace::view::cloud_agent_capacity_modal::CloudAgentCapacityModalV
|
||||
use crate::workspace::{
|
||||
CommandSearchOptions, ForkAIConversationParams, ForkFromExchange,
|
||||
ForkedConversationDestination, OneTimeModalModel, ToastStack, WorkspaceAction,
|
||||
WorkspaceRegistry,
|
||||
};
|
||||
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
|
||||
use crate::workspaces::workspace::CustomerType;
|
||||
@@ -1428,6 +1429,9 @@ pub enum ContextMenuAction {
|
||||
CopyAIBlockQuery {
|
||||
ai_block_view_id: EntityId,
|
||||
},
|
||||
CopyAIBlockTimestamp {
|
||||
ai_block_view_id: EntityId,
|
||||
},
|
||||
/// Copy the AI block output text
|
||||
CopyAIBlockOutput {
|
||||
ai_block_view_id: EntityId,
|
||||
@@ -1528,6 +1532,7 @@ impl fmt::Debug for ContextMenuAction {
|
||||
StopSharing => f.write_str("StopSharing"),
|
||||
CopyAIDebuggingLink { .. } => f.write_str("CopyAIDebuggingLink"),
|
||||
CopyAIBlockQuery { .. } => f.write_str("CopyAIBlockPrompt"),
|
||||
CopyAIBlockTimestamp { .. } => f.write_str("CopyAIBlockTimestamp"),
|
||||
CopyAIBlockOutput { .. } => f.write_str("CopyAIBlockOutput"),
|
||||
CopyAIBlock { .. } => f.write_str("CopyAIBlockBoth"),
|
||||
CopyAIBlockConversation { .. } => f.write_str("CopyAIBlockConversation"),
|
||||
@@ -12149,7 +12154,17 @@ impl TerminalView {
|
||||
// case, we want the block to be focused because otherwise,
|
||||
// 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.
|
||||
self.focus_terminal(ctx);
|
||||
//
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
ModelEvent::AfterBlockStarted {
|
||||
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 } => {
|
||||
// Copy only the current AI block's output
|
||||
for rich_content in self.rich_content_views.iter() {
|
||||
|
||||
@@ -74,6 +74,8 @@ fn render_user_query(
|
||||
&user_display_name,
|
||||
profile_image_url.as_ref(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&Default::default(),
|
||||
&Default::default(),
|
||||
0,
|
||||
|
||||
@@ -119,6 +119,27 @@ impl TerminalView {
|
||||
.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(
|
||||
MenuItemFields::new("Save as prompt")
|
||||
|
||||
@@ -51,6 +51,7 @@ use crate::server::ids::{ClientId, SyncId};
|
||||
use crate::server::server_api::ai::SpawnAgentRequest;
|
||||
use crate::settings::import::model::ImportedConfigModel;
|
||||
use crate::settings::{AISettings, AppEditorSettings, WarpPromptSeparator};
|
||||
use crate::tab::NewSessionMenuItem;
|
||||
use crate::terminal::alt_screen::should_intercept_mouse;
|
||||
use crate::terminal::block_list_element::{SnackbarPoint, SnackbarTranslationMode};
|
||||
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::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;
|
||||
|
||||
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)
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -76,6 +76,8 @@ fn circle_padding(total: f32) -> f32 {
|
||||
(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 {
|
||||
total * style.ring_ratio
|
||||
}
|
||||
@@ -141,8 +143,11 @@ pub(crate) enum IconWithStatusVariant {
|
||||
is_ambient: bool,
|
||||
},
|
||||
/// A pre-rendered avatar with an optional status overlay (cloud lobe when
|
||||
/// ambient). Caller must size `avatar` to `circle_size(total_size)` so the
|
||||
/// overlay's overhang matches the other variants.
|
||||
/// ambient). The overlay is anchored to the `total_size` box's bottom-right
|
||||
/// 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 {
|
||||
avatar: Box<dyn Element>,
|
||||
status: Option<ConversationStatus>,
|
||||
@@ -444,9 +449,8 @@ fn render_with_optional_status_badge(
|
||||
status_container_background: GalaxyThemeFill,
|
||||
) -> Box<dyn Element> {
|
||||
let Some(status) = status else {
|
||||
// No status badge: still occupy the full `total_size` footprint so the agent
|
||||
// circle (which is only `circle_size(total)` wide) sits centered in the box
|
||||
// the caller reserved.
|
||||
// Reserve the same footprint with or without a badge. ConstrainedBox does not
|
||||
// center the circle; callers that need centering must wrap it in Align.
|
||||
return ConstrainedBox::new(circle)
|
||||
.with_width(total_size)
|
||||
.with_height(total_size)
|
||||
|
||||
@@ -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::ToggleGalaxyDrive => {
|
||||
if OperatingSystem::get().is_mac() {
|
||||
|
||||
@@ -2,6 +2,14 @@ use std::ops::Sub;
|
||||
|
||||
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.
|
||||
const SEC_TO_MS: f64 = 1000.;
|
||||
const MIN_TO_MS: f64 = 60. * SEC_TO_MS;
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
use chrono::TimeZone;
|
||||
|
||||
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]
|
||||
fn test_format_sigfigs() {
|
||||
assert_eq!(format_sigfigs(0.000456, 2,), "0.00046");
|
||||
|
||||
+14
-12
@@ -69,11 +69,11 @@ use crate::workspace::view::{
|
||||
LEFT_PANEL_AGENT_CONVERSATIONS_BINDING_NAME, LEFT_PANEL_GLOBAL_SEARCH_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_TAB_BINDING_NAME, NEW_TERMINAL_TAB_BINDING_NAME, OPEN_GLOBAL_SEARCH_BINDING_NAME,
|
||||
TOGGLE_CONVERSATION_LIST_VIEW_BINDING_NAME, TOGGLE_NOTIFICATION_MAILBOX_BINDING_NAME,
|
||||
TOGGLE_PROJECT_EXPLORER_BINDING_NAME, TOGGLE_RIGHT_PANEL_BINDING_NAME,
|
||||
TOGGLE_TAB_CONFIGS_MENU_BINDING_NAME, TOGGLE_VERTICAL_TABS_PANEL_BINDING_NAME,
|
||||
TOGGLE_WARP_DRIVE_BINDING_NAME,
|
||||
NEW_TAB_BINDING_NAME, NEW_TERMINAL_TAB_BINDING_NAME, NEW_WINDOW_BINDING_NAME,
|
||||
OPEN_GLOBAL_SEARCH_BINDING_NAME, TOGGLE_CONVERSATION_LIST_VIEW_BINDING_NAME,
|
||||
TOGGLE_NOTIFICATION_MAILBOX_BINDING_NAME, TOGGLE_PROJECT_EXPLORER_BINDING_NAME,
|
||||
TOGGLE_RIGHT_PANEL_BINDING_NAME, TOGGLE_TAB_CONFIGS_MENU_BINDING_NAME,
|
||||
TOGGLE_VERTICAL_TABS_PANEL_BINDING_NAME, TOGGLE_WARP_DRIVE_BINDING_NAME,
|
||||
};
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
@@ -307,15 +307,17 @@ pub fn init(app: &mut AppContext) {
|
||||
"Switch to previous tab",
|
||||
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(
|
||||
NEW_FILE_BINDING_NAME,
|
||||
BindingDescription::new("New File"),
|
||||
|
||||
@@ -98,6 +98,7 @@ use galaxyui::{
|
||||
AppContext, Entity, EntityId, FocusContext, ModelHandle, SingletonEntity, TypedActionView,
|
||||
UpdateModel, UpdateView, View, ViewAsRef, ViewContext, ViewHandle, WeakViewHandle, WindowId,
|
||||
};
|
||||
use instant::Instant;
|
||||
use itertools::Itertools;
|
||||
use lazy_static::lazy_static;
|
||||
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_TERMINAL_TAB_BINDING_NAME: &str = "workspace:new_terminal_tab";
|
||||
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_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";
|
||||
@@ -1034,6 +1036,7 @@ pub struct Workspace {
|
||||
import_modal: ViewHandle<ImportModal>,
|
||||
theme_chooser_view: ViewHandle<ThemeChooser>,
|
||||
previous_theme: Option<ThemeKind>,
|
||||
background_image_animation_start_time: Instant,
|
||||
reward_modal: ViewHandle<Modal<RewardView>>,
|
||||
reward_modal_pending: Option<RewardKind>,
|
||||
pub(crate) current_workspace_state: WorkspaceState,
|
||||
@@ -1462,10 +1465,15 @@ impl Workspace {
|
||||
.is_any_tab_group_being_renamed()
|
||||
{
|
||||
match event {
|
||||
EditorEvent::Blurred | EditorEvent::Enter => {
|
||||
EditorEvent::Enter => {
|
||||
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);
|
||||
}
|
||||
_ => {}
|
||||
@@ -3354,6 +3362,7 @@ impl Workspace {
|
||||
ctrl_tab_palette,
|
||||
mouse_states: Default::default(),
|
||||
previous_theme: None,
|
||||
background_image_animation_start_time: Instant::now(),
|
||||
settings_pane,
|
||||
theme_chooser_view,
|
||||
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.
|
||||
pub fn rename_tab_group(&mut self, group_id: TabGroupId, ctx: &mut ViewContext<Self>) {
|
||||
let Some(group) = self.tab_groups.get(&group_id) else {
|
||||
@@ -27101,6 +27122,9 @@ impl View for Workspace {
|
||||
.cover()
|
||||
.with_opacity(opacity_ratio)
|
||||
.with_corner_radius(window_corner_radius)
|
||||
.enable_animation_with_start_time(
|
||||
self.background_image_animation_start_time,
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
|
||||
@@ -28,6 +28,8 @@ use crate::ai::agent_tips::AITipModel;
|
||||
use crate::ai::ambient_agents::github_auth_notifier::GitHubAuthNotifier;
|
||||
use crate::ai::blocklist::agent_view::orchestration_pill_bar_model::OrchestrationPillBarModel;
|
||||
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::execution_profiles::profiles::AIExecutionProfilesModel;
|
||||
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(AgentConversationsModel::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(HarnessAvailabilityModel::new);
|
||||
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| {
|
||||
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(|ctx| PersistedWorkspace::new(vec![], HashMap::new(), None, ctx));
|
||||
app.add_singleton_model(|_| PricingInfoModel::new());
|
||||
app.add_singleton_model(AIDocumentModel::new);
|
||||
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"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user