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 }, + ); + }); +});