Skip to content

Commit f658c30

Browse files
authored
Add icon collage to vertical tab collapsed group (warpdotdev#12386)
## Description When a vertical tab group is collapsed, replaces the static chevron with the same icon collage used in horizontal tab group headers — showing up to 4 deduped pane-kind icons from the group's members. 1–2 unique pane kinds reuse the existing Single/Pair summary layout (same as the vertical tabs Summary row icons). 3–4 unique pane kinds fall back to a geometric collage: • 3 icons → equilateral triangle, one vertex down, vertically centered within the slot • 4 icons → 2×2 grid ## Linked Issue https://linear.app/warpdotdev/issue/APP-4670/add-icons-to-vertical-tab-group-on-collapse ## Testing - [x] I have manually tested my changes locally with `./script/run` ### Screenshots / Videos <!-- Attach screenshots or a short video demonstrating the change, where appropriate. Remove this section if it is not relevant to your PR. --> [Demo Video](https://www.loom.com/share/e9339cfae169406496ac6744bcc3cc6c)
1 parent ebaef15 commit f658c30

2 files changed

Lines changed: 91 additions & 55 deletions

File tree

app/src/workspace/view.rs

Lines changed: 53 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,8 @@ use warpui::{
113113
use self::vertical_tabs::telemetry::{VerticalTabsDisplayOption, VerticalTabsTelemetryEvent};
114114
use self::vertical_tabs::{
115115
htab_group_position_id, pane_summary_kind, render_detail_sidecar, render_settings_popup,
116-
render_summary_pane_kind_icon_circle, render_summary_pane_kind_icons, vtab_group_position_id,
117-
SummaryPaneKind, SummaryPaneKindIcons, VerticalTabsPanelState,
118-
VERTICAL_TABS_SETTINGS_BUTTON_POSITION_ID,
116+
render_summary_pane_kind_icons, vtab_group_position_id, SummaryPaneKind, SummaryPaneKindIcons,
117+
VerticalTabsPanelState, VERTICAL_TABS_SETTINGS_BUTTON_POSITION_ID,
119118
};
120119
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
121120
use super::action::AutoCloudHandoffTrigger;
@@ -19088,7 +19087,8 @@ impl Workspace {
1908819087
let header_selected = is_collapsed && any_member_active;
1908919088

1909019089
let member_kinds = self.compute_group_member_kinds(group.id, ctx);
19091-
let icon_circle = render_group_member_icon_collage(&member_kinds, appearance);
19090+
let icon_circle =
19091+
render_group_member_icon_collage(&member_kinds, GROUP_ICON_COLLAGE_SIZE, appearance);
1909219092

1909319093
let is_being_renamed = self
1909419094
.current_workspace_state
@@ -27287,10 +27287,6 @@ fn should_reserve_traffic_light_space_in_tab_bar(side: TrafficLightSide) -> bool
2728727287

2728827288
/// Total width/height of the collage area in the group header.
2728927289
const GROUP_ICON_COLLAGE_SIZE: f32 = 22.0;
27290-
/// Size of each icon when the collage shows 3 or 4 of them.
27291-
const GROUP_ICON_COLLAGE_MINI_SIZE: f32 = 12.0;
27292-
/// How far inside the collage area each mini icon's center sits.
27293-
const GROUP_ICON_COLLAGE_CENTER_INSET: f32 = 2.0;
2729427290

2729527291
/// Renders the icon block for a tab-group header from 0-4 deduped pane kinds.
2729627292
/// 1 or 2 icons reuse the vertical Summary `Single`/`Pair` layout so the
@@ -27299,19 +27295,20 @@ const GROUP_ICON_COLLAGE_CENTER_INSET: f32 = 2.0;
2729927295
/// define a layout beyond 2 icons.
2730027296
fn render_group_member_icon_collage(
2730127297
kinds: &[SummaryPaneKind],
27298+
total_size: f32,
2730227299
appearance: &Appearance,
2730327300
) -> Box<dyn Element> {
2730427301
let count = kinds.len().min(4);
2730527302
if count == 0 {
2730627303
return ConstrainedBox::new(Empty::new().finish())
27307-
.with_width(GROUP_ICON_COLLAGE_SIZE)
27308-
.with_height(GROUP_ICON_COLLAGE_SIZE)
27304+
.with_width(total_size)
27305+
.with_height(total_size)
2730927306
.finish();
2731027307
}
2731127308
if count == 1 {
2731227309
return render_summary_pane_kind_icons(
2731327310
SummaryPaneKindIcons::Single(kinds[0].clone()),
27314-
GROUP_ICON_COLLAGE_SIZE,
27311+
total_size,
2731527312
appearance,
2731627313
);
2731727314
}
@@ -27321,43 +27318,68 @@ fn render_group_member_icon_collage(
2732127318
primary: kinds[0].clone(),
2732227319
secondary: kinds[1].clone(),
2732327320
},
27324-
GROUP_ICON_COLLAGE_SIZE,
27321+
total_size,
2732527322
appearance,
2732627323
);
2732727324
}
2732827325

27329-
// Corners at radius/sqrt(2) on each axis; bottom-middle (3-icon) at radius.
27330-
let radius = GROUP_ICON_COLLAGE_SIZE / 2.0 - GROUP_ICON_COLLAGE_CENTER_INSET;
27331-
let diag = radius / std::f32::consts::SQRT_2;
27326+
// icon_diameter = total/2 - 1 gives a constant 2 px gap between adjacent circles.
27327+
let icon_diameter = total_size / 2.0 - 1.0;
27328+
// Distance from the collage center to each icon center; keeps icon edges within bounds.
27329+
let icon_center_offset = total_size / 2.0 - icon_diameter / 2.0;
27330+
27331+
// 3 icons: equilateral triangle (one vertex down), vertically centered.
27332+
// 4 icons: 2×2 grid. This maps (number of icons, index of icon) -> position.
27333+
let positions: [Vector2F; 4] = if count == 3 {
27334+
let sqrt_3 = 3.0_f32.sqrt();
27335+
let r = 2.0 * icon_center_offset / sqrt_3;
27336+
[
27337+
vec2f(-r * sqrt_3 / 2.0, -0.75 * r),
27338+
vec2f(r * sqrt_3 / 2.0, -0.75 * r),
27339+
vec2f(0.0, 0.75 * r),
27340+
vec2f(0.0, 0.0), // unused
27341+
]
27342+
} else {
27343+
[
27344+
vec2f(-icon_center_offset, -icon_center_offset),
27345+
vec2f(icon_center_offset, -icon_center_offset),
27346+
vec2f(-icon_center_offset, icon_center_offset),
27347+
vec2f(icon_center_offset, icon_center_offset),
27348+
]
27349+
};
2733227350

2733327351
let mut stack = Stack::new().with_child(
2733427352
ConstrainedBox::new(Empty::new().finish())
27335-
.with_width(GROUP_ICON_COLLAGE_SIZE)
27336-
.with_height(GROUP_ICON_COLLAGE_SIZE)
27353+
.with_width(total_size)
27354+
.with_height(total_size)
2733727355
.finish(),
2733827356
);
2733927357
for (idx, kind) in kinds.iter().take(count).enumerate() {
27340-
let mini = render_summary_pane_kind_icon_circle(
27358+
let mini = vertical_tabs::render_summary_pane_kind_icon_circle(
2734127359
kind.clone(),
27342-
GROUP_ICON_COLLAGE_MINI_SIZE,
27360+
icon_diameter,
2734327361
appearance,
2734427362
);
27345-
// Placement mapping for each icon.
27346-
// (number of icons, index of icon) -> position.
27347-
let offset = match (count, idx) {
27348-
(3, 0) => vec2f(-diag, -diag),
27349-
(3, 1) => vec2f(diag, -diag),
27350-
(3, 2) => vec2f(0.0, radius),
27351-
(4, 0) => vec2f(-diag, -diag),
27352-
(4, 1) => vec2f(diag, -diag),
27353-
(4, 2) => vec2f(-diag, diag),
27354-
(4, 3) => vec2f(diag, diag),
27355-
_ => vec2f(0.0, 0.0),
27363+
27364+
// Ambient icons place their brand circle at the top-left of a total_size
27365+
// element (leaving room for the cloud badge). Shift right-down by
27366+
// (1 - CIRCLE_RATIO)/2 * icon_diameter so the circle centers on the grid point.
27367+
let collage_pos = match &kind {
27368+
SummaryPaneKind::OzAgent { is_ambient: true }
27369+
| SummaryPaneKind::CLIAgent {
27370+
is_ambient: true, ..
27371+
} => {
27372+
let shift = icon_diameter
27373+
* (1.0 - crate::ui_components::icon_with_status::CIRCLE_RATIO)
27374+
/ 2.0;
27375+
positions[idx] + vec2f(shift, shift)
27376+
}
27377+
_ => positions[idx],
2735627378
};
2735727379
stack.add_positioned_child(
2735827380
mini,
2735927381
OffsetPositioning::offset_from_parent(
27360-
offset,
27382+
collage_pos,
2736127383
ParentOffsetBounds::Unbounded,
2736227384
ParentAnchor::Center,
2736327385
ChildAnchor::Center,

app/src/workspace/view/vertical_tabs.rs

Lines changed: 38 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ use warpui::ui_components::components::{UiComponent, UiComponentStyles};
3535
use warpui::ui_components::text_input::TextInput;
3636
use warpui::{AppContext, EntityId, SingletonEntity, ViewHandle, WindowId};
3737

38-
use super::select_unique_pane_kinds;
38+
use super::{render_group_member_icon_collage, select_unique_pane_kinds};
3939
use crate::ai::agent::conversation::{ConversationStatus, StatusColorStyle};
4040
use crate::ai::agent_management::AgentNotificationsModel;
4141
use crate::ai::cloud_environments::CloudAmbientAgentEnvironment;
@@ -2587,9 +2587,14 @@ fn render_tab_group_header_icon_button(
25872587
.finish()
25882588
}
25892589

2590-
/// Renders the header row for a tab group: chevron, title + "N tabs", and (on hover)
2591-
/// kebab + close buttons. Single-clicking outside the per-button regions toggles collapse;
2592-
/// double-clicking opens the inline rename editor.
2590+
/// Renders the header row for a tab group: leading icon (chevron when expanded,
2591+
/// member icon collage when collapsed), title + "N tabs", and (on hover) kebab
2592+
/// + close buttons. Single-clicking outside the per-button regions toggles
2593+
/// collapse; double-clicking opens the inline rename editor.
2594+
///
2595+
/// `collapsed_member_kinds` is the deduped list of pane kinds used to build the
2596+
/// icon collage shown in place of the chevron when the group is collapsed.
2597+
/// Pass `None` when the group is expanded; the chevron is rendered instead.
25932598
#[allow(clippy::too_many_arguments)]
25942599
fn render_grouped_tabs_header(
25952600
group: &TabGroup,
@@ -2600,6 +2605,7 @@ fn render_grouped_tabs_header(
26002605
show_action_buttons: bool,
26012606
is_being_renamed: bool,
26022607
rename_editor: Option<&ViewHandle<EditorView>>,
2608+
collapsed_member_kinds: Option<&[SummaryPaneKind]>,
26032609
app: &AppContext,
26042610
) -> Box<dyn Element> {
26052611
let appearance = Appearance::as_ref(app);
@@ -2609,31 +2615,34 @@ fn render_grouped_tabs_header(
26092615
let sub_text_color = theme.sub_text_color(theme.background());
26102616
let group_id = group.id;
26112617

2612-
let chevron_icon = if is_collapsed {
2613-
WarpIcon::ChevronRight
2618+
// Collapsed groups show the icon collage (same component as horizontal tab
2619+
// groups) in place of the chevron, sized to VERTICAL_TABS_ICON_SIZE so
2620+
// the 2-icon variant matches the tab Summary Pair layout exactly.
2621+
let tab_group_icon = if is_collapsed {
2622+
let kinds = collapsed_member_kinds.unwrap_or(&[]);
2623+
render_group_member_icon_collage(kinds, VERTICAL_TABS_ICON_SIZE, appearance)
26142624
} else {
2615-
WarpIcon::ChevronDown
2616-
};
2617-
let chevron_button = render_tab_group_header_icon_button(
2618-
chevron_icon,
2619-
TAB_GROUP_ICON_SIZE,
2620-
main_text_color,
2621-
internal_colors::fg_overlay_2(theme),
2622-
mouse_states.chevron.clone(),
2623-
Some(WorkspaceAction::ToggleTabGroupCollapsed(group_id)),
2624-
);
2625-
// Center the chevron in a `VERTICAL_TABS_ICON_SIZE` slot so the title aligns with member rows.
2626-
let chevron_slot = ConstrainedBox::new(
2625+
let chevron_button = render_tab_group_header_icon_button(
2626+
WarpIcon::ChevronDown,
2627+
TAB_GROUP_ICON_SIZE,
2628+
main_text_color,
2629+
internal_colors::fg_overlay_2(theme),
2630+
mouse_states.chevron.clone(),
2631+
Some(WorkspaceAction::ToggleTabGroupCollapsed(group_id)),
2632+
);
2633+
// Center the chevron in a `VERTICAL_TABS_ICON_SIZE` slot so the
2634+
// title aligns with member rows.
26272635
Flex::row()
26282636
.with_main_axis_size(MainAxisSize::Max)
26292637
.with_main_axis_alignment(MainAxisAlignment::Center)
26302638
.with_cross_axis_alignment(CrossAxisAlignment::Center)
26312639
.with_child(chevron_button)
2632-
.finish(),
2633-
)
2634-
.with_width(VERTICAL_TABS_ICON_SIZE)
2635-
.with_height(VERTICAL_TABS_ICON_SIZE)
2636-
.finish();
2640+
.finish()
2641+
};
2642+
let tab_group_icon = ConstrainedBox::new(tab_group_icon)
2643+
.with_width(VERTICAL_TABS_ICON_SIZE)
2644+
.with_height(VERTICAL_TABS_ICON_SIZE)
2645+
.finish();
26372646

26382647
let title_element: Box<dyn Element> =
26392648
if let Some(editor) = rename_editor.filter(|_| is_being_renamed) {
@@ -2711,7 +2720,7 @@ fn render_grouped_tabs_header(
27112720
.with_main_axis_size(MainAxisSize::Max)
27122721
.with_cross_axis_alignment(CrossAxisAlignment::Center)
27132722
.with_spacing(ICON_WITH_STATUS_GAP)
2714-
.with_child(chevron_slot)
2723+
.with_child(tab_group_icon)
27152724
.with_child(Shrinkable::new(1., text_column).finish())
27162725
.finish(),
27172726
)
@@ -2810,6 +2819,10 @@ fn render_grouped_tab_container(
28102819
.current_workspace_state
28112820
.is_tab_group_being_renamed(group.id);
28122821
let rename_editor = is_being_renamed.then(|| workspace.tab_group_rename_editor.clone());
2822+
// Compute member kinds only when collapsed — the collage is only
2823+
// rendered then, so this skips the per-tab pane walk when expanded.
2824+
let collapsed_member_kinds =
2825+
is_collapsed.then(|| workspace.compute_group_member_kinds(group.id, app));
28132826
content.add_child(render_grouped_tabs_header(
28142827
&group,
28152828
member_count,
@@ -2819,6 +2832,7 @@ fn render_grouped_tab_container(
28192832
hover_state.is_hovered(),
28202833
is_being_renamed,
28212834
rename_editor.as_ref(),
2835+
collapsed_member_kinds.as_deref(),
28222836
app,
28232837
));
28242838

0 commit comments

Comments
 (0)