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
+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 {