Skip to content

Commit d375729

Browse files
authored
Add basic tab group rendering for horizontal tabs (warpdotdev#12089)
## Description Adds basic rendering for **horizontal tab groups** in the top tab bar, on top of the existing vertical tab grouping data model. A group renders as a single tab-bar slot containing a header (icon collage + name) followed by its member tabs. - Click the header → collapse/expand the group (collapsed shows just the header). - Double-click the header → rename inline. - Right-click the header → open the group context menu (labels adapt to "left/right" for horizontal layouts, "up/down/above/below" for vertical). - Right-click a member tab → standard tab menu, including "Remove from group". Dragging is out of scope for this PR — neither groups nor members can be dragged yet. All new behavior is gated behind `FeatureFlag::GroupedTabs`; when the flag is off, the horizontal tab bar renders exactly as before. ## Changes **`workspace/view.rs`** - Added `TabBarSlot { Single | Group }` enum and a preprocessing pass in `render_tab_bar_contents` that turns the flat tab list into a list of slots (ungrouped tabs become `Single`, contiguous same-group runs collapse into `Group`). - Added `render_horizontal_tab_group` (container + member tabs) and `render_horizontal_tab_group_header` (icon collage + name + click/double-click/right-click handlers). - Added `compute_group_member_kinds` — picks up to 4 distinct pane icons for a group by reusing each member tab's "Summary pair" selection, so the group icons are always a subset of what the member tabs would show in vertical Summary. - Added `render_group_member_icon_collage` — 1 or 2 icons reuse vertical Summary's `Single`/`Pair` layout for visual parity; 3 or 4 fall back to a corner collage. Designs for reference: <img width="222" height="92" alt="Screenshot 2026-06-02 at 4 14 24 PM" src="https://github.com/user-attachments/assets/53a04f14-6ff9-4385-8236-878ca8630947" /> <img width="193" height="80" alt="Screenshot 2026-06-02 at 4 14 52 PM" src="https://github.com/user-attachments/assets/d0016562-10c3-4226-8828-ef16fb295511" /> - Added `select_unique_pane_kinds` — shared selection helper used by both vertical Summary (`select_summary_pane_kind_icons`) and the horizontal collage. - Added per-group hover state map (`horizontal_tab_group_mouse_states`) on `Workspace`. - `tab_group_menu_items` now takes `is_vertical: bool` so labels adapt to layout. **`tab.rs`** - `TabComponent` gained a `grouped_member` flag and `.for_grouped_member()` builder. In that mode the tab skips side dividers, paints an inset rounded highlight, and skips the `Draggable` wrapper (member dragging is a follow-up). **`workspace/view/vertical_tabs.rs`** - `render_summary_pane_kind_icons` and `SummaryPaneKindIcons` are now `pub(super)` and accept a `total_size` parameter so the horizontal collage can reuse them at its own sizing. - `select_summary_pane_kind_icons` now delegates to the shared `select_unique_pane_kinds` helper. ## Linked Issue https://linear.app/warpdotdev/issue/APP-4641/horizontal-tab-grouping-basic-rendering ## Testing <!-- How did you test this change? What automated tests did you add? If you didn't add any new tests, what's your justification for not adding any? Manual testing is required for changes that can be manually tested, and almost all changes can be manually tested. If your change can be manually tested, please include screenshots or a screen recording that show it working end to end. You can run the app locally using `./script/run` - see WARP.md for more details on how to get set up. --> - [x] I have manually tested my changes locally with `./script/run` ### Screenshots / Videos [Demo Video](https://www.loom.com/share/fff045cca12841bab0abd6b9764d89b9)
1 parent 5967abf commit d375729

3 files changed

Lines changed: 615 additions & 127 deletions

File tree

app/src/tab.rs

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,8 @@ pub struct TabComponent<'a> {
764764
/// flicker), and
765765
/// * does not act as its own draggable / drop target.
766766
for_drag_ghost: bool,
767+
/// Set when rendered as a member of a horizontal tab group.
768+
grouped_member: bool,
767769
}
768770

769771
/// Structure that holds TabComponent styles.
@@ -924,6 +926,7 @@ impl<'a> TabComponent<'a> {
924926
is_drag_target,
925927
background_opacity,
926928
for_drag_ghost: false,
929+
grouped_member: false,
927930
}
928931
}
929932

@@ -934,6 +937,13 @@ impl<'a> TabComponent<'a> {
934937
self
935938
}
936939

940+
/// Marks this tab as a member of a horizontal tab group. See the
941+
/// [`TabComponent`] `grouped_member` field for the rendering differences.
942+
pub fn for_grouped_member(mut self) -> Self {
943+
self.grouped_member = true;
944+
self
945+
}
946+
937947
/// Returns the agent indicator for the focused session's active conversation,
938948
/// or `None` if there is no non-empty, non-passive conversation to display.
939949
/// When a shell command is long-running the status is overridden to
@@ -1452,9 +1462,13 @@ impl<'a> TabComponent<'a> {
14521462
)
14531463
.finish(),
14541464
);
1455-
Container::new(flex_row.finish())
1456-
.with_horizontal_padding(8.)
1457-
.finish()
1465+
let mut container = Container::new(flex_row.finish()).with_horizontal_padding(8.);
1466+
// Pad inside the Stack so the close-button overlay (anchored to
1467+
// the Stack) stays vertically centered within the visible pill.
1468+
if self.grouped_member {
1469+
container = container.with_vertical_padding(5.);
1470+
}
1471+
container.finish()
14581472
};
14591473

14601474
let compact_icon = {
@@ -1591,6 +1605,19 @@ impl<'a> TabComponent<'a> {
15911605
)
15921606
.finish();
15931607

1608+
// Grouped member: inset rounded highlight, no side dividers, no
1609+
// drop target (members can't be dragged currently).
1610+
if self.grouped_member {
1611+
let highlight = Container::new(stack)
1612+
.with_background(background_color)
1613+
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.0)))
1614+
.finish();
1615+
return Container::new(highlight)
1616+
.with_vertical_padding(3.)
1617+
.with_horizontal_padding(3.)
1618+
.finish();
1619+
}
1620+
15941621
let mut tab = Container::new(stack)
15951622
.with_vertical_padding(2.)
15961623
.with_background(background_color);
@@ -1657,6 +1684,7 @@ impl UiComponent for TabComponent<'_> {
16571684
let mouse_close_state = self.tab.close_mouse_state.clone();
16581685
// Capture before `self` is moved into the Hoverable closure below.
16591686
let for_drag_ghost = self.for_drag_ghost;
1687+
let grouped_member = self.grouped_member;
16601688

16611689
// Extract values before moving self into closure
16621690
let tooltip_text = self.tooltip_message.clone();
@@ -1849,6 +1877,10 @@ impl UiComponent for TabComponent<'_> {
18491877
// position cache, breaking `tab_insertion_index_for_cursor`.
18501878
let full_tab: Box<dyn Element> = if for_drag_ghost {
18511879
constrained_tab
1880+
} else if grouped_member {
1881+
// Skipping dragging within a group for now.
1882+
// TODO(johnturcoo) support dragging tabs within a group.
1883+
SavePosition::new(constrained_tab, &tab_position_id(tab_index)).finish()
18521884
} else {
18531885
let draggable = Draggable::new(draggable_state, constrained_tab)
18541886
.on_drag_start(|ctx, _, _| ctx.dispatch_typed_action(WorkspaceAction::StartTabDrag))

0 commit comments

Comments
 (0)