diff --git a/app/src/terminal/shared_session/mod.rs b/app/src/terminal/shared_session/mod.rs index b99ffff87..1f02ad1c9 100644 --- a/app/src/terminal/shared_session/mod.rs +++ b/app/src/terminal/shared_session/mod.rs @@ -149,6 +149,19 @@ impl SharedSessionStatus { !matches!(self, Self::NotShared) } + /// Returns `true` when the session currently has a usable sharing + /// link the user can copy — i.e. when the share has fully + /// established (sharer side) or been joined (viewer side). Pending + /// and finished states return `false` so the right-click "Copy + /// session sharing link" menu item is not offered when the link + /// would either not exist yet or no longer be valid (GH#9736). + pub fn has_active_share_link(&self) -> bool { + matches!( + self, + Self::ActiveSharer | Self::ActiveViewer { .. } + ) + } + pub fn as_keymap_context(&self) -> &'static str { match self { Self::NotShared => "SharedSessionStatus_NotShared", diff --git a/app/src/terminal/shared_session/mod_test.rs b/app/src/terminal/shared_session/mod_test.rs index f00d7d997..67df6d24a 100644 --- a/app/src/terminal/shared_session/mod_test.rs +++ b/app/src/terminal/shared_session/mod_test.rs @@ -1,4 +1,6 @@ -use super::{decode_scrollback, SharedSessionScrollbackType}; +use super::{decode_scrollback, SharedSessionScrollbackType, SharedSessionStatus}; +use session_sharing_protocol::common::Role; +use session_sharing_protocol::sharer::SessionSourceType; use crate::ai::blocklist::agent_view::AgentViewState; use crate::assert_lines_approx_eq; @@ -347,3 +349,40 @@ fn test_loading_scrollback_in_alt_screen() { // Make sure we're in the alt screen. assert!(model.is_alt_screen_active()); } + +/// Regression test for GH#9736: the right-click "Copy session sharing link" +/// menu item must only be offered when a sharing URL actually exists. Pending +/// shares (which can include shares that failed to establish) and finished +/// viewer states leave no link to copy and previously surfaced the menu item +/// anyway, writing a stale or empty value to the clipboard. +#[test] +fn has_active_share_link_only_true_for_active_states() { + // States with a usable link. + assert!(SharedSessionStatus::ActiveSharer.has_active_share_link()); + assert!( + SharedSessionStatus::ActiveViewer { role: Role::Reader }.has_active_share_link(), + "ActiveViewer (reader) must offer copy link" + ); + assert!( + SharedSessionStatus::ActiveViewer { + role: Role::Executor, + } + .has_active_share_link(), + "ActiveViewer (executor) must offer copy link" + ); + + // States without a usable link — these are the ones that produced + // the GH#9736 regression. + assert!(!SharedSessionStatus::NotShared.has_active_share_link()); + assert!(!SharedSessionStatus::SharePending.has_active_share_link()); + assert!( + !SharedSessionStatus::SharePendingPreBootstrap { + source_type: SessionSourceType::default(), + } + .has_active_share_link(), + "SharePendingPreBootstrap must not offer copy link — \ + this is the state a failed-to-share session sits in" + ); + assert!(!SharedSessionStatus::ViewPending.has_active_share_link()); + assert!(!SharedSessionStatus::FinishedViewer.has_active_share_link()); +} diff --git a/app/src/terminal/view/pane_impl.rs b/app/src/terminal/view/pane_impl.rs index 48b245775..c4e8347f6 100644 --- a/app/src/terminal/view/pane_impl.rs +++ b/app/src/terminal/view/pane_impl.rs @@ -652,7 +652,14 @@ impl BackingView for TerminalView { let shared_session_status = model.shared_session_status(); let is_ambient_agent = self.is_ambient_agent_session(ctx); if shared_session_status.is_sharer_or_viewer() { - if !is_ambient_agent { + // "Copy link" is gated on `has_active_share_link()` rather than + // the outer `is_sharer_or_viewer()` so that pending and failed + // states (which leave no usable URL) do not surface a copy + // entry — see GH#9736. The neighbouring "Stop sharing session" + // entry deliberately stays under `is_sharer_or_viewer()` / + // `is_sharer()` so the user can still cancel a stuck pending + // share. + if !is_ambient_agent && shared_session_status.has_active_share_link() { items.push( MenuItemFields::new("Copy link") .with_on_select_action(TerminalAction::CopySharedSessionLink { source }) diff --git a/app/src/terminal/view/shared_session/view_impl.rs b/app/src/terminal/view/shared_session/view_impl.rs index 651269e3a..18eb55629 100644 --- a/app/src/terminal/view/shared_session/view_impl.rs +++ b/app/src/terminal/view/shared_session/view_impl.rs @@ -1630,7 +1630,13 @@ impl TerminalView { ); } - if model.shared_session_status().is_sharer_or_viewer() { + // Only offer "Copy session sharing link" when a link actually + // exists. Pending shares (SharePending / SharePendingPreBootstrap) + // have not yet produced a URL, and a session that finished or + // failed to share leaves no link to copy. Surfacing the menu + // entry in those states writes a stale or empty value to the + // clipboard — see GH#9736. + if model.shared_session_status().has_active_share_link() { items.push( MenuItemFields::new("Copy session sharing link") .with_on_select_action(TerminalAction::CopySharedSessionLink {