ui: Add a `CopyButton` component (#45821)

Danilo Leal created

There were several places adding a copy icon button, so thought of
encapsulating the logic to copy a given string into the clipboard (and
other small details like swapping the icon and tooltip if copied) into a
component, making it easier to introduce this sort of functionality in
the future, with fewer lines of code.

All it takes (for the simplest case) is:

```rs
CopyButton::new(your_message)
```

<img width="600" height="714" alt="Screenshot 2025-12-29 at 10  50@2x"
src="https://github.com/user-attachments/assets/e6949863-a056-4855-82d8-e4ffb5d62c90"
/>

Release Notes:

- N/A

Change summary

crates/acp_tools/src/acp_tools.rs                |  53 +----
crates/agent_ui/src/acp/thread_view.rs           |  23 +-
crates/collab_ui/src/collab_panel.rs             |  17 -
crates/editor/src/hover_popover.rs               |  26 --
crates/git_ui/src/blame_ui.rs                    |  16 -
crates/git_ui/src/commit_tooltip.rs              |  20 -
crates/markdown/src/markdown.rs                  |  25 --
crates/markdown_preview/src/markdown_renderer.rs |  26 --
crates/repl/src/outputs.rs                       | 134 +++++---------
crates/ui/src/components/button.rs               |   2 
crates/ui/src/components/button/copy_button.rs   | 162 ++++++++++++++++++
crates/workspace/src/notifications.rs            |  20 -
12 files changed, 273 insertions(+), 251 deletions(-)

Detailed changes

crates/acp_tools/src/acp_tools.rs 🔗

@@ -4,22 +4,20 @@ use std::{
     fmt::Display,
     rc::{Rc, Weak},
     sync::Arc,
-    time::Duration,
 };
 
 use agent_client_protocol as acp;
 use collections::HashMap;
 use gpui::{
-    App, ClipboardItem, Empty, Entity, EventEmitter, FocusHandle, Focusable, Global, ListAlignment,
-    ListState, StyleRefinement, Subscription, Task, TextStyleRefinement, Window, actions, list,
-    prelude::*,
+    App, Empty, Entity, EventEmitter, FocusHandle, Focusable, Global, ListAlignment, ListState,
+    StyleRefinement, Subscription, Task, TextStyleRefinement, Window, actions, list, prelude::*,
 };
 use language::LanguageRegistry;
 use markdown::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownStyle};
 use project::Project;
 use settings::Settings;
 use theme::ThemeSettings;
-use ui::{Tooltip, WithScrollbar, prelude::*};
+use ui::{CopyButton, Tooltip, WithScrollbar, prelude::*};
 use util::ResultExt as _;
 use workspace::{
     Item, ItemHandle, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace,
@@ -544,15 +542,11 @@ impl Render for AcpTools {
 
 pub struct AcpToolsToolbarItemView {
     acp_tools: Option<Entity<AcpTools>>,
-    just_copied: bool,
 }
 
 impl AcpToolsToolbarItemView {
     pub fn new() -> Self {
-        Self {
-            acp_tools: None,
-            just_copied: false,
-        }
+        Self { acp_tools: None }
     }
 }
 
@@ -572,37 +566,14 @@ impl Render for AcpToolsToolbarItemView {
         h_flex()
             .gap_2()
             .child({
-                let acp_tools = acp_tools.clone();
-                IconButton::new(
-                    "copy_all_messages",
-                    if self.just_copied {
-                        IconName::Check
-                    } else {
-                        IconName::Copy
-                    },
-                )
-                .icon_size(IconSize::Small)
-                .tooltip(Tooltip::text(if self.just_copied {
-                    "Copied!"
-                } else {
-                    "Copy All Messages"
-                }))
-                .disabled(!has_messages)
-                .on_click(cx.listener(move |this, _, _window, cx| {
-                    if let Some(content) = acp_tools.read(cx).serialize_observed_messages() {
-                        cx.write_to_clipboard(ClipboardItem::new_string(content));
-
-                        this.just_copied = true;
-                        cx.spawn(async move |this, cx| {
-                            cx.background_executor().timer(Duration::from_secs(2)).await;
-                            this.update(cx, |this, cx| {
-                                this.just_copied = false;
-                                cx.notify();
-                            })
-                        })
-                        .detach();
-                    }
-                }))
+                let message = acp_tools
+                    .read(cx)
+                    .serialize_observed_messages()
+                    .unwrap_or_default();
+
+                CopyButton::new(message)
+                    .tooltip_label("Copy All Messages")
+                    .disabled(!has_messages)
             })
             .child(
                 IconButton::new("clear_messages", IconName::Trash)

crates/agent_ui/src/acp/thread_view.rs 🔗

@@ -24,11 +24,11 @@ use file_icons::FileIcons;
 use fs::Fs;
 use futures::FutureExt as _;
 use gpui::{
-    Action, Animation, AnimationExt, AnyView, App, BorderStyle, ClickEvent, ClipboardItem,
-    CursorStyle, EdgesRefinement, ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, Length,
-    ListOffset, ListState, PlatformDisplay, SharedString, StyleRefinement, Subscription, Task,
-    TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, Window, WindowHandle, div,
-    ease_in_out, linear_color_stop, linear_gradient, list, point, pulsating_between,
+    Action, Animation, AnimationExt, AnyView, App, BorderStyle, ClickEvent, CursorStyle,
+    EdgesRefinement, ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, Length, ListOffset,
+    ListState, PlatformDisplay, SharedString, StyleRefinement, Subscription, Task, TextStyle,
+    TextStyleRefinement, UnderlineStyle, WeakEntity, Window, WindowHandle, div, ease_in_out,
+    linear_color_stop, linear_gradient, list, point, pulsating_between,
 };
 use language::Buffer;
 
@@ -47,9 +47,9 @@ use terminal_view::terminal_panel::TerminalPanel;
 use text::Anchor;
 use theme::{AgentFontSize, ThemeSettings};
 use ui::{
-    Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, Disclosure, Divider, DividerColor,
-    ElevationIndex, KeyBinding, PopoverMenuHandle, SpinnerLabel, TintColor, Tooltip, WithScrollbar,
-    prelude::*, right_click_menu,
+    Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, CopyButton, Disclosure, Divider,
+    DividerColor, ElevationIndex, KeyBinding, PopoverMenuHandle, SpinnerLabel, TintColor, Tooltip,
+    WithScrollbar, prelude::*, right_click_menu,
 };
 use util::{ResultExt, size::format_file_size, time::duration_alt_display};
 use workspace::{CollaboratorId, NewTerminal, Workspace};
@@ -5921,12 +5921,7 @@ impl AcpThreadView {
     fn create_copy_button(&self, message: impl Into<String>) -> impl IntoElement {
         let message = message.into();
 
-        IconButton::new("copy", IconName::Copy)
-            .icon_size(IconSize::Small)
-            .tooltip(Tooltip::text("Copy Error Message"))
-            .on_click(move |_, _, cx| {
-                cx.write_to_clipboard(ClipboardItem::new_string(message.clone()))
-            })
+        CopyButton::new(message).tooltip_label("Copy Error Message")
     }
 
     fn dismiss_error_button(&self, cx: &mut Context<Self>) -> impl IntoElement {

crates/collab_ui/src/collab_panel.rs 🔗

@@ -31,9 +31,9 @@ use smallvec::SmallVec;
 use std::{mem, sync::Arc};
 use theme::{ActiveTheme, ThemeSettings};
 use ui::{
-    Avatar, AvatarAvailabilityIndicator, Button, Color, ContextMenu, Facepile, HighlightedLabel,
-    Icon, IconButton, IconName, IconSize, Indicator, Label, ListHeader, ListItem, Tab, Tooltip,
-    prelude::*, tooltip_container,
+    Avatar, AvatarAvailabilityIndicator, Button, Color, ContextMenu, CopyButton, Facepile,
+    HighlightedLabel, Icon, IconButton, IconName, IconSize, Indicator, Label, ListHeader, ListItem,
+    Tab, Tooltip, prelude::*, tooltip_container,
 };
 use util::{ResultExt, TryFutureExt, maybe};
 use workspace::{
@@ -2527,16 +2527,9 @@ impl CollabPanel {
 
         let button = match section {
             Section::ActiveCall => channel_link.map(|channel_link| {
-                let channel_link_copy = channel_link;
-                IconButton::new("channel-link", IconName::Copy)
-                    .icon_size(IconSize::Small)
-                    .size(ButtonSize::None)
+                CopyButton::new(channel_link)
                     .visible_on_hover("section-header")
-                    .on_click(move |_, _, cx| {
-                        let item = ClipboardItem::new_string(channel_link_copy.clone());
-                        cx.write_to_clipboard(item)
-                    })
-                    .tooltip(Tooltip::text("Copy channel link"))
+                    .tooltip_label("Copy Channel Link")
                     .into_any_element()
             }),
             Section::Contacts => Some(

crates/editor/src/hover_popover.rs 🔗

@@ -8,8 +8,8 @@ use crate::{
 };
 use anyhow::Context as _;
 use gpui::{
-    AnyElement, AsyncWindowContext, ClipboardItem, Context, Entity, Focusable as _, FontWeight,
-    Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, ScrollHandle, Size,
+    AnyElement, AsyncWindowContext, Context, Entity, Focusable as _, FontWeight, Hsla,
+    InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, ScrollHandle, Size,
     StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task, TextStyleRefinement,
     Window, div, px,
 };
@@ -24,7 +24,7 @@ use std::{borrow::Cow, cell::RefCell};
 use std::{ops::Range, sync::Arc, time::Duration};
 use std::{path::PathBuf, rc::Rc};
 use theme::ThemeSettings;
-use ui::{Scrollbars, Tooltip, WithScrollbar, prelude::*, theme_is_transparent};
+use ui::{CopyButton, Scrollbars, WithScrollbar, prelude::*, theme_is_transparent};
 use url::Url;
 use util::TryFutureExt;
 use workspace::{OpenOptions, OpenVisible, Workspace};
@@ -1026,25 +1026,7 @@ impl DiagnosticPopover {
                             )
                             .child({
                                 let message = self.local_diagnostic.diagnostic.message.clone();
-                                let copied = cx
-                                    .read_from_clipboard()
-                                    .map(|item| item.text().as_ref() == Some(&message))
-                                    .unwrap_or(false);
-                                let (icon, color) = if copied {
-                                    (IconName::Check, Color::Success)
-                                } else {
-                                    (IconName::Copy, Color::Muted)
-                                };
-
-                                IconButton::new("copy-diagnostic", icon)
-                                    .icon_color(color)
-                                    .icon_size(IconSize::Small)
-                                    .tooltip(Tooltip::text("Copy Diagnostic"))
-                                    .on_click(move |_, _, cx| {
-                                        cx.write_to_clipboard(ClipboardItem::new_string(
-                                            message.clone(),
-                                        ));
-                                    })
+                                CopyButton::new(message).tooltip_label("Copy Diagnostic")
                             }),
                     )
                     .custom_scrollbars(

crates/git_ui/src/blame_ui.rs 🔗

@@ -13,7 +13,7 @@ use project::{git_store::Repository, project_settings::ProjectSettings};
 use settings::Settings as _;
 use theme::ThemeSettings;
 use time::OffsetDateTime;
-use ui::{ContextMenu, Divider, prelude::*, tooltip_container};
+use ui::{ContextMenu, CopyButton, Divider, prelude::*, tooltip_container};
 use workspace::Workspace;
 
 const GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED: usize = 20;
@@ -335,18 +335,10 @@ impl BlameRenderer for GitBlameRenderer {
                                                     cx.stop_propagation();
                                                 }),
                                             )
+                                            .child(Divider::vertical())
                                             .child(
-                                                IconButton::new("copy-sha-button", IconName::Copy)
-                                                    .icon_size(IconSize::Small)
-                                                    .icon_color(Color::Muted)
-                                                    .on_click(move |_, _, cx| {
-                                                        cx.stop_propagation();
-                                                        cx.write_to_clipboard(
-                                                            ClipboardItem::new_string(
-                                                                sha.to_string(),
-                                                            ),
-                                                        )
-                                                    }),
+                                                CopyButton::new(sha.to_string())
+                                                    .tooltip_label("Copy SHA"),
                                             ),
                                     ),
                             ),

crates/git_ui/src/commit_tooltip.rs 🔗

@@ -5,7 +5,7 @@ use git::blame::BlameEntry;
 use git::repository::CommitSummary;
 use git::{GitRemote, commit::ParsedCommitMessage};
 use gpui::{
-    App, Asset, ClipboardItem, Element, Entity, MouseButton, ParentElement, Render, ScrollHandle,
+    App, Asset, Element, Entity, MouseButton, ParentElement, Render, ScrollHandle,
     StatefulInteractiveElement, WeakEntity, prelude::*,
 };
 use markdown::{Markdown, MarkdownElement};
@@ -14,7 +14,7 @@ use settings::Settings;
 use std::hash::Hash;
 use theme::ThemeSettings;
 use time::{OffsetDateTime, UtcOffset};
-use ui::{Avatar, Divider, IconButtonShape, prelude::*, tooltip_container};
+use ui::{Avatar, CopyButton, Divider, prelude::*, tooltip_container};
 use workspace::Workspace;
 
 #[derive(Clone, Debug)]
@@ -315,8 +315,8 @@ impl Render for CommitTooltip {
                                                     cx.open_url(pr.url.as_str())
                                                 }),
                                             )
+                                            .child(Divider::vertical())
                                         })
-                                        .child(Divider::vertical())
                                         .child(
                                             Button::new(
                                                 "commit-sha-button",
@@ -342,18 +342,8 @@ impl Render for CommitTooltip {
                                                 },
                                             ),
                                         )
-                                        .child(
-                                            IconButton::new("copy-sha-button", IconName::Copy)
-                                                .shape(IconButtonShape::Square)
-                                                .icon_size(IconSize::Small)
-                                                .icon_color(Color::Muted)
-                                                .on_click(move |_, _, cx| {
-                                                    cx.stop_propagation();
-                                                    cx.write_to_clipboard(
-                                                        ClipboardItem::new_string(full_sha.clone()),
-                                                    )
-                                                }),
-                                        ),
+                                        .child(Divider::vertical())
+                                        .child(CopyButton::new(full_sha).tooltip_label("Copy SHA")),
                                 ),
                         ),
                 )

crates/markdown/src/markdown.rs 🔗

@@ -8,6 +8,7 @@ use language::LanguageName;
 use log::Level;
 pub use path_range::{LineCol, PathWithRange};
 use ui::Checkbox;
+use ui::CopyButton;
 
 use std::borrow::Cow;
 use std::iter;
@@ -32,7 +33,7 @@ use parser::{MarkdownEvent, MarkdownTag, MarkdownTagEnd, parse_links_only, parse
 use pulldown_cmark::Alignment;
 use sum_tree::TreeMap;
 use theme::SyntaxTheme;
-use ui::{ScrollAxes, Scrollbars, Tooltip, WithScrollbar, prelude::*};
+use ui::{ScrollAxes, Scrollbars, WithScrollbar, prelude::*};
 use util::ResultExt;
 
 use crate::parser::CodeBlockKind;
@@ -1202,7 +1203,6 @@ impl Element for MarkdownElement {
                                     range.end,
                                     code,
                                     self.markdown.clone(),
-                                    cx,
                                 );
                                 el.child(
                                     h_flex()
@@ -1233,7 +1233,6 @@ impl Element for MarkdownElement {
                                     range.end,
                                     code,
                                     self.markdown.clone(),
-                                    cx,
                                 );
                                 el.child(
                                     h_flex()
@@ -1449,26 +1448,12 @@ fn render_copy_code_block_button(
     id: usize,
     code: String,
     markdown: Entity<Markdown>,
-    cx: &App,
 ) -> impl IntoElement {
     let id = ElementId::named_usize("copy-markdown-code", id);
-    let was_copied = markdown.read(cx).copied_code_blocks.contains(&id);
-    IconButton::new(
-        id.clone(),
-        if was_copied {
-            IconName::Check
-        } else {
-            IconName::Copy
-        },
-    )
-    .icon_color(Color::Muted)
-    .icon_size(IconSize::Small)
-    .style(ButtonStyle::Filled)
-    .shape(ui::IconButtonShape::Square)
-    .tooltip(Tooltip::text("Copy"))
-    .on_click({
+
+    CopyButton::new(code.clone()).custom_on_click({
         let markdown = markdown;
-        move |_event, _window, cx| {
+        move |_window, cx| {
             let id = id.clone();
             markdown.update(cx, |this, cx| {
                 this.copied_code_blocks.insert(id.clone());

crates/markdown_preview/src/markdown_renderer.rs 🔗

@@ -6,10 +6,10 @@ use crate::markdown_elements::{
 };
 use fs::normalize_path;
 use gpui::{
-    AbsoluteLength, AnyElement, App, AppContext as _, ClipboardItem, Context, Div, Element,
-    ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement, Keystroke,
-    Modifiers, ParentElement, Render, Resource, SharedString, Styled, StyledText, TextStyle,
-    WeakEntity, Window, div, img, px, rems,
+    AbsoluteLength, AnyElement, App, AppContext as _, Context, Div, Element, ElementId, Entity,
+    HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement, Keystroke, Modifiers,
+    ParentElement, Render, Resource, SharedString, Styled, StyledText, TextStyle, WeakEntity,
+    Window, div, img, px, rems,
 };
 use settings::Settings;
 use std::{
@@ -18,12 +18,7 @@ use std::{
     vec,
 };
 use theme::{ActiveTheme, SyntaxTheme, ThemeSettings};
-use ui::{
-    ButtonCommon, Clickable, Color, FluentBuilder, IconButton, IconName, IconSize,
-    InteractiveElement, Label, LabelCommon, LabelSize, LinkPreview, Pixels, Rems,
-    StatefulInteractiveElement, StyledExt, StyledImage, ToggleState, Tooltip, VisibleOnHover,
-    h_flex, tooltip_container, v_flex,
-};
+use ui::{CopyButton, LinkPreview, ToggleState, prelude::*, tooltip_container};
 use workspace::{OpenOptions, OpenVisible, Workspace};
 
 pub struct CheckboxClickedEvent {
@@ -626,15 +621,8 @@ fn render_markdown_code_block(
         StyledText::new(parsed.contents.clone())
     };
 
-    let copy_block_button = IconButton::new("copy-code", IconName::Copy)
-        .icon_size(IconSize::Small)
-        .on_click({
-            let contents = parsed.contents.clone();
-            move |_, _window, cx| {
-                cx.write_to_clipboard(ClipboardItem::new_string(contents.to_string()));
-            }
-        })
-        .tooltip(Tooltip::text("Copy code block"))
+    let copy_block_button = CopyButton::new(parsed.contents.clone())
+        .tooltip_label("Copy Codeblock")
         .visible_on_hover("markdown-block");
 
     let font = gpui::Font {

crates/repl/src/outputs.rs 🔗

@@ -37,10 +37,7 @@ use editor::{Editor, MultiBuffer};
 use gpui::{AnyElement, ClipboardItem, Entity, Render, WeakEntity};
 use language::Buffer;
 use runtimelib::{ExecutionState, JupyterMessageContent, MimeBundle, MimeType};
-use ui::{
-    ButtonStyle, CommonAnimationExt, Context, IconButton, IconName, IntoElement, Styled, Tooltip,
-    Window, div, h_flex, prelude::*, v_flex,
-};
+use ui::{CommonAnimationExt, CopyButton, IconButton, Tooltip, prelude::*};
 
 mod image;
 use image::ImageView;
@@ -236,89 +233,62 @@ impl Output {
                 Self::Image { content, .. } => {
                     Self::render_output_controls(content.clone(), workspace, window, cx)
                 }
-                Self::ErrorOutput(err) => {
-                    // Add buttons for the traceback section
-                    Some(
-                        h_flex()
-                            .pl_1()
-                            .child(
-                                IconButton::new(
-                                    ElementId::Name("copy-full-error-traceback".into()),
-                                    IconName::Copy,
-                                )
-                                .style(ButtonStyle::Transparent)
-                                .tooltip(Tooltip::text("Copy Full Error"))
-                                .on_click({
-                                    let ename = err.ename.clone();
-                                    let evalue = err.evalue.clone();
-                                    let traceback = err.traceback.clone();
-                                    move |_, _window, cx| {
+                Self::ErrorOutput(err) => Some(
+                    h_flex()
+                        .pl_1()
+                        .child({
+                            let ename = err.ename.clone();
+                            let evalue = err.evalue.clone();
+                            let traceback = err.traceback.clone();
+                            let traceback_text = traceback.read(cx).full_text();
+                            let full_error = format!("{}: {}\n{}", ename, evalue, traceback_text);
+
+                            CopyButton::new(full_error).tooltip_label("Copy Full Error")
+                        })
+                        .child(
+                            IconButton::new(
+                                ElementId::Name("open-full-error-in-buffer-traceback".into()),
+                                IconName::FileTextOutlined,
+                            )
+                            .style(ButtonStyle::Transparent)
+                            .tooltip(Tooltip::text("Open Full Error in Buffer"))
+                            .on_click({
+                                let ename = err.ename.clone();
+                                let evalue = err.evalue.clone();
+                                let traceback = err.traceback.clone();
+                                move |_, window, cx| {
+                                    if let Some(workspace) = workspace.upgrade() {
                                         let traceback_text = traceback.read(cx).full_text();
                                         let full_error =
                                             format!("{}: {}\n{}", ename, evalue, traceback_text);
-                                        let clipboard_content =
-                                            ClipboardItem::new_string(full_error);
-                                        cx.write_to_clipboard(clipboard_content);
-                                    }
-                                }),
-                            )
-                            .child(
-                                IconButton::new(
-                                    ElementId::Name("open-full-error-in-buffer-traceback".into()),
-                                    IconName::FileTextOutlined,
-                                )
-                                .style(ButtonStyle::Transparent)
-                                .tooltip(Tooltip::text("Open Full Error in Buffer"))
-                                .on_click({
-                                    let ename = err.ename.clone();
-                                    let evalue = err.evalue.clone();
-                                    let traceback = err.traceback.clone();
-                                    move |_, window, cx| {
-                                        if let Some(workspace) = workspace.upgrade() {
-                                            let traceback_text = traceback.read(cx).full_text();
-                                            let full_error = format!(
-                                                "{}: {}\n{}",
-                                                ename, evalue, traceback_text
-                                            );
-                                            let buffer = cx.new(|cx| {
-                                                let mut buffer = Buffer::local(full_error, cx)
-                                                    .with_language(
-                                                        language::PLAIN_TEXT.clone(),
-                                                        cx,
-                                                    );
-                                                buffer.set_capability(
-                                                    language::Capability::ReadOnly,
-                                                    cx,
-                                                );
-                                                buffer
-                                            });
-                                            let editor = Box::new(cx.new(|cx| {
-                                                let multibuffer = cx.new(|cx| {
-                                                    let mut multi_buffer =
-                                                        MultiBuffer::singleton(buffer.clone(), cx);
-                                                    multi_buffer
-                                                        .set_title("Full Error".to_string(), cx);
-                                                    multi_buffer
-                                                });
-                                                Editor::for_multibuffer(
-                                                    multibuffer,
-                                                    None,
-                                                    window,
-                                                    cx,
-                                                )
-                                            }));
-                                            workspace.update(cx, |workspace, cx| {
-                                                workspace.add_item_to_active_pane(
-                                                    editor, None, true, window, cx,
-                                                );
+                                        let buffer = cx.new(|cx| {
+                                            let mut buffer = Buffer::local(full_error, cx)
+                                                .with_language(language::PLAIN_TEXT.clone(), cx);
+                                            buffer
+                                                .set_capability(language::Capability::ReadOnly, cx);
+                                            buffer
+                                        });
+                                        let editor = Box::new(cx.new(|cx| {
+                                            let multibuffer = cx.new(|cx| {
+                                                let mut multi_buffer =
+                                                    MultiBuffer::singleton(buffer.clone(), cx);
+                                                multi_buffer
+                                                    .set_title("Full Error".to_string(), cx);
+                                                multi_buffer
                                             });
-                                        }
+                                            Editor::for_multibuffer(multibuffer, None, window, cx)
+                                        }));
+                                        workspace.update(cx, |workspace, cx| {
+                                            workspace.add_item_to_active_pane(
+                                                editor, None, true, window, cx,
+                                            );
+                                        });
                                     }
-                                }),
-                            )
-                            .into_any_element(),
-                    )
-                }
+                                }
+                            }),
+                        )
+                        .into_any_element(),
+                ),
                 Self::Message(_) => None,
                 Self::Table { content, .. } => {
                     Self::render_output_controls(content.clone(), workspace, window, cx)

crates/ui/src/components/button.rs 🔗

@@ -2,6 +2,7 @@ mod button;
 mod button_icon;
 mod button_like;
 mod button_link;
+mod copy_button;
 mod icon_button;
 mod split_button;
 mod toggle_button;
@@ -9,6 +10,7 @@ mod toggle_button;
 pub use button::*;
 pub use button_like::*;
 pub use button_link::*;
+pub use copy_button::*;
 pub use icon_button::*;
 pub use split_button::*;
 pub use toggle_button::*;

crates/ui/src/components/button/copy_button.rs 🔗

@@ -0,0 +1,162 @@
+use gpui::{
+    AnyElement, App, ClipboardItem, IntoElement, ParentElement, RenderOnce, Styled, Window,
+};
+
+use crate::{Tooltip, prelude::*};
+
+#[derive(IntoElement, RegisterComponent)]
+pub struct CopyButton {
+    message: SharedString,
+    icon_size: IconSize,
+    disabled: bool,
+    tooltip_label: SharedString,
+    visible_on_hover: Option<SharedString>,
+    custom_on_click: Option<Box<dyn Fn(&mut Window, &mut App) + 'static>>,
+}
+
+impl CopyButton {
+    pub fn new(message: impl Into<SharedString>) -> Self {
+        Self {
+            message: message.into(),
+            icon_size: IconSize::Small,
+            disabled: false,
+            tooltip_label: "Copy".into(),
+            visible_on_hover: None,
+            custom_on_click: None,
+        }
+    }
+
+    pub fn icon_size(mut self, icon_size: IconSize) -> Self {
+        self.icon_size = icon_size;
+        self
+    }
+
+    pub fn disabled(mut self, disabled: bool) -> Self {
+        self.disabled = disabled;
+        self
+    }
+
+    pub fn tooltip_label(mut self, tooltip_label: impl Into<SharedString>) -> Self {
+        self.tooltip_label = tooltip_label.into();
+        self
+    }
+
+    pub fn visible_on_hover(mut self, visible_on_hover: impl Into<SharedString>) -> Self {
+        self.visible_on_hover = Some(visible_on_hover.into());
+        self
+    }
+
+    pub fn custom_on_click(
+        mut self,
+        custom_on_click: impl Fn(&mut Window, &mut App) + 'static,
+    ) -> Self {
+        self.custom_on_click = Some(Box::new(custom_on_click));
+        self
+    }
+}
+
+impl RenderOnce for CopyButton {
+    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+        let message = self.message;
+        let message_clone = message.clone();
+
+        let id = format!("copy-button-{}", message_clone);
+
+        let copied = cx
+            .read_from_clipboard()
+            .map(|item| item.text().as_ref() == Some(&message_clone.into()))
+            .unwrap_or(false);
+
+        let (icon, color, tooltip) = if copied {
+            (IconName::Check, Color::Success, "Copied!".into())
+        } else {
+            (IconName::Copy, Color::Muted, self.tooltip_label)
+        };
+
+        let custom_on_click = self.custom_on_click;
+        let visible_on_hover = self.visible_on_hover;
+
+        let button = IconButton::new(id, icon)
+            .icon_color(color)
+            .icon_size(self.icon_size)
+            .disabled(self.disabled)
+            .tooltip(Tooltip::text(tooltip))
+            .on_click(move |_, window, cx| {
+                if let Some(custom_on_click) = custom_on_click.as_ref() {
+                    (custom_on_click)(window, cx);
+                } else {
+                    cx.stop_propagation();
+                    cx.write_to_clipboard(ClipboardItem::new_string(message.clone().into()));
+                }
+            });
+
+        if let Some(visible_on_hover) = visible_on_hover {
+            button.visible_on_hover(visible_on_hover)
+        } else {
+            button
+        }
+    }
+}
+
+impl Component for CopyButton {
+    fn scope() -> ComponentScope {
+        ComponentScope::Input
+    }
+
+    fn description() -> Option<&'static str> {
+        Some("An icon button that encapsulates the logic to copy a string into the clipboard.")
+    }
+
+    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
+        let label_text = "Here's an example label";
+        let mut counter: usize = 0;
+
+        let mut copy_b = || {
+            counter += 1;
+            CopyButton::new(format!(
+                "Here's an example label (id for uniqueness: {} — ignore this)",
+                counter
+            ))
+        };
+
+        let example = vec![
+            single_example(
+                "Default",
+                h_flex()
+                    .gap_1()
+                    .child(Label::new(label_text).size(LabelSize::Small))
+                    .child(copy_b())
+                    .into_any_element(),
+            ),
+            single_example(
+                "Multiple Icon Sizes",
+                h_flex()
+                    .gap_1()
+                    .child(Label::new(label_text).size(LabelSize::Small))
+                    .child(copy_b().icon_size(IconSize::XSmall))
+                    .child(copy_b().icon_size(IconSize::Medium))
+                    .child(copy_b().icon_size(IconSize::XLarge))
+                    .into_any_element(),
+            ),
+            single_example(
+                "Custom Tooltip Label",
+                h_flex()
+                    .gap_1()
+                    .child(Label::new(label_text).size(LabelSize::Small))
+                    .child(copy_b().tooltip_label("Custom tooltip label"))
+                    .into_any_element(),
+            ),
+            single_example(
+                "Visible On Hover",
+                h_flex()
+                    .group("container")
+                    .gap_1()
+                    .child(Label::new(label_text).size(LabelSize::Small))
+                    .child(copy_b().visible_on_hover("container"))
+                    .into_any_element(),
+            ),
+        ];
+
+        Some(example_group(example).vertical().into_any_element())
+    }
+}

crates/workspace/src/notifications.rs 🔗

@@ -1,9 +1,9 @@
 use crate::{SuppressNotification, Toast, Workspace};
 use anyhow::Context as _;
 use gpui::{
-    AnyView, App, AppContext as _, AsyncWindowContext, ClickEvent, ClipboardItem, Context,
-    DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, PromptLevel, Render, ScrollHandle,
-    Task, TextStyleRefinement, UnderlineStyle, svg,
+    AnyView, App, AppContext as _, AsyncWindowContext, ClickEvent, Context, DismissEvent, Entity,
+    EventEmitter, FocusHandle, Focusable, PromptLevel, Render, ScrollHandle, Task,
+    TextStyleRefinement, UnderlineStyle, svg,
 };
 use markdown::{Markdown, MarkdownElement, MarkdownStyle};
 use parking_lot::Mutex;
@@ -13,7 +13,7 @@ use theme::ThemeSettings;
 use std::ops::Deref;
 use std::sync::{Arc, LazyLock};
 use std::{any::TypeId, time::Duration};
-use ui::{Tooltip, prelude::*};
+use ui::{CopyButton, Tooltip, prelude::*};
 use util::ResultExt;
 
 #[derive(Default)]
@@ -308,16 +308,8 @@ impl Render for LanguageServerPrompt {
                                 h_flex()
                                     .gap_1()
                                     .child(
-                                        IconButton::new("copy", IconName::Copy)
-                                            .on_click({
-                                                let message = request.message.clone();
-                                                move |_, _, cx| {
-                                                    cx.write_to_clipboard(
-                                                        ClipboardItem::new_string(message.clone()),
-                                                    )
-                                                }
-                                            })
-                                            .tooltip(Tooltip::text("Copy Description")),
+                                        CopyButton::new(request.message.clone())
+                                            .tooltip_label("Copy Description"),
                                     )
                                     .child(
                                         IconButton::new(close_id, close_icon)