collab: Improve project and call notification UI (#47964)

Danilo Leal created

This PR moves the `CollabNotification` component from the collab_ui
crate to the UI crate, so that we can add a component preview to it. It
also fixes problems with text truncation and other little details.

<img width="700" height="1674" alt="Screenshot 2026-01-29 at 4  07@2x"
src="https://github.com/user-attachments/assets/6aca6cff-564a-4729-b505-c02d8c3af97f"
/>

Release Notes:

- Collab: Fixed overflowing text in project sharing and call
notifications.

Change summary

Cargo.lock                                                        |   2 
crates/collab_ui/Cargo.toml                                       |   2 
crates/collab_ui/src/notifications.rs                             |   7 
crates/collab_ui/src/notifications/collab_notification.rs         |  52 
crates/collab_ui/src/notifications/incoming_call_notification.rs  |   7 
crates/collab_ui/src/notifications/project_shared_notification.rs |  27 
crates/collab_ui/src/notifications/stories/collab_notification.rs |  48 
crates/storybook/Cargo.toml                                       |   1 
crates/storybook/src/story_selector.rs                            |   4 
crates/ui/src/components.rs                                       |   2 
crates/ui/src/components/collab.rs                                |   0 
crates/ui/src/components/collab/collab_notification.rs            | 134 +
12 files changed, 151 insertions(+), 135 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -3448,7 +3448,6 @@ dependencies = [
  "serde_json",
  "settings",
  "smallvec",
- "story",
  "telemetry",
  "theme",
  "time",
@@ -15917,7 +15916,6 @@ version = "0.1.0"
 dependencies = [
  "anyhow",
  "clap",
- "collab_ui",
  "ctrlc",
  "dialoguer",
  "editor",

crates/collab_ui/Cargo.toml 🔗

@@ -14,7 +14,6 @@ doctest = false
 
 [features]
 default = []
-stories = ["dep:story"]
 test-support = [
     "call/test-support",
     "client/test-support",
@@ -52,7 +51,6 @@ serde.workspace = true
 serde_json.workspace = true
 settings.workspace = true
 smallvec.workspace = true
-story = { workspace = true, optional = true }
 telemetry.workspace = true
 theme.workspace = true
 time.workspace = true

crates/collab_ui/src/notifications.rs 🔗

@@ -1,17 +1,10 @@
-mod collab_notification;
 pub mod incoming_call_notification;
 pub mod project_shared_notification;
 
-#[cfg(feature = "stories")]
-mod stories;
-
 use gpui::App;
 use std::sync::Arc;
 use workspace::AppState;
 
-#[cfg(feature = "stories")]
-pub use stories::*;
-
 pub fn init(app_state: &Arc<AppState>, cx: &mut App) {
     incoming_call_notification::init(app_state, cx);
     project_shared_notification::init(app_state, cx);

crates/collab_ui/src/notifications/collab_notification.rs 🔗

@@ -1,52 +0,0 @@
-use gpui::{AnyElement, SharedUri, img, prelude::*};
-use smallvec::SmallVec;
-use ui::prelude::*;
-
-#[derive(IntoElement)]
-pub struct CollabNotification {
-    avatar_uri: SharedUri,
-    accept_button: Button,
-    dismiss_button: Button,
-    children: SmallVec<[AnyElement; 2]>,
-}
-
-impl CollabNotification {
-    pub fn new(
-        avatar_uri: impl Into<SharedUri>,
-        accept_button: Button,
-        dismiss_button: Button,
-    ) -> Self {
-        Self {
-            avatar_uri: avatar_uri.into(),
-            accept_button,
-            dismiss_button,
-            children: SmallVec::new(),
-        }
-    }
-}
-
-impl ParentElement for CollabNotification {
-    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
-        self.children.extend(elements)
-    }
-}
-
-impl RenderOnce for CollabNotification {
-    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
-        h_flex()
-            .text_ui(cx)
-            .justify_between()
-            .size_full()
-            .overflow_hidden()
-            .elevation_3(cx)
-            .p_2()
-            .gap_2()
-            .child(img(self.avatar_uri).w_12().h_12().rounded_full())
-            .child(v_flex().overflow_hidden().children(self.children))
-            .child(
-                v_flex()
-                    .child(self.accept_button)
-                    .child(self.dismiss_button),
-            )
-    }
-}

crates/collab_ui/src/notifications/incoming_call_notification.rs 🔗

@@ -1,11 +1,10 @@
 use crate::notification_window_options;
-use crate::notifications::collab_notification::CollabNotification;
 use call::{ActiveCall, IncomingCall};
 use futures::StreamExt;
 use gpui::{App, WindowHandle, prelude::*};
 
 use std::sync::{Arc, Weak};
-use ui::{Button, Label, prelude::*};
+use ui::{CollabNotification, prelude::*};
 use util::ResultExt;
 use workspace::AppState;
 
@@ -118,10 +117,10 @@ impl Render for IncomingCallNotification {
                     move |_, _, cx| state.respond(false, cx)
                 }),
             )
-            .child(v_flex().overflow_hidden().child(Label::new(format!(
+            .child(Label::new(format!(
                 "{} is sharing a project in Zed",
                 self.state.call.calling_user.github_login
-            )))),
+            ))),
         )
     }
 }

crates/collab_ui/src/notifications/project_shared_notification.rs 🔗

@@ -1,12 +1,11 @@
 use crate::notification_window_options;
-use crate::notifications::collab_notification::CollabNotification;
 use call::{ActiveCall, room};
 use client::User;
 use collections::HashMap;
 use gpui::{App, Size};
 use std::sync::{Arc, Weak};
 
-use ui::{Button, Label, prelude::*};
+use ui::{CollabNotification, prelude::*};
 use util::ResultExt;
 use workspace::AppState;
 
@@ -122,6 +121,14 @@ impl ProjectSharedNotification {
 impl Render for ProjectSharedNotification {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let ui_font = theme::setup_ui_font(window, cx);
+        let no_worktree_root_names = self.worktree_root_names.is_empty();
+
+        let punctuation = if no_worktree_root_names { "" } else { ":" };
+        let main_label = format!(
+            "{} is sharing a project with you{}",
+            self.owner.github_login.clone(),
+            punctuation
+        );
 
         div().size_full().font(ui_font).child(
             CollabNotification::new(
@@ -135,19 +142,9 @@ impl Render for ProjectSharedNotification {
                     },
                 )),
             )
-            .child(Label::new(self.owner.github_login.clone()))
-            .child(Label::new(format!(
-                "is sharing a project in Zed{}",
-                if self.worktree_root_names.is_empty() {
-                    ""
-                } else {
-                    ":"
-                }
-            )))
-            .children(if self.worktree_root_names.is_empty() {
-                None
-            } else {
-                Some(Label::new(self.worktree_root_names.join(", ")))
+            .child(Label::new(main_label))
+            .when(!no_worktree_root_names, |this| {
+                this.child(Label::new(self.worktree_root_names.join(", ")).color(Color::Muted))
             }),
         )
     }

crates/collab_ui/src/notifications/stories/collab_notification.rs 🔗

@@ -1,48 +0,0 @@
-use gpui::prelude::*;
-use story::{Story, StoryItem, StorySection};
-use ui::prelude::*;
-
-use crate::notifications::collab_notification::CollabNotification;
-
-pub struct CollabNotificationStory;
-
-impl Render for CollabNotificationStory {
-    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let window_container = |width, height| div().w(px(width)).h(px(height));
-
-        Story::container(cx)
-            .child(Story::title_for::<CollabNotification>(cx))
-            .child(
-                StorySection::new().child(StoryItem::new(
-                    "Incoming Call Notification",
-                    window_container(400., 72.).child(
-                        CollabNotification::new(
-                            "https://avatars.githubusercontent.com/u/1486634?v=4",
-                            Button::new("accept", "Accept"),
-                            Button::new("decline", "Decline"),
-                        )
-                        .child(
-                            v_flex()
-                                .overflow_hidden()
-                                .child(Label::new("maxdeviant is sharing a project in Zed")),
-                        ),
-                    ),
-                )),
-            )
-            .child(
-                StorySection::new().child(StoryItem::new(
-                    "Project Shared Notification",
-                    window_container(400., 72.).child(
-                        CollabNotification::new(
-                            "https://avatars.githubusercontent.com/u/1714999?v=4",
-                            Button::new("open", "Open"),
-                            Button::new("dismiss", "Dismiss"),
-                        )
-                        .child(Label::new("iamnbutler"))
-                        .child(Label::new("is sharing a project in Zed:"))
-                        .child(Label::new("zed")),
-                    ),
-                )),
-            )
-    }
-}

crates/storybook/Cargo.toml 🔗

@@ -15,7 +15,6 @@ path = "src/storybook.rs"
 [dependencies]
 anyhow.workspace = true
 clap = { workspace = true, features = ["derive", "string"] }
-collab_ui = { workspace = true, features = ["stories"] }
 ctrlc = "3.4"
 dialoguer = { version = "0.11.0", features = ["fuzzy-select"] }
 editor.workspace = true

crates/storybook/src/story_selector.rs 🔗

@@ -13,7 +13,6 @@ use ui::prelude::*;
 pub enum ComponentStory {
     ApplicationMenu,
     AutoHeightEditor,
-    CollabNotification,
     ContextMenu,
     Cursor,
     Focus,
@@ -33,9 +32,6 @@ impl ComponentStory {
                 .new(|cx| title_bar::ApplicationMenuStory::new(window, cx))
                 .into(),
             Self::AutoHeightEditor => AutoHeightEditorStory::new(window, cx).into(),
-            Self::CollabNotification => cx
-                .new(|_| collab_ui::notifications::CollabNotificationStory)
-                .into(),
             Self::ContextMenu => cx.new(|_| ui::ContextMenuStory).into(),
             Self::Cursor => cx.new(|_| crate::stories::CursorStory).into(),
             Self::Focus => FocusStory::model(window, cx).into(),

crates/ui/src/components.rs 🔗

@@ -4,6 +4,7 @@ mod banner;
 mod button;
 mod callout;
 mod chip;
+mod collab;
 mod content_group;
 mod context_menu;
 mod data_table;
@@ -50,6 +51,7 @@ pub use banner::*;
 pub use button::*;
 pub use callout::*;
 pub use chip::*;
+pub use collab::*;
 pub use content_group::*;
 pub use context_menu::*;
 pub use data_table::*;

crates/ui/src/components/collab/collab_notification.rs 🔗

@@ -0,0 +1,134 @@
+use gpui::{AnyElement, SharedUri, prelude::*};
+use smallvec::SmallVec;
+
+use crate::{Avatar, prelude::*};
+
+#[derive(IntoElement, RegisterComponent)]
+pub struct CollabNotification {
+    avatar_uri: SharedUri,
+    accept_button: Button,
+    dismiss_button: Button,
+    children: SmallVec<[AnyElement; 2]>,
+}
+
+impl CollabNotification {
+    pub fn new(
+        avatar_uri: impl Into<SharedUri>,
+        accept_button: Button,
+        dismiss_button: Button,
+    ) -> Self {
+        Self {
+            avatar_uri: avatar_uri.into(),
+            accept_button,
+            dismiss_button,
+            children: SmallVec::new(),
+        }
+    }
+}
+
+impl ParentElement for CollabNotification {
+    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
+        self.children.extend(elements)
+    }
+}
+
+impl RenderOnce for CollabNotification {
+    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
+        h_flex()
+            .p_2()
+            .size_full()
+            .text_ui(cx)
+            .justify_between()
+            .overflow_hidden()
+            .elevation_3(cx)
+            .gap_1()
+            .child(
+                h_flex()
+                    .min_w_0()
+                    .gap_4()
+                    .child(Avatar::new(self.avatar_uri).size(px(40.)))
+                    .child(v_flex().truncate().children(self.children)),
+            )
+            .child(
+                v_flex()
+                    .items_center()
+                    .child(self.accept_button)
+                    .child(self.dismiss_button),
+            )
+    }
+}
+
+impl Component for CollabNotification {
+    fn scope() -> ComponentScope {
+        ComponentScope::Collaboration
+    }
+
+    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
+        let avatar = "https://avatars.githubusercontent.com/u/67129314?v=4";
+        let container = || div().h(px(72.)).w(px(400.)); // Size of the actual notification window
+
+        let examples = vec![
+            single_example(
+                "Incoming Call",
+                container()
+                    .child(
+                        CollabNotification::new(
+                            avatar,
+                            Button::new("accept", "Accept"),
+                            Button::new("decline", "Decline"),
+                        )
+                        .child(Label::new("the user is inviting you to a call")),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "Screen Share Request",
+                container()
+                    .child(
+                        CollabNotification::new(
+                            avatar,
+                            Button::new("accept", "View"),
+                            Button::new("decline", "Ignore"),
+                        )
+                        .child(Label::new("the user is sharing their screen")),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "Project Shared",
+                container()
+                    .child(
+                        CollabNotification::new(
+                            avatar,
+                            Button::new("accept", "Open"),
+                            Button::new("decline", "Dismiss"),
+                        )
+                        .child(Label::new("the user is sharing a project"))
+                        .child(Label::new("zed").color(Color::Muted)),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "Overflowing Content",
+                container()
+                    .child(
+                        CollabNotification::new(
+                            avatar,
+                            Button::new("accept", "Accept"),
+                            Button::new("decline", "Decline"),
+                        )
+                        .child(Label::new(
+                            "a_very_long_username_that_might_overflow is sharing a project in Zed:",
+                        ))
+                        .child(
+                            Label::new("zed-cloud, zed, edit-prediction-bench, zed.dev")
+                                .color(Color::Muted),
+                        ),
+                    )
+                    .into_any_element(),
+            ),
+        ];
+
+        Some(example_group(examples).vertical().into_any_element())
+    }
+}