From a134af906f1fcdbe9e68e47377b89123259d322a Mon Sep 17 00:00:00 2001 From: Jeff Hedlund Date: Mon, 19 Oct 2026 11:45:24 -0400 Subject: [PATCH] feat: shared thread titles on NIP-AR artifacts A thread's title is a kind-45010 artifact with type buzz.thread-title (the name proposed upstream in block/buzz#6739): - d = UUIDv5(namespace, "buzz.thread-title:" + root). Folding the type into the name means a future type change gets fresh ids, since NIP-AR never reuses a deleted d. - h = channel, root = thread root, title tag. The title is also copied into content for readers that ignore tags; relay search does not index kind 45010, so titles are not searchable yet. Clearing is op=delete; setting again after a clear is restore. The convention lives in buzz_sdk::thread_title (type, namespace, head selection, write planning, revision builder) so the CLI and the desktop write byte-identical artifacts. CLI: `buzz threads title set|clear|get|list`. Writes plan create/update/delete/restore from the current head and re-read on head conflicts. `set --if-unset` leaves an existing or cleared title alone. buzz-acp: when a human's new top-level channel message mentions agents, the first-mentioned agent is asked to title the thread with --if-unset. Replies, edits, DMs and agent-authored roots never trigger it. Desktop, through Tauri commands (artifact queries are HTTP-only on the relay): - The thread panel header shows the title with an inline editor (pencil, Enter saves, Escape cancels, blank clears). Edits carry the revision they started from, so a concurrent rename is reported, not overwritten. - The title sits on its own line under the author of a thread root in the channel (clickable, opens the thread) and on Inbox rows and the Inbox detail header. - Titles are looked up per root by their derived d, in batches of 100, and lookups made in the same tick share one query, so lookups stay exact however many titles exist. A returned title only counts when its d matches its root. - Artifact events in the open channel refresh titles live. Co-authored-by: Engineer Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jeff Hedlund --- Cargo.lock | 1 + crates/buzz-acp/src/lib.rs | 16 + crates/buzz-acp/src/pool.rs | 2 + crates/buzz-acp/src/queue.rs | 215 ++++++++ crates/buzz-cli/src/commands/messages.rs | 5 +- crates/buzz-cli/src/commands/mod.rs | 1 + crates/buzz-cli/src/commands/threads.rs | 211 ++++++++ crates/buzz-cli/src/lib.rs | 59 +++ crates/buzz-sdk/Cargo.toml | 3 +- crates/buzz-sdk/src/lib.rs | 1 + crates/buzz-sdk/src/thread_title.rs | 485 ++++++++++++++++++ desktop/playwright.config.ts | 1 + desktop/src-tauri/src/commands/mod.rs | 2 + .../src-tauri/src/commands/thread_titles.rs | 207 ++++++++ desktop/src-tauri/src/lib.rs | 2 + .../src/features/home/ui/InboxDetailPane.tsx | 18 +- .../src/features/home/ui/InboxListPane.tsx | 2 + .../src/features/messages/ui/MessageRow.tsx | 16 + .../messages/ui/MessageThreadPanel.tsx | 16 + .../ui/MessageThreadPanelSkeleton.tsx | 18 +- .../features/messages/ui/MessageTimeline.tsx | 8 + .../messages/ui/TimelineMessageRow.tsx | 11 + desktop/src/features/threads/hooks.ts | 169 ++++++ .../threads/lib/threadTitles.test.mjs | 167 ++++++ .../src/features/threads/lib/threadTitles.ts | 153 ++++++ .../features/threads/ui/ThreadTitleEditor.tsx | 147 ++++++ .../features/threads/ui/ThreadTitleLine.tsx | 60 +++ .../src/shared/api/relayQueryInvalidation.ts | 1 + desktop/src/shared/api/tauriThreadTitles.ts | 66 +++ desktop/src/shared/constants/kinds.ts | 3 + desktop/src/testing/e2eBridge.ts | 96 ++++ desktop/tests/e2e/thread-titles.spec.ts | 198 +++++++ 32 files changed, 2352 insertions(+), 8 deletions(-) create mode 100644 crates/buzz-cli/src/commands/threads.rs create mode 100644 crates/buzz-sdk/src/thread_title.rs create mode 100644 desktop/src-tauri/src/commands/thread_titles.rs create mode 100644 desktop/src/features/threads/hooks.ts create mode 100644 desktop/src/features/threads/lib/threadTitles.test.mjs create mode 100644 desktop/src/features/threads/lib/threadTitles.ts create mode 100644 desktop/src/features/threads/ui/ThreadTitleEditor.tsx create mode 100644 desktop/src/features/threads/ui/ThreadTitleLine.tsx create mode 100644 desktop/src/shared/api/tauriThreadTitles.ts create mode 100644 desktop/tests/e2e/thread-titles.spec.ts diff --git a/Cargo.lock b/Cargo.lock index 1cb97df5cdf..6a6c3ce13d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11402,6 +11402,7 @@ dependencies = [ "getrandom 0.4.3", "js-sys", "serde_core", + "sha1_smol", "wasm-bindgen", ] diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index e9f2eb7dbc9..9c893b86da5 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -11209,6 +11209,22 @@ mod edit_native_steer_tests { assert!(steer.is_none(), "no native steer without a recorded prompt"); assert_eq!(control, Some(ControlSignal::Steer)); } + + /// A new top-level channel message opens its own thread, so it is never + /// steered natively into a running turn. Thread auto-titling relies on + /// this: the ask lives in ``, which only the cancel+merge + /// re-prompt carries. + #[tokio::test] + async fn new_channel_thread_during_running_turn_cancels_and_merges() { + let root = "ab".repeat(32); + for running in [message(None), message(Some(&root))] { + let (steer, control) = + steer_into_running_turn(false, Some(false), running, message(None), None); + + assert!(steer.is_none(), "no native steer for a new thread"); + assert_eq!(control, Some(ControlSignal::Steer)); + } + } } #[cfg(test)] diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 7a7e3229e70..f04e3c84a97 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -3126,6 +3126,7 @@ pub async fn run_prompt_task( ); } + let agent_pubkey = ctx.agent_keys.public_key().to_hex(); crate::queue::format_prompt( b, &crate::queue::FormatPromptArgs { @@ -3141,6 +3142,7 @@ pub async fn run_prompt_task( team_instructions: standing.team_instructions, agent_canvas: standing.agent_canvas, standing_context_sent, + agent_pubkey: Some(&agent_pubkey), }, ) } else { diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 1cf53baef9c..28289d300f0 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -1719,6 +1719,63 @@ fn append_new_thread_reply_instruction(s: &mut String, event_id: &str) { )); } +/// Ask the agent to name the thread a human just started by mentioning it. +/// +/// `--if-unset` makes the write create-only, so a title the human (or another +/// agent) already set is never replaced, and concurrent agents cannot clobber +/// each other. +fn append_auto_title_instruction(s: &mut String, channel_id: Uuid, event_id: &str) { + s.push_str(&format!( + "\nTHREAD TITLE: You are the first agent mentioned in this new thread, \ + so name it. Once you understand the request, run \ + `buzz threads title set --channel {channel_id} --event {event_id} \ + --if-unset --title \"\"` with a short, specific title \ + (about 3–8 words, no trailing punctuation) describing what the thread \ + is about. Do this once, without mentioning it in your reply. If the \ + command reports \"already titled\", leave the existing title alone." + )); +} + +/// Whether this agent should auto-title the thread the triggering event starts. +/// +/// Only a human's new top-level message qualifies (never a reply, an edit, or +/// a DM), and only for the first agent it mentions, so a later mention inside +/// an existing thread never renames it. Pubkeys whose profile is unknown are +/// skipped rather than treated as agents; if one was an agent too, the +/// create-only write in [`append_auto_title_instruction`] settles the race. +fn should_auto_title( + be: &BatchEvent, + thread_tags: &ThreadTags, + is_dm: bool, + agent_pubkey: Option<&str>, + profile_lookup: Option<&PromptProfileLookup>, +) -> bool { + let Some(agent_pubkey) = agent_pubkey else { + return false; + }; + if is_dm || thread_tags.root_event_id.is_some() || edit_target_id(&be.event).is_some() { + return false; + } + let is_agent = |pubkey: &str| { + profile_lookup + .and_then(|m| m.get(&normalize_lookup_key(pubkey))) + .is_some_and(|p| p.is_agent) + }; + if is_agent(&be.event.pubkey.to_hex()) { + return false; + } + let me = normalize_lookup_key(agent_pubkey); + for pubkey in &thread_tags.mentioned_pubkeys { + if normalize_lookup_key(pubkey) == me { + return true; + } + if is_agent(pubkey) { + return false; + } + } + false +} + /// Decide whether a turn is human-facing for reply-anchor purposes. /// /// A turn is human-facing when the triggering sender is a human, OR a human @@ -1955,6 +2012,7 @@ fn format_context_hints( is_dm: bool, conversation_context_status: ConversationContextStatus, reply_anchor: Option<&str>, + auto_title_event: Option<&str>, ) -> String { let channel_id = scope.channel_id(); let channel_display = match channel_info { @@ -2051,6 +2109,9 @@ fn format_context_hints( append_new_thread_reply_instruction(&mut s, event_id); } } + if let Some(event_id) = auto_title_event { + append_auto_title_instruction(&mut s, channel_id, event_id); + } crate::prompt_framing::semantic_section("context", &s) } else { let mut s = format!( @@ -2066,6 +2127,9 @@ fn format_context_hints( if let Some(event_id) = reply_anchor { append_new_thread_reply_instruction(&mut s, event_id); } + if let Some(event_id) = auto_title_event { + append_auto_title_instruction(&mut s, channel_id, event_id); + } crate::prompt_framing::semantic_section("context", &s) } } @@ -2231,6 +2295,9 @@ pub struct FormatPromptArgs<'a> { /// Defaults to `false` so a caller that never sets it behaves as if this /// were the session's first message. pub standing_context_sent: bool, + /// This agent's own pubkey (hex). Required to recognize when it is the + /// first agent mentioned in a new thread; `None` disables auto-titling. + pub agent_pubkey: Option<&'a str>, } /// The prompt sections that do not change for the life of a session: base @@ -2401,6 +2468,14 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec<Str args.profile_lookup, ) }; + let auto_title_event = should_auto_title( + last_event, + &thread_tags, + is_dm, + args.agent_pubkey, + args.profile_lookup, + ) + .then_some(routing_event_id.as_str()); sections.push(format_context_hints( &batch.scope, args.channel_info, @@ -2412,6 +2487,7 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec<Str args.conversation_context_had_session_events, ), reply_anchor.as_deref(), + auto_title_event, )); // 3. Conversation context (thread or DM). @@ -7408,4 +7484,143 @@ mod tests { "unresolved metadata must not render a Description field; got: {prompt}" ); } + + fn auto_title_batch(channel_id: Uuid, tags: Vec<nostr::Tag>) -> FlushBatch { + let event = EventBuilder::new(Kind::Custom(9), "@Agent please help") + .tags(tags) + .sign_with_keys(&Keys::generate()) + .unwrap(); + FlushBatch { + channel_id, + scope: conv(channel_id), + events: vec![BatchEvent { + edit: None, + event, + prompt_tag: "@mention".into(), + received_at: Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + } + } + + fn p_tag(pubkey: &str) -> nostr::Tag { + nostr::Tag::parse(["p", pubkey]).unwrap() + } + + fn agent_profiles(agents: &[&str], humans: &[&str]) -> PromptProfileLookup { + agents + .iter() + .map(|pk| (pk, true)) + .chain(humans.iter().map(|pk| (pk, false))) + .map(|(pk, is_agent)| { + ( + pk.to_string(), + PromptProfile { + is_agent, + ..Default::default() + }, + ) + }) + .collect() + } + + fn auto_title_prompt(batch: &FlushBatch, me: &str, profiles: &PromptProfileLookup) -> String { + format_prompt( + batch, + &FormatPromptArgs { + profile_lookup: Some(profiles), + agent_pubkey: Some(me), + ..Default::default() + }, + ) + .join("\n\n") + } + + #[test] + fn first_mentioned_agent_is_asked_to_title_a_new_thread() { + let ch = Uuid::new_v4(); + let (me, other, human) = ("a".repeat(64), "b".repeat(64), "c".repeat(64)); + // A human mention ahead of the agents doesn't count; agent order does. + let batch = auto_title_batch(ch, vec![p_tag(&human), p_tag(&me), p_tag(&other)]); + let profiles = agent_profiles(&[&me, &other], &[&human]); + let event_id = batch.events[0].event.id.to_hex(); + + let mine = auto_title_prompt(&batch, &me, &profiles); + assert!(mine.contains("THREAD TITLE:"), "got: {mine}"); + assert!(mine.contains(&format!( + "buzz threads title set --channel {ch} --event {event_id} --if-unset" + ))); + let theirs = auto_title_prompt(&batch, &other, &profiles); + assert!( + !theirs.contains("THREAD TITLE:"), + "only the first agent titles" + ); + } + + #[test] + fn unknown_profile_ahead_of_me_does_not_block_auto_title() { + let ch = Uuid::new_v4(); + let (me, unknown) = ("a".repeat(64), "d".repeat(64)); + let batch = auto_title_batch(ch, vec![p_tag(&unknown), p_tag(&me)]); + let prompt = auto_title_prompt(&batch, &me, &agent_profiles(&[&me], &[])); + assert!(prompt.contains("THREAD TITLE:")); + } + + #[test] + fn replies_agent_authors_dms_and_unmentioned_agents_never_auto_title() { + let ch = Uuid::new_v4(); + let me = "a".repeat(64); + let profiles = agent_profiles(&[&me], &[]); + + // A mention inside an existing thread never (re)names it. + let root = "f".repeat(64); + let reply = auto_title_batch( + ch, + vec![ + nostr::Tag::parse(["e", root.as_str(), "", "reply"]).unwrap(), + p_tag(&me), + ], + ); + assert!(!auto_title_prompt(&reply, &me, &profiles).contains("THREAD TITLE:")); + + // Not mentioned at all. + let unmentioned = auto_title_batch(ch, vec![]); + assert!(!auto_title_prompt(&unmentioned, &me, &profiles).contains("THREAD TITLE:")); + + // Started by another agent rather than a human. + let agent_batch = auto_title_batch(ch, vec![p_tag(&me)]); + let author = agent_batch.events[0].event.pubkey.to_hex(); + let with_agent_author = agent_profiles(&[&me, &author], &[]); + assert!(!auto_title_prompt(&agent_batch, &me, &with_agent_author).contains("THREAD TITLE:")); + + // DMs have no thread list to name. + let dm_info = PromptChannelInfo { + name: "dm".into(), + channel_type: "dm".into(), + ..Default::default() + }; + let dm = format_prompt( + &agent_batch, + &FormatPromptArgs { + channel_info: Some(&dm_info), + profile_lookup: Some(&profiles), + agent_pubkey: Some(&me), + ..Default::default() + }, + ) + .join("\n\n"); + assert!(!dm.contains("THREAD TITLE:")); + + // No identity wired in → feature off. + let off = format_prompt( + &agent_batch, + &FormatPromptArgs { + profile_lookup: Some(&profiles), + ..Default::default() + }, + ) + .join("\n\n"); + assert!(!off.contains("THREAD TITLE:")); + } } diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 1ef6d9fca41..cbc21ce1dfb 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -51,7 +51,10 @@ fn thread_ref_from_parent_tags( /// - Nested reply: `root` is the parent's own root marker; `parent` is unchanged. /// /// Ensures CLI-sent replies thread correctly using the same NIP-10 logic. -async fn fetch_event(client: &BuzzClient, event_id: &str) -> Result<serde_json::Value, CliError> { +pub(crate) async fn fetch_event( + client: &BuzzClient, + event_id: &str, +) -> Result<serde_json::Value, CliError> { let filter = serde_json::json!({ "ids": [event_id], "limit": 1 }); let raw = client.query(&filter).await?; let events: serde_json::Value = serde_json::from_str(&raw) diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index f7a3ebe9e0d..8ff03231327 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -20,6 +20,7 @@ pub mod reactions; mod repo_default_branch; pub mod repos; pub mod social; +pub mod threads; pub mod upload; pub mod users; pub mod workflows; diff --git a/crates/buzz-cli/src/commands/threads.rs b/crates/buzz-cli/src/commands/threads.rs new file mode 100644 index 00000000000..30bc142bcd7 --- /dev/null +++ b/crates/buzz-cli/src/commands/threads.rs @@ -0,0 +1,211 @@ +//! `buzz threads title …` — shared thread titles. The convention lives in +//! [`buzz_sdk::thread_title`] so the CLI and desktop write identical artifacts. + +use buzz_sdk::thread_title::{ + build_thread_title_revision, is_thread_title_head_conflict, normalize_thread_title, + parse_thread_title, plan_thread_title, select_thread_title_head, thread_title_current_filter, + thread_title_history_filter, thread_title_id, thread_title_list_filter, ThreadTitleHead, + MAX_THREAD_TITLE_WRITE_ATTEMPTS, +}; +use uuid::Uuid; + +use crate::client::{normalize_write_response, BuzzClient}; +use crate::error::CliError; +use crate::validate::{parse_uuid, validate_hex64}; + +fn parse_events(raw: &str) -> Result<Vec<nostr::Event>, CliError> { + serde_json::from_str(raw) + .map_err(|e| CliError::Other(format!("failed to parse artifact query response: {e}"))) +} + +/// Fetch the current head, including a deleted one (which the `current` view omits). +async fn fetch_head(client: &BuzzClient, root: &str) -> Result<Option<ThreadTitleHead>, CliError> { + let current = parse_events(&client.query(&thread_title_current_filter(root)).await?)?; + if let Some(event) = current.first() { + return Ok(Some(ThreadTitleHead::from_event(event))); + } + let history = parse_events(&client.query(&thread_title_history_filter(root)).await?)?; + Ok(select_thread_title_head(&history)) +} + +/// Resolve any message in a thread to the channel-checked thread root. +async fn resolve_root(client: &BuzzClient, channel: Uuid, event: &str) -> Result<String, CliError> { + validate_hex64(event)?; + let event = event.to_ascii_lowercase(); + let selected = super::messages::fetch_event(client, &event).await?; + super::messages::resolve_thread_target(channel, &event, None, &selected) +} + +fn is_head_conflict(err: &CliError) -> bool { + matches!(err, CliError::Relay { status: 409, body } if is_thread_title_head_conflict(body)) +} + +/// `--if-unset` writes only a thread's first title. Any existing head, including +/// a cleared one, means someone already decided, so it is left alone. +fn skip_existing_title(if_unset: bool, head: Option<&ThreadTitleHead>) -> bool { + if_unset && head.is_some() +} + +async fn write_title( + client: &BuzzClient, + channel: &str, + event: &str, + desired: Option<&str>, + if_unset: bool, +) -> Result<(), CliError> { + let channel = parse_uuid(channel)?; + let root = resolve_root(client, channel, event).await?; + let id = thread_title_id(&root).to_string(); + for attempt in 1..=MAX_THREAD_TITLE_WRITE_ATTEMPTS { + let head = fetch_head(client, &root).await?; + if skip_existing_title(if_unset, head.as_ref()) { + println!( + "{}", + serde_json::json!({ + "event_id": head.map(|h| h.event_id), + "accepted": true, + "message": "already titled", + "artifact": id, + "root": root, + }) + ); + return Ok(()); + } + let plan = plan_thread_title(head.as_ref(), desired); + let Some(builder) = build_thread_title_revision(channel, &root, &plan, desired) + .map_err(|e| CliError::Other(e.to_string()))? + else { + println!( + "{}", + serde_json::json!({ + "event_id": head.map(|h| h.event_id), + "accepted": true, + "message": "unchanged", + "artifact": id, + "root": root, + }) + ); + return Ok(()); + }; + let signed = client.sign_event(builder)?; + match client.submit_event(signed).await { + Ok(resp) => { + let mut out: serde_json::Value = + serde_json::from_str(&normalize_write_response(&resp)) + .unwrap_or_else(|_| serde_json::json!({"raw": resp})); + out["artifact"] = id.into(); + out["root"] = root.into(); + out["op"] = plan.op().into(); + println!("{out}"); + return Ok(()); + } + Err(e) if is_head_conflict(&e) && attempt < MAX_THREAD_TITLE_WRITE_ATTEMPTS => continue, + Err(e) if is_head_conflict(&e) => { + return Err(CliError::Conflict(format!( + "thread title for {root} kept changing during {MAX_THREAD_TITLE_WRITE_ATTEMPTS} attempts; re-run to apply" + ))) + } + Err(e) => return Err(e), + } + } + unreachable!("loop returns on its last attempt") +} + +async fn cmd_get(client: &BuzzClient, channel: &str, event: &str) -> Result<(), CliError> { + let channel = parse_uuid(channel)?; + let root = resolve_root(client, channel, event).await?; + let current = parse_events(&client.query(&thread_title_current_filter(&root)).await?)?; + let out = match current.first().and_then(parse_thread_title) { + Some(title) => serde_json::to_value(title) + .map_err(|e| CliError::Other(format!("failed to serialize title: {e}")))?, + None => serde_json::json!({ + "title": null, + "channel": channel.to_string(), + "root": root, + "artifact": thread_title_id(&root).to_string(), + }), + }; + println!("{out}"); + Ok(()) +} + +async fn cmd_list(client: &BuzzClient, channel: Option<&str>, limit: u32) -> Result<(), CliError> { + let channel = channel.map(parse_uuid).transpose()?; + let events = parse_events( + &client + .query(&thread_title_list_filter(channel, limit)) + .await?, + )?; + let titles: Vec<_> = events.iter().filter_map(parse_thread_title).collect(); + println!( + "{}", + serde_json::to_string(&titles) + .map_err(|e| CliError::Other(format!("failed to serialize titles: {e}")))? + ); + Ok(()) +} + +pub async fn dispatch(cmd: crate::ThreadsCmd, client: &BuzzClient) -> Result<(), CliError> { + use crate::{ThreadTitleCmd, ThreadsCmd}; + match cmd { + ThreadsCmd::Title(sub) => match sub { + ThreadTitleCmd::Set { + channel, + event, + title, + if_unset, + } => { + let title = normalize_thread_title(&title).map_err(|e| { + CliError::Usage(format!("{e} (use `threads title clear` to remove a title)")) + })?; + write_title(client, &channel, &event, Some(&title), if_unset).await + } + ThreadTitleCmd::Clear { channel, event } => { + write_title(client, &channel, &event, None, false).await + } + ThreadTitleCmd::Get { channel, event } => cmd_get(client, &channel, &event).await, + ThreadTitleCmd::List { channel, limit } => { + cmd_list(client, channel.as_deref(), limit).await + } + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_409_head_conflicts_retry() { + let relay = |status, body: &str| CliError::Relay { + status, + body: body.into(), + }; + assert!(is_head_conflict(&relay( + 409, + "conflict: artifact head changed" + ))); + assert!(is_head_conflict(&relay( + 409, + "conflict: artifact identity is taken" + ))); + assert!(!is_head_conflict(&relay( + 409, + "conflict: artifact home changed" + ))); + assert!(!is_head_conflict(&relay(400, "artifact head changed"))); + } + + #[test] + fn if_unset_skips_live_and_cleared_titles_but_not_untitled_threads() { + let head = |deleted| ThreadTitleHead { + event_id: "e".repeat(64), + deleted, + title: (!deleted).then(|| "Existing".into()), + }; + assert!(!skip_existing_title(true, None)); + assert!(skip_existing_title(true, Some(&head(false)))); + assert!(skip_existing_title(true, Some(&head(true)))); + assert!(!skip_existing_title(false, Some(&head(false)))); + } +} diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 1dc1438e1b0..b5cbbc4fccb 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -275,6 +275,9 @@ enum Cmd { /// Add, remove, and list emoji reactions #[command(subcommand)] Reactions(ReactionsCmd), + /// Shared, editable thread titles (NIP-AR artifacts) + #[command(subcommand)] + Threads(ThreadsCmd), /// Manage your custom emoji set (workspace palette is the union of all members' sets) #[command(subcommand)] Emoji(EmojiCmd), @@ -894,6 +897,60 @@ pub enum ReactionsCmd { }, } +#[derive(Subcommand)] +pub enum ThreadsCmd { + /// Set, clear, get, and list shared thread titles + #[command(subcommand)] + Title(ThreadTitleCmd), +} + +#[derive(Subcommand)] +pub enum ThreadTitleCmd { + /// Set or rename a thread's title (visible to everyone in the channel) + Set { + /// Channel UUID + #[arg(long)] + channel: String, + /// Any message in the thread (64-char hex); resolved to the thread root + #[arg(long)] + event: String, + /// Title text, at most 512 UTF-8 bytes + #[arg(long)] + title: String, + /// Only name a thread that has never had a title; an existing or + /// cleared title is left alone and reported as "already titled" + #[arg(long)] + if_unset: bool, + }, + /// Remove a thread's title + Clear { + /// Channel UUID + #[arg(long)] + channel: String, + /// Any message in the thread (64-char hex); resolved to the thread root + #[arg(long)] + event: String, + }, + /// Show a thread's current title + Get { + /// Channel UUID + #[arg(long)] + channel: String, + /// Any message in the thread (64-char hex); resolved to the thread root + #[arg(long)] + event: String, + }, + /// List titled threads, most recently titled first + List { + /// Restrict to one channel (default: every channel you can read) + #[arg(long)] + channel: Option<String>, + /// Maximum results (1–1000) + #[arg(long, default_value_t = 100, value_parser = clap::value_parser!(u32).range(1..=1000))] + limit: u32, + }, +} + #[derive(Subcommand)] pub enum EmojiCmd { /// List the workspace custom emoji palette (union of every member's set) @@ -2266,6 +2323,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { Cmd::Channels(sub) => commands::channels::dispatch(sub, &client, &cli.format).await, Cmd::Canvas(sub) => commands::channels::dispatch_canvas(sub, &client).await, Cmd::Reactions(sub) => commands::reactions::dispatch(sub, &client).await, + Cmd::Threads(sub) => commands::threads::dispatch(sub, &client).await, Cmd::Emoji(sub) => commands::emoji::dispatch(sub, &client).await, Cmd::Gifs(sub) => commands::gifs::dispatch(sub, &client).await, Cmd::Dms(sub) => commands::dms::dispatch(sub, &client).await, @@ -2472,6 +2530,7 @@ mod tests { "reactions", "repos", "social", + "threads", "upload", "users", "workflows", diff --git a/crates/buzz-sdk/Cargo.toml b/crates/buzz-sdk/Cargo.toml index da8fd0bd0e2..f66552d40ed 100644 --- a/crates/buzz-sdk/Cargo.toml +++ b/crates/buzz-sdk/Cargo.toml @@ -10,7 +10,8 @@ description = "Typed Nostr event builders for Buzz operations" [dependencies] buzz-core = { workspace = true } nostr = { workspace = true } -uuid = { workspace = true } +# v5 derives thread-title artifact ids (thread_title.rs) +uuid = { workspace = true, features = ["v5"] } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/buzz-sdk/src/lib.rs b/crates/buzz-sdk/src/lib.rs index f80e861c1e3..938cd4c4561 100644 --- a/crates/buzz-sdk/src/lib.rs +++ b/crates/buzz-sdk/src/lib.rs @@ -16,6 +16,7 @@ pub mod broker; pub mod builders; pub mod mentions; pub mod nip_oa; +pub mod thread_title; pub use builders::*; diff --git a/crates/buzz-sdk/src/thread_title.rs b/crates/buzz-sdk/src/thread_title.rs new file mode 100644 index 00000000000..97a4971f854 --- /dev/null +++ b/crates/buzz-sdk/src/thread_title.rs @@ -0,0 +1,485 @@ +//! Shared, editable thread titles stored as NIP-AR artifacts (kind 45010). +//! +//! Convention (client-defined; the relay treats it as opaque): +//! - `type` = [`THREAD_TITLE_TYPE`] +//! - `d` = UUIDv5([`THREAD_TITLE_NAMESPACE`], `"<type>:<lowercase root hex>"`), +//! so every client derives the same identity and a thread has at most one title. +//! The type is part of the name because NIP-AR never reuses a deleted `d`: +//! moving to another type name must yield fresh ids. +//! - `h` = channel, `root` = thread root, `title` = the title +//! - `content` = the title again, so a reader that ignores tags still sees it. +//! This does not make titles searchable: relay search does not index kind +//! 45010, and client search does not query it. +//! +//! The relay enforces permissions (same as posting in the channel) and +//! compare-and-swap ordering through `prev`; clearing is a soft `delete`, and +//! setting a cleared title is a `restore`. + +use std::collections::HashSet; + +use nostr::{Event, EventBuilder, Kind, Tag}; +use uuid::Uuid; + +use crate::SdkError; + +/// Artifact type for thread titles, as proposed upstream in block/buzz#6739. +/// NIP-AR reserves `buzz.*` for published Buzz client contracts; this one is +/// not published yet, and the relay does not enforce the reservation. +pub const THREAD_TITLE_TYPE: &str = "buzz.thread-title"; + +/// Namespace for deriving a thread title's artifact id from its type and root event id. +pub const THREAD_TITLE_NAMESPACE: Uuid = uuid::uuid!("2039f9bf-c754-483e-a7cf-56f1ec0e4abe"); + +/// Maximum title size in UTF-8 bytes (the NIP-AR `title` limit). +pub const MAX_THREAD_TITLE_BYTES: usize = 512; + +/// Writes that lose a compare-and-swap race re-read the head and retry this many times in total. +pub const MAX_THREAD_TITLE_WRITE_ATTEMPTS: usize = 3; + +const KIND_ARTIFACT: u16 = buzz_core::kind::KIND_ARTIFACT as u16; + +/// Derive the artifact id (`d`) for a thread root. +pub fn thread_title_id(root_event_id: &str) -> Uuid { + let name = format!("{THREAD_TITLE_TYPE}:{}", root_event_id.to_ascii_lowercase()); + Uuid::new_v5(&THREAD_TITLE_NAMESPACE, name.as_bytes()) +} + +/// Trim and bound a title; blank titles are rejected (clearing is a separate operation). +pub fn normalize_thread_title(title: &str) -> Result<String, SdkError> { + let title = title.trim(); + if title.is_empty() { + return Err(SdkError::InvalidInput("title must not be blank".into())); + } + if title.len() > MAX_THREAD_TITLE_BYTES { + return Err(SdkError::ContentTooLarge { + max: MAX_THREAD_TITLE_BYTES, + got: title.len(), + }); + } + Ok(title.to_string()) +} + +/// Whether a relay rejection is a lost compare-and-swap race worth re-reading and retrying. +pub fn is_thread_title_head_conflict(message: &str) -> bool { + message.contains("artifact head changed") || message.contains("artifact identity is taken") +} + +/// HTTP `/query` filter for one thread's live title (deleted titles are omitted). +pub fn thread_title_current_filter(root_event_id: &str) -> serde_json::Value { + serde_json::json!({ + "artifact": "current", + "#d": [thread_title_id(root_event_id).to_string()], + "limit": 1, + }) +} + +/// HTTP `/query` filter for every revision of one thread's title, including deletions. +pub fn thread_title_history_filter(root_event_id: &str) -> serde_json::Value { + serde_json::json!({ + "artifact": "history", + "#d": [thread_title_id(root_event_id).to_string()], + "limit": 1000, + }) +} + +/// HTTP `/query` filter for live titles, newest revision first, optionally in one channel. +pub fn thread_title_list_filter(channel: Option<Uuid>, limit: u32) -> serde_json::Value { + let mut filter = serde_json::json!({ + "artifact": "current", + "#type": [THREAD_TITLE_TYPE], + "limit": limit, + }); + if let Some(channel) = channel { + filter["#h"] = serde_json::json!([channel.to_string()]); + } + filter +} + +/// Most roots one [`thread_titles_for_roots_filter`] should name; the relay +/// caps an artifact query at 256 predicate values. +pub const MAX_THREAD_TITLE_ROOTS_PER_QUERY: usize = 100; + +/// HTTP `/query` filter for the live titles of specific thread roots. Looks +/// titles up by their derived `d`, so it never depends on how many other +/// titles exist. +pub fn thread_titles_for_roots_filter(root_event_ids: &[&str]) -> serde_json::Value { + let ids: Vec<String> = root_event_ids + .iter() + .map(|root| thread_title_id(root).to_string()) + .collect(); + serde_json::json!({ + "artifact": "current", + "#type": [THREAD_TITLE_TYPE], + "#d": ids, + "limit": ids.len().max(1), + }) +} + +fn tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> { + event.tags.iter().find_map(|tag| match tag.as_slice() { + [key, value, ..] if key == name => Some(value.as_str()), + _ => None, + }) +} + +/// A live thread title as read from its current revision. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct ThreadTitle { + /// The title text. + pub title: String, + /// Home channel UUID. + pub channel: String, + /// Thread root event id. + pub root: String, + /// Artifact id (`d`). + pub artifact: String, + /// Current revision event id. + pub revision: String, + /// Pubkey of whoever last set the title. + pub author: String, + /// Unix seconds of the current revision. + pub updated_at: u64, +} + +/// Read a thread title from a current revision; `None` for other types, deletions, or malformed events. +pub fn parse_thread_title(event: &Event) -> Option<ThreadTitle> { + if event.kind.as_u16() != KIND_ARTIFACT + || tag_value(event, "type") != Some(THREAD_TITLE_TYPE) + || tag_value(event, "op") == Some("delete") + { + return None; + } + Some(ThreadTitle { + title: tag_value(event, "title")?.to_string(), + channel: tag_value(event, "h")?.to_string(), + root: tag_value(event, "root")?.to_string(), + artifact: tag_value(event, "d")?.to_string(), + revision: event.id.to_hex(), + author: event.pubkey.to_hex(), + updated_at: event.created_at.as_secs(), + }) +} + +/// The artifact's current accepted revision, as seen by this reader. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ThreadTitleHead { + /// Revision event id, used as the next write's `prev`. + pub event_id: String, + /// Whether the head is a deletion (only `restore` may follow). + pub deleted: bool, + /// Title on the head, absent on deletions. + pub title: Option<String>, +} + +impl ThreadTitleHead { + /// The head represented by one revision. + pub fn from_event(event: &Event) -> Self { + Self { + event_id: event.id.to_hex(), + deleted: tag_value(event, "op") == Some("delete"), + title: tag_value(event, "title").map(str::to_string), + } + } +} + +/// Pick the head of a revision history: the revision no other revision names +/// in `prev`. Redaction can hide a link and leave several candidates; the +/// newest wins, and the relay's `prev` check rejects the write if it is stale. +pub fn select_thread_title_head(revisions: &[Event]) -> Option<ThreadTitleHead> { + let superseded: HashSet<&str> = revisions + .iter() + .filter_map(|e| tag_value(e, "prev")) + .collect(); + revisions + .iter() + .filter(|e| !superseded.contains(e.id.to_hex().as_str())) + .max_by_key(|e| e.created_at) + .map(ThreadTitleHead::from_event) +} + +/// What a write must do, given the current head. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ThreadTitlePlan { + /// No artifact yet. + Create, + /// Rename a live title. + Update { + /// Current head. + prev: String, + }, + /// Set a title that was cleared. + Restore { + /// Current (deletion) head. + prev: String, + }, + /// Clear a live title. + Delete { + /// Current head. + prev: String, + }, + /// Already in the requested state. + Unchanged, +} + +impl ThreadTitlePlan { + /// The NIP-AR `op` for this plan. + pub fn op(&self) -> &'static str { + match self { + Self::Create => "create", + Self::Update { .. } => "update", + Self::Restore { .. } => "restore", + Self::Delete { .. } => "delete", + Self::Unchanged => "unchanged", + } + } +} + +/// Plan a write; `desired = None` clears the title. +pub fn plan_thread_title(head: Option<&ThreadTitleHead>, desired: Option<&str>) -> ThreadTitlePlan { + match (head, desired) { + (None, Some(_)) => ThreadTitlePlan::Create, + (None, None) => ThreadTitlePlan::Unchanged, + (Some(h), None) if h.deleted => ThreadTitlePlan::Unchanged, + (Some(h), None) => ThreadTitlePlan::Delete { + prev: h.event_id.clone(), + }, + (Some(h), Some(_)) if h.deleted => ThreadTitlePlan::Restore { + prev: h.event_id.clone(), + }, + (Some(h), Some(t)) if h.title.as_deref() == Some(t) => ThreadTitlePlan::Unchanged, + (Some(h), Some(_)) => ThreadTitlePlan::Update { + prev: h.event_id.clone(), + }, + } +} + +/// Build the revision for `plan`. Returns `None` for [`ThreadTitlePlan::Unchanged`]. +pub fn build_thread_title_revision( + channel: Uuid, + root_event_id: &str, + plan: &ThreadTitlePlan, + title: Option<&str>, +) -> Result<Option<EventBuilder>, SdkError> { + let prev = match plan { + ThreadTitlePlan::Unchanged => return Ok(None), + ThreadTitlePlan::Create => None, + ThreadTitlePlan::Update { prev } + | ThreadTitlePlan::Restore { prev } + | ThreadTitlePlan::Delete { prev } => Some(prev.as_str()), + }; + let root = root_event_id.to_ascii_lowercase(); + let id = thread_title_id(&root).to_string(); + let channel = channel.to_string(); + let mut parts: Vec<[&str; 2]> = vec![ + ["ar", "1"], + ["d", &id], + ["h", &channel], + ["type", THREAD_TITLE_TYPE], + ["op", plan.op()], + ["root", &root], + ]; + if let Some(prev) = prev { + parts.push(["prev", prev]); + } + let content = if matches!(plan, ThreadTitlePlan::Delete { .. }) { + "" + } else { + let title = title + .ok_or_else(|| SdkError::InvalidInput("title required for this operation".into()))?; + parts.push(["title", title]); + title + }; + let tags = parts + .into_iter() + .map(|t| Tag::parse(t).map_err(|e| SdkError::InvalidTag(e.to_string()))) + .collect::<Result<Vec<_>, _>>()?; + Ok(Some( + EventBuilder::new(Kind::Custom(KIND_ARTIFACT), content).tags(tags), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{Keys, Timestamp}; + + const ROOT: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + fn revision( + keys: &Keys, + created_at: u64, + plan: &ThreadTitlePlan, + title: Option<&str>, + ) -> Event { + build_thread_title_revision(Uuid::new_v4(), ROOT, plan, title) + .unwrap() + .unwrap() + .custom_created_at(Timestamp::from(created_at)) + .sign_with_keys(keys) + .unwrap() + } + + #[test] + fn id_is_uuidv5_of_type_and_root_hex() { + // Independently computed with Python's uuid.uuid5(namespace, "buzz.thread-title:" + root). + assert_eq!( + thread_title_id(ROOT).to_string(), + "0d943738-f875-5089-91fe-135fe16d0f72" + ); + assert_eq!(thread_title_id(&ROOT.to_uppercase()), thread_title_id(ROOT)); + } + + #[test] + fn head_follows_prev_chain_not_timestamps() { + let keys = Keys::generate(); + let a = revision(&keys, 100, &ThreadTitlePlan::Create, Some("one")); + let b = revision( + &keys, + 300, + &ThreadTitlePlan::Update { + prev: a.id.to_hex(), + }, + Some("two"), + ); + // Older timestamp, but it supersedes `b`. + let c = revision( + &keys, + 200, + &ThreadTitlePlan::Delete { + prev: b.id.to_hex(), + }, + None, + ); + let head = select_thread_title_head(&[a, b, c.clone()]).unwrap(); + assert_eq!(head.event_id, c.id.to_hex()); + assert!(head.deleted); + assert_eq!(select_thread_title_head(&[]), None); + } + + #[test] + fn plan_covers_lifecycle() { + use ThreadTitlePlan::*; + let live = ThreadTitleHead { + event_id: "h".into(), + deleted: false, + title: Some("x".into()), + }; + let gone = ThreadTitleHead { + event_id: "h".into(), + deleted: true, + title: None, + }; + let prev = || "h".to_string(); + assert_eq!(plan_thread_title(None, Some("x")), Create); + assert_eq!(plan_thread_title(None, None), Unchanged); + assert_eq!(plan_thread_title(Some(&live), Some("x")), Unchanged); + assert_eq!( + plan_thread_title(Some(&live), Some("y")), + Update { prev: prev() } + ); + assert_eq!( + plan_thread_title(Some(&live), None), + Delete { prev: prev() } + ); + assert_eq!( + plan_thread_title(Some(&gone), Some("y")), + Restore { prev: prev() } + ); + assert_eq!(plan_thread_title(Some(&gone), None), Unchanged); + } + + /// Every op emitted must pass the relay's own envelope validator. + #[test] + fn revisions_pass_relay_validation() { + let channel = Uuid::new_v4(); + let prev = "b".repeat(64); + for (plan, title) in [ + (ThreadTitlePlan::Create, Some("Release checklist")), + ( + ThreadTitlePlan::Update { prev: prev.clone() }, + Some("Renamed"), + ), + ( + ThreadTitlePlan::Restore { prev: prev.clone() }, + Some("Back"), + ), + (ThreadTitlePlan::Delete { prev: prev.clone() }, None), + ] { + let event = build_thread_title_revision(channel, &ROOT.to_uppercase(), &plan, title) + .unwrap() + .unwrap() + .sign_with_keys(&Keys::generate()) + .unwrap(); + let env = + buzz_core::artifact::validate(&event).unwrap_or_else(|e| panic!("{plan:?}: {e}")); + assert_eq!(env.id, thread_title_id(ROOT)); + assert_eq!(env.home, channel); + assert_eq!(env.artifact_type, THREAD_TITLE_TYPE); + assert_eq!(env.root.as_deref(), Some([0xaa; 32].as_slice())); + assert_eq!(event.content, title.unwrap_or("")); + assert_eq!( + parse_thread_title(&event).map(|t| t.title), + title.map(str::to_string) + ); + } + assert!( + build_thread_title_revision(channel, ROOT, &ThreadTitlePlan::Unchanged, Some("x")) + .unwrap() + .is_none() + ); + } + + #[test] + fn parse_ignores_other_artifact_types() { + let event = EventBuilder::new(Kind::Custom(KIND_ARTIFACT), "x") + .tags([ + Tag::parse(["type", "buzz.task"]).unwrap(), + Tag::parse(["title", "x"]).unwrap(), + ]) + .sign_with_keys(&Keys::generate()) + .unwrap(); + assert_eq!(parse_thread_title(&event), None); + } + + #[test] + fn title_normalization() { + assert_eq!(normalize_thread_title(" Spaced ").unwrap(), "Spaced"); + assert!(normalize_thread_title(" ").is_err()); + assert!(normalize_thread_title(&"é".repeat(256)).is_ok()); // 512 bytes + assert!(normalize_thread_title(&"é".repeat(257)).is_err()); + } + + #[test] + fn roots_filter_names_each_derived_id() { + let other = "b".repeat(64); + let filter = thread_titles_for_roots_filter(&[ROOT, &other]); + assert_eq!(filter["artifact"], "current"); + assert_eq!(filter["#type"], serde_json::json!([THREAD_TITLE_TYPE])); + assert_eq!( + filter["#d"], + serde_json::json!([ + thread_title_id(ROOT).to_string(), + thread_title_id(&other).to_string() + ]) + ); + assert_eq!(filter["limit"], 2); + // A limit of 0 is rejected by the relay. + assert_eq!(thread_titles_for_roots_filter(&[])["limit"], 1); + } + + #[test] + fn only_cas_losses_are_retryable() { + assert!(is_thread_title_head_conflict( + "conflict: artifact head changed" + )); + assert!(is_thread_title_head_conflict( + "conflict: artifact identity is taken" + )); + assert!(!is_thread_title_head_conflict( + "conflict: artifact home changed" + )); + assert!(!is_thread_title_head_conflict( + "invalid: delete preserves root" + )); + } +} diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index bb26f558802..adcf3a37f7b 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -35,6 +35,7 @@ export default defineConfig({ "**/channels.spec.ts", "**/channel-shared-header-backdrop.spec.ts", "**/auxiliary-pane-close-visibility.spec.ts", + "**/thread-titles.spec.ts", "**/channel-composer-overflow.spec.ts", "**/badge.spec.ts", "**/channel-browser.spec.ts", diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 01b1ddc8828..68694efbfa4 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -70,6 +70,7 @@ mod relay_reconnect; mod social; mod team_snapshot; mod teams; +mod thread_titles; mod updater; mod window_chrome; mod window_vibrancy; @@ -129,6 +130,7 @@ pub use relay_reconnect::*; pub use social::*; pub use team_snapshot::*; pub use teams::*; +pub use thread_titles::*; pub use updater::*; pub use window_chrome::*; pub use window_vibrancy::*; diff --git a/desktop/src-tauri/src/commands/thread_titles.rs b/desktop/src-tauri/src/commands/thread_titles.rs new file mode 100644 index 00000000000..f468472ea3a --- /dev/null +++ b/desktop/src-tauri/src/commands/thread_titles.rs @@ -0,0 +1,207 @@ +//! Shared thread titles (NIP-AR artifacts). The convention lives in +//! `buzz_sdk::thread_title` so the desktop and the `buzz` CLI write identical events. +//! +//! Artifact queries are HTTP-only on the relay, so these run in Rust rather +//! than through the TS WebSocket client. + +use std::collections::{BTreeSet, HashSet}; + +use buzz_sdk_pkg::thread_title::{ + build_thread_title_revision, is_thread_title_head_conflict, normalize_thread_title, + parse_thread_title, plan_thread_title, select_thread_title_head, thread_title_current_filter, + thread_title_history_filter, thread_title_id, thread_titles_for_roots_filter, ThreadTitle, + ThreadTitleHead, MAX_THREAD_TITLE_ROOTS_PER_QUERY, +}; +use tauri::State; + +use crate::{ + app_state::AppState, + relay::{query_relay, submit_event}, +}; + +const CONFLICT_MESSAGE: &str = + "conflict: someone else changed this thread's title while you were editing"; + +#[derive(Debug, serde::Serialize)] +pub struct SetThreadTitleResult { + title: Option<String>, + /// The live revision after the write; `None` when the title is cleared. + revision: Option<String>, +} + +fn validate_root(root_id: &str) -> Result<String, String> { + let root = root_id.to_ascii_lowercase(); + if root.len() != 64 || !root.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(format!("invalid thread root id: {root_id}")); + } + Ok(root) +} + +/// Live titles among `events` for the requested roots. A title only counts +/// when its `d` is the one derived from its root, so a title naming a root it +/// wasn't derived from can't stand in for that thread's real one. +fn titles_for_roots(events: &[nostr::Event], roots: &HashSet<String>) -> Vec<ThreadTitle> { + events + .iter() + .filter_map(parse_thread_title) + .filter(|title| { + let root = title.root.to_ascii_lowercase(); + roots.contains(&root) && title.artifact == thread_title_id(&root).to_string() + }) + .collect() +} + +/// Live titles for specific thread roots (untitled roots are omitted). Looks +/// each one up by its derived `d`, so it stays exact however many titles the +/// user can read. +#[tauri::command] +pub async fn get_thread_titles( + root_ids: Vec<String>, + state: State<'_, AppState>, +) -> Result<Vec<ThreadTitle>, String> { + let roots: Vec<String> = root_ids + .iter() + .map(|id| validate_root(id)) + .collect::<Result<BTreeSet<_>, _>>()? + .into_iter() + .collect(); + let mut titles = Vec::new(); + for batch in roots.chunks(MAX_THREAD_TITLE_ROOTS_PER_QUERY) { + let ids: Vec<&str> = batch.iter().map(String::as_str).collect(); + let events = query_relay(&state, &[thread_titles_for_roots_filter(&ids)]).await?; + titles.extend(titles_for_roots(&events, &batch.iter().cloned().collect())); + } + Ok(titles) +} + +async fn fetch_head(state: &AppState, root: &str) -> Result<Option<ThreadTitleHead>, String> { + let current = query_relay(state, &[thread_title_current_filter(root)]).await?; + if let Some(event) = current.first() { + return Ok(Some(ThreadTitleHead::from_event(event))); + } + let history = query_relay(state, &[thread_title_history_filter(root)]).await?; + Ok(select_thread_title_head(&history)) +} + +/// An edit is only valid against the title the user started from. `None` +/// means they started from an untitled thread (never titled, or cleared). +fn check_expected(head: Option<&ThreadTitleHead>, expected: Option<&str>) -> Result<(), String> { + let live = head.filter(|h| !h.deleted).map(|h| h.event_id.as_str()); + if live == expected { + Ok(()) + } else { + Err(CONFLICT_MESSAGE.into()) + } +} + +/// Set (`title = Some`) or clear (`title = None`) a thread's shared title, +/// provided it is still at `expected_revision`. Never overwrites a concurrent +/// edit: a lost race is reported as a conflict rather than retried. +#[tauri::command] +pub async fn set_thread_title( + channel_id: String, + root_id: String, + title: Option<String>, + expected_revision: Option<String>, + state: State<'_, AppState>, +) -> Result<SetThreadTitleResult, String> { + let channel = uuid::Uuid::parse_str(&channel_id) + .map_err(|_| format!("invalid channel UUID: {channel_id}"))?; + let root = validate_root(&root_id)?; + let desired = title + .as_deref() + .map(normalize_thread_title) + .transpose() + .map_err(|e| e.to_string())?; + let head = fetch_head(&state, &root).await?; + check_expected(head.as_ref(), expected_revision.as_deref())?; + let plan = plan_thread_title(head.as_ref(), desired.as_deref()); + let Some(builder) = build_thread_title_revision(channel, &root, &plan, desired.as_deref()) + .map_err(|e| e.to_string())? + else { + return Ok(SetThreadTitleResult { + revision: head.filter(|h| !h.deleted).map(|h| h.event_id), + title: desired, + }); + }; + match submit_event(builder, &state).await { + Ok(response) => Ok(SetThreadTitleResult { + revision: desired.as_ref().map(|_| response.event_id), + title: desired, + }), + // The relay's `prev` check caught an edit that landed after our read. + Err(e) if is_thread_title_head_conflict(&e) => Err(CONFLICT_MESSAGE.into()), + Err(e) => Err(e), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + #[test] + fn edits_must_start_from_the_live_title() { + let live = ThreadTitleHead { + event_id: "rev-a".into(), + deleted: false, + title: Some("A".into()), + }; + let cleared = ThreadTitleHead { + event_id: "rev-d".into(), + deleted: true, + title: None, + }; + assert!(check_expected(Some(&live), Some("rev-a")).is_ok()); + assert!(check_expected(None, None).is_ok()); + assert!(check_expected(Some(&cleared), None).is_ok()); + // Someone renamed, titled, or cleared it after the editor opened. + assert!(check_expected(Some(&live), Some("rev-old")).is_err()); + assert!(check_expected(Some(&live), None).is_err()); + assert!(check_expected(Some(&cleared), Some("rev-a")).is_err()); + } + + fn title_event(root: &str, d: &str) -> nostr::Event { + EventBuilder::new(Kind::Custom(45010), "t") + .tags( + [ + ["type", "buzz.thread-title"], + ["d", d], + ["h", "c"], + ["root", root], + ["title", "t"], + ] + .iter() + .map(|t| Tag::parse(*t).unwrap()), + ) + .sign_with_keys(&Keys::generate()) + .unwrap() + } + + #[test] + fn root_lookup_keeps_only_titles_derived_from_their_root() { + let asked = "a".repeat(64); + let other = "b".repeat(64); + let roots: HashSet<String> = [asked.clone()].into(); + let id = |root: &str| thread_title_id(root).to_string(); + let found = titles_for_roots( + &[ + title_event(&asked, &id(&asked)), + // Names the asked root under another thread's id. + title_event(&asked, &id(&other)), + // A root nobody asked for. + title_event(&other, &id(&other)), + ], + &roots, + ); + assert_eq!(found.len(), 1); + assert_eq!(found[0].artifact, id(&asked)); + } + + #[test] + fn root_ids_are_validated_and_lowercased() { + assert_eq!(validate_root(&"A".repeat(64)).unwrap(), "a".repeat(64)); + assert!(validate_root("abc").is_err()); + assert!(validate_root(&"g".repeat(64)).is_err()); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 4497eb78e56..49671bed1d9 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -650,6 +650,8 @@ pub fn run() { get_canvas, get_canvas_history, set_canvas, + get_thread_titles, + set_thread_title, get_feed, search_messages, send_channel_message, diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index a29a0e927dc..3a890fd10a6 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -14,6 +14,7 @@ import type { InboxItem, InboxReply, } from "@/features/home/lib/inbox"; +import { useThreadTitleForRoot } from "@/features/threads/hooks"; import { getProjectInboxReference } from "@/features/home/lib/projectInbox"; import { ProjectInboxDetail } from "@/features/home/ui/ProjectInboxDetail"; import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar"; @@ -434,6 +435,9 @@ function InboxMessageDetailPane({ settleAtBottomAfterLayout, ); + // Looked up before the empty-state return; only used for thread contexts. + const sharedThreadTitle = useThreadTitleForRoot(item?.conversationId ?? null); + if (!item) { return ( <section @@ -503,11 +507,15 @@ function InboxMessageDetailPane({ ?.tags ?? []) : []; const contextLabel = isThreadContext - ? isDirectMessage - ? `Thread with ${item.senderLabel}` - : channelContextName - ? `Thread in #${channelContextName}` - : "Thread" + ? sharedThreadTitle + ? isDirectMessage || !channelContextName + ? sharedThreadTitle + : `${sharedThreadTitle} · #${channelContextName}` + : isDirectMessage + ? `Thread with ${item.senderLabel}` + : channelContextName + ? `Thread in #${channelContextName}` + : "Thread" : isDirectMessage ? `DM with ${item.senderLabel}` : channelContextName diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index d40ba26b29c..f9155808b48 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -51,6 +51,7 @@ import { Switch } from "@/shared/ui/switch"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { VirtualizedList } from "@/shared/ui/VirtualizedList"; +import { ThreadTitleLine } from "@/features/threads/ui/ThreadTitleLine"; const INBOX_EMPTY_STATE_TITLES: Record<InboxFilter, string> = { all: "No activity yet", @@ -444,6 +445,7 @@ export function InboxListPane({ isDone={isDone} label={typeLabel} /> + <ThreadTitleLine className="mt-1" rootId={item.conversationId} /> {dueReminder ? ( <div className="mt-1 flex items-center gap-1 text-2xs font-medium text-amber-600/80 dark:text-amber-300/80" diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 75b7d0d3391..c7ac6d957ef 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -60,6 +60,7 @@ import { SentFromThreadLine } from "./SentFromThreadLine"; import { WaveMessageAttachment } from "./WaveMessageAttachment"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { useMessageAgentAddressPrefix } from "./MessageAgentAddressPrefix"; +import { ThreadTitleLine } from "@/features/threads/ui/ThreadTitleLine"; const DiffMessage = React.lazy(() => import("./DiffMessage")); const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded")); export type ThreadDepthGuideAction = { @@ -105,9 +106,11 @@ export const MessageRow = React.memo( onEntranceComplete, playEntrance = false, onUnfollowThread, + onOpenThreadTitle, profiles, searchQuery, showDepthGuides = true, + threadTitleRootId = null, videoReviewCommentRootId, videoReviewContext, }: { @@ -159,6 +162,9 @@ export const MessageRow = React.memo( profiles?: UserProfileLookup; searchQuery?: string; showDepthGuides?: boolean; + /** Thread root whose shared title shows under the author, if titled. */ + threadTitleRootId?: string | null; + onOpenThreadTitle?: () => void; videoReviewCommentRootId?: string; videoReviewContext?: VideoReviewContext; }) { @@ -679,6 +685,13 @@ export const MessageRow = React.memo( /> </MessageHeaderRow> ); + const threadTitleNode = threadTitleRootId ? ( + <ThreadTitleLine + className="mt-0.5" + onOpen={onOpenThreadTitle} + rootId={threadTitleRootId} + /> + ) : null; const bodyContainerClass = isDisplayedAsContinuation ? "mt-0" : bodyOffsetClass; @@ -916,6 +929,7 @@ export const MessageRow = React.memo( {avatarGutterNode} <div className="flex min-w-0 flex-1 flex-col"> {headerNode} + {threadTitleNode} <div className={bodyContainerClass} data-testid="message-body"> {messageBodyNode} </div> @@ -926,6 +940,7 @@ export const MessageRow = React.memo( {avatarGutterNode} <div className="flex min-w-0 flex-1 flex-col"> {headerNode} + {threadTitleNode} <div className={bodyContainerClass} data-testid="message-body"> {messageBodyNode} </div> @@ -998,6 +1013,7 @@ export const MessageRow = React.memo( prev.onSendToChannel === next.onSendToChannel && prev.profiles === next.profiles && prev.searchQuery === next.searchQuery && + prev.threadTitleRootId === next.threadTitleRootId && prev.videoReviewCommentRootId === next.videoReviewCommentRootId && prev.videoReviewContext === next.videoReviewContext, ); diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index bec44224d71..c5db680013b 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -16,6 +16,7 @@ import type { MessageComposerEditTarget } from "@/features/messages/ui/MessageCo import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage"; import { handleTimelineMentionCopy } from "@/features/messages/lib/timelineMentionCopy"; import type { TimelineMessage } from "@/features/messages/types"; +import { useThreadTitleLiveSync } from "@/features/threads/hooks"; import type { VideoReviewPresentation } from "@/features/messages/lib/videoReviewContext"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { Channel } from "@/shared/api/types"; @@ -223,6 +224,20 @@ export function MessageThreadPanel({ >(null); const isOverlay = useIsThreadPanelOverlay(); const threadHeadId = threadHead?.id ?? null; + // A sub-thread head can be a nested reply; the title belongs to the root. + const threadRootId = threadHead ? (threadHead.rootId ?? threadHead.id) : null; + const threadTitleTarget = React.useMemo( + () => + channelId && threadRootId && !disabled + ? { channelId, rootId: threadRootId } + : null, + [channelId, disabled, threadRootId], + ); + const titleSyncChannels = React.useMemo( + () => (isHuddleTranscript || !channelId ? [] : [channelId]), + [channelId, isHuddleTranscript], + ); + useThreadTitleLiveSync(titleSyncChannels); useEscapeKey( onClose, !isHuddleTranscript && (isOverlay || isSinglePanelView || isFocusMode), @@ -916,6 +931,7 @@ export function MessageThreadPanel({ onClose={onClose} onHeaderTitleClick={onHeaderTitleClick} showBackButton={showBackButton} + threadTitleTarget={threadTitleTarget} /> ) } diff --git a/desktop/src/features/messages/ui/MessageThreadPanelSkeleton.tsx b/desktop/src/features/messages/ui/MessageThreadPanelSkeleton.tsx index 763591983d4..6cfe2e44822 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanelSkeleton.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanelSkeleton.tsx @@ -5,6 +5,10 @@ import { THREAD_PANEL_COMPOSER_GUTTER_CLASS, THREAD_PANEL_MESSAGE_GUTTER_CLASS, } from "@/features/messages/lib/messageThreadPanelLayout"; +import { + ThreadTitleEditor, + type ThreadTitleTarget, +} from "@/features/threads/ui/ThreadTitleEditor"; import { useEscapeKey } from "@/shared/hooks/useEscapeKey"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; import { cn } from "@/shared/lib/cn"; @@ -27,6 +31,7 @@ export function MessageThreadPanelHeader({ onClose, onHeaderTitleClick, showBackButton, + threadTitleTarget, }: { headerLeading?: React.ReactNode; headerTitle?: string; @@ -36,8 +41,10 @@ export function MessageThreadPanelHeader({ onClose: () => void; onHeaderTitleClick?: () => void; showBackButton?: boolean; + /** When set, the title shows and edits this thread's shared title. */ + threadTitleTarget?: ThreadTitleTarget | null; }) { - const title = onHeaderTitleClick ? ( + const panelTitle = onHeaderTitleClick ? ( <button aria-label={headerTitleAriaLabel ?? `Open ${headerTitle}`} className="min-w-0 max-w-full truncate text-left hover:underline" @@ -51,6 +58,15 @@ export function MessageThreadPanelHeader({ ) : ( headerTitle ); + const title = threadTitleTarget ? ( + <ThreadTitleEditor + fallback={panelTitle} + keepFallback={Boolean(onHeaderTitleClick)} + target={threadTitleTarget} + /> + ) : ( + panelTitle + ); return ( <AuxiliaryPanelHeader backdrop> diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index cecb291883d..8ed9acb5387 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -9,6 +9,7 @@ import { import { handleTimelineMentionCopy } from "@/features/messages/lib/timelineMentionCopy"; import { preloadTimelineImages } from "@/features/messages/lib/timelineImagePreload"; import type { TimelineMessage } from "@/features/messages/types"; +import { useThreadTitleLiveSync } from "@/features/threads/hooks"; import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; @@ -218,6 +219,13 @@ const MessageTimelineBase = React.forwardRef< }: MessageTimelineProps, ref, ) { + // Thread roots show their shared titles, which anyone (often an agent + // naming a new thread) can change while the channel is open. + const titleSyncChannels = React.useMemo( + () => (channelId ? [channelId] : []), + [channelId], + ); + useThreadTitleLiveSync(titleSyncChannels); const internalScrollRef = React.useRef<HTMLDivElement>(null); const scrollContainerRef = externalScrollRef ?? internalScrollRef; const contentRef = React.useRef<HTMLDivElement>(null); diff --git a/desktop/src/features/messages/ui/TimelineMessageRow.tsx b/desktop/src/features/messages/ui/TimelineMessageRow.tsx index e123c251ffd..273c29c7cd9 100644 --- a/desktop/src/features/messages/ui/TimelineMessageRow.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageRow.tsx @@ -134,6 +134,8 @@ export function MessageRowItem({ if (summary && onOpenThread) { const isHighlighted = message.id === highlightedMessageId; + // Titles belong to whole threads, so nested sub-thread heads show none. + const isThreadRoot = !message.rootId || message.rootId === message.id; return ( <div className={cn( @@ -175,8 +177,10 @@ export function MessageRowItem({ ? () => unfollowThreadById(effectiveThreadRootId) : undefined } + onOpenThreadTitle={() => onOpenThread(message)} profiles={profiles} showDepthGuides={false} + threadTitleRootId={isThreadRoot ? message.id : null} videoReviewContext={videoReviewContext} /> <MessageThreadSummaryRow @@ -195,6 +199,9 @@ export function MessageRowItem({ const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false; const isSearchActive = message.id === searchActiveMessageId; + // A root with no replies yet can already be titled (agents name the thread + // before they answer), so it shows the title too. + const isTopLevel = !message.rootId; return ( <div @@ -235,9 +242,13 @@ export function MessageRowItem({ ? () => unfollowThreadById(effectiveThreadRootId) : undefined } + onOpenThreadTitle={ + isTopLevel && onOpenThread ? () => onOpenThread(message) : undefined + } profiles={profiles} searchQuery={isSearchMatch ? searchQuery : undefined} showDepthGuides={false} + threadTitleRootId={isTopLevel ? message.id : null} videoReviewContext={videoReviewContext} /> {footer} diff --git a/desktop/src/features/threads/hooks.ts b/desktop/src/features/threads/hooks.ts new file mode 100644 index 00000000000..6d359e7a51e --- /dev/null +++ b/desktop/src/features/threads/hooks.ts @@ -0,0 +1,169 @@ +import * as React from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { + applyRootTitleWrite, + batchTitleSyncChannels, + createThreadTitleBatcher, + describeTitleSaveError, + THREAD_TITLES_QUERY_KEY, + threadTitleRootQueryKey, +} from "@/features/threads/lib/threadTitles"; +import { relayClient } from "@/shared/api/relayClient"; +import { + getThreadTitles, + setThreadTitle, + type ThreadTitleRecord, +} from "@/shared/api/tauriThreadTitles"; +import { KIND_ARTIFACT, KIND_ARTIFACT_REMOVAL } from "@/shared/constants/kinds"; + +const LIVE_RETRY_MS = 5_000; +const THREAD_TITLE_STALE_MS = 30_000; + +const loadThreadTitle = createThreadTitleBatcher(getThreadTitles); + +/** + * One root's live title, looked up by its derived id, so it stays exact + * however many titles exist. Lookups made in the same tick share one relay + * query. + */ +function useThreadTitleRecord(rootId: string | null) { + const root = rootId?.toLowerCase() ?? null; + const query = useQuery({ + queryKey: threadTitleRootQueryKey(root ?? ""), + queryFn: () => loadThreadTitle(root ?? ""), + enabled: root !== null, + staleTime: THREAD_TITLE_STALE_MS, + }); + return root === null ? null : (query.data ?? null); +} + +export function useThreadTitle( + channelId: string | null, + rootId: string | null, +): ThreadTitleRecord | null { + const title = useThreadTitleRecord(rootId); + return channelId && title?.channelId === channelId ? title : null; +} + +/** + * Title for a thread root without knowing its channel; `null` skips the + * lookup. Event ids are unique and the relay only accepts a title whose root + * exists in its home channel, so the root alone identifies the thread. + */ +export function useThreadTitleForRoot(rootId: string | null): string | null { + return useThreadTitleRecord(rootId)?.title ?? null; +} + +export function useSetThreadTitleMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: setThreadTitle, + onMutate: async (write) => { + const rootKey = threadTitleRootQueryKey(write.rootId); + await queryClient.cancelQueries({ queryKey: rootKey }); + const previousRecord = queryClient.getQueryData<ThreadTitleRecord | null>( + rootKey, + ); + queryClient.setQueryData<ThreadTitleRecord | null>(rootKey, (old) => + applyRootTitleWrite(old, write, Math.floor(Date.now() / 1000)), + ); + return { previousRecord }; + }, + onSuccess: (result, write) => { + const stored = { + ...write, + title: result.title, + revision: result.revision, + }; + queryClient.setQueryData<ThreadTitleRecord | null>( + threadTitleRootQueryKey(write.rootId), + (old) => + applyRootTitleWrite(old, stored, Math.floor(Date.now() / 1000)), + ); + }, + // Hook-level so the toast still shows when the editor unmounted on blur. + onError: (error, write, context) => { + queryClient.setQueryData( + threadTitleRootQueryKey(write.rootId), + context?.previousRecord, + ); + toast.error(describeTitleSaveError(error)); + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: THREAD_TITLES_QUERY_KEY }); + }, + }); +} + +/** + * Refetch titles when anyone edits an artifact in these channels. Artifact + * type can't be filtered on a live REQ, so any artifact change triggers a + * refetch. The relay only fans channel events out to subscriptions that name + * the channel, so a channel-less subscription would never fire. + */ +export function useThreadTitleLiveSync(channelIds: readonly string[]) { + const queryClient = useQueryClient(); + const handleArtifactEvent = React.useEffectEvent(() => { + void queryClient.invalidateQueries({ queryKey: THREAD_TITLES_QUERY_KEY }); + }); + const batchesKey = JSON.stringify(batchTitleSyncChannels(channelIds)); + + React.useEffect(() => { + const batches: string[][] = JSON.parse(batchesKey); + if (batches.length === 0) { + return; + } + let isCancelled = false; + const disposers: Array<() => Promise<void>> = []; + const retryTimers = new Set<ReturnType<typeof globalThis.setTimeout>>(); + + const subscribe = (channels: string[]) => { + relayClient + .subscribeLive( + { + kinds: [KIND_ARTIFACT, KIND_ARTIFACT_REMOVAL], + "#h": channels, + limit: 0, + since: Math.floor(Date.now() / 1000), + }, + handleArtifactEvent, + ) + .then((nextDispose) => { + if (isCancelled) { + void nextDispose(); + return; + } + disposers.push(nextDispose); + }) + .catch((error: unknown) => { + console.error("Failed to subscribe to thread title changes", error); + if (!isCancelled) { + const timer = globalThis.setTimeout(() => { + retryTimers.delete(timer); + subscribe(channels); + }, LIVE_RETRY_MS); + retryTimers.add(timer); + } + }); + }; + for (const channels of batches) { + subscribe(channels); + } + // A title set while the socket was down never reaches the live REQ. + const unsubscribeReconnects = + relayClient.subscribeToReconnects(handleArtifactEvent); + + return () => { + isCancelled = true; + for (const timer of retryTimers) { + globalThis.clearTimeout(timer); + } + unsubscribeReconnects(); + for (const dispose of disposers) { + void dispose(); + } + }; + }, [batchesKey]); +} diff --git a/desktop/src/features/threads/lib/threadTitles.test.mjs b/desktop/src/features/threads/lib/threadTitles.test.mjs new file mode 100644 index 00000000000..c2fea11f086 --- /dev/null +++ b/desktop/src/features/threads/lib/threadTitles.test.mjs @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + applyRootTitleWrite, + batchTitleSyncChannels, + createThreadTitleBatcher, + interpretTitleDraft, + MAX_LIVE_SYNC_CHANNELS_PER_FILTER, + THREAD_TITLES_QUERY_KEY, + threadTitleRootQueryKey, +} from "./threadTitles.ts"; + +const CHANNEL = "634a9a88-e929-427c-9dc7-3b7f7194e882"; +const ROOT = "a".repeat(64); +const OTHER_ROOT = "b".repeat(64); + +function entry(overrides = {}) { + return { + author: "p".repeat(64), + channelId: CHANNEL, + revision: "r".repeat(64), + rootId: ROOT, + title: "Release checklist", + updatedAt: 100, + ...overrides, + }; +} + +/** A batcher whose flush runs only when the test calls `flush()`. */ +function manualBatcher(fetchTitles) { + const scheduled = []; + const load = createThreadTitleBatcher(fetchTitles, (run) => + scheduled.push(run), + ); + return { + load, + scheduled, + flush: () => { + for (const run of scheduled.splice(0)) run(); + }, + }; +} + +test("root lookups in one tick share a single fetch", async () => { + const calls = []; + const { flush, load, scheduled } = manualBatcher(async (roots) => { + calls.push(roots); + return [entry()]; + }); + const titled = load(ROOT.toUpperCase()); + const untitled = load(OTHER_ROOT); + const again = load(ROOT); + assert.equal(scheduled.length, 1); + flush(); + assert.equal((await titled)?.title, "Release checklist"); + assert.equal(await untitled, null); + assert.equal((await again)?.title, "Release checklist"); + // Duplicate and differently-cased roots are fetched once. + assert.deepEqual(calls, [[ROOT, OTHER_ROOT]]); + + // The next tick starts a new batch. + const later = load(OTHER_ROOT); + flush(); + assert.equal(await later, null); + assert.deepEqual(calls[1], [OTHER_ROOT]); +}); + +test("root lookups skip ids that can't be event ids", async () => { + const { load, scheduled } = manualBatcher(async () => { + throw new Error("should not fetch"); + }); + assert.equal(await load("dm:alice"), null); + assert.equal(await load("a".repeat(63)), null); + assert.equal(scheduled.length, 0); +}); + +test("a failed batch rejects every lookup in it", async () => { + const { flush, load } = manualBatcher(async () => { + throw new Error("relay down"); + }); + const first = load(ROOT); + const second = load(OTHER_ROOT); + flush(); + await assert.rejects(first, /relay down/); + await assert.rejects(second, /relay down/); +}); + +test("root query keys sit under the titles key and ignore case", () => { + assert.deepEqual(threadTitleRootQueryKey(ROOT.toUpperCase()).slice(0, 1), [ + ...THREAD_TITLES_QUERY_KEY, + ]); + assert.deepEqual( + threadTitleRootQueryKey(ROOT.toUpperCase()), + threadTitleRootQueryKey(ROOT), + ); +}); + +test("applyRootTitleWrite sets, keeps the known revision, and clears", () => { + const previous = entry(); + const renamed = applyRootTitleWrite( + previous, + { channelId: CHANNEL, rootId: ROOT.toUpperCase(), title: "Renamed" }, + 200, + ); + assert.equal(renamed?.title, "Renamed"); + assert.equal(renamed?.rootId, ROOT); + assert.equal(renamed?.revision, previous.revision); + assert.equal(renamed?.updatedAt, 200); + assert.equal( + applyRootTitleWrite( + previous, + { channelId: CHANNEL, rootId: ROOT, title: "Renamed", revision: "new" }, + 200, + )?.revision, + "new", + ); + assert.equal( + applyRootTitleWrite( + previous, + { channelId: CHANNEL, rootId: ROOT, title: null }, + 200, + ), + null, + ); + // First title on an untitled thread. + assert.equal( + applyRootTitleWrite( + null, + { channelId: CHANNEL, rootId: ROOT, title: "New" }, + 200, + )?.revision, + "", + ); +}); + +test("interpretTitleDraft trims, clears on blank, and skips no-op writes", () => { + assert.deepEqual(interpretTitleDraft(" New ", "Old"), { + kind: "set", + title: "New", + }); + assert.deepEqual(interpretTitleDraft(" Old ", "Old"), { kind: "unchanged" }); + assert.deepEqual(interpretTitleDraft(" ", "Old"), { kind: "clear" }); + assert.deepEqual(interpretTitleDraft("", null), { kind: "unchanged" }); +}); + +test("interpretTitleDraft bounds titles by UTF-8 bytes, not characters", () => { + assert.equal(interpretTitleDraft("é".repeat(256), null).kind, "set"); + assert.equal(interpretTitleDraft("é".repeat(257), null).kind, "too-long"); +}); + +test("batchTitleSyncChannels dedupes, sorts, and splits at the relay #h cap", () => { + assert.deepEqual(batchTitleSyncChannels([]), []); + assert.deepEqual(batchTitleSyncChannels(["b", "a", "b", ""]), [["a", "b"]]); + const ids = Array.from( + { length: 130 }, + (_, i) => `c${String(i).padStart(3, "0")}`, + ); + const batches = batchTitleSyncChannels(ids.reverse()); + assert.equal(MAX_LIVE_SYNC_CHANNELS_PER_FILTER, 128); + assert.deepEqual( + batches.map((batch) => batch.length), + [128, 2], + ); + assert.equal(batches[0][0], "c000"); + assert.equal(batches[1][1], "c129"); +}); diff --git a/desktop/src/features/threads/lib/threadTitles.ts b/desktop/src/features/threads/lib/threadTitles.ts new file mode 100644 index 00000000000..e7481946459 --- /dev/null +++ b/desktop/src/features/threads/lib/threadTitles.ts @@ -0,0 +1,153 @@ +import type { ThreadTitleRecord } from "@/shared/api/tauriThreadTitles"; + +/** Same bound the relay enforces on the NIP-AR `title` tag. */ +export const MAX_THREAD_TITLE_BYTES = 512; + +export const THREAD_TITLES_QUERY_KEY = ["thread-titles"] as const; + +/** + * One root's title. Nested under {@link THREAD_TITLES_QUERY_KEY} so every + * title invalidation also refreshes the per-root lookups. + */ +export function threadTitleRootQueryKey(rootId: string) { + return [...THREAD_TITLES_QUERY_KEY, "root", rootId.toLowerCase()] as const; +} + +const ROOT_ID_PATTERN = /^[0-9a-f]{64}$/; + +type TitleWaiter = { + resolve: (title: ThreadTitleRecord | null) => void; + reject: (error: unknown) => void; +}; + +/** + * Coalesces per-root title lookups made in the same tick into one fetch, so a + * timeline full of thread roots costs one lookup rather than one per root. + * Ids that can't be event ids (e.g. a DM conversation id) resolve to `null` + * without a fetch. + */ +export function createThreadTitleBatcher( + fetchTitles: (rootIds: string[]) => Promise<readonly ThreadTitleRecord[]>, + schedule: (flush: () => void) => void = (flush) => { + globalThis.setTimeout(flush, 0); + }, +): (rootId: string) => Promise<ThreadTitleRecord | null> { + let pending = new Map<string, TitleWaiter[]>(); + + const flush = async () => { + const batch = pending; + pending = new Map(); + try { + const titles = await fetchTitles([...batch.keys()]); + const byRoot = new Map( + titles.map((entry) => [entry.rootId.toLowerCase(), entry]), + ); + for (const [root, waiters] of batch) { + for (const waiter of waiters) { + waiter.resolve(byRoot.get(root) ?? null); + } + } + } catch (error) { + for (const waiters of batch.values()) { + for (const waiter of waiters) { + waiter.reject(error); + } + } + } + }; + + return (rootId) => { + const root = rootId.toLowerCase(); + if (!ROOT_ID_PATTERN.test(root)) { + return Promise.resolve(null); + } + return new Promise((resolve, reject) => { + const waiters = pending.get(root); + if (waiters) { + waiters.push({ resolve, reject }); + return; + } + if (pending.size === 0) { + schedule(() => void flush()); + } + pending.set(root, [{ resolve, reject }]); + }); + }; +} + +/** User-facing message for a failed title save. */ +export function describeTitleSaveError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + if (message.startsWith("conflict:")) { + return "Someone else changed this thread's title while you were editing. Your change wasn't saved."; + } + return `Couldn't save the thread title: ${message}`; +} + +export type TitleDraftResult = + | { kind: "set"; title: string } + | { kind: "clear" } + | { kind: "unchanged" } + | { kind: "too-long" }; + +/** Interpret an edited title: blank clears it, an unchanged value writes nothing. */ +export function interpretTitleDraft( + draft: string, + current: string | null, +): TitleDraftResult { + const title = draft.trim(); + if (title === (current ?? "")) { + return { kind: "unchanged" }; + } + if (title.length === 0) { + return { kind: "clear" }; + } + if (new TextEncoder().encode(title).length > MAX_THREAD_TITLE_BYTES) { + return { kind: "too-long" }; + } + return { kind: "set", title }; +} + +/** Optimistically apply a write to one root's cached title. */ +export function applyRootTitleWrite( + previous: ThreadTitleRecord | null | undefined, + write: { + channelId: string; + rootId: string; + title: string | null; + /** Stored revision once known; until then the previous one is kept. */ + revision?: string | null; + }, + nowSeconds: number, +): ThreadTitleRecord | null { + if (write.title === null) { + return null; + } + return { + author: previous?.author ?? "", + channelId: write.channelId, + revision: write.revision ?? previous?.revision ?? "", + rootId: write.rootId.toLowerCase(), + title: write.title, + updatedAt: nowSeconds, + }; +} + +/** The relay rejects a REQ naming more than 128 `#h` values. */ +export const MAX_LIVE_SYNC_CHANNELS_PER_FILTER = 128; + +/** + * Deduplicated, sorted channel batches for the title live sync. Sorting makes + * the batching stable so an unrelated re-render doesn't resubscribe. + */ +export function batchTitleSyncChannels( + channelIds: readonly string[], + batchSize = MAX_LIVE_SYNC_CHANNELS_PER_FILTER, +): string[][] { + const unique = Array.from(new Set(channelIds.filter(Boolean))).sort(); + const batches: string[][] = []; + for (let i = 0; i < unique.length; i += batchSize) { + batches.push(unique.slice(i, i + batchSize)); + } + return batches; +} diff --git a/desktop/src/features/threads/ui/ThreadTitleEditor.tsx b/desktop/src/features/threads/ui/ThreadTitleEditor.tsx new file mode 100644 index 00000000000..d4c6faf6d74 --- /dev/null +++ b/desktop/src/features/threads/ui/ThreadTitleEditor.tsx @@ -0,0 +1,147 @@ +import { Pencil } from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; + +import { + useSetThreadTitleMutation, + useThreadTitle, +} from "@/features/threads/hooks"; +import { + interpretTitleDraft, + MAX_THREAD_TITLE_BYTES, +} from "@/features/threads/lib/threadTitles"; + +export type ThreadTitleTarget = { channelId: string; rootId: string }; + +/** The title and revision an edit started from. */ +type EditSession = { + draft: string; + startTitle: string | null; + startRevision: string | null; +}; + +/** + * Thread panel title: shows the shared thread title (falling back to the + * panel's default label) with an inline editor. Blank input clears the title. + * When the fallback is navigational context (e.g. a project panel's channel + * link), it stays visible after the title. + */ +export function ThreadTitleEditor({ + fallback, + keepFallback = false, + target, +}: { + fallback: React.ReactNode; + keepFallback?: boolean; + target: ThreadTitleTarget; +}) { + const shared = useThreadTitle(target.channelId, target.rootId); + const mutation = useSetThreadTitleMutation(); + const [session, setSession] = React.useState<EditSession | null>(null); + const inputRef = React.useRef<HTMLInputElement>(null); + // Enter commits and unmounts the input, which can also fire blur; commit once. + const committedRef = React.useRef(false); + const currentTitle = shared?.title ?? null; + const isEditing = session !== null; + + React.useEffect(() => { + if (isEditing) { + committedRef.current = false; + inputRef.current?.focus(); + } + }, [isEditing]); + + const startEditing = () => { + setSession({ + draft: currentTitle ?? "", + startTitle: currentTitle, + // An optimistic, not-yet-confirmed write has no revision to expect. + startRevision: shared?.revision || null, + }); + }; + + const commit = () => { + if (session === null || committedRef.current) { + return; + } + committedRef.current = true; + setSession(null); + // Compare with the title the edit started from, so an untouched editor + // never re-sends a stale title over someone else's rename. + const result = interpretTitleDraft(session.draft, session.startTitle); + if (result.kind === "unchanged") { + return; + } + if (result.kind === "too-long") { + toast.error( + `Thread titles are limited to ${MAX_THREAD_TITLE_BYTES} bytes.`, + ); + return; + } + mutation.mutate({ + channelId: target.channelId, + expectedRevision: session.startRevision, + rootId: target.rootId, + title: result.kind === "set" ? result.title : null, + }); + }; + + if (session !== null) { + return ( + <input + aria-label="Thread title" + className="h-7 w-full min-w-0 rounded-md border border-input bg-background px-2 text-sm font-semibold leading-6 outline-none focus-visible:ring-1 focus-visible:ring-ring" + data-testid="thread-title-input" + onBlur={commit} + onChange={(event) => + setSession({ ...session, draft: event.target.value }) + } + onFocus={(event) => event.currentTarget.select()} + onKeyDown={(event) => { + if (event.nativeEvent.isComposing) { + return; + } + if (event.key === "Enter") { + event.preventDefault(); + commit(); + } else if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + committedRef.current = true; + setSession(null); + } + }} + placeholder="Name this thread" + ref={inputRef} + value={session.draft} + /> + ); + } + + return ( + <span className="group/thread-title flex min-w-0 items-center gap-1"> + <span + className="min-w-0 truncate" + data-testid="thread-title" + title={currentTitle ?? undefined} + > + {currentTitle ?? fallback} + </span> + <button + aria-label={currentTitle ? "Rename thread" : "Name thread"} + className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover/thread-title:opacity-100" + data-testid="thread-title-edit" + onClick={startEditing} + title={currentTitle ? "Rename thread" : "Name thread"} + type="button" + > + <Pencil className="h-3.5 w-3.5" /> + </button> + {currentTitle && keepFallback ? ( + <span className="min-w-0 shrink truncate text-sm font-normal text-muted-foreground"> + {fallback} + </span> + ) : null} + </span> + ); +} diff --git a/desktop/src/features/threads/ui/ThreadTitleLine.tsx b/desktop/src/features/threads/ui/ThreadTitleLine.tsx new file mode 100644 index 00000000000..ddbe548252e --- /dev/null +++ b/desktop/src/features/threads/ui/ThreadTitleLine.tsx @@ -0,0 +1,60 @@ +import { MessagesSquare } from "lucide-react"; + +import { useThreadTitleForRoot } from "@/features/threads/hooks"; +import { cn } from "@/shared/lib/cn"; + +/** + * A thread's shared title on its own line, shown under the author of the + * thread root (channel) or under the context label (Inbox). Renders nothing + * for an untitled thread. With `onOpen` it is a button that opens the thread. + */ +export function ThreadTitleLine({ + className, + onOpen, + rootId, +}: { + className?: string; + onOpen?: () => void; + rootId: string | null; +}) { + const title = useThreadTitleForRoot(rootId); + if (!title) { + return null; + } + + const content = ( + <> + <MessagesSquare + aria-hidden="true" + className="h-3.5 w-3.5 shrink-0 text-muted-foreground" + /> + <span className="min-w-0 truncate">{title}</span> + </> + ); + const lineClass = cn( + "flex min-w-0 max-w-full items-center gap-1.5 text-sm font-semibold leading-5 text-foreground", + className, + ); + + if (!onOpen) { + return ( + <div className={lineClass} data-testid="thread-title-line"> + {content} + </div> + ); + } + return ( + <button + aria-label={`Open thread "${title}"`} + className={cn( + lineClass, + "w-fit rounded text-left hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring", + )} + data-testid="thread-title-line" + onClick={onOpen} + type="button" + > + {content} + </button> + ); +} diff --git a/desktop/src/shared/api/relayQueryInvalidation.ts b/desktop/src/shared/api/relayQueryInvalidation.ts index cb6e197a7b2..d95356c4815 100644 --- a/desktop/src/shared/api/relayQueryInvalidation.ts +++ b/desktop/src/shared/api/relayQueryInvalidation.ts @@ -26,6 +26,7 @@ const RELAY_QUERY_ROOTS = new Set<string>([ "run-approvals", "search-messages", "thread-replies", + "thread-titles", "user-profile", "user-search", "user-status", diff --git a/desktop/src/shared/api/tauriThreadTitles.ts b/desktop/src/shared/api/tauriThreadTitles.ts new file mode 100644 index 00000000000..076aa48e434 --- /dev/null +++ b/desktop/src/shared/api/tauriThreadTitles.ts @@ -0,0 +1,66 @@ +import { invokeTauri } from "@/shared/api/tauri"; + +/** A shared thread title (NIP-AR artifact, type `buzz.thread-title`). */ +export type ThreadTitleRecord = { + title: string; + channelId: string; + rootId: string; + revision: string; + author: string; + updatedAt: number; +}; + +type RawThreadTitleRecord = { + title: string; + channel: string; + root: string; + revision: string; + author: string; + updated_at: number; +}; + +/** Live titles for specific thread roots; untitled roots are omitted. */ +export async function getThreadTitles( + rootIds: readonly string[], +): Promise<ThreadTitleRecord[]> { + const raw = await invokeTauri<RawThreadTitleRecord[]>("get_thread_titles", { + rootIds, + }); + return raw.map((entry) => ({ + title: entry.title, + channelId: entry.channel, + rootId: entry.root, + revision: entry.revision, + author: entry.author, + updatedAt: entry.updated_at, + })); +} + +export type SetThreadTitleInput = { + channelId: string; + rootId: string; + /** New title, or `null` to clear it. */ + title: string | null; + /** Revision the edit started from; `null` when the thread had no title. */ + expectedRevision: string | null; +}; + +export type SetThreadTitleResult = { + title: string | null; + revision: string | null; +}; + +/** + * Set or clear a thread's title. Rejects with a `conflict:` error instead of + * overwriting when the title is no longer at `expectedRevision`. + */ +export async function setThreadTitle( + input: SetThreadTitleInput, +): Promise<SetThreadTitleResult> { + return invokeTauri<SetThreadTitleResult>("set_thread_title", { + channelId: input.channelId, + rootId: input.rootId, + title: input.title, + expectedRevision: input.expectedRevision, + }); +} diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index 7ebbe97023a..ea14b299a4c 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -20,6 +20,9 @@ export const KIND_STREAM_MESSAGE_V2 = 40002; export const KIND_STREAM_MESSAGE_EDIT = 40003; export const KIND_CHANNEL_THREAD_SUMMARY = 39005; export const KIND_CHANNEL_WINDOW_BOUNDS = 39006; +// NIP-AR channel artifact revision and relay-signed removal marker. +export const KIND_ARTIFACT = 45010; +export const KIND_ARTIFACT_REMOVAL = 45011; export const KIND_STREAM_MESSAGE_DIFF = 40008; export const KIND_REMINDER = 40007; export const KIND_SYSTEM_MESSAGE = 40099; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index e03c85d40d4..ac958bdf41f 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -57,6 +57,7 @@ import { } from "@/shared/api/customEmoji"; import { KIND_AGENT_OBSERVER_FRAME, + KIND_ARTIFACT, KIND_CHANNEL_THREAD_SUMMARY, KIND_CHANNEL_WINDOW_BOUNDS, KIND_DM_VISIBILITY, @@ -1313,6 +1314,13 @@ declare global { pubkey: string; status: PresenceStatus; }) => void; + /** Another user titles a thread: store it and emit the artifact live. */ + __BUZZ_E2E_SET_REMOTE_THREAD_TITLE__?: (input: { + channelName: string; + rootId: string; + title: string; + pubkey?: string; + }) => void; __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { channelName: string; content: string; @@ -3408,6 +3416,17 @@ type MockCanvasRevision = { }; let mockCanvasRevisions = new Map<string, MockCanvasRevision[]>(); +type MockThreadTitle = { + title: string; + channel: string; + root: string; + revision: string; + author: string; + updated_at: number; +}; +/** Keyed by `${channel}:${root}`; mirrors the get/set thread title commands. */ +let mockThreadTitles = new Map<string, MockThreadTitle>(); + // The canvas UI reaches the mock via the starter "general" channel in specs. const DEFAULT_STARTER_CANVAS_CHANNEL = STARTER_GENERAL_CHANNEL_ID; @@ -11510,6 +11529,7 @@ export function maybeInstallE2eTauriMocks() { resetMockTeamCatalogEvents(config); resetMockSaveSubscriptions(config); resetMockCanvasRevisions(config); + mockThreadTitles = new Map(); resetMockPendingCommunityDeepLinks(config); resetMockPendingNavigationDeepLinks(config); resetMockPendingEntityDeepLinks(config); @@ -11672,6 +11692,41 @@ export function maybeInstallE2eTauriMocks() { id, ); }; + window.__BUZZ_E2E_SET_REMOTE_THREAD_TITLE__ = ({ + channelName, + rootId, + title, + pubkey, + }) => { + const channel = mockChannels.find( + (candidate) => candidate.name === channelName, + ); + if (!channel) { + throw new Error(`Mock channel ${channelName} not found.`); + } + const root = rootId.toLowerCase(); + const author = pubkey ?? ALICE_PUBKEY; + const event = createMockEvent( + KIND_ARTIFACT, + title, + [ + ["h", channel.id], + ["type", "buzz.thread-title"], + ["root", root], + ["title", title], + ], + author, + ); + mockThreadTitles.set(`${channel.id}:${root}`, { + title, + channel: channel.id, + root, + revision: event.id, + author, + updated_at: event.created_at, + }); + emitMockLiveEvent(channel.id, event); + }; window.__BUZZ_E2E_SET_MOCK_USER_STATUS__ = ({ text, emoji, @@ -14951,6 +15006,47 @@ export function maybeInstallE2eTauriMocks() { mockCanvasRevisions.set(req.channelId, [revision, ...stream]); return { ok: true, event_id: revision.eventId, verified: true }; } + case "get_thread_titles": { + const req = payload as { rootIds: string[] }; + const roots = new Set(req.rootIds.map((id) => id.toLowerCase())); + return [...mockThreadTitles.values()].filter((entry) => + roots.has(entry.root), + ); + } + case "set_thread_title": { + const req = payload as { + channelId: string; + rootId: string; + title: string | null; + expectedRevision: string | null; + }; + const root = req.rootId.toLowerCase(); + const key = `${req.channelId}:${root}`; + // Mirror the command's compare-and-set: edits apply only to the + // revision they started from. + if ( + (mockThreadTitles.get(key)?.revision ?? null) !== req.expectedRevision + ) { + throw new Error( + "conflict: someone else changed this thread's title while you were editing", + ); + } + const title = req.title?.trim() ?? ""; + if (!title) { + mockThreadTitles.delete(key); + return { title: null, revision: null }; + } + const now = Math.floor(Date.now() / 1000); + mockThreadTitles.set(key, { + title, + channel: req.channelId, + root, + revision: mockEventId(), + author: DEFAULT_MOCK_IDENTITY.pubkey, + updated_at: now, + }); + return { title, revision: mockThreadTitles.get(key)?.revision ?? null }; + } case "get_canvas": { const canvasReadError = activeConfig?.mock?.canvasReadError; if (canvasReadError) { diff --git a/desktop/tests/e2e/thread-titles.spec.ts b/desktop/tests/e2e/thread-titles.spec.ts new file mode 100644 index 00000000000..92314055b80 --- /dev/null +++ b/desktop/tests/e2e/thread-titles.spec.ts @@ -0,0 +1,198 @@ +import { expect, type Page, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +type MockMessageWindow = Window & { + __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { + channelName: string; + content: string; + parentEventId?: string | null; + createdAt?: number; + pubkey?: string; + }) => { id: string } | undefined; + __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { + channelName: string; + kind?: number; + }) => boolean; + __BUZZ_E2E_SET_REMOTE_THREAD_TITLE__?: (input: { + channelName: string; + rootId: string; + title: string; + }) => void; +}; + +const CHANNEL_NAME = "engineering"; +const MOCK_IDENTITY_PUBKEY = "deadbeef".repeat(8); +const ALICE_PUBKEY = + "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f"; + +async function openThread( + page: Page, + rootContent: string, + options: { + channelName?: string; + replyCreatedAt?: number; + replyPubkey?: string; + } = {}, +): Promise<string> { + const channelName = options.channelName ?? CHANNEL_NAME; + await page.getByTestId(`channel-${channelName}`).click(); + await expect(page.getByTestId("chat-title")).toHaveText(channelName); + await expect + .poll(() => + page.evaluate( + (name) => + ( + window as MockMessageWindow + ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName: name }) ?? + false, + channelName, + ), + ) + .toBe(true); + + const summariesBefore = await page + .getByTestId("message-thread-summary") + .count(); + const rootId = await page.evaluate( + ({ channelName, content, pubkey }) => + (window as MockMessageWindow).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName, + content, + pubkey, + })?.id ?? null, + { + channelName, + content: rootContent, + pubkey: MOCK_IDENTITY_PUBKEY, + }, + ); + expect(rootId).not.toBeNull(); + await page.evaluate( + ({ channelName, createdAt, parentEventId, pubkey }) => { + (window as MockMessageWindow).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName, + content: "First reply.", + createdAt, + parentEventId, + pubkey, + }); + }, + { + channelName, + createdAt: options.replyCreatedAt, + parentEventId: rootId, + pubkey: options.replyPubkey ?? ALICE_PUBKEY, + }, + ); + // A new root collapses its reply into a summary row as it renders, so a + // click on the inline reply can land on a detached node. Wait for the + // summary instead; `.last()` alone could still resolve to an earlier one. + const summaries = page.getByTestId("message-thread-summary"); + await expect(summaries).toHaveCount(summariesBefore + 1); + await summaries.last().click(); + const panel = page.getByTestId("message-thread-panel"); + await expect(panel).toBeVisible(); + await expect(panel).toContainText(rootContent); + return rootId as string; +} + +test.describe("thread titles", () => { + test.use({ viewport: { width: 1280, height: 720 } }); + + test("name a thread from its header, then clear it", async ({ page }) => { + await installMockBridge(page); + await page.goto("/"); + await openThread(page, "Root message that will get a title."); + + const panel = page.getByTestId("message-thread-panel"); + await expect(panel.getByTestId("thread-title")).toHaveText("Thread"); + + await panel.getByTestId("thread-title").hover(); + await panel.getByTestId("thread-title-edit").click(); + const input = panel.getByTestId("thread-title-input"); + await expect(input).toBeFocused(); + await input.fill(" Release checklist "); + await input.press("Enter"); + await expect(panel.getByTestId("thread-title")).toHaveText( + "Release checklist", + ); + // The root message in the channel carries the title on its own line, and + // the thread panel's copy of the root doesn't repeat it under the header. + const titleLine = page.getByTestId("thread-title-line"); + await expect(titleLine).toHaveCount(1); + await expect(titleLine).toHaveText("Release checklist"); + await expect(panel.getByTestId("thread-title-line")).toHaveCount(0); + + // Escape abandons an edit without writing. + await panel.getByTestId("thread-title").hover(); + await panel.getByTestId("thread-title-edit").click(); + await panel.getByTestId("thread-title-input").fill("Discarded"); + await panel.getByTestId("thread-title-input").press("Escape"); + await expect(panel.getByTestId("thread-title")).toHaveText( + "Release checklist", + ); + + // A blank title clears it everywhere. + await panel.getByTestId("thread-title").hover(); + await panel.getByTestId("thread-title-edit").click(); + await panel.getByTestId("thread-title-input").fill(" "); + await panel.getByTestId("thread-title-input").press("Enter"); + await expect(panel.getByTestId("thread-title")).toHaveText("Thread"); + await expect(titleLine).toHaveCount(0); + }); + + test("a title another user sets appears before anyone replies", async ({ + page, + }) => { + await installMockBridge(page); + await page.goto("/"); + await page.getByTestId(`channel-${CHANNEL_NAME}`).click(); + await expect(page.getByTestId("chat-title")).toHaveText(CHANNEL_NAME); + await expect + .poll(() => + page.evaluate( + (name) => + ( + window as MockMessageWindow + ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: name, + kind: 45010, + }) ?? false, + CHANNEL_NAME, + ), + ) + .toBe(true); + + const rootId = await page.evaluate( + ({ channelName, pubkey }) => + (window as MockMessageWindow).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName, + content: "Question for an agent, no reply yet.", + pubkey, + })?.id ?? null, + { channelName: CHANNEL_NAME, pubkey: MOCK_IDENTITY_PUBKEY }, + ); + expect(rootId).not.toBeNull(); + await expect( + page.getByText("Question for an agent, no reply yet."), + ).toBeVisible(); + + await page.evaluate( + ({ channelName, rootId }) => + (window as MockMessageWindow).__BUZZ_E2E_SET_REMOTE_THREAD_TITLE__?.({ + channelName, + rootId, + title: "Agent-named thread", + }), + { channelName: CHANNEL_NAME, rootId: rootId as string }, + ); + + // The untitled root's lookup is cached for 30s, so only the live + // refresh can deliver the title this quickly. + await expect(page.getByTestId("thread-title-line")).toHaveText( + "Agent-named thread", + { timeout: 5_000 }, + ); + }); +});