Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions apps/maple-agent/app/src/ui/chat/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand Down
85 changes: 85 additions & 0 deletions apps/maple-agent/app/src/ui/chat/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
11 changes: 10 additions & 1 deletion apps/maple-agent/app/src/ui/chat/transcript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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| {
Expand Down Expand Up @@ -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)
Expand Down
39 changes: 34 additions & 5 deletions apps/maple-agent/app/src/ui/rich_text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
anchor: Option<SelectionPos>,
head: Option<SelectionPos>,
down: Option<SelectionPos>,
Expand All @@ -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
}
Expand All @@ -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();
}
Expand Down Expand Up @@ -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 {
Expand Down
Loading