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