Port upstream UI polish and refresh dependencies

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

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

Include the pending Cargo.lock dependency refresh.

Adapted upstream PRs: #15948, #15884, #15691, #14618, #15772, #15705,
#15605, #14895, #15996, #15771, #14893, and #14603.
This commit is contained in:
2026-09-17 23:44:47 -05:00
parent f084548b34
commit a29ee94d84
46 changed files with 2529 additions and 1479 deletions
+11
View File
@@ -255,6 +255,17 @@ impl ActiveAgentViewsModel {
}
}
/// Whether the terminal view still belongs to an attached pane, including hidden child panes.
pub(crate) fn is_terminal_view_attached(
&self,
terminal_view_id: EntityId,
ctx: &AppContext,
) -> bool {
self.agent_view_handles
.get(&terminal_view_id)
.is_some_and(|handles| handles.controller.upgrade(ctx).is_some())
}
pub fn handle_pane_focus_change(
&mut self,
window_id: WindowId,
+1 -1
View File
@@ -11,7 +11,7 @@ pub(crate) mod orchestration_conversation_links;
pub mod orchestration_pill_bar;
pub mod orchestration_pill_bar_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()
}
+29 -27
View File
@@ -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(
&timestamp,
)));
}
}
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);
+4 -1
View File
@@ -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 {
+4
View File
@@ -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,
+28 -4
View File
@@ -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(&timestamp)),
tooltip_handle,
avatar,
ParentAnchor::TopLeft,
ChildAnchor::BottomLeft,
vec2f(0., -8.),
)
} else {
avatar
};
let avatar = Container::new(avatar).with_margin_right(16.).finish();
let properties = Properties {
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() {
+20
View File
@@ -2167,6 +2167,26 @@ impl BlocklistAIHistoryModel {
);
}
/// Clears a closed surface without notifying a controller whose conversations moved elsewhere.
pub(crate) fn clear_conversations_for_closed_terminal_surface(
&mut self,
terminal_surface_id: EntityId,
ctx: &mut ModelContext<Self>,
) {
if self
.live_conversation_ids_for_terminal_surface
.get(&terminal_surface_id)
.is_none_or(Vec::is_empty)
{
self.active_conversation_for_terminal_surface
.remove(&terminal_surface_id);
self.live_conversation_ids_for_terminal_surface
.remove(&terminal_surface_id);
return;
}
self.clear_conversations_for_terminal_surface(terminal_surface_id, ctx);
}
/// Handle removing a conversation from the history model, blocklist and in-memory.
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 {
+7
View File
@@ -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
View File
@@ -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(
+11 -9
View File
@@ -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 {
+32 -4
View File
@@ -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 {
+96
View File
@@ -2098,6 +2098,102 @@ fn test_ensure_hidden_child_agent_pane_skips_child_owned_by_another_pane_group()
});
}
#[test]
fn test_ensure_hidden_child_agent_pane_restores_child_from_detached_group() {
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
App::test((), |mut app| async move {
initialize_app(&mut app);
let closed_pane_group = mock_pane_group(&mut app, Default::default());
let reopened_pane_group = mock_pane_group(&mut app, Default::default());
let (parent_conversation, child_conversation_id, previous_owner) = closed_pane_group
.update(&mut app, |panes, ctx| {
let parent_pane_id = panes.focused_pane_id(ctx);
let parent_conversation_id = start_parent_conversation(panes, parent_pane_id, ctx);
let mut child = AIConversation::new(false, false);
child.set_parent_conversation_id(parent_conversation_id);
child.set_agent_name("architect".to_string());
let child_conversation_id = child.id();
panes.create_hidden_child_agent_pane(child, parent_pane_id, ctx);
let previous_owner = panes
.terminal_view_from_pane_id(
panes.child_agent_panes[&child_conversation_id],
ctx,
)
.unwrap();
let parent_conversation = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&parent_conversation_id)
.unwrap()
.clone();
panes.swap_active_pane_to_conversation(parent_pane_id, child_conversation_id, ctx);
panes.detach_panes(ctx);
(parent_conversation, child_conversation_id, previous_owner)
});
let restored_child = reopened_pane_group.update(&mut app, |panes, ctx| {
let parent_pane_id = panes.focused_pane_id(ctx);
let parent_view = panes
.terminal_view_from_pane_id(parent_pane_id, ctx)
.unwrap();
let parent_conversation_id =
restore_conversation_for_terminal_view(parent_view.id(), parent_conversation, ctx);
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history.set_active_conversation_id(parent_conversation_id, parent_view.id(), ctx);
});
assert!(
panes.ensure_hidden_child_agent_pane_for_conversation(child_conversation_id, ctx)
);
let child_pane_id = *panes
.child_agent_panes
.get(&child_conversation_id)
.expect("an undo-retained owner must not prevent restoring the child");
panes.swap_active_pane_to_conversation(parent_pane_id, child_conversation_id, ctx);
assert_eq!(panes.focused_pane_id(ctx), child_pane_id);
let child_view = panes
.terminal_view_from_pane_id(child_pane_id, ctx)
.unwrap();
assert_eq!(
child_view.as_ref(ctx).active_conversation_id(ctx),
Some(child_conversation_id)
);
child_view
});
assert_ne!(restored_child.id(), previous_owner.id());
closed_pane_group.update(&mut app, |panes, ctx| {
panes.reattach_panes(ctx);
assert!(!panes.child_agent_panes.contains_key(&child_conversation_id));
assert_eq!(
panes.find_pane_id_for_terminal_view(previous_owner.id(), ctx),
None
);
assert_eq!(panes.visible_pane_count(), 1);
assert!(
panes.ensure_hidden_child_agent_pane_for_conversation(child_conversation_id, ctx)
);
});
closed_pane_group.update(&mut app, |panes, ctx| {
panes.clean_up_panes(ctx);
});
app.read(|ctx| {
assert_eq!(
BlocklistAIHistoryModel::as_ref(ctx)
.terminal_surface_id_for_conversation(&child_conversation_id),
Some(restored_child.id())
);
assert_eq!(
restored_child.as_ref(ctx).active_conversation_id(ctx),
Some(child_conversation_id)
);
});
});
}
#[test]
fn test_entering_parent_agent_view_skips_child_owned_by_another_pane_group() {
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
+4 -2
View File
@@ -423,8 +423,10 @@ impl PaneContent for TerminalPane {
// Only immediately clear conversations and delete blocks if the session is being
// 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);
}
-5
View File
@@ -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(),
+6 -1
View File
@@ -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 {
+1 -1
View File
@@ -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);
+29 -1
View File
@@ -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,
+21
View File
@@ -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")
+73 -1
View File
@@ -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)
}));
});
}
+9 -5
View File
@@ -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)
-1
View File
@@ -383,7 +383,6 @@ pub fn custom_tag_to_keystroke(custom: CustomTag) -> Option<Keystroke> {
}
}
// This is one of the app's hardcoded keybindings.
CustomAction::AddWindow => Keystroke::parse(cmd_or_ctrl_shift("n")).ok(),
CustomAction::ToggleGalaxyDrive => {
if OperatingSystem::get().is_mac() {
+8
View File
@@ -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;
+21
View File
@@ -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
View File
@@ -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"),
+26 -2
View 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(),
+55 -1
View File
@@ -28,6 +28,8 @@ use crate::ai::agent_tips::AITipModel;
use crate::ai::ambient_agents::github_auth_notifier::GitHubAuthNotifier;
use crate::ai::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"
);
});
});
}