From f816f346e4c9b81f83543849045bf9088eae3168 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:30:47 -0300 Subject: [PATCH] title_bar: Improve design for call controls (#58691) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For the longest time, we've had a labeled "Share" button on the title bar whenever you're in a call. However, it was extremely easy to click that button thinking you'd be sharing _your screen_ instead of your project. At the end of the day, there wasn't any specific cue that'd allow you to know what you'd be sharing. This is why people just assumed you'd share the screen, which is arguably way more common than sharing the project. We've seen users bump into this confusion countless times, and this also even happens to us (the whole Zed staff) weekly whenever we're in meetings. So, this PR changes that button to be an icon button instead, using a folder icon to better communicate it means sharing the project. I also took advantage of the opportunity to change the approach we use for adding these buttons, using `when` instead of `children.push`; I think the former is just easier to understand, allowing to more easily picture the UI in your (my) head. Lastly, also improved what we display in the tooltip for the project share button, by showing the names of all projects you'll be sharing, as well as the tooltip that displayed call diagnostics. | Before | After | |--------|--------| | Screenshot 2026-06-05 at 3  48
2@2x | Screenshot 2026-06-05 at 3  48
3@2x | Release Notes: - Improved call controls design, making it clearer what button shares _the project_ vs. _the screen_. --- assets/icons/folder_share.svg | 5 + assets/icons/folder_shared.svg | 5 + crates/icons/src/icons.rs | 2 + crates/title_bar/src/collab.rs | 504 +++++++++++++++++------------- crates/title_bar/src/title_bar.rs | 2 +- 5 files changed, 294 insertions(+), 224 deletions(-) create mode 100644 assets/icons/folder_share.svg create mode 100644 assets/icons/folder_shared.svg diff --git a/assets/icons/folder_share.svg b/assets/icons/folder_share.svg new file mode 100644 index 0000000000..09a232a689 --- /dev/null +++ b/assets/icons/folder_share.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/folder_shared.svg b/assets/icons/folder_shared.svg new file mode 100644 index 0000000000..c511390305 --- /dev/null +++ b/assets/icons/folder_shared.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index bbe9d6fe8b..ff90efe851 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -140,6 +140,8 @@ pub enum IconName { FolderOpen, FolderOpenAdd, FolderSearch, + FolderShare, + FolderShared, Font, FontSize, FontWeight, diff --git a/crates/title_bar/src/collab.rs b/crates/title_bar/src/collab.rs index 64cb0e3e27..ae503e4db7 100644 --- a/crates/title_bar/src/collab.rs +++ b/crates/title_bar/src/collab.rs @@ -5,7 +5,7 @@ use call::{ActiveCall, Room}; use channel::ChannelStore; use client::{User, proto::PeerId}; use gpui::{ - AnyElement, Hsla, IntoElement, MouseButton, Path, ScreenCaptureSource, Styled, TaskExt, + AnyElement, Empty, Hsla, IntoElement, MouseButton, Path, ScreenCaptureSource, Styled, TaskExt, WeakEntity, canvas, point, }; use gpui::{App, Task, Window}; @@ -18,7 +18,8 @@ use settings::{Settings as _, SettingsLocation}; use theme::ActiveTheme; use ui::{ Avatar, AvatarAudioStatusIndicator, ContextMenu, ContextMenuItem, Divider, DividerColor, - Facepile, PopoverMenu, SplitButton, SplitButtonStyle, TintColor, Tooltip, prelude::*, + Facepile, KeyBinding, PopoverMenu, SplitButton, SplitButtonStyle, TintColor, Tooltip, + prelude::*, }; use util::rel_path::RelPath; use workspace::{ParticipantLocation, notifications::DetachAndPromptErr}; @@ -336,9 +337,9 @@ impl TitleBar { &self, window: &mut Window, cx: &mut Context, - ) -> Vec { + ) -> AnyElement { let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() else { - return Vec::new(); + return Empty.into_any_element(); }; let is_connecting_to_project = self @@ -372,8 +373,6 @@ impl TitleBar { .channel_id() .and_then(|channel_id| channel_store.read(cx).channel_for_id(channel_id).cloned()); - let mut children = Vec::new(); - let effective_quality = stats.effective_quality.unwrap_or(ConnectionQuality::Lost); let (signal_icon, signal_color, quality_label) = match effective_quality { ConnectionQuality::Excellent => { @@ -386,246 +385,305 @@ impl TitleBar { let quality_label: SharedString = quality_label.into(); - children.push( - h_flex() - .gap_1() - .child( - IconButton::new("leave-call", IconName::Exit) - .style(ButtonStyle::Subtle) - .tooltip(Tooltip::text("Leave Call")) - .icon_size(IconSize::Small) - .on_click(move |_, _window, cx| { - ActiveCall::global(cx) - .update(cx, |call, cx| call.hang_up(cx)) - .detach_and_log_err(cx); - }), - ) - .child(Divider::vertical().color(DividerColor::Border)) - .into_any_element(), - ); - - children.push( - IconButton::new("call-quality", signal_icon) - .icon_size(IconSize::Small) - .when_some(signal_color, |button, color| button.icon_color(color)) - .tooltip(move |_window, cx| { - let quality_label = quality_label.clone(); - let latency = format_stat(stats.latency_ms, |v| format!("{:.0}ms", v)); - let jitter = format_stat(stats.jitter_ms, |v| format!("{:.0}ms", v)); - let packet_loss = format_stat(stats.packet_loss_pct, |v| format!("{:.1}%", v)); - let input_lag = - format_stat(stats.input_lag.map(|d| d.as_secs_f64() * 1000.0), |v| { - format!("{:.1}ms", v) - }); - - Tooltip::with_meta( - format!("Connection: {quality_label}"), - Some(&ShowCallStats), - format!( - "Latency: {latency} · Jitter: {jitter} · Loss: {packet_loss} · Input lag: {input_lag}", - ), - cx, + h_flex() + .gap_1() + .pr_1p5() + .child( + h_flex() + .gap_1() + .child( + IconButton::new("leave-call", IconName::Exit) + .tooltip(Tooltip::text("Leave Call")) + .icon_size(IconSize::Small) + .on_click(move |_, _window, cx| { + ActiveCall::global(cx) + .update(cx, |call, cx| call.hang_up(cx)) + .detach_and_log_err(cx); + }), ) - }) - .on_click(move |_, window, cx| { - window.dispatch_action(Box::new(ShowCallStats), cx); - }) - .into_any_element(), - ); + .child(Divider::vertical().color(DividerColor::Border)), + ) + .child( + IconButton::new("call-quality", signal_icon) + .icon_size(IconSize::Small) + .when_some(signal_color, |button, color| button.icon_color(color)) + .tooltip(Tooltip::element(move |window, cx| { + let quality_label = quality_label.clone(); + let latency = format_stat(stats.latency_ms, |v| format!("{:.0}ms", v)); + let jitter = format_stat(stats.jitter_ms, |v| format!("{:.0}ms", v)); + let packet_loss = + format_stat(stats.packet_loss_pct, |v| format!("{:.1}%", v)); + let input_lag = + format_stat(stats.input_lag.map(|d| d.as_secs_f64() * 1000.0), |v| { + format!("{:.1}ms", v) + }); - if is_local && can_share_projects && !is_connecting_to_project { - let is_sharing_disabled = channel.is_some_and(|channel| match channel.visibility { - proto::ChannelVisibility::Public => project.visible_worktrees(cx).any(|worktree| { - let worktree_id = worktree.read(cx).id(); + let key_binding = KeyBinding::for_action(&ShowCallStats, cx); + let has_key_binding = key_binding.has_binding(window); - let settings_location = Some(SettingsLocation { - worktree_id, - path: RelPath::empty(), - }); + let stat_row = |label: &'static str, value: String| { + h_flex() + .justify_between() + .gap_4() + .child(Label::new(label).size(LabelSize::Small).color(Color::Muted)) + .child(Label::new(value).size(LabelSize::Small)) + }; - WorktreeSettings::get(settings_location, cx).prevent_sharing_in_public_channels - }), - proto::ChannelVisibility::Members => false, - }); - - children.push( - Button::new( - "toggle_sharing", - if is_shared { "Unshare" } else { "Share" }, + v_flex() + .child( + h_flex() + .gap_4() + .justify_between() + .child(Label::new(format!("Connection: {quality_label}"))) + .when(has_key_binding, |this| this.child(key_binding)), + ) + .child( + v_flex() + .gap_0p5() + .child(stat_row("Latency", latency)) + .child(stat_row("Jitter", jitter)) + .child(stat_row("Packet loss", packet_loss)) + .child(stat_row("Input lag", input_lag)), + ) + .into_any_element() + })) + .on_click(move |_, window, cx| { + window.dispatch_action(Box::new(ShowCallStats), cx); + }), + ) + .when(can_use_microphone, |this| { + this.child( + IconButton::new( + "mute-microphone", + if is_muted { + IconName::MicMute + } else { + IconName::Mic + }, + ) + .tooltip(move |_window, cx| { + if is_muted { + if is_deafened { + Tooltip::with_meta( + "Unmute Microphone", + None, + "Audio will be unmuted", + cx, + ) + } else { + Tooltip::simple("Unmute Microphone", cx) + } + } else { + Tooltip::simple("Mute Microphone", cx) + } + }) + .icon_size(IconSize::Small) + .toggle_state(is_muted) + .selected_style(ButtonStyle::Tinted(TintColor::Error)) + .on_click(move |_, _window, cx| toggle_mute(cx)), ) - .tooltip(Tooltip::text(if is_shared { - "Stop sharing project with call participants" - } else { - "Share project with call participants" - })) - .style(ButtonStyle::Subtle) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .toggle_state(is_shared) - .label_size(LabelSize::Small) - .when(is_sharing_disabled, |parent| { - parent.disabled(true).tooltip(Tooltip::text( - "This project may not be shared in a public channel.", - )) - }) - .on_click(cx.listener(move |this, _, window, cx| { - if is_shared { - this.unshare_project(window, cx); - } else { - this.share_project(cx); - } - })) - .into_any_element(), - ); - } - - if can_use_microphone { - children.push( + }) + .child( IconButton::new( - "mute-microphone", - if is_muted { - IconName::MicMute + "mute-sound", + if is_deafened { + IconName::AudioOff } else { - IconName::Mic + IconName::AudioOn }, ) + .selected_style(ButtonStyle::Tinted(TintColor::Error)) + .icon_size(IconSize::Small) + .toggle_state(is_deafened) .tooltip(move |_window, cx| { - if is_muted { - if is_deafened { - Tooltip::with_meta( - "Unmute Microphone", - None, - "Audio will be unmuted", - cx, - ) + if is_deafened { + let label = "Unmute Audio"; + + if !muted_by_user { + Tooltip::with_meta(label, None, "Microphone will be unmuted", cx) } else { - Tooltip::simple("Unmute Microphone", cx) + Tooltip::simple(label, cx) } } else { - Tooltip::simple("Mute Microphone", cx) + let label = "Mute Audio"; + + if !muted_by_user { + Tooltip::with_meta(label, None, "Microphone will be muted", cx) + } else { + Tooltip::simple(label, cx) + } } }) - .style(ButtonStyle::Subtle) - .icon_size(IconSize::Small) - .toggle_state(is_muted) - .selected_style(ButtonStyle::Tinted(TintColor::Error)) - .on_click(move |_, _window, cx| toggle_mute(cx)) - .into_any_element(), - ); - } + .on_click(move |_, _, cx| toggle_deafen(cx)), + ) + .when( + is_local && can_share_projects && !is_connecting_to_project, + |this| { + let is_sharing_disabled = + channel.is_some_and(|channel| match channel.visibility { + proto::ChannelVisibility::Public => { + project.visible_worktrees(cx).any(|worktree| { + let worktree_id = worktree.read(cx).id(); - children.push( - IconButton::new( - "mute-sound", - if is_deafened { - IconName::AudioOff - } else { - IconName::AudioOn + let settings_location = Some(SettingsLocation { + worktree_id, + path: RelPath::empty(), + }); + + WorktreeSettings::get(settings_location, cx) + .prevent_sharing_in_public_channels + }) + } + proto::ChannelVisibility::Members => false, + }); + + let icon = if is_shared { + IconName::FolderShared + } else { + IconName::FolderShare + }; + + let folder_names = project + .visible_worktrees(cx) + .filter_map(|worktree| { + worktree + .read(cx) + .root_name() + .file_name() + .map(|name| name.to_string()) + }) + .collect::>(); + let folder_list = folder_names.join(", "); + + let unshare_meta: SharedString = if folder_list.is_empty() { + "Stop sharing project with call participants".into() + } else { + format!("Stop sharing {folder_list} with call participants").into() + }; + let share_meta: SharedString = if folder_list.is_empty() { + "Share active project with call participants".into() + } else { + format!("Share {folder_list} with call participants").into() + }; + + this.child( + IconButton::new("toggle_sharing", icon) + .icon_size(IconSize::Small) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .toggle_state(is_shared) + .map(|this| { + if is_shared { + this.tooltip(move |_, cx| { + Tooltip::with_meta( + "Unshare Project", + None, + unshare_meta.clone(), + cx, + ) + }) + .on_click(cx.listener( + move |this, _, window, cx| { + this.unshare_project(window, cx); + }, + )) + } else if is_sharing_disabled { + this.disabled(true).tooltip(Tooltip::text( + "This project may not be shared in a public channel.", + )) + } else { + this.tooltip(move |_, cx| { + Tooltip::with_meta( + "Share Project", + None, + share_meta.clone(), + cx, + ) + }) + .on_click(cx.listener( + move |this, _, _, cx| { + this.share_project(cx); + }, + )) + } + }), + ) }, ) - .style(ButtonStyle::Subtle) - .selected_style(ButtonStyle::Tinted(TintColor::Error)) - .icon_size(IconSize::Small) - .toggle_state(is_deafened) - .tooltip(move |_window, cx| { - if is_deafened { - let label = "Unmute Audio"; + .when(can_use_microphone && screen_sharing_supported, |parent| { + #[cfg(target_os = "linux")] + let is_wayland = gpui::guess_compositor() == "Wayland"; + #[cfg(not(target_os = "linux"))] + let is_wayland = false; - if !muted_by_user { - Tooltip::with_meta(label, None, "Microphone will be unmuted", cx) + let trigger = IconButton::new("screen-share", IconName::Screen) + .style(ButtonStyle::Subtle) + .icon_size(IconSize::Small) + .toggle_state(is_screen_sharing) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .tooltip(Tooltip::text(if is_screen_sharing { + "Stop Sharing Screen" } else { - Tooltip::simple(label, cx) - } + "Share Screen" + })) + .on_click(move |_, window, cx| { + let should_share = ActiveCall::global(cx) + .read(cx) + .room() + .is_some_and(|room| !room.read(cx).is_sharing_screen()); + + #[cfg(target_os = "linux")] + { + if is_wayland + && let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() + { + let task = room.update(cx, |room, cx| { + if should_share { + room.share_screen_wayland(cx) + } else { + room.unshare_screen(true, cx) + .map(|()| Task::ready(Ok(()))) + .unwrap_or_else(|e| Task::ready(Err(e))) + } + }); + task.detach_and_prompt_err( + "Sharing Screen Failed", + window, + cx, + |e, _, _| Some(format!("{e:?}")), + ); + } + } + if !is_wayland { + window + .spawn(cx, async move |cx| { + let screen = if should_share { + cx.update(|_, cx| pick_default_screen(cx))?.await + } else { + Ok(None) + }; + cx.update(|window, cx| { + toggle_screen_sharing(screen, window, cx) + })?; + + Result::<_, anyhow::Error>::Ok(()) + }) + .detach(); + } + }); + + if is_wayland { + parent.child(trigger) } else { - let label = "Mute Audio"; - - if !muted_by_user { - Tooltip::with_meta(label, None, "Microphone will be muted", cx) - } else { - Tooltip::simple(label, cx) - } + parent.child( + SplitButton::new( + trigger.render(window, cx), + self.render_screen_list().into_any_element(), + ) + .style(SplitButtonStyle::Transparent), + ) } }) - .on_click(move |_, _, cx| toggle_deafen(cx)) - .into_any_element(), - ); - - if can_use_microphone && screen_sharing_supported { - #[cfg(target_os = "linux")] - let is_wayland = gpui::guess_compositor() == "Wayland"; - #[cfg(not(target_os = "linux"))] - let is_wayland = false; - - let trigger = IconButton::new("screen-share", IconName::Screen) - .style(ButtonStyle::Subtle) - .icon_size(IconSize::Small) - .toggle_state(is_screen_sharing) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .tooltip(Tooltip::text(if is_screen_sharing { - "Stop Sharing Screen" - } else { - "Share Screen" - })) - .on_click(move |_, window, cx| { - let should_share = ActiveCall::global(cx) - .read(cx) - .room() - .is_some_and(|room| !room.read(cx).is_sharing_screen()); - - #[cfg(target_os = "linux")] - { - if is_wayland - && let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() - { - let task = room.update(cx, |room, cx| { - if should_share { - room.share_screen_wayland(cx) - } else { - room.unshare_screen(true, cx) - .map(|()| Task::ready(Ok(()))) - .unwrap_or_else(|e| Task::ready(Err(e))) - } - }); - task.detach_and_prompt_err( - "Sharing Screen Failed", - window, - cx, - |e, _, _| Some(format!("{e:?}")), - ); - } - } - if !is_wayland { - window - .spawn(cx, async move |cx| { - let screen = if should_share { - cx.update(|_, cx| pick_default_screen(cx))?.await - } else { - Ok(None) - }; - cx.update(|window, cx| toggle_screen_sharing(screen, window, cx))?; - - Result::<_, anyhow::Error>::Ok(()) - }) - .detach(); - } - }); - - if is_wayland { - children.push(trigger.into_any_element()); - } else { - children.push( - SplitButton::new( - trigger.render(window, cx), - self.render_screen_list().into_any_element(), - ) - .style(SplitButtonStyle::Transparent) - .into_any_element(), - ); - } - } - - children.push(div().pr_2().into_any_element()); - - children + .into_any_element() } fn render_screen_list(&self) -> impl IntoElement { diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 85f2905ea8..1da19d6250 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -362,7 +362,7 @@ impl Render for TitleBar { }) .gap_1() .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .children(self.render_call_controls(window, cx)) + .child(self.render_call_controls(window, cx)) .children(self.render_connection_status(status, cx)) .child(self.update_version.clone()) .when(