From 5ce7c3afc388ed3dc96c0bf6e7add0a30e0b7bc4 Mon Sep 17 00:00:00 2001 From: Tee8z Date: Wed, 4 Nov 2026 12:45:45 -0500 Subject: [PATCH] feat(gpui): copy a whole thread as Markdown Adds "Copy thread" to the root message's menu in the thread panel. It copies the loaded thread with the root's link, notes about unloaded replies and thread banners, each message in time order with author and UTC time, and each agent's activity status. Closes #95 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tee8z --- .../app/src/external_plugins/message_view.rs | 1 + .../app/src/ui/agent_activity/bubble.rs | 20 ++ apps/buzz-gpui/crates/app/src/ui/channel.rs | 1 + apps/buzz-gpui/crates/app/src/ui/markdown.rs | 2 +- .../crates/app/src/ui/media/comments.rs | 1 + apps/buzz-gpui/crates/app/src/ui/message.rs | 38 ++- .../crates/app/src/ui/message_actions.rs | 11 + .../crates/app/src/ui/message_copy.rs | 278 +++++++++++++++++- apps/buzz-gpui/crates/app/src/ui/thread.rs | 87 +++++- 9 files changed, 428 insertions(+), 11 deletions(-) diff --git a/apps/buzz-gpui/crates/app/src/external_plugins/message_view.rs b/apps/buzz-gpui/crates/app/src/external_plugins/message_view.rs index 93b75c6cf64..462261e6d77 100644 --- a/apps/buzz-gpui/crates/app/src/external_plugins/message_view.rs +++ b/apps/buzz-gpui/crates/app/src/external_plugins/message_view.rs @@ -116,6 +116,7 @@ impl MessageView { .and_then(|view| view.read(cx).row.clone()) .filter(|row| row.id == id) }), + copy_thread: None, thread: None, composer: WeakEntity::new_invalid(), }; diff --git a/apps/buzz-gpui/crates/app/src/ui/agent_activity/bubble.rs b/apps/buzz-gpui/crates/app/src/ui/agent_activity/bubble.rs index 37a4cecbb0c..9db2978931b 100644 --- a/apps/buzz-gpui/crates/app/src/ui/agent_activity/bubble.rs +++ b/apps/buzz-gpui/crates/app/src/ui/agent_activity/bubble.rs @@ -74,6 +74,26 @@ fn label(work: &RequestWork) -> String { local_work_label(state, work.reason.as_deref(), work.step.as_ref()) } +/// One agent's status as text, for "Copy thread": what its bubble and +/// popover show, "Could not finish" with the reason when one is known. +pub fn status(work: &RequestWork) -> String { + if !work.lifecycle { + return if work.working { + "Working" + } else { + "Status unknown" + } + .into(); + } + if work.state == WorkState::Error { + return match work.reason.as_deref().filter(|r| !r.is_empty()) { + Some(reason) => format!("Could not finish: {reason}"), + None => "Could not finish".into(), + }; + } + label(work) +} + /// Names for the bubble's agents: the directory's, with the key's suffix /// when two read alike. fn names(works: &[RequestWork], names: &Names) -> Vec { diff --git a/apps/buzz-gpui/crates/app/src/ui/channel.rs b/apps/buzz-gpui/crates/app/src/ui/channel.rs index 195d1e497e3..f854128871e 100644 --- a/apps/buzz-gpui/crates/app/src/ui/channel.rs +++ b/apps/buzz-gpui/crates/app/src/ui/channel.rs @@ -936,6 +936,7 @@ impl ChannelPage { .map(|item| item.row.clone()) }) }), + copy_thread: None, thread: None, composer: composer.downgrade(), }; diff --git a/apps/buzz-gpui/crates/app/src/ui/markdown.rs b/apps/buzz-gpui/crates/app/src/ui/markdown.rs index 9659198dbe1..dc8c1fe674f 100644 --- a/apps/buzz-gpui/crates/app/src/ui/markdown.rs +++ b/apps/buzz-gpui/crates/app/src/ui/markdown.rs @@ -2546,7 +2546,7 @@ pub(super) mod tests { ) } - fn directory() -> Directory { + pub(in crate::ui) fn directory() -> Directory { Directory::new(Profiles::default(), Arc::from([]), media()) } diff --git a/apps/buzz-gpui/crates/app/src/ui/media/comments.rs b/apps/buzz-gpui/crates/app/src/ui/media/comments.rs index 64d8856aa73..2b42d228db5 100644 --- a/apps/buzz-gpui/crates/app/src/ui/media/comments.rs +++ b/apps/buzz-gpui/crates/app/src/ui/media/comments.rs @@ -167,6 +167,7 @@ impl Comments { find.upgrade() .and_then(|comments| comments.read(cx).row(id)) }), + copy_thread: None, thread: Some(root), composer: composer.downgrade(), }; diff --git a/apps/buzz-gpui/crates/app/src/ui/message.rs b/apps/buzz-gpui/crates/app/src/ui/message.rs index aed896fe6ad..3b7a78c0e86 100644 --- a/apps/buzz-gpui/crates/app/src/ui/message.rs +++ b/apps/buzz-gpui/crates/app/src/ui/message.rs @@ -175,6 +175,8 @@ pub enum Command { ReviewReport, CopyLink, CopyMessage, + /// The whole loaded thread as Markdown, from its root. + CopyThread, MarkRead, MarkUnread, SendToChannel, @@ -276,6 +278,8 @@ pub struct Actions { /// A reader's conversation offers no Reply (`ChannelPreview` has no /// `onReply`). pub no_reply: bool, + /// The page can copy its whole thread: the root offers "Copy thread". + pub copy_thread: bool, } /// A page's loaded row by id. @@ -1627,8 +1631,9 @@ struct Entry { } /// `MessageActionBar`'s menu, in its order: read state, Copy message, -/// Send to channel, Edit, Delete, Follow, registered actions, View -/// activity, then Report under a separator. +/// Copy thread (a thread panel's root), Send to channel, Edit, Delete, +/// Follow, registered actions, View activity, then Report under a +/// separator. fn menu_entries(row: &MessageRow, rows: &Rows) -> Vec { let mut entries = Vec::new(); let managed = rows.managed(row) && rows.actions.on_command.is_some(); @@ -1662,6 +1667,17 @@ fn menu_entries(row: &MessageRow, rows: &Rows) -> Vec { disabled: false, separated: false, }); + if rows.actions.copy_thread && row.thread_root_id.is_none() { + entries.push(Entry { + name: "copy-thread".into(), + label: "Copy thread".into(), + icon: IconName::Copy, + icon_size: 16., + command: Command::CopyThread, + disabled: false, + separated: false, + }); + } let writable = rows.listed() && !rows.archived() && !rows.read_only(); if writable && row.thread_root_id.is_some() @@ -2626,6 +2642,24 @@ mod tests { use buzz_relay_client::fold::{LocalDelivery, Reaction}; use core::prelude::v1::test; + #[test] + fn only_a_copyable_threads_root_offers_copy_thread() { + let offers = |copy_thread: bool, reply: bool| { + let mut rows = Rows::new("", super::super::markdown::tests::directory()); + rows.actions.copy_thread = copy_thread; + let mut row = super::super::markdown::tests::row("x"); + if reply { + row.thread_root_id = Some(EventId::from_byte_array([7; 32])); + } + menu_entries(&row, &rows) + .iter() + .any(|entry| entry.command == Command::CopyThread) + }; + assert!(offers(true, false)); + assert!(!offers(true, true)); + assert!(!offers(false, false)); + } + /// The quiet line's label for `content` from Morgarita, which names /// Rivet in a `p` tag. fn quiet_line(content: &str) -> String { diff --git a/apps/buzz-gpui/crates/app/src/ui/message_actions.rs b/apps/buzz-gpui/crates/app/src/ui/message_actions.rs index a3fdbd40118..82db656704f 100644 --- a/apps/buzz-gpui/crates/app/src/ui/message_actions.rs +++ b/apps/buzz-gpui/crates/app/src/ui/message_actions.rs @@ -27,6 +27,8 @@ pub type RowHook = Rc, &mut Window, &mut App)>; pub type FocusHook = Rc; pub type RootHook = Rc Option>>; pub type FindHook = Rc Option>>; +/// The page's whole thread as Markdown, `None` before it has loaded. +pub type CopyThreadHook = Rc Option>; /// What only the page knows how to do. pub struct Hooks { @@ -41,6 +43,8 @@ pub struct Hooks { pub thread_root: Option, /// A loaded row by id, for the picker's target. pub find: FindHook, + /// "Copy thread" on a thread panel's root. + pub copy_thread: Option, /// Typing in this view: the channel, or a thread root. pub thread: Option, /// The page's composer, where edits happen. @@ -563,6 +567,7 @@ impl MessageActions { on_open_thread: self.open_thread.clone(), find_row: Some(self.hooks.find.clone()), no_reply: self.no_reply, + copy_thread: self.hooks.copy_thread.is_some(), on_retry: Some(Rc::new(move |row, _, cx| { let _ = retry.update(cx, |this, cx| { let (session, id) = (this.session(cx), row.id); @@ -710,6 +715,12 @@ impl MessageActions { cx.write_to_clipboard(ClipboardItem::new_string(text)); notice::success("Message copied", cx); } + Command::CopyThread => { + if let Some(text) = self.hooks.copy_thread.as_ref().and_then(|copy| copy(cx)) { + cx.write_to_clipboard(ClipboardItem::new_string(text)); + notice::success("Thread copied", cx); + } + } Command::MarkRead | Command::MarkUnread => { self.error = None; let read = command == Command::MarkRead; diff --git a/apps/buzz-gpui/crates/app/src/ui/message_copy.rs b/apps/buzz-gpui/crates/app/src/ui/message_copy.rs index 126222aa673..d2f3730d106 100644 --- a/apps/buzz-gpui/crates/app/src/ui/message_copy.rs +++ b/apps/buzz-gpui/crates/app/src/ui/message_copy.rs @@ -1,11 +1,13 @@ -//! What "Copy message" and "Copy link" put on the clipboard -//! (`message-copy.ts`). +//! What "Copy message", "Copy link" and "Copy thread" put on the +//! clipboard (`message-copy.ts`). use super::identity::Names; use super::markdown; -use buzz_relay_client::Profiles; use buzz_relay_client::fold::{MessageRow, message_markdown}; +use buzz_relay_client::{EventId, Profiles}; use nostr::nips::nip19::ToBech32; +use std::collections::HashMap; +use std::sync::Arc; /// `messageCopyLink`: `buzz://message?channel&id`, with `thread` for a /// reply. `None` until the relay has the message. @@ -13,17 +15,21 @@ pub fn link(row: &MessageRow) -> Option { if !super::message::delivered(row) { return None; } + Some(message_link(&row.channel, row.id, row.thread_root_id)) +} + +fn message_link(channel: &str, id: EventId, thread: Option) -> String { let mut url = url::Url::parse("buzz://message").expect("static URL"); { let mut query = url.query_pairs_mut(); query - .append_pair("channel", &row.channel) - .append_pair("id", &row.id.to_hex()); - if let Some(root) = row.thread_root_id { + .append_pair("channel", channel) + .append_pair("id", &id.to_hex()); + if let Some(root) = thread { query.append_pair("thread", &root.to_hex()); } } - Some(url.to_string()) + url.to_string() } /// `messageCopyText`: the body with bound mentions as `[@Name](nostr:npub…)` @@ -63,6 +69,123 @@ pub fn text(row: &MessageRow, profiles: &Profiles, names: &Names) -> String { .join("\n\n") } +/// What a thread panel has loaded, for [`thread`]. +pub struct LoadedThread<'a> { + /// The conversation's id, for the root's link. + pub channel: &'a str, + /// `#name` for a channel, or how else to name the conversation. + pub place: Option, + pub root_id: EventId, + /// `None` when the original message is unavailable. + pub root: Option<&'a MessageRow>, + pub replies: &'a [Arc], + /// Lines the panel shows about the thread as a whole: replies not + /// loaded, and banners such as a remote session's state. + pub notes: Vec, + /// Each agent's status on a message, as its activity bubble shows it: + /// the agent's key and the status line. + pub activity: HashMap>, +} + +/// "Copy thread": Markdown of what the viewer sees in a thread. A heading +/// with the root's link, the thread's notes, then every loaded message in +/// time order with its author, UTC time, body and attachment URLs as +/// written, and the agents' status on it. +pub fn thread(loaded: &LoadedThread, names: &Names) -> String { + let mut out = String::from("# Thread"); + if let Some(place) = &loaded.place { + out.push_str(" in "); + out.push_str(place); + } + // An unsent root has no link yet. + if loaded.root.is_none_or(super::message::delivered) { + out.push_str(" · "); + out.push_str(&message_link(loaded.channel, loaded.root_id, None)); + } + out.push('\n'); + let mut notes = loaded.notes.clone(); + if loaded.root.is_none() { + notes.insert(0, "The original message is unavailable.".into()); + } + if !notes.is_empty() { + out.push('\n'); + for note in ¬es { + out.push_str("e(note)); + } + } + let mut replies: Vec<&MessageRow> = loaded.replies.iter().map(Arc::as_ref).collect(); + replies.sort_by_key(|row| (row.created_at_ms, row.id)); + for row in loaded.root.into_iter().chain(replies) { + // Join and leave rows carry no body. + if row.membership.is_some() { + continue; + } + out.push('\n'); + out.push_str(&byline(row, names)); + out.push('\n'); + let body = std::iter::once(row.content.as_str()) + .chain(row.attachments.iter().map(|a| a.url.as_str())) + .filter(|part| !part.is_empty()) + .collect::>() + .join("\n\n"); + if !body.is_empty() { + out.push_str(&body); + out.push('\n'); + } + for (agent, status) in loaded.activity.get(&row.id).into_iter().flatten() { + out.push('\n'); + out.push_str("e(&format!( + "Activity: {} — {status}", + names.display(agent) + ))); + } + } + out +} + +/// `**Name** (agent) · 2026-10-29 11:22:30 UTC`, and whether it was sent. +fn byline(row: &MessageRow, names: &Names) -> String { + let author = row.author.to_hex(); + let name = names.display(&author); + let mut line = format!("**{}**", escape(&name)); + if (row.is_agent_envelope() || names.is_agent(&author)) && !name.ends_with("(agent)") { + line.push_str(" (agent)"); + } + line.push_str(" · "); + line.push_str(&utc(row.created_at_ms)); + if !super::message::delivered(row) { + line.push_str(" · not sent"); + } + line +} + +fn utc(ms: u64) -> String { + i64::try_from(ms) + .ok() + .and_then(chrono::DateTime::from_timestamp_millis) + .map_or_else( + || "unknown time".into(), + |at| at.format("%Y-%m-%d %H:%M:%S UTC").to_string(), + ) +} + +/// A name inside `**…**`. +fn escape(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + for c in name.chars() { + if matches!(c, '\\' | '*' | '_' | '`' | '[' | ']') { + out.push('\\'); + } + out.push(c); + } + out +} + +/// A blockquote, every line marked. +fn quote(text: &str) -> String { + text.lines().map(|line| format!("> {line}\n")).collect() +} + #[cfg(test)] mod tests { use super::*; @@ -124,6 +247,147 @@ mod tests { ); } + fn person(name: &str, agent: bool) -> (nostr::prelude::PublicKey, Arc) { + let key = buzz_identity::Identity::ephemeral().public_key(); + let profile = Arc::new(Profile { + name: name.into(), + is_agent: agent, + ..Default::default() + }); + (key, profile) + } + + #[test] + fn copies_a_thread_in_time_order_with_its_status() { + let (blu, blu_profile) = person("blu", false); + let (augustus, augustus_profile) = person("Augustus", true); + let profiles: Profiles = Arc::new(HashMap::from([ + (blu.to_hex(), blu_profile), + (augustus.to_hex(), augustus_profile), + ])); + let names = identity::names(&profiles, &AgentLibrary::default(), "", None, None); + let mut root = super::super::markdown::tests::row("@Augustus ping"); + root.channel = Arc::from("chan"); + root.author = blu; + root.created_at_ms = 1_793_272_950_000; + let reply = |byte: u8, text: &str, at: u64| { + let mut row = super::super::markdown::tests::row(text); + row.id = EventId::from_byte_array([byte; 32]); + row.author = augustus; + row.thread_root_id = Some(root.id); + row.created_at_ms = at; + Arc::new(row) + }; + let later = reply(3, "done", 1_793_273_100_000); + let mut first = (*reply(2, "@blu pong", 1_793_273_022_000)).clone(); + first.attachments = vec![Attachment { + url: "https://x.test/media/a.png".into(), + kind: AttachmentKind::Image, + mime: None, + size: None, + name: None, + duration: None, + waveform: None, + voice_note: false, + dimensions: None, + blurhash: None, + preview_url: None, + insecure: false, + }]; + // Rendered order is by ancestry; the copy is by time. + let replies = [later.clone(), Arc::new(first)]; + let loaded = LoadedThread { + channel: "chan", + place: Some("#general".into()), + root_id: root.id, + root: Some(&root), + replies: &replies, + notes: vec![ + "Older replies were not loaded.".into(), + "The session has ended.".into(), + ], + activity: HashMap::from([( + later.id, + vec![( + augustus.to_hex(), + "Could not finish: sandbox restarted".into(), + )], + )]), + }; + let root_hex = root.id.to_hex(); + assert_eq!( + thread(&loaded, &names), + format!( + "# Thread in #general · buzz://message?channel=chan&id={root_hex}\n\ + \n\ + > Older replies were not loaded.\n\ + > The session has ended.\n\ + \n\ + **blu** · 2026-10-29 11:22:30 UTC\n\ + @Augustus ping\n\ + \n\ + **Augustus** (agent) · 2026-10-29 11:23:42 UTC\n\ + @blu pong\n\ + \n\ + https://x.test/media/a.png\n\ + \n\ + **Augustus** (agent) · 2026-10-29 11:25:00 UTC\n\ + done\n\ + \n\ + > Activity: Augustus — Could not finish: sandbox restarted\n" + ) + ); + } + + #[test] + fn an_unavailable_root_still_links_the_thread() { + let names = identity::names( + &Profiles::default(), + &AgentLibrary::default(), + "", + None, + None, + ); + let root_id = EventId::from_byte_array([9; 32]); + let mut pending = super::super::markdown::tests::row("on its way"); + pending.created_at_ms = 0; + pending.delivery = Some(LocalDelivery { + status: Delivery::Sending, + error: None, + }); + let replies = [Arc::new(pending)]; + let copied = thread( + &LoadedThread { + channel: "chan", + place: None, + root_id, + root: None, + replies: &replies, + notes: Vec::new(), + activity: HashMap::new(), + }, + &names, + ); + let mut lines = copied.lines(); + assert_eq!( + lines.next(), + Some( + format!( + "# Thread · buzz://message?channel=chan&id={}", + root_id.to_hex() + ) + .as_str() + ) + ); + assert!(copied.contains("> The original message is unavailable.\n")); + assert!(copied.contains(" · 1970-01-01 00:00:00 UTC · not sent\non its way\n")); + } + + #[test] + fn names_cannot_break_the_bold_author() { + assert_eq!(escape("a*b_c"), "a\\*b\\_c"); + } + #[test] fn links_keep_the_thread_for_replies() { let mut row = super::super::markdown::tests::row("x"); diff --git a/apps/buzz-gpui/crates/app/src/ui/thread.rs b/apps/buzz-gpui/crates/app/src/ui/thread.rs index 91aeef0157b..dd866b4c2e3 100644 --- a/apps/buzz-gpui/crates/app/src/ui/thread.rs +++ b/apps/buzz-gpui/crates/app/src/ui/thread.rs @@ -562,7 +562,7 @@ impl ThreadPanel { cx: &mut Context, ) -> Entity { let panel = cx.entity().downgrade(); - let (reply, root, find) = (panel.clone(), panel.clone(), panel); + let (reply, root, find, copy) = (panel.clone(), panel.clone(), panel.clone(), panel); let (edit_composer, focus_composer) = (composer.downgrade(), composer.downgrade()); let hooks = Hooks { reply: Rc::new(move |row, window, cx| { @@ -595,6 +595,10 @@ impl ThreadPanel { .cloned() }) }), + copy_thread: Some(Rc::new(move |cx| { + copy.upgrade() + .and_then(|panel| panel.read(cx).copy_text(cx)) + })), thread: Some(root_id), composer: composer.downgrade(), }; @@ -602,6 +606,87 @@ impl ThreadPanel { cx.new(|cx| MessageActions::new(community, channel, hooks, window, cx)) } + /// "Copy thread": what this viewer sees here as Markdown, `None` + /// before anything has loaded. + fn copy_text(&self, cx: &App) -> Option { + let snapshot = &self.snapshot; + if snapshot.root.is_none() && snapshot.replies.is_empty() { + return None; + } + let community = self.community.read(cx); + let summary = community + .snapshot + .channels + .iter() + .find(|channel| channel.id == self.channel); + let place = summary.map(|channel| match channel.channel_type { + Some(ChannelType::Dm) => "a direct message".to_string(), + _ => format!("#{}", channel.name), + }); + let directory = Directory::of( + community, + summary.map(|channel| channel.members.as_slice()), + cx, + ); + let mut notes = Vec::new(); + if snapshot.can_load_more { + notes.push(match snapshot.direction { + Some(Direction::Forward) => "Later replies were not loaded.", + _ => "Older replies were not loaded.", + }); + } else if snapshot.limited { + notes.push("Thread history limit reached: some replies were not loaded."); + } + let mut notes: Vec = notes.into_iter().map(String::from).collect(); + let thread = super::thread_banners::BannerThread { + relay: community.origin.websocket().to_owned(), + channel: self.channel.clone(), + root: self.root_id.to_hex(), + }; + notes.extend( + super::thread_banners::for_thread(&thread, cx) + .into_iter() + .map(|banner| banner.text.to_string()), + ); + let observed = crate::agent_activity::observer::snapshot(&self.community, cx); + let activity = snapshot + .root + .iter() + .chain(snapshot.replies.iter()) + .filter_map(|row| { + let works = crate::agent_activity::request::message_activity( + &observed, + &self.channel, + &row.id.to_hex(), + ); + (!works.is_empty()).then(|| { + let statuses = works + .iter() + .map(|work| { + ( + work.agent.clone(), + super::agent_activity::bubble::status(work), + ) + }) + .collect(); + (row.id, statuses) + }) + }) + .collect(); + Some(super::message_copy::thread( + &super::message_copy::LoadedThread { + channel: &self.channel, + place, + root_id: self.root_id, + root: snapshot.root.as_deref(), + replies: &snapshot.replies, + notes, + activity, + }, + &directory.names, + )) + } + /// Up in an empty composer edits the viewer's newest editable reply. fn edit_last(&mut self, window: &mut Window, cx: &mut Context) { let rows: Vec> = self