diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index a957647a9..226cf0f8a 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -4340,7 +4340,7 @@ impl ChatScreen { cx.notify(); } - /// Right-click menu over the transcript: copy the selection, select all. + /// Right-click menu over the transcript: copy a link or the selection, select all. fn render_transcript_menu( &self, window: &mut Window, @@ -4358,9 +4358,23 @@ impl ChatScreen { this.execute_command(command, window, cx); } }; - let menu = Menu::new("transcript-menu", px(160.)) + let mut menu = Menu::new("transcript-menu", px(160.)) .label("Transcript") - .application_vim(self.application_vim_enabled) + .application_vim(self.application_vim_enabled); + if let Some(url) = self + .selection + .as_ref() + .and_then(|selection| selection.read(cx).context_link()) + { + let url = url.to_owned(); + menu = menu.item( + MenuItem::new("transcript-menu-copy-link", "Copy Link", move |_, _, cx| { + cx.write_to_clipboard(gpui::ClipboardItem::new_string(url.clone())); + }) + .icon("copy"), + ); + } + let menu = menu .item( MenuItem::new( "transcript-menu-copy", diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 37298edaa..231e03aad 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -1163,6 +1163,91 @@ mod state_tests { } } + #[gpui::test] + fn transcript_context_menu_copies_link_destinations(cx: &mut TestAppContext) { + let url = "https://example.test/docs?q=maple#install"; + let (chat, cx) = chat_window(cx, |this, _| { + let mut reply = item( + "reply", + "message", + Some(&format!("[Read the docs]({url}) and ordinary text")), + ); + reply.role = Some("assistant".into()); + this.replace_timeline(vec![reply]); + }); + let row = cx.debug_bounds("timeline-row-reply").expect("reply"); + let at = gpui::point(row.left() + px(5.), row.top() + px(10.)); + let right_click = |cx: &mut gpui::VisualTestContext, at| { + cx.simulate_mouse_move(at, None, gpui::Modifiers::default()); + cx.simulate_mouse_down(at, gpui::MouseButton::Right, gpui::Modifiers::default()); + cx.simulate_mouse_up(at, gpui::MouseButton::Right, gpui::Modifiers::default()); + cx.run_until_parked(); + }; + right_click(cx, at); + assert!(cx.debug_bounds("transcript-menu-copy-link").is_some()); + assert!(!cx.update(|_, app| { + chat.read(app) + .selection + .as_ref() + .unwrap() + .read(app) + .has_selection() + })); + press(cx, "transcript-menu-copy-link"); + assert_eq!( + cx.update(|_, app| app.read_from_clipboard().and_then(|item| item.text())), + Some(url.to_owned()), + ); + assert!(chat.update(cx, |this, _| this.popup.open_key().is_none())); + + // A later press past the text must not reuse the previous link. + right_click(cx, gpui::point(row.right() - px(20.), at.y)); + assert!(cx.debug_bounds("transcript-menu").is_some()); + assert!(cx.debug_bounds("transcript-menu-copy-link").is_none()); + cx.simulate_keystrokes("escape"); + + // Link actions coexist with copying an existing text selection. + chat.update(cx, |this, cx| this.select_all_text(cx)); + cx.run_until_parked(); + right_click(cx, at); + assert!(cx.debug_bounds("transcript-menu-copy-link").is_some()); + press(cx, "transcript-menu-copy"); + assert_eq!( + cx.update(|_, app| app.read_from_clipboard().and_then(|item| item.text())), + Some("Read the docs and ordinary text".to_owned()), + ); + + // Bare URLs and table cells use the same hit testing and clipboard path. + for (source, table) in [ + (url.to_owned(), false), + ( + format!("| Docs |\n| --- |\n| [Read the docs]({url}) |"), + true, + ), + ] { + chat.update(cx, |this, cx| { + let mut reply = item("reply", "message", Some(&source)); + reply.role = Some("assistant".into()); + this.replace_timeline(vec![reply]); + cx.notify(); + }); + cx.run_until_parked(); + let at = if table { + let cell = cx.debug_bounds("md-cell-1-0").expect("link cell"); + gpui::point(cell.left() + px(15.), cell.center().y) + } else { + let row = cx.debug_bounds("timeline-row-reply").expect("reply"); + gpui::point(row.left() + px(5.), row.top() + px(10.)) + }; + right_click(cx, at); + press(cx, "transcript-menu-copy-link"); + assert_eq!( + cx.update(|_, app| app.read_from_clipboard().and_then(|item| item.text())), + Some(url.to_owned()), + ); + } + } + /// The subagent card shows one row per live `delegate` call, follows /// the tool each subagent runs, and empties when they end. #[gpui::test] diff --git a/apps/maple-agent/app/src/ui/chat/transcript.rs b/apps/maple-agent/app/src/ui/chat/transcript.rs index 392313a77..6cb349282 100644 --- a/apps/maple-agent/app/src/ui/chat/transcript.rs +++ b/apps/maple-agent/app/src/ui/chat/transcript.rs @@ -165,6 +165,13 @@ impl ChatScreen { .flex() .flex_col() .min_h_0() + .capture_any_mouse_down(cx.listener(|this, event: &gpui::MouseDownEvent, _, cx| { + if event.button == gpui::MouseButton::Right + && let Some(selection) = &this.selection + { + selection.update(cx, |selection, _| selection.clear_context_link()); + } + })) .on_mouse_down( gpui::MouseButton::Right, cx.listener(|this, event: &gpui::MouseDownEvent, window, cx| { @@ -378,7 +385,9 @@ pub(super) fn render_timeline_item( match role { Some(role) => { let (position, count) = transcript.position; - row.id(SharedString::from(format!("timeline-row-{item_id}"))) + let row_id = SharedString::from(format!("timeline-row-{item_id}")); + row.id(row_id.clone()) + .debug_selector(move || row_id.to_string()) .role(role) .when_some(label, |row, label| row.aria_label(label)) .accessibility_id(item_id) diff --git a/apps/maple-agent/app/src/ui/rich_text.rs b/apps/maple-agent/app/src/ui/rich_text.rs index 20a9cbad4..ba44918d3 100644 --- a/apps/maple-agent/app/src/ui/rich_text.rs +++ b/apps/maple-agent/app/src/ui/rich_text.rs @@ -105,6 +105,8 @@ impl Granularity { /// Shared drag-selection state, owned by the chat screen. #[derive(Default)] pub struct TextSelection { + /// Destination under the most recent transcript context-menu press. + context_link: Option, anchor: Option, head: Option, down: Option, @@ -119,6 +121,16 @@ pub struct TextSelection { } impl TextSelection { + pub fn context_link(&self) -> Option<&str> { + self.context_link.as_deref() + } + + /// Clear before dispatching a right-click to the transcript's paragraphs, + /// so a press on empty space cannot reuse a previous link. + pub fn clear_context_link(&mut self) { + self.context_link = None; + } + pub fn is_selecting(&self) -> bool { self.selecting } @@ -135,6 +147,7 @@ impl TextSelection { /// Forget the selection and every registered paragraph. Call this when /// the transcript changes session so stale text cannot be copied. pub fn clear(&mut self) { + self.clear_context_link(); self.reset_selection(); self.registry.clear(); } @@ -579,17 +592,33 @@ impl Element for RichText { let ordinal = self.ordinal; let focus = self.focus.clone(); - // Press: start a selection at the character under the pointer. + // Press: resolve a context link or start a text selection. { let entity = entity.clone(); let layout = layout.clone(); let hitbox = hitbox.clone(); + let links = self.links.clone(); window.on_mouse_event( move |event: &gpui::MouseDownEvent, phase, window: &mut Window, cx: &mut App| { - if phase != gpui::DispatchPhase::Bubble - || event.button != MouseButton::Left - || !hitbox.is_hovered(window) - { + if phase != gpui::DispatchPhase::Bubble || !hitbox.is_hovered(window) { + return; + } + if event.button == MouseButton::Right { + // The transcript opens its menu later in this same + // bubbling dispatch. Leave the selection intact. + if let Some(entity) = &entity { + let url = layout + .index_for_position(event.position) + .ok() + .and_then(|index| { + links.iter().find(|(range, _)| range.contains(&index)) + }) + .map(|(_, url)| url.clone()); + entity.update(cx, |state, _| state.context_link = url); + } + return; + } + if event.button != MouseButton::Left { return; } if let Some(focus) = &focus {